diff --git a/app/Data/Ui/Dashboard/ServiceAppDto.php b/app/Data/Ui/Dashboard/ServiceAppDto.php index 495e1c3..1179c32 100644 --- a/app/Data/Ui/Dashboard/ServiceAppDto.php +++ b/app/Data/Ui/Dashboard/ServiceAppDto.php @@ -15,5 +15,7 @@ public function __construct( public string $duration, public int $days_remaining, public bool $is_warning, + public ?string $protocol_name = null, + public ?string $provider_slug = null, ) {} } diff --git a/app/Enums/ConnectionProtocolEnum.php b/app/Enums/ConnectionProtocolEnum.php index 198ea46..beac291 100644 --- a/app/Enums/ConnectionProtocolEnum.php +++ b/app/Enums/ConnectionProtocolEnum.php @@ -13,4 +13,5 @@ enum ConnectionProtocolEnum: string case OIDC = 'oidc'; case SAML = 'saml'; case OAUTH2 = 'oauth2'; + case URL = 'url'; } diff --git a/app/Http/Controllers/EntraController.php b/app/Http/Controllers/EntraController.php new file mode 100644 index 0000000..bc0a727 --- /dev/null +++ b/app/Http/Controllers/EntraController.php @@ -0,0 +1,221 @@ +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'); + } + + // ── Step 1: Redirect user to Microsoft login ────────────────────────────── + + 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) { + abort(403, 'You do not have permission to access the required application.'); + } + + $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' => '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 + { + $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) { + 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 ($request->has('error')) { + return redirect()->route('dashboard.user')->with('entra_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('dashboard.user')->with('entra_error', '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(), + 'provider' => self::PROVIDER, + ], + [ + '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), + ] + ); + + return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft connected successfully!'); + } + + // ── Open Entra (refresh token silently 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) { + abort(403, 'You do not have permission to access the required application.'); + } + + $token = $user->oauthToken(self::PROVIDER); + + if (! $token) { + 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'); + } + } + + // Redirect to Outlook Web — SSO session handles silent sign-in + return redirect('https://outlook.office.com/mail/'); + } + + // ── Disconnect ──────────────────────────────────────────────────────────── + + 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) { + 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.'); + } + + // ── Internal: Refresh access token using refresh_token ─────────────────── + + private function refreshToken(OauthToken $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; + } +} diff --git a/app/Http/Controllers/OutlookController.php b/app/Http/Controllers/OutlookController.php deleted file mode 100644 index 851a4ce..0000000 --- a/app/Http/Controllers/OutlookController.php +++ /dev/null @@ -1,185 +0,0 @@ -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/Livewire/Forms/StoreConnectedAppFrom.php b/app/Livewire/Forms/StoreConnectedAppFrom.php index b90e34c..8e956ca 100644 --- a/app/Livewire/Forms/StoreConnectedAppFrom.php +++ b/app/Livewire/Forms/StoreConnectedAppFrom.php @@ -52,14 +52,17 @@ public function rules(): array ]; $protocol = ConnectionProtocol::query()->find($this->protocolId); - if ($protocol && ConnectionProtocolEnum::SAML->value === mb_strtolower($protocol->name)) { - $rules['samlEntityId'] = 'required|string|min:3'; - $rules['samlAcsUrl'] = 'required|url'; - $rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified'; - $rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id'; - $rules['samlAttributes'] = 'array'; - $rules['samlAttributes.*.saml_name'] = 'required|string|min:1'; - $rules['samlAttributes.*.user_field'] = 'required|string|in:email,name,immutable_id,id'; + if ($protocol) { + $protocolName = mb_strtolower($protocol->name); + if (ConnectionProtocolEnum::SAML->value === $protocolName) { + $rules['samlEntityId'] = 'required|string|min:3'; + $rules['samlAcsUrl'] = 'required|url'; + $rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified'; + $rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id'; + $rules['samlAttributes'] = 'array'; + $rules['samlAttributes.*.saml_name'] = 'required|string|min:1'; + $rules['samlAttributes.*.user_field'] = 'required|string|in:email,name,immutable_id,id'; + } } return $rules; diff --git a/app/Models/OauthToken.php b/app/Models/OauthToken.php new file mode 100644 index 0000000..c3aceeb --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,63 @@ + 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + /** + * @param Builder $query + * + * @return Builder + */ + public function scopeForProvider(Builder $query, string $provider): Builder + { + return $query->where('provider', $provider); + } +} diff --git a/app/Models/OutlookToken.php b/app/Models/OutlookToken.php deleted file mode 100644 index d2765e7..0000000 --- a/app/Models/OutlookToken.php +++ /dev/null @@ -1,33 +0,0 @@ - '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 59f7939..00e43fd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,13 +8,13 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\{Fillable, Hidden}; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; 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'])] @@ -62,8 +62,19 @@ protected function casts(): array ]; } - public function outlookToken(): HasOne + /** + * @return HasMany + */ + public function oauthTokens(): HasMany { - return $this->hasOne(OutlookToken::class); + return $this->hasMany(OauthToken::class); + } + + /** + * Get the OAuth token for a specific provider. + */ + public function oauthToken(string $provider): ?OauthToken + { + return $this->oauthTokens()->forProvider($provider)->first(); } } diff --git a/app/Services/AppRoleService.php b/app/Services/AppRoleService.php index 50d561a..3735daa 100644 --- a/app/Services/AppRoleService.php +++ b/app/Services/AppRoleService.php @@ -147,8 +147,11 @@ public function searchAppsForSelect(string $search = ''): Collection if (auth()->check()) { $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; - $query->whereHas('roles', function ($q) use ($userPriority): void { - $q->where('priority', '>', $userPriority); + $query->where(function ($q) use ($userPriority): void { + $q->whereDoesntHave('roles') + ->orWhereHas('roles', function ($q2) use ($userPriority): void { + $q2->where('priority', '>', $userPriority); + }); }); } @@ -172,8 +175,11 @@ public function getAllAppIds(): array if (auth()->check()) { $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; - $query->whereHas('roles', function ($q) use ($userPriority): void { - $q->where('priority', '>', $userPriority); + $query->where(function ($q) use ($userPriority): void { + $q->whereDoesntHave('roles') + ->orWhereHas('roles', function ($q2) use ($userPriority): void { + $q2->where('priority', '>', $userPriority); + }); }); } diff --git a/app/Services/UserAppAccessService.php b/app/Services/UserAppAccessService.php index 268f3e3..1d4f212 100644 --- a/app/Services/UserAppAccessService.php +++ b/app/Services/UserAppAccessService.php @@ -21,7 +21,7 @@ class UserAppAccessService */ public function getActiveServices(User $user): Collection { - $user->loadMissing(['roles.apps', 'roles.permissions']); + $user->loadMissing(['roles.apps.protocol', 'roles.apps.provider', 'roles.permissions']); $restrictedPermissions = $this->getRestrictedPermissions($user); @@ -123,6 +123,9 @@ private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, str $existing = $appsMap->get($app->id); $duration = Carbon::parse($durationStr); + $protocolName = $app->protocol?->name ? mb_strtolower($app->protocol->name) : null; + $providerSlug = $app->provider?->slug ? mb_strtolower($app->provider->slug) : null; + if (! $existing || Carbon::parse($existing->duration)->lt($duration)) { $appsMap->put($app->id, new ServiceAppDto( id: $app->id, @@ -131,6 +134,8 @@ private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, str duration: $durationStr, days_remaining: $daysRemaining, is_warning: $daysRemaining <= 3, + protocol_name: $protocolName, + provider_slug: $providerSlug, )); } } diff --git a/config/services.php b/config/services.php index 5f09ffa..a09e848 100644 --- a/config/services.php +++ b/config/services.php @@ -46,9 +46,9 @@ 'mfa_behavior' => env('MICROSOFT_GRAPH_MFA_BEHAVIOR', 'acceptIfMfaDoneByFederatedIdp'), ], - 'microsoft' => [ - 'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'), - 'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'), + '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 index d5b4794..8f4ed08 100644 --- a/database/migrations/2026_06_10_081837_create_outlook_tokens_table.php +++ b/database/migrations/2026_06_10_081837_create_outlook_tokens_table.php @@ -1,14 +1,16 @@ id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->text('access_token'); @@ -25,4 +27,4 @@ public function down(): void { Schema::dropIfExists('outlook_tokens'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_06_10_120925_create_oauth_tokens_table.php b/database/migrations/2026_06_10_120925_create_oauth_tokens_table.php new file mode 100644 index 0000000..f6008cc --- /dev/null +++ b/database/migrations/2026_06_10_120925_create_oauth_tokens_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('provider')->index(); + $table->text('access_token'); + $table->text('refresh_token')->nullable(); + $table->string('token_type')->default('Bearer'); + $table->text('scopes')->nullable(); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->unique(['user_id', 'provider']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_tokens'); + } +}; diff --git a/database/migrations/2026_06_10_120931_drop_outlook_tokens_table.php b/database/migrations/2026_06_10_120931_drop_outlook_tokens_table.php new file mode 100644 index 0000000..50170a1 --- /dev/null +++ b/database/migrations/2026_06_10_120931_drop_outlook_tokens_table.php @@ -0,0 +1,29 @@ +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'); + }); + } +}; diff --git a/resources/views/pages/connected-apps/⚡edit.blade.php b/resources/views/pages/connected-apps/⚡edit.blade.php index 7702775..22f9752 100644 --- a/resources/views/pages/connected-apps/⚡edit.blade.php +++ b/resources/views/pages/connected-apps/⚡edit.blade.php @@ -14,7 +14,6 @@ use App\Services\ConnectionStatusService; use Illuminate\Support\Collection; use Livewire\Attributes\Computed; -use Livewire\Attributes\Lazy; use Livewire\Component; use Livewire\Attributes\Title; use Mary\Traits\Toast; @@ -23,8 +22,7 @@ /** * @property $protocols */ -new #[Lazy] -#[Title("Edit a connected app")] +new #[Title("Edit a connected app")] class extends Component { use HandlesOperations; diff --git a/resources/views/pages/dashboards/⚡user.blade.php b/resources/views/pages/dashboards/⚡user.blade.php index 38dd3c7..527a2a5 100644 --- a/resources/views/pages/dashboards/⚡user.blade.php +++ b/resources/views/pages/dashboards/⚡user.blade.php @@ -1,14 +1,18 @@ event('dashboard_access') ->log('User accessed their assigned services dashboard.'); } + + // Show toast from OAuth redirect flash data + if (session('entra_success')) { + $this->success(session('entra_success')); + } + + if (session('entra_error')) { + $this->error(session('entra_error')); + } } #[Computed] @@ -40,6 +53,20 @@ public function services(): Collection return $this->service->getActiveServices($user); } + + #[Computed] + public function entraToken(): ?OauthToken + { + return auth()->user()?->oauthToken('microsoft'); + } + + #[Computed] + public function isEntraConnected(): bool + { + $token = $this->entraToken(); + + return $token !== null && !$token->isExpired(); + } }; ?> @@ -114,6 +141,65 @@ class="w-3.5 h-3.5 inline animate-pulse text-amber-600 dark:text-amber-400"/>

{{ $service->slug }}

+ + @if($service->protocol_name === 'url' && $service->provider_slug === 'azure-fd') +
+
+ Microsoft Integration + @if($this->isEntraConnected) + + Connected + + @else + + Not Connected + + @endif +
+ + @if($this->isEntraConnected && $this->entraToken) +
+ + Expires {{ $this->entraToken->expires_at->diffForHumans() }} +
+ @endif + +
+ @if($this->isEntraConnected) + + +
+ @csrf + @method('DELETE') + + + @else + + @endif +
+
+ @endif
@@ -137,11 +223,31 @@ class="btn-sm btn-circle btn-soft btn-warning" /> @endif - + @if($service->provider_slug === 'azure-fd') + @if($this->isEntraConnected) + + @else + + @endif + @else + + @endif
diff --git a/resources/views/test/outlook.blade.php b/resources/views/test/outlook.blade.php deleted file mode 100644 index 8ed9683..0000000 --- a/resources/views/test/outlook.blade.php +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - 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 a95a542..96c070a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,10 +8,10 @@ PermissionPermissionEnum, RoleAndAppsPermissionEnum, UserPermissionEnum}; +use App\Http\Controllers\EntraController; 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'); @@ -82,22 +82,18 @@ }); }); -Route::middleware(['auth', 'verified'])->prefix('test')->name('outlook.')->group(function (): void { - - // Test page with Connect button - Route::get('outlook', [OutlookController::class, 'index'])->name('test'); - +Route::middleware(['auth', 'verified'])->prefix('entra')->name('entra.')->group(function (): void { // Step 1: Kick off OAuth flow - Route::get('outlook/connect', [OutlookController::class, 'connect'])->name('connect'); - + Route::get('connect', [EntraController::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'); - + Route::get('callback', [EntraController::class, 'callback'])->name('callback'); + + // Open Entra (refreshes token silently if expired) + Route::get('open', [EntraController::class, 'openEntra'])->name('open'); + // Disconnect / revoke saved token - Route::delete('outlook/disconnect', [OutlookController::class, 'disconnect'])->name('disconnect'); + Route::delete('disconnect', [EntraController::class, 'disconnect'])->name('disconnect'); }); require __DIR__.'/settings.php'; diff --git a/tests/Feature/EntraOAuthTest.php b/tests/Feature/EntraOAuthTest.php new file mode 100644 index 0000000..c1e5330 --- /dev/null +++ b/tests/Feature/EntraOAuthTest.php @@ -0,0 +1,201 @@ +where('name', 'url')->first(); + if (! $protocol) { + $protocol = new App\Models\ConnectionProtocol(); + $protocol->name = 'url'; + $protocol->save(); + } + + $provider = App\Models\ConnectionProvider::query()->where('slug', 'azure-fd')->first(); + if (! $provider) { + $provider = new App\Models\ConnectionProvider(); + $provider->name = 'Azure FD'; + $provider->slug = 'azure-fd'; + $provider->save(); + } + + $status = App\Models\ConnectionStatus::query()->where('name', 'connected')->first(); + if (! $status) { + $status = new App\Models\ConnectionStatus(); + $status->name = 'connected'; + $status->save(); + } + + $app = App\Models\ConnectedApp::query()->create([ + 'name' => 'Azure FD Portal', + 'slug' => 'azure-fd-portal', + 'connection_protocol_id' => $protocol->id, + 'connection_provider_id' => $provider->id, + 'connection_status_id' => $status->id, + 'settings' => [ + 'url' => [ + 'redirect_url' => 'https://azure.microsoft.com', + ], + ], + ]); + + $role = App\Models\Role::findOrCreate('Azure User'); + $role->update(['priority' => 10]); + $role->apps()->attach($app->id, ['duration' => now()->addYear()->toDateTimeString()]); + + $user->assignRole($role); +} + +it('redirects unauthenticated users to login when connecting', function (): void { + $this->get(route('entra.connect')) + ->assertRedirect(route('login')); +}); + +it('returns 403 forbidden if user does not have permission for azure-fd app', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('entra.connect')) + ->assertStatus(403); +}); + +it('redirects to Microsoft login on connect when authorized', function (): void { + $user = User::factory()->create(); + assignAzureFdAccess($user); + + $response = $this->actingAs($user) + ->get(route('entra.connect')); + + $response->assertRedirectContains('login.microsoftonline.com'); + $response->assertRedirectContains('oauth2/v2.0/authorize'); +}); + +it('stores token and redirects to dashboard on successful callback when authorized', function (): void { + $user = User::factory()->create(); + assignAzureFdAccess($user); + + Http::fake([ + 'login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'fake-access-token', + 'refresh_token' => 'fake-refresh-token', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + ]), + ]); + + $state = 'test-state-value'; + + $response = $this->actingAs($user) + ->withSession(['oauth_state' => $state]) + ->get(route('entra.callback', [ + 'code' => 'fake-auth-code', + 'state' => $state, + ])); + + $response->assertRedirect(route('dashboard.user')); + $response->assertSessionHas('entra_success', 'Microsoft connected successfully!'); + + $this->assertDatabaseHas('oauth_tokens', [ + 'user_id' => $user->id, + 'provider' => 'microsoft', + 'token_type' => 'Bearer', + ]); +}); + +it('redirects to dashboard with error on invalid state when authorized', function (): void { + $user = User::factory()->create(); + assignAzureFdAccess($user); + + $response = $this->actingAs($user) + ->withSession(['oauth_state' => 'correct-state']) + ->get(route('entra.callback', [ + 'code' => 'fake-code', + 'state' => 'wrong-state', + ])); + + $response->assertRedirect(route('dashboard.user')); + $response->assertSessionHas('entra_error'); +}); + +it('redirects to dashboard with error when Microsoft returns error and authorized', function (): void { + $user = User::factory()->create(); + assignAzureFdAccess($user); + $state = 'test-state'; + + $response = $this->actingAs($user) + ->withSession(['oauth_state' => $state]) + ->get(route('entra.callback', [ + 'error' => 'access_denied', + 'error_description' => 'User cancelled the request', + 'state' => $state, + ])); + + $response->assertRedirect(route('dashboard.user')); + $response->assertSessionHas('entra_error', 'User cancelled the request'); +}); + +it('disconnects and removes the token when authorized', function (): void { + $user = User::factory()->create(); + assignAzureFdAccess($user); + + OauthToken::create([ + 'user_id' => $user->id, + 'provider' => 'microsoft', + 'access_token' => 'fake-token', + 'refresh_token' => 'fake-refresh', + 'token_type' => 'Bearer', + 'expires_at' => now()->addHour(), + ]); + + $response = $this->actingAs($user) + ->delete(route('entra.disconnect')); + + $response->assertRedirect(route('dashboard.user')); + $response->assertSessionHas('entra_success', 'Microsoft disconnected.'); + + $this->assertDatabaseMissing('oauth_tokens', [ + 'user_id' => $user->id, + 'provider' => 'microsoft', + ]); +}); + +it('updates existing token on re-connect when authorized', function (): void { + $user = User::factory()->create(); + assignAzureFdAccess($user); + + OauthToken::create([ + 'user_id' => $user->id, + 'provider' => 'microsoft', + 'access_token' => 'old-token', + 'refresh_token' => 'old-refresh', + 'token_type' => 'Bearer', + 'expires_at' => now()->subHour(), + ]); + + Http::fake([ + 'login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + ]), + ]); + + $state = 'reconnect-state'; + + $this->actingAs($user) + ->withSession(['oauth_state' => $state]) + ->get(route('entra.callback', [ + 'code' => 'new-auth-code', + 'state' => $state, + ])); + + expect(OauthToken::where('user_id', $user->id)->where('provider', 'microsoft')->count())->toBe(1); + + $token = $user->oauthToken('microsoft'); + expect($token->access_token)->toBe('new-access-token'); + expect($token->refresh_token)->toBe('new-refresh-token'); +}); diff --git a/tests/Feature/UrlProtocolTest.php b/tests/Feature/UrlProtocolTest.php new file mode 100644 index 0000000..4f348d3 --- /dev/null +++ b/tests/Feature/UrlProtocolTest.php @@ -0,0 +1,107 @@ +seed(Database\Seeders\ConnectionProtocolSeeder::class); + $this->seed(Database\Seeders\ConnectionProviderSeeder::class); + $this->seed(Database\Seeders\ConnectionStatusSeeder::class); + + $this->urlProtocol = ConnectionProtocol::query()->where('name', 'url')->firstOrFail(); + $this->provider = ConnectionProvider::query()->where('slug', 'azure-fd')->firstOrFail(); + $this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); +}); + +it('successfully creates a connected app with URL protocol', function (): void { + $admin = User::factory()->create(); + $admin->assignRole(App\Models\Role::findOrCreate('Admin')); + + $this->actingAs($admin); + + Livewire::test('pages::connected-apps.create') + ->set('form.name', 'Azure Portal') + ->set('form.protocolId', $this->urlProtocol->id) + ->set('form.providerId', $this->provider->id) + ->set('form.statusId', $this->status->id) + ->call('save'); + + $this->assertDatabaseHas('connected_apps', [ + 'name' => 'Azure Portal', + 'connection_protocol_id' => $this->urlProtocol->id, + ]); +}); + +it('successfully edits a connected app with URL protocol', function (): void { + $admin = User::factory()->create(); + $admin->assignRole(App\Models\Role::findOrCreate('Admin')); + + $this->actingAs($admin); + + $app = ConnectedApp::query()->create([ + 'name' => 'Azure Portal', + 'slug' => 'azure-portal', + 'connection_protocol_id' => $this->urlProtocol->id, + 'connection_provider_id' => $this->provider->id, + 'connection_status_id' => $this->status->id, + ]); + + Livewire::test('pages::connected-apps.edit', ['id' => $app->id]) + ->set('form.name', 'Updated Azure Portal') + ->call('save'); + + $app->refresh(); + expect($app->name)->toBe('Updated Azure Portal'); +}); + +it('maps URL protocol to ServiceAppDto in UserAppAccessService', function (): void { + $user = User::factory()->create(); + + $app = ConnectedApp::query()->create([ + 'name' => 'Azure Portal', + 'slug' => 'azure-portal', + 'connection_protocol_id' => $this->urlProtocol->id, + 'connection_provider_id' => $this->provider->id, + 'connection_status_id' => $this->status->id, + ]); + + $role = App\Models\Role::findOrCreate('Standard User'); + $role->update(['priority' => 10]); + $role->apps()->attach($app->id, ['duration' => now()->addYear()->toDateTimeString()]); + + $user->assignRole($role); + + $service = app(UserAppAccessService::class); + $activeServices = $service->getActiveServices($user); + + expect($activeServices->count())->toBe(1); + + $dto = $activeServices->first(); + expect($dto->protocol_name)->toBe('url') + ->and($dto->provider_slug)->toBe('azure-fd'); +}); + +it('shows newly created connected apps with no roles in searchAppsForSelect dropdown', function (): void { + $admin = User::factory()->create(); + $role = App\Models\Role::findOrCreate('Admin'); + $role->update(['priority' => 1]); + $admin->assignRole($role); + + $this->actingAs($admin); + + $app = ConnectedApp::query()->create([ + 'name' => 'Brand New App', + 'slug' => 'brand-new-app', + 'connection_protocol_id' => $this->urlProtocol->id, + 'connection_provider_id' => $this->provider->id, + 'connection_status_id' => $this->status->id, + ]); + + $appRoleService = app(App\Services\AppRoleService::class); + $apps = $appRoleService->searchAppsForSelect(''); + + expect($apps->pluck('id')->toArray())->toContain($app->id); +});