172 lines
5.4 KiB
PHP
172 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\OauthToken;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
final class KekaOutlookService
|
|
{
|
|
private const PROVIDER = 'keka';
|
|
|
|
private string $clientId;
|
|
private string $clientSecret;
|
|
private string $tenantId;
|
|
private string $redirectUri;
|
|
private array $scopes;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->clientId = (string) config('microsoft.client_id');
|
|
$this->clientSecret = (string) config('microsoft.client_secret');
|
|
$this->tenantId = (string) config('microsoft.tenant_id');
|
|
$this->redirectUri = (string) config('microsoft.keka_redirect_uri');
|
|
$this->scopes = (array) config('microsoft.keka_scopes', [
|
|
'openid', 'profile', 'email', 'offline_access', 'User.Read',
|
|
]);
|
|
}
|
|
|
|
public function getAuthUrl(): string
|
|
{
|
|
$state = Str::random(40);
|
|
session(['keka_ms_oauth_state' => $state]);
|
|
|
|
return 'https://login.microsoftonline.com/' . $this->tenantId . '/oauth2/v2.0/authorize?' . http_build_query([
|
|
'client_id' => $this->clientId,
|
|
'response_type' => 'code',
|
|
'redirect_uri' => $this->redirectUri,
|
|
'response_mode' => 'query',
|
|
'scope' => implode(' ', $this->scopes),
|
|
'state' => $state,
|
|
'prompt' => 'select_account',
|
|
]);
|
|
}
|
|
|
|
public function isValidState(?string $state): bool
|
|
{
|
|
$expected = session('keka_ms_oauth_state');
|
|
if (! $expected || ! $state) {
|
|
return false;
|
|
}
|
|
return hash_equals($expected, $state);
|
|
}
|
|
|
|
public function isConnected(User $user): bool
|
|
{
|
|
return OauthToken::where('user_id', $user->id)
|
|
->where('provider', self::PROVIDER)
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, message?: string}
|
|
*/
|
|
public function handleCallback(User $user, string $code): array
|
|
{
|
|
$response = Http::asForm()->post(
|
|
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
|
[
|
|
'client_id' => $this->clientId,
|
|
'client_secret' => $this->clientSecret,
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $code,
|
|
'redirect_uri' => $this->redirectUri,
|
|
'scope' => implode(' ', $this->scopes),
|
|
]
|
|
);
|
|
|
|
if (! $response->successful()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $response->json('error_description', 'Failed to exchange code for token.'),
|
|
];
|
|
}
|
|
|
|
$data = $response->json();
|
|
$this->storeTokens($user, $data);
|
|
session()->forget('keka_ms_oauth_state');
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns a valid access token, silently refreshing if expired.
|
|
*
|
|
* @throws \RuntimeException when not connected or refresh fails (caller must redirect to connect)
|
|
*/
|
|
public function getValidToken(User $user): string
|
|
{
|
|
$record = OauthToken::where('user_id', $user->id)
|
|
->where('provider', self::PROVIDER)
|
|
->first();
|
|
|
|
if (! $record) {
|
|
throw new \RuntimeException('Not connected.');
|
|
}
|
|
|
|
// Valid with 5-minute buffer
|
|
if ($record->expires_at->gt(now()->addMinutes(5))) {
|
|
return $record->access_token;
|
|
}
|
|
|
|
// Expired — silent refresh
|
|
if (! $record->refresh_token) {
|
|
$record->delete();
|
|
throw new \RuntimeException('No refresh token. Please reconnect.');
|
|
}
|
|
|
|
$response = Http::asForm()->post(
|
|
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
|
[
|
|
'client_id' => $this->clientId,
|
|
'client_secret' => $this->clientSecret,
|
|
'grant_type' => 'refresh_token',
|
|
'refresh_token' => $record->refresh_token,
|
|
'scope' => implode(' ', $this->scopes),
|
|
]
|
|
);
|
|
|
|
if (! $response->successful()) {
|
|
$record->delete();
|
|
throw new \RuntimeException('Session expired. Please reconnect Keka.');
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
$record->update([
|
|
'access_token' => $data['access_token'],
|
|
'refresh_token' => $data['refresh_token'] ?? $record->refresh_token,
|
|
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
|
|
]);
|
|
|
|
return $data['access_token'];
|
|
}
|
|
|
|
public function disconnect(User $user): void
|
|
{
|
|
OauthToken::where('user_id', $user->id)
|
|
->where('provider', self::PROVIDER)
|
|
->delete();
|
|
}
|
|
|
|
private function storeTokens(User $user, array $data): void
|
|
{
|
|
OauthToken::updateOrCreate(
|
|
['user_id' => $user->id, 'provider' => self::PROVIDER],
|
|
[
|
|
'access_token' => $data['access_token'],
|
|
'refresh_token' => $data['refresh_token'] ?? null,
|
|
'token_type' => $data['token_type'] ?? 'Bearer',
|
|
'scopes' => $data['scope'] ?? implode(' ', $this->scopes),
|
|
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
|
|
]
|
|
);
|
|
}
|
|
}
|