- Introduced `is_unlimited` field in `app_roles` table to support roles with unlimited duration. - Enhanced front-end and back-end implementations to manage unlimited roles, including UI updates and validation. - Refactored `KekaOutlookController` and `KekaOutlookService` for streamlined exception handling and code consistency. - Applied linting and formatting improvements across updated files.
226 lines
6.9 KiB
PHP
226 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Handles Microsoft OAuth (delegated, authorization code flow) for Keka-Outlook integration.
|
|
*
|
|
* Flow:
|
|
* - connect() -> build Microsoft login URL
|
|
* - handleCallback() -> exchange code for tokens, store on user
|
|
* - getValidToken() -> return a valid access token, refreshing silently if expired
|
|
* - disconnect() -> wipe stored tokens for the user
|
|
*/
|
|
final class KekaOutlookService
|
|
{
|
|
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 = config('microsoft.keka_scopes', [
|
|
'openid',
|
|
'profile',
|
|
'email',
|
|
'offline_access',
|
|
'User.Read',
|
|
'Mail.Read',
|
|
'Mail.ReadWrite',
|
|
'Mail.Send',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Build the Microsoft login URL the user is redirected to.
|
|
* A random "state" value is generated and stored in the session to prevent CSRF.
|
|
*/
|
|
public function getAuthUrl(): string
|
|
{
|
|
$state = Str::random(40);
|
|
session(['keka_ms_oauth_state' => $state]);
|
|
|
|
$query = 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 forces account chooser on first connect.
|
|
'prompt' => 'select_account',
|
|
]);
|
|
|
|
return "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}";
|
|
}
|
|
|
|
/**
|
|
* Validate the "state" returned by Microsoft against the one stored in session.
|
|
*/
|
|
public function isValidState(?string $state): bool
|
|
{
|
|
$expected = session('keka_ms_oauth_state');
|
|
|
|
if (! $expected || ! $state) {
|
|
return false;
|
|
}
|
|
|
|
return hash_equals($expected, $state);
|
|
}
|
|
|
|
/**
|
|
* Exchange the authorization code for access + refresh tokens and store them on the user.
|
|
*
|
|
* @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);
|
|
|
|
// Fetch the connected mailbox's email address for display purposes.
|
|
$profile = Http::withToken($data['access_token'])
|
|
->get('https://graph.microsoft.com/v1.0/me');
|
|
|
|
if ($profile->successful()) {
|
|
$user->keka_ms_email = $profile->json('mail') ?? $profile->json('userPrincipalName');
|
|
$user->save();
|
|
}
|
|
|
|
session()->forget('keka_ms_oauth_state');
|
|
|
|
return ['success' => true];
|
|
}
|
|
|
|
/**
|
|
* Persist tokens + expiry on the user model.
|
|
*/
|
|
private function storeTokens(User $user, array $tokenData): void
|
|
{
|
|
$user->keka_ms_access_token = $tokenData['access_token'];
|
|
$user->keka_ms_token_expires_at = now()->addSeconds((int) $tokenData['expires_in']);
|
|
|
|
// Microsoft only returns refresh_token if 'offline_access' scope was granted.
|
|
// If a new one isn't returned on refresh, keep the existing one.
|
|
if (! empty($tokenData['refresh_token'])) {
|
|
$user->keka_ms_refresh_token = $tokenData['refresh_token'];
|
|
}
|
|
|
|
$user->save();
|
|
}
|
|
|
|
/**
|
|
* Get a valid access token for the user.
|
|
* Silently refreshes via refresh_token if the current access token has expired.
|
|
*
|
|
* @throws RuntimeException if the user has not connected or refresh fails.
|
|
*/
|
|
public function getValidToken(User $user): string
|
|
{
|
|
if (! $this->isConnected($user)) {
|
|
throw new RuntimeException('User has not connected Keka/Outlook.');
|
|
}
|
|
|
|
// Still valid? (1 minute buffer)
|
|
if (
|
|
$user->keka_ms_access_token
|
|
&& $user->keka_ms_token_expires_at
|
|
&& now()->lt($user->keka_ms_token_expires_at->subMinute())
|
|
) {
|
|
return $user->keka_ms_access_token;
|
|
}
|
|
|
|
return $this->refreshToken($user);
|
|
}
|
|
|
|
/**
|
|
* Returns true if the user has connected Keka/Outlook (has a refresh token stored).
|
|
*/
|
|
public function isConnected(User $user): bool
|
|
{
|
|
return ! empty($user->keka_ms_refresh_token);
|
|
}
|
|
|
|
/**
|
|
* Use the stored refresh_token to obtain a new access_token silently.
|
|
*
|
|
* @throws RuntimeException if Microsoft rejects the refresh token (user must reconnect).
|
|
*/
|
|
private function refreshToken(User $user): string
|
|
{
|
|
$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' => $user->keka_ms_refresh_token,
|
|
'scope' => implode(' ', $this->scopes),
|
|
]
|
|
);
|
|
|
|
if (! $response->successful()) {
|
|
// Refresh token expired/revoked -> force user to reconnect.
|
|
$this->disconnect($user);
|
|
|
|
throw new RuntimeException(
|
|
$response->json('error_description', 'Session expired. Please reconnect Keka.')
|
|
);
|
|
}
|
|
|
|
$data = $response->json();
|
|
$this->storeTokens($user, $data);
|
|
|
|
return $data['access_token'];
|
|
}
|
|
|
|
/**
|
|
* Disconnect: wipe stored tokens. User must go through login flow again to reconnect.
|
|
*/
|
|
public function disconnect(User $user): void
|
|
{
|
|
$user->keka_ms_access_token = null;
|
|
$user->keka_ms_refresh_token = null;
|
|
$user->keka_ms_token_expires_at = null;
|
|
$user->keka_ms_email = null;
|
|
$user->save();
|
|
}
|
|
}
|