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)), ] ); } }