diff --git a/app/Http/Controllers/OutlookController.php b/app/Http/Controllers/OutlookController.php new file mode 100644 index 0000000..851a4ce --- /dev/null +++ b/app/Http/Controllers/OutlookController.php @@ -0,0 +1,185 @@ +tenantId = config('services.microsoft.tenant_id'); + $this->clientId = config('services.microsoft.client_id'); + $this->clientSecret = config('services.microsoft.client_secret'); + $this->redirectUri = route('outlook.callback'); + } + + // ── Test page ───────────────────────────────────────────────────────────── + + public function index(): View + { + $token = auth()->user()?->outlookToken; + + $connected = $token && ! $token->isExpired(); + $outlookUrl = null; + + if ($connected) { + // Silent redirect URL — opens Outlook web with the user already signed in + // via SSO cookie / session; no token is passed in the URL (not safe). + // Instead we redirect to Outlook and the browser SSO session handles it. + $outlookUrl = 'https://outlook.office.com/mail/'; + } + + return view('test.outlook', compact('connected', 'outlookUrl', 'token')); + } + + // ── Step 1: Redirect user to Microsoft login ────────────────────────────── + + public function connect(): RedirectResponse + { + $state = Str::random(40); + session(['oauth_state' => $state]); + + $query = http_build_query([ + 'client_id' => $this->clientId, + 'response_type' => 'code', + 'redirect_uri' => $this->redirectUri, + 'response_mode' => 'query', + 'scope' => self::SCOPES, + 'state' => $state, + // prompt=none tries silent login first; falls back to login page if needed + 'prompt' => 'select_account', + ]); + + return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}"); + } + + // ── Step 2: Handle callback, exchange code for tokens ──────────────────── + + public function callback(Request $request): RedirectResponse + { + // CSRF state check + if ($request->state !== session('oauth_state')) { + return redirect()->route('outlook.test')->with('error', 'Invalid OAuth state. Please try again.'); + } + + if ($request->has('error')) { + return redirect()->route('outlook.test')->with('error', $request->error_description ?? 'Microsoft login failed.'); + } + + // Exchange authorization code for tokens + $response = Http::asForm()->post( + "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", + [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'code' => $request->code, + 'redirect_uri' => $this->redirectUri, + 'grant_type' => 'authorization_code', + ] + ); + + if ($response->failed()) { + return redirect()->route('outlook.test')->with('error', 'Failed to retrieve tokens from Microsoft.'); + } + + $data = $response->json(); + + // Persist token for this user (upsert so re-connecting just updates) + OutlookToken::updateOrCreate( + ['user_id' => auth()->id()], + [ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'] ?? null, + 'token_type' => $data['token_type'] ?? 'Bearer', + 'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600), + ] + ); + + return redirect()->route('outlook.test')->with('success', 'Outlook connected successfully!'); + } + + // ── Open Outlook (refresh token silently if expired) ───────────────────── + + public function openOutlook(): RedirectResponse + { + $token = auth()->user()?->outlookToken; + + if (! $token) { + return redirect()->route('outlook.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('outlook.connect'); + } + } + + // Redirect to Outlook Web — SSO session handles silent sign-in + return redirect('https://outlook.office.com/mail/'); + } + + // ── Disconnect ──────────────────────────────────────────────────────────── + + public function disconnect(): RedirectResponse + { + auth()->user()?->outlookToken?->delete(); + + return redirect()->route('outlook.test')->with('success', 'Outlook disconnected.'); + } + + // ── Internal: Refresh access token using refresh_token ─────────────────── + + private function refreshToken(OutlookToken $token): bool + { + if (! $token->refresh_token) { + return false; + } + + $response = Http::asForm()->post( + "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", + [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'refresh_token' => $token->refresh_token, + 'grant_type' => 'refresh_token', + 'scope' => self::SCOPES, + ] + ); + + if ($response->failed()) { + return false; + } + + $data = $response->json(); + + $token->update([ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'] ?? $token->refresh_token, + 'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600), + ]); + + return true; + } +} \ No newline at end of file diff --git a/app/Models/OutlookToken.php b/app/Models/OutlookToken.php new file mode 100644 index 0000000..d2765e7 --- /dev/null +++ b/app/Models/OutlookToken.php @@ -0,0 +1,33 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index cf750d5..59f7939 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,6 +14,7 @@ use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; use Spatie\Permission\Traits\HasRoles; +use Illuminate\Database\Eloquent\Relations\HasOne; #[Fillable(['name', 'email', 'password', 'immutable_id'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] @@ -60,4 +61,9 @@ protected function casts(): array 'password' => 'hashed', ]; } + + public function outlookToken(): HasOne + { + return $this->hasOne(OutlookToken::class); + } } diff --git a/config/services.php b/config/services.php index ba69a61..5f09ffa 100644 --- a/config/services.php +++ b/config/services.php @@ -46,4 +46,10 @@ 'mfa_behavior' => env('MICROSOFT_GRAPH_MFA_BEHAVIOR', 'acceptIfMfaDoneByFederatedIdp'), ], + 'microsoft' => [ + 'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'), + 'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'), + 'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'), + ], + ]; diff --git a/database/migrations/2026_06_10_081837_create_outlook_tokens_table.php b/database/migrations/2026_06_10_081837_create_outlook_tokens_table.php new file mode 100644 index 0000000..d5b4794 --- /dev/null +++ b/database/migrations/2026_06_10_081837_create_outlook_tokens_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('access_token'); + $table->text('refresh_token')->nullable(); + $table->string('token_type')->default('Bearer'); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->unique('user_id'); // one token record per user + }); + } + + public function down(): void + { + Schema::dropIfExists('outlook_tokens'); + } +}; \ No newline at end of file diff --git a/resources/views/test/outlook.blade.php b/resources/views/test/outlook.blade.php new file mode 100644 index 0000000..8ed9683 --- /dev/null +++ b/resources/views/test/outlook.blade.php @@ -0,0 +1,103 @@ + + +
+ + +Connect your Microsoft Outlook account
++ You'll be redirected to Microsoft login. After connecting, you won't need to log in again. +
+ @endif + +