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(); } /** * 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. * * @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); } /** * 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(); } }