diff --git a/app/Http/Controllers/EntraController.php b/app/Http/Controllers/EntraController.php index bc0a727..23d3354 100644 --- a/app/Http/Controllers/EntraController.php +++ b/app/Http/Controllers/EntraController.php @@ -5,32 +5,28 @@ namespace App\Http\Controllers; use App\Models\OauthToken; +use App\Services\UserAppAccessService; use Illuminate\Http\{RedirectResponse, Request}; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; class EntraController extends Controller { - // ── OAuth config from .env ──────────────────────────────────────────────── - - private string $tenantId; - - private string $clientId; - - private string $clientSecret; - - private string $redirectUri; - private const PROVIDER = 'microsoft'; private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send'; + private string $tenantId; + private string $clientId; + private string $clientSecret; + private string $redirectUri; + public function __construct() { - $this->tenantId = config('services.microsoft.tenant_id'); - $this->clientId = config('services.microsoft.client_id'); - $this->clientSecret = config('services.microsoft.client_secret'); - $this->redirectUri = route('entra.callback'); + $this->tenantId = (string) config('services.microsoft.tenant_id'); + $this->clientId = (string) config('services.microsoft.client_id'); + $this->clientSecret = (string) config('services.microsoft.client_secret'); + $this->redirectUri = route('entra.callback'); } // ── Step 1: Redirect user to Microsoft login ────────────────────────────── @@ -38,28 +34,43 @@ public function __construct() public function connect(): RedirectResponse { $user = auth()->user(); + if (! $user) { abort(403, 'Unauthorized'); } - $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user); - $hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug); - - if (! $hasAzureFdAccess) { + if (! $this->hasAzureAccess($user)) { abort(403, 'You do not have permission to access the required application.'); } + // Already connected and token still valid → open Outlook directly + $token = $user->oauthToken(self::PROVIDER); + + if ($token && $token->expires_at->gt(now()->addMinutes(5))) { + return redirect('https://outlook.office.com/mail/'); + } + + // Token exists but expired → try silent refresh first + if ($token && $token->refresh_token) { + if ($this->refreshToken($token)) { + return redirect('https://outlook.office.com/mail/'); + } + // Refresh failed → delete and fall through to full login + $token->delete(); + } + + // No token or refresh failed → full Microsoft login $state = Str::random(40); session(['oauth_state' => $state]); $query = http_build_query([ - 'client_id' => $this->clientId, + 'client_id' => $this->clientId, 'response_type' => 'code', - 'redirect_uri' => $this->redirectUri, + 'redirect_uri' => $this->redirectUri, 'response_mode' => 'query', - 'scope' => self::SCOPES, - 'state' => $state, - 'prompt' => 'select_account', + 'scope' => self::SCOPES, + 'state' => $state, + 'prompt' => 'select_account', ]); return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}"); @@ -70,75 +81,83 @@ public function connect(): RedirectResponse public function callback(Request $request): RedirectResponse { $user = auth()->user(); + if (! $user) { abort(403, 'Unauthorized'); } - $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user); - $hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug); - - if (! $hasAzureFdAccess) { + if (! $this->hasAzureAccess($user)) { abort(403, 'You do not have permission to access the required application.'); } // CSRF state check - if ($request->state !== session('oauth_state')) { - return redirect()->route('dashboard.user')->with('entra_error', 'Invalid OAuth state. Please try again.'); + if (! hash_equals((string) session('oauth_state'), (string) $request->state)) { + session()->forget('oauth_state'); + return redirect()->route('dashboard.user') + ->with('entra_error', 'Invalid OAuth state. Please try again.'); } + session()->forget('oauth_state'); + if ($request->has('error')) { - return redirect()->route('dashboard.user')->with('entra_error', $request->error_description ?? 'Microsoft login failed.'); + return redirect()->route('dashboard.user') + ->with('entra_error', $request->error_description ?? 'Microsoft login failed.'); + } + + if (! $request->filled('code')) { + return redirect()->route('dashboard.user') + ->with('entra_error', 'No authorization code received from Microsoft.'); } // Exchange authorization code for tokens $response = Http::asForm()->post( "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", [ - 'client_id' => $this->clientId, + 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, - 'code' => $request->code, - 'redirect_uri' => $this->redirectUri, - 'grant_type' => 'authorization_code', + 'code' => $request->code, + 'redirect_uri' => $this->redirectUri, + 'grant_type' => 'authorization_code', + 'scope' => self::SCOPES, ] ); if ($response->failed()) { - return redirect()->route('dashboard.user')->with('entra_error', 'Failed to retrieve tokens from Microsoft.'); + return redirect()->route('dashboard.user') + ->with('entra_error', $response->json('error_description', 'Failed to retrieve tokens from Microsoft.')); } $data = $response->json(); - // Persist token for this user (upsert so re-connecting just updates) OauthToken::updateOrCreate( [ - 'user_id' => auth()->id(), + 'user_id' => $user->id, 'provider' => self::PROVIDER, ], [ - 'access_token' => $data['access_token'], + 'access_token' => $data['access_token'], 'refresh_token' => $data['refresh_token'] ?? null, - 'token_type' => $data['token_type'] ?? 'Bearer', - 'scopes' => self::SCOPES, - 'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600), + 'token_type' => $data['token_type'] ?? 'Bearer', + 'scopes' => self::SCOPES, + 'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)), ] ); - return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft connected successfully!'); + return redirect()->route('dashboard.user') + ->with('entra_success', 'Microsoft connected successfully!'); } - // ── Open Entra (refresh token silently if expired) ─────────────────────── + // ── Open Outlook (silent refresh if expired) ────────────────────────────── public function openEntra(): RedirectResponse { $user = auth()->user(); + if (! $user) { abort(403, 'Unauthorized'); } - $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user); - $hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug); - - if (! $hasAzureFdAccess) { + if (! $this->hasAzureAccess($user)) { abort(403, 'You do not have permission to access the required application.'); } @@ -148,20 +167,20 @@ public function openEntra(): RedirectResponse return redirect()->route('entra.connect'); } - // If token is expired, try to refresh silently - if ($token->isExpired()) { - $refreshed = $this->refreshToken($token); - - if (! $refreshed) { - // Refresh failed — force re-login - $token->delete(); - - return redirect()->route('entra.connect'); - } + // Token still valid (5-min buffer) + if ($token->expires_at->gt(now()->addMinutes(5))) { + return redirect('https://outlook.office.com/mail/'); } - // Redirect to Outlook Web — SSO session handles silent sign-in - return redirect('https://outlook.office.com/mail/'); + // Token expired — try silent refresh + if ($this->refreshToken($token)) { + return redirect('https://outlook.office.com/mail/'); + } + + // Refresh failed — force full re-login + $token->delete(); + + return redirect()->route('entra.connect'); } // ── Disconnect ──────────────────────────────────────────────────────────── @@ -169,23 +188,22 @@ public function openEntra(): RedirectResponse public function disconnect(): RedirectResponse { $user = auth()->user(); + if (! $user) { abort(403, 'Unauthorized'); } - $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user); - $hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug); - - if (! $hasAzureFdAccess) { + if (! $this->hasAzureAccess($user)) { abort(403, 'You do not have permission to access the required application.'); } $user->oauthToken(self::PROVIDER)?->delete(); - return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft disconnected.'); + return redirect()->route('dashboard.user') + ->with('entra_success', 'Microsoft disconnected.'); } - // ── Internal: Refresh access token using refresh_token ─────────────────── + // ── Internal: silent refresh ────────────────────────────────────────────── private function refreshToken(OauthToken $token): bool { @@ -196,11 +214,11 @@ private function refreshToken(OauthToken $token): bool $response = Http::asForm()->post( "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", [ - 'client_id' => $this->clientId, + 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, 'refresh_token' => $token->refresh_token, - 'grant_type' => 'refresh_token', - 'scope' => self::SCOPES, + 'grant_type' => 'refresh_token', + 'scope' => self::SCOPES, ] ); @@ -211,11 +229,20 @@ private function refreshToken(OauthToken $token): bool $data = $response->json(); $token->update([ - 'access_token' => $data['access_token'], + 'access_token' => $data['access_token'], 'refresh_token' => $data['refresh_token'] ?? $token->refresh_token, - 'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600), + 'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)), ]); return true; } -} + + // ── Internal: permission check (DRY) ───────────────────────────────────── + + private function hasAzureAccess(mixed $user): bool + { + $activeServices = app(UserAppAccessService::class)->getActiveServices($user); + + return $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug); + } +} \ No newline at end of file diff --git a/app/Models/OauthToken.php b/app/Models/OauthToken.php index c3aceeb..c50496a 100644 --- a/app/Models/OauthToken.php +++ b/app/Models/OauthToken.php @@ -46,9 +46,15 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } + public function isExpired(): bool { - return $this->expires_at->isPast(); + return $this->expires_at->lte(now()->addMinutes(5)); // 5-min buffer + } + + public function isValid(): bool + { + return ! $this->isExpired(); } /** diff --git a/app/Services/KekaOutlookService.php b/app/Services/KekaOutlookService.php index b36d8d3..d485e1b 100644 --- a/app/Services/KekaOutlookService.php +++ b/app/Services/KekaOutlookService.php @@ -4,29 +4,19 @@ namespace App\Services; +use App\Models\OauthToken; use App\Models\User; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -/** - * 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 const PROVIDER = 'keka'; + private string $clientId; - private string $clientSecret; - private string $tenantId; - private string $redirectUri; - private array $scopes; public function __construct() @@ -35,58 +25,44 @@ public function __construct() $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', + $this->scopes = (array) config('microsoft.keka_scopes', [ + 'openid', 'profile', 'email', 'offline_access', 'User.Read', ]); } - /** - * 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([ + 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 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); } + public function isConnected(User $user): bool + { + return OauthToken::where('user_id', $user->id) + ->where('provider', self::PROVIDER) + ->exists(); + } + /** - * 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 @@ -111,114 +87,83 @@ public function handleCallback(User $user, string $code): array } $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(); - } - - /** - * 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); - } - - /** - * Get a valid access token for the user. - * Silently refreshes via refresh_token if the current access token has expired. + * Returns a valid access token, silently refreshing if expired. * - * @throws \RuntimeException if the user has not connected or refresh fails. + * @throws \RuntimeException when not connected or refresh fails (caller must redirect to connect) */ public function getValidToken(User $user): string { - if (! $this->isConnected($user)) { - throw new \RuntimeException('User has not connected Keka/Outlook.'); + $record = OauthToken::where('user_id', $user->id) + ->where('provider', self::PROVIDER) + ->first(); + + if (! $record) { + throw new \RuntimeException('Not connected.'); } - // 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; + // Valid with 5-minute buffer + if ($record->expires_at->gt(now()->addMinutes(5))) { + return $record->access_token; } - return $this->refreshToken($user); - } + // Expired — silent refresh + if (! $record->refresh_token) { + $record->delete(); + throw new \RuntimeException('No refresh token. Please reconnect.'); + } - /** - * 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, + 'refresh_token' => $record->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.') - ); + $record->delete(); + throw new \RuntimeException('Session expired. Please reconnect Keka.'); } $data = $response->json(); - $this->storeTokens($user, $data); + + $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']; } - /** - * 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(); + 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)), + ] + ); } } \ No newline at end of file diff --git a/config/microsoft.php b/config/microsoft.php index 4a78e2d..f95d05e 100644 --- a/config/microsoft.php +++ b/config/microsoft.php @@ -5,22 +5,13 @@ 'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'), 'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'), - // Your app's redirect URI for Keka OAuth flow - 'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'), - - // After first-time login: Keka's own Microsoft SSO URL - // After already connected: Keka dashboard - 'keka_login_url' => env('KEKA_LOGIN_URL', 'https://sentientgeeks.keka.com/'), - 'keka_dashboard_url'=> env('KEKA_DASHBOARD_URL', 'https://sentientgeeks.keka.com/'), + 'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'), + 'keka_dashboard_url' => env('KEKA_DASHBOARD_URL', 'https://sentientgeeks.keka.com/'), 'keka_scopes' => [ - 'openid', - 'profile', - 'email', - 'offline_access', + 'openid', 'profile', 'email', + 'offline_access', // ← required for refresh_token 'User.Read', - 'Mail.Read', - 'Mail.ReadWrite', - 'Mail.Send', + 'Mail.Read', 'Mail.ReadWrite', 'Mail.Send', ], ]; \ No newline at end of file