diff --git a/app/Services/Applications/OIDC/OidcConfigResolver.php b/app/Services/Applications/OIDC/OidcConfigResolver.php index 364338a..627db3f 100644 --- a/app/Services/Applications/OIDC/OidcConfigResolver.php +++ b/app/Services/Applications/OIDC/OidcConfigResolver.php @@ -33,4 +33,14 @@ public function needSignOutUris(): bool return true; // return ApplicationTypeEnum::Machine !== $this->applicationType; } + + public function isConfidential(\Laravel\Passport\Client $client): bool + { + return $client->confidential(); + } + + public function requiresPkce(\Laravel\Passport\Client $client): bool + { + return ! $client->confidential(); + } } diff --git a/app/Services/Applications/OIDC/OidcService.php b/app/Services/Applications/OIDC/OidcService.php index 600d42c..e1c49a4 100644 --- a/app/Services/Applications/OIDC/OidcService.php +++ b/app/Services/Applications/OIDC/OidcService.php @@ -7,11 +7,12 @@ use App\Data\Application\{CreatedApplicationData, StoreOIDCAppData}; use App\Data\ConnectedApp\ConnectAppRequest; use App\Enums\{ConnectionProtocolEnum, ConnectionStatusEnum}; -use App\Models\{ConnectionProtocol, ConnectionStatus}; +use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionStatus}; use App\Services\{ApplicationLogoUploader, ConnectedAppService}; use App\Services\Applications\OIDC\Factory\ClientCreationFactory; use Illuminate\Support\Facades\{DB, Log}; use Illuminate\Support\Str; +use Laravel\Passport\Client; use Throwable; readonly class OidcService @@ -43,6 +44,7 @@ public function createApp(StoreOIDCAppData $data): CreatedApplicationData connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id, settings: [ 'access_url' => $data->accessUrl, + 'sign_out_uris' => $data->signOutUris, ], logo: $logo, ) @@ -74,4 +76,33 @@ public function createApp(StoreOIDCAppData $data): CreatedApplicationData throw $e; } } + + public function getPassportClient(ConnectedApp $app): ?Client + { + return Client::where('name', $app->name)->first(); + } + + public function generateNewSecret(Client $client): string + { + $plainSecret = Str::random(40); + $client->forceFill(['secret' => bcrypt($plainSecret)])->save(); + + return $plainSecret; + } + + public function toggleStatus(ConnectedApp $app): void + { + $statusEnum = $app->status->name === ConnectionStatusEnum::Connected->value + ? ConnectionStatusEnum::Disconnected + : ConnectionStatusEnum::Connected; + + $newStatus = ConnectionStatus::where('name', $statusEnum->value)->firstOrFail(); + $app->connection_status_id = $newStatus->id; + $app->save(); + } + + public function getSignOutUris(ConnectedApp $app): array + { + return data_get($app, 'settings.sign_out_uris', []); + } } diff --git a/resources/views/pages/apps/oidc/⚡show.blade.php b/resources/views/pages/apps/oidc/⚡show.blade.php new file mode 100644 index 0000000..a220792 --- /dev/null +++ b/resources/views/pages/apps/oidc/⚡show.blade.php @@ -0,0 +1,479 @@ +oidcService = $oidcService; + $this->configResolver = $configResolver; + } + + public function mount(int $id): void + { + $this->authorize(AppPermissionEnum::Read->value); + $this->appId = $id; + $this->loadAppData(); + } + + public function loadAppData(): void + { + $this->app = ConnectedApp::with(['protocol', 'status', 'roles'])->findOrFail($this->appId); + $this->passportClient = $this->oidcService->getPassportClient($this->app); + $this->signOutUris = $this->oidcService->getSignOutUris($this->app); + } + + public function generateNewSecret(): void + { + $this->authorize(AppPermissionEnum::Update->value); + + if (!$this->passportClient) { + $this->error('No corresponding Passport Client found to update.'); + return; + } + + $this->attempt( + action: function () { + $plainSecret = $this->oidcService->generateNewSecret($this->passportClient); + $this->newSecretPlain = $plainSecret; + $this->success('A new client secret has been generated! Make sure to copy it now.'); + $this->loadAppData(); + }, + successMessage: 'New client secret generated successfully!', + showSuccess: false + ); + } + + public function toggleStatus(): void + { + $this->authorize(AppPermissionEnum::Update->value); + + $this->attempt( + action: function () { + $this->oidcService->toggleStatus($this->app); + $this->loadAppData(); + }, + successMessage: 'Application status updated successfully!' + ); + } + + public function goBack(): void + { + $this->redirectRoute('apps.index', navigate: true); + } + + public function editApp(): void + { + $this->redirectRoute('apps.edit', ['id' => $this->appId], navigate: true); + } + + public function requiresPkce(Client $client) + { + return $this->configResolver->requiresPkce($client); + } +}; +?> + +
+ + +
+
+
+ + +
+
+
+

{{ $app->name }}

+ +
+

Protocol: {{ strtoupper($app->protocol->name) }} +

+
+
+ +
+ + + + + + +
+
+
+ + + + + + + +
+ @if($newSecretPlain) + + +
+ + +
+ @endif + + @if(!$passportClient) + + @endif + + +
+
+

Client Credentials

+
+
+ +
+ Client ID +
+ + @if($passportClient) + + @endif +
+
+
+
+
+ Public identifier for the client that is required for all OAuth flows. +
+
+ + +
+ Client Authentication +
+ + +
+
+ + +
+ Proof Key for Code Exchange (PKCE) +
+ +
+
+
+
+ + +
+
+

Client Secrets

+ @if($passportClient) + + @endif +
+
+ + + + + + + + + + @if($passportClient) + + + + + + @else + + + + @endif + +
Creation dateSecretStatus
+ {{ $passportClient->updated_at?->format('M j, Y H:i:s') ?? 'N/A' }} + + •••••••••••••••••••••••••••••••••••••••• + + + {{ !$passportClient->revoked ? 'Active' : 'Revoked' }} + +
No secrets + available +
+
+
+
+
+ + + +
+
+
+

Sign In Redirect URIs

+
+
+ @if(!empty($passportClient?->redirect_uris)) + @foreach($passportClient->redirect_uris as $uri) +
+ + {{ $uri }} +
+ @endforeach + @else + No Sign In Redirect URIs configured + @endif +
+
+ + +
+
+

Sign Out Redirect URIs

+
+
+ @if(!empty($signOutUris)) + @foreach($signOutUris as $uri) +
+ + {{ $uri }} +
+ @endforeach + @else + No Sign Out Redirect URIs configured + @endif +
+
+
+
+ + + +
+
+
+

Assigned Roles

+
+
+ + + + + + + + + + @forelse($app->roles as $role) + + + + + + @empty + + + + @endforelse + +
Role NamePriorityAccess Type
+ {{ $role->name }} + + {{ $role->priority }} + + + SSO Access + +
+ No specific roles assigned. Accessible by everyone depending on user access + setting. +
+
+
+
+
+ + + +
+
+
+

Granted OAuth/OIDC + Scopes

+
+
+
+
+ + + +
+
openid
+

Allows the application to fetch ID + tokens for authenticating users.

+
+
+
+ + + +
+
profile
+

Allows reading default profile + fields like name, picture, and locale.

+
+
+
+ + + +
+
email
+

Grants access to the user's primary + email address.

+
+
+
+ + + +
+
offline_access
+

Allows requesting refresh tokens to + maintain access offline.

+
+
+
+
+
+
+
+ +
+
+
diff --git a/resources/views/pages/apps/⚡index.blade.php b/resources/views/pages/apps/⚡index.blade.php index 60fafad..a4dc812 100644 --- a/resources/views/pages/apps/⚡index.blade.php +++ b/resources/views/pages/apps/⚡index.blade.php @@ -7,6 +7,7 @@ use App\Enums\ApplicationTypeEnum; use App\Enums\ConnectionProtocolEnum; use App\Livewire\Forms\CreateAppSelectionForm; +use App\Models\ConnectedApp; use App\Services\ConnectedAppService; use Livewire\Attributes\Computed; use Livewire\Attributes\Title; @@ -61,6 +62,18 @@ public function edit(int $appId): void $this->redirectRoute('apps.edit', ['id' => $appId], navigate: true); } + public function viewApp(int $appId): void + { + $app = ConnectedApp::with('protocol')->findOrFail($appId); + $protocol = mb_strtolower($app->protocol->name ?? ''); + + if ($protocol === 'oidc') { + $this->redirectRoute('apps.oidc.show', ['id' => $appId], navigate: true); + } else { + $this->redirectRoute('apps.edit', ['id' => $appId], navigate: true); + } + } + public function delete(int $appId): void { $this->attempt( @@ -101,8 +114,8 @@ public function createApp(): void @scope('actions', $row)
- + middleware(PermissionMiddleware::using(AppPermissionEnum::Update))->name('edit'); + + Route::livewire( + 'oidc/{id}/view', + 'pages::apps.oidc.show' + )->middleware(PermissionMiddleware::using(AppPermissionEnum::Read))->name('oidc.show'); + Route::livewire('/', 'pages::apps.index')->name('index'); }); diff --git a/tests/Feature/OidcAppViewTest.php b/tests/Feature/OidcAppViewTest.php new file mode 100644 index 0000000..4939806 --- /dev/null +++ b/tests/Feature/OidcAppViewTest.php @@ -0,0 +1,89 @@ +seed(Database\Seeders\PermissionSeeder::class); + $this->seed(Database\Seeders\DefaultRoleWithPermissionSeeder::class); + $this->seed(Database\Seeders\ConnectionProtocolSeeder::class); + $this->seed(Database\Seeders\ConnectionProviderSeeder::class); + $this->seed(Database\Seeders\ConnectionStatusSeeder::class); + + $this->oidcProtocol = ConnectionProtocol::query()->where('name', 'oidc')->firstOrFail(); + $this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); + + // Create a mock OIDC application in database + $this->connectedApp = ConnectedApp::query()->create([ + 'name' => 'Test OIDC App', + 'slug' => 'test-oidc-app', + 'connection_protocol_id' => $this->oidcProtocol->id, + 'connection_provider_id' => 0, + 'connection_status_id' => $this->status->id, + 'settings' => [ + 'access_url' => 'https://test-oidc-app.example.com', + 'signOutUris' => ['https://test-oidc-app.example.com/logout'], + ], + ]); + + // Create corresponding Passport Client + $this->passportClient = Client::query()->create([ + 'id' => (string) Str::uuid(), + 'name' => 'Test OIDC App', + 'secret' => bcrypt('old-secret-value'), + 'provider' => 'users', + 'redirect_uris' => ['https://test-oidc-app.example.com/callback'], + 'grant_types' => ['authorization_code'], + 'revoked' => false, + ]); +}); + +it('authorizes only admin users to access the OIDC view page', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); // standard user + + $this->actingAs($user); + + // Should fail with 403 Forbidden + Livewire::actingAs($user) + ->test('pages::apps.oidc.show', ['id' => $this->connectedApp->id]) + ->assertForbidden(); + + $admin = User::factory()->create(); + $admin->assignRole('admin'); // admin user + + $this->actingAs($admin); + + // Should load successfully + Livewire::actingAs($admin) + ->test('pages::apps.oidc.show', ['id' => $this->connectedApp->id]) + ->assertOk(); +}); + +it('displays client credentials and allows generating a new client secret', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + $component = Livewire::test('pages::apps.oidc.show', ['id' => $this->connectedApp->id]) + ->assertSet('appId', $this->connectedApp->id) + ->assertSet('passportClient.id', $this->passportClient->id); + + // Call generateNewSecret + $component->call('generateNewSecret'); + + // Get plain secret set in component + $newSecretPlain = $component->get('newSecretPlain'); + expect($newSecretPlain)->toBeString() + ->and(mb_strlen($newSecretPlain))->toBe(40); + + // Verify it is updated in DB + $this->passportClient->refresh(); + expect(Hash::check($newSecretPlain, $this->passportClient->secret))->toBeTrue(); +});