From d1637c5a35df16ec5334a2a2ff5320736be6895c Mon Sep 17 00:00:00 2001 From: subhajit Date: Wed, 10 Jun 2026 14:31:36 +0530 Subject: [PATCH] implement outlook login --- app/Http/Controllers/OutlookController.php | 185 ++++++++++++++++++ app/Models/OutlookToken.php | 33 ++++ app/Models/User.php | 6 + config/services.php | 6 + ..._10_081837_create_outlook_tokens_table.php | 28 +++ resources/views/test/outlook.blade.php | 103 ++++++++++ routes/web.php | 19 ++ 7 files changed, 380 insertions(+) create mode 100644 app/Http/Controllers/OutlookController.php create mode 100644 app/Models/OutlookToken.php create mode 100644 database/migrations/2026_06_10_081837_create_outlook_tokens_table.php create mode 100644 resources/views/test/outlook.blade.php 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 @@ + + + + + + Outlook Connect Test + + + + +
+ + {{-- Logo --}} +
+ + + + + + +
+ +
+

Outlook Integration

+

Connect your Microsoft Outlook account

+
+ + {{-- Flash messages --}} + @if(session('success')) +
+ ✅ {{ session('success') }} +
+ @endif + + @if(session('error')) +
+ ❌ {{ session('error') }} +
+ @endif + + {{-- Status card --}} +
+
+ Status + @if($connected) + + Connected + + @else + + Not connected + + @endif +
+ + @if($connected && $token) +
+ Token expires + {{ $token->expires_at->diffForHumans() }} +
+ @endif +
+ + {{-- Action buttons --}} + @if($connected) + {{-- Open Outlook --}} + + + + + Open Outlook + + + {{-- Disconnect --}} +
+ @csrf + @method('DELETE') + +
+ @else + {{-- Connect button --}} + + + + + Connect to Outlook + + +

+ You'll be redirected to Microsoft login. After connecting, you won't need to log in again. +

+ @endif + +
+ + + \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 43285d9..a95a542 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController}; use Illuminate\Support\Facades\Route; use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware}; +use App\Http\Controllers\OutlookController; Route::redirect('/', '/dashboard')->name('home'); @@ -81,4 +82,22 @@ }); }); +Route::middleware(['auth', 'verified'])->prefix('test')->name('outlook.')->group(function (): void { + + // Test page with Connect button + Route::get('outlook', [OutlookController::class, 'index'])->name('test'); + + // Step 1: Kick off OAuth flow + Route::get('outlook/connect', [OutlookController::class, 'connect'])->name('connect'); + + // Step 2: Microsoft redirects back here with auth code + Route::get('outlook/callback', [OutlookController::class, 'callback'])->name('callback'); + + // Open Outlook (refreshes token silently if expired) + Route::get('outlook/open', [OutlookController::class, 'openOutlook'])->name('open'); + + // Disconnect / revoke saved token + Route::delete('outlook/disconnect', [OutlookController::class, 'disconnect'])->name('disconnect'); +}); + require __DIR__.'/settings.php';