From 7f02209631e855fe64ea441a2ec758262ba7d809 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 22 Jun 2026 05:54:36 +0000 Subject: [PATCH] Feat: Add OIDC app creation feature with access URL support, Hide SAML, Microservice - Introduced tests for OIDC app creation, including validation for access URL formatting. - Updated enums and services to streamline application type and protocol handling. - Added `accessUrl` support across backend, services, and UI to enhance app functionality and user accessibility. - Refactored and commented out unused enum values and providers to improve maintainability. --- app/Data/Application/StoreOIDCAppData.php | 2 + app/Data/Ui/Dashboard/ServiceAppDto.php | 1 + app/Enums/ApplicationTypeEnum.php | 6 +- app/Enums/ConnectionProtocolEnum.php | 8 +-- app/Livewire/Forms/StoreConnectedAppFrom.php | 23 ++++---- app/Livewire/Forms/StoreOIDCAppForm.php | 4 ++ .../OIDC/Factory/ClientCreationFactory.php | 4 +- .../Applications/OIDC/OidcConfigResolver.php | 8 +-- .../Applications/OIDC/OidcService.php | 3 + app/Services/UserAppAccessService.php | 1 + .../components/dashboard-sidebar.blade.php | 27 ++++----- .../views/pages/apps/oidc/⚡create.blade.php | 8 ++- resources/views/pages/apps/⚡index.blade.php | 1 - .../views/pages/dashboards/⚡user.blade.php | 9 +++ tests/Feature/OidcAppCreationTest.php | 56 +++++++++++++++++++ 15 files changed, 120 insertions(+), 41 deletions(-) create mode 100644 tests/Feature/OidcAppCreationTest.php diff --git a/app/Data/Application/StoreOIDCAppData.php b/app/Data/Application/StoreOIDCAppData.php index 483b5bf..de65d31 100644 --- a/app/Data/Application/StoreOIDCAppData.php +++ b/app/Data/Application/StoreOIDCAppData.php @@ -14,6 +14,7 @@ public function __construct( public ApplicationTypeEnum $type, public string $name, public UserAccessTypeEnum $userAccessType, + public string $accessUrl, /** @var array */ public array $signInUris = [], /** @var array */ @@ -29,6 +30,7 @@ public static function fromArray(ApplicationTypeEnum $applicationType, array $va type: $applicationType, name: $value['name'], userAccessType: UserAccessTypeEnum::from($value['userAccessOption']), + accessUrl: $value['accessUrl'], signInUris: $value['signInUris'] ?? [], signOutUris: $value['signOutUris'] ?? [], logo: $value['logo'] ?? null, diff --git a/app/Data/Ui/Dashboard/ServiceAppDto.php b/app/Data/Ui/Dashboard/ServiceAppDto.php index 1179c32..07692f8 100644 --- a/app/Data/Ui/Dashboard/ServiceAppDto.php +++ b/app/Data/Ui/Dashboard/ServiceAppDto.php @@ -17,5 +17,6 @@ public function __construct( public bool $is_warning, public ?string $protocol_name = null, public ?string $provider_slug = null, + public ?string $accessUrl = null, ) {} } diff --git a/app/Enums/ApplicationTypeEnum.php b/app/Enums/ApplicationTypeEnum.php index 7b4cde0..4179b41 100644 --- a/app/Enums/ApplicationTypeEnum.php +++ b/app/Enums/ApplicationTypeEnum.php @@ -10,7 +10,7 @@ enum ApplicationTypeEnum: string implements EnumMorphsToOptionsContract { case Web = 'web'; case SPA = 'spa'; - case Machine = 'machine'; + // case Machine = 'machine'; public static function asOptions(): array { @@ -26,7 +26,7 @@ public function toLabel(): string return match ($this) { self::Web => 'Web Applications', self::SPA => 'Single Page Applications', - self::Machine => 'Microservices', + // self::Machine => 'Microservices', }; } @@ -35,7 +35,7 @@ public function toHint(): string return match ($this) { self::Web => 'Server-side rendered web application, where authentication is handled by the server.', self::SPA => 'Single Page Application where client side framework e.g. React, Vue, Angular is used, and gets the authentication token from the server.', - self::Machine => 'Application that runs on a machine, e.g. a serverless function, or a container.', + // self::Machine => 'Application that runs on a machine, e.g. a serverless function, or a container.', }; } } diff --git a/app/Enums/ConnectionProtocolEnum.php b/app/Enums/ConnectionProtocolEnum.php index a600457..9d88e04 100644 --- a/app/Enums/ConnectionProtocolEnum.php +++ b/app/Enums/ConnectionProtocolEnum.php @@ -12,7 +12,7 @@ enum ConnectionProtocolEnum: string implements EnumMorphsToOptionsContract use EnumValuesAsArray; case OIDC = 'oidc'; - case SAML = 'saml'; + // case SAML = 'saml'; case URL = 'url'; public static function asOptions(): array @@ -28,7 +28,7 @@ public function toLabel(): string { return match ($this) { self::OIDC => 'OIDC - OpenID Connect', - self::SAML => 'SAML 2.0', + // self::SAML => 'SAML 2.0', self::URL => 'Redirect URL', }; } @@ -37,8 +37,8 @@ public function toHint(): string { return match ($this) { self::OIDC => 'Modern authentication protocol built on OAuth 2.0 for secure user sign-in across web, mobile, and API-based applications.', - self::SAML => 'XML-based Single Sign-On (SSO) protocol commonly used by enterprise applications and identity providers', - self::URL => 'Simple URL that will redirect the user to the authentication page.', + // self::SAML => 'XML-based Single Sign-On (SSO) protocol commonly used by enterprise applications and identity providers', + self::URL => 'Simple URL that will redirect the user to the authentication page. E.g. Keka, Outlook, etc.', }; } } diff --git a/app/Livewire/Forms/StoreConnectedAppFrom.php b/app/Livewire/Forms/StoreConnectedAppFrom.php index 61f6b7c..1caa559 100644 --- a/app/Livewire/Forms/StoreConnectedAppFrom.php +++ b/app/Livewire/Forms/StoreConnectedAppFrom.php @@ -5,8 +5,7 @@ namespace App\Livewire\Forms; use App\Data\ConnectedApp\ConnectedAppData; -use App\Enums\ConnectionProtocolEnum; -use App\Models\ConnectionProtocol; +use App\Models\{ConnectionProtocol, ConnectionProvider}; use App\Services\ConnectedAppService; use Livewire\Form; @@ -56,18 +55,18 @@ public function rules(): array $protocol = ConnectionProtocol::query()->find($this->protocolId); 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'; - } + // 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'; + // } } - $provider = \App\Models\ConnectionProvider::query()->find($this->providerId); + $provider = ConnectionProvider::query()->find($this->providerId); if ($provider && 'keka-hr' === $provider->slug) { $rules['kekaUrl'] = 'required|url'; } diff --git a/app/Livewire/Forms/StoreOIDCAppForm.php b/app/Livewire/Forms/StoreOIDCAppForm.php index d31faf9..663d785 100644 --- a/app/Livewire/Forms/StoreOIDCAppForm.php +++ b/app/Livewire/Forms/StoreOIDCAppForm.php @@ -14,6 +14,9 @@ class StoreOIDCAppForm extends Form #[Validate('required|string|max:255|min:3')] public string $name = ''; + #[Validate('required|url|max:255')] + public string $accessUrl = ''; + #[Validate('nullable|image|max:1024')] public ?TemporaryUploadedFile $logo = null; @@ -46,6 +49,7 @@ class StoreOIDCAppForm extends Form public function validationAttributes(): array { return [ + 'accessUrl' => 'Access URL', 'signInUris.*' => 'Sign-in URI', 'signOutUris.*' => 'Sign-out URI', 'roleIds.*' => 'Role', diff --git a/app/Services/Applications/OIDC/Factory/ClientCreationFactory.php b/app/Services/Applications/OIDC/Factory/ClientCreationFactory.php index b8d9ce9..022369a 100644 --- a/app/Services/Applications/OIDC/Factory/ClientCreationFactory.php +++ b/app/Services/Applications/OIDC/Factory/ClientCreationFactory.php @@ -9,7 +9,7 @@ class ClientCreationFactory { public function __construct( - private readonly ClientCredentialsClientFactory $clientCredentialsClient, + // private readonly ClientCredentialsClientFactory $clientCredentialsClient, private readonly AuthorizationCodeClientFactory $authorizationCodeClient, ) {} @@ -17,7 +17,7 @@ public function for(ApplicationTypeEnum $applicationType): ClientCreationContrac { return match ($applicationType) { ApplicationTypeEnum::Web, ApplicationTypeEnum::SPA => $this->authorizationCodeClient, - ApplicationTypeEnum::Machine => $this->clientCredentialsClient, + // ApplicationTypeEnum::Machine => $this->clientCredentialsClient, }; } } diff --git a/app/Services/Applications/OIDC/OidcConfigResolver.php b/app/Services/Applications/OIDC/OidcConfigResolver.php index 9babdc9..364338a 100644 --- a/app/Services/Applications/OIDC/OidcConfigResolver.php +++ b/app/Services/Applications/OIDC/OidcConfigResolver.php @@ -24,13 +24,13 @@ public function hasClientSecret(): bool public function needSignInUris(): bool { - - return ApplicationTypeEnum::Machine !== $this->applicationType; + return true; + // return ApplicationTypeEnum::Machine !== $this->applicationType; } public function needSignOutUris(): bool { - - return ApplicationTypeEnum::Machine !== $this->applicationType; + return true; + // return ApplicationTypeEnum::Machine !== $this->applicationType; } } diff --git a/app/Services/Applications/OIDC/OidcService.php b/app/Services/Applications/OIDC/OidcService.php index e7e8376..600d42c 100644 --- a/app/Services/Applications/OIDC/OidcService.php +++ b/app/Services/Applications/OIDC/OidcService.php @@ -41,6 +41,9 @@ public function createApp(StoreOIDCAppData $data): CreatedApplicationData connectionProviderId: 0, slug: Str::slug($data->name), connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id, + settings: [ + 'access_url' => $data->accessUrl, + ], logo: $logo, ) ); diff --git a/app/Services/UserAppAccessService.php b/app/Services/UserAppAccessService.php index 1d4f212..ef3fb8f 100644 --- a/app/Services/UserAppAccessService.php +++ b/app/Services/UserAppAccessService.php @@ -136,6 +136,7 @@ private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, str is_warning: $daysRemaining <= 3, protocol_name: $protocolName, provider_slug: $providerSlug, + accessUrl: data_get($app, 'settings.access_url'), )); } } diff --git a/resources/views/components/dashboard-sidebar.blade.php b/resources/views/components/dashboard-sidebar.blade.php index 4bbdd26..9eab13d 100644 --- a/resources/views/components/dashboard-sidebar.blade.php +++ b/resources/views/components/dashboard-sidebar.blade.php @@ -8,7 +8,8 @@ RoleAndAppsPermissionEnum, SecurityPermissionEnum, UserPermissionEnum, - PermissionPermissionEnum}; + PermissionPermissionEnum +}; use Livewire\Component; use Spatie\LaravelData\DataCollection; @@ -51,18 +52,18 @@ public function navItems(): DataCollection active_pattern: 'apps.index', permission: AppPermissionEnum::Access ), - new NavSubItemData( - label: 'Providers', - route: 'apps.connectionProviders.index', - active_pattern: 'apps.connectionProviders.*', - permission: ConnectionProviderPermissionEnum::Access - ), - new NavSubItemData( - label: 'Microsoft Graph', - route: 'apps.microsoft-federation', - active_pattern: 'apps.microsoft-federation', - permission: ConnectionProviderPermissionEnum::Access - ) +// new NavSubItemData( +// label: 'Providers', +// route: 'apps.connectionProviders.index', +// active_pattern: 'apps.connectionProviders.*', +// permission: ConnectionProviderPermissionEnum::Access +// ), +// new NavSubItemData( +// label: 'Microsoft Graph', +// route: 'apps.microsoft-federation', +// active_pattern: 'apps.microsoft-federation', +// permission: ConnectionProviderPermissionEnum::Access +// ) ], DataCollection::class) ), diff --git a/resources/views/pages/apps/oidc/⚡create.blade.php b/resources/views/pages/apps/oidc/⚡create.blade.php index c25867e..e54c535 100644 --- a/resources/views/pages/apps/oidc/⚡create.blade.php +++ b/resources/views/pages/apps/oidc/⚡create.blade.php @@ -82,7 +82,7 @@ public function title(): string $title .= match ($this->applicationType) { ApplicationTypeEnum::Web => "Web Application ", ApplicationTypeEnum::SPA => "Single Page Application ", - ApplicationTypeEnum::Machine => "Microservice Application ", +// ApplicationTypeEnum::Machine => "Microservice Application ", }; $title .= "Integration"; return $title; @@ -198,9 +198,13 @@ class="btn-primary" + + + - diff --git a/resources/views/pages/apps/⚡index.blade.php b/resources/views/pages/apps/⚡index.blade.php index 2ac96eb..60fafad 100644 --- a/resources/views/pages/apps/⚡index.blade.php +++ b/resources/views/pages/apps/⚡index.blade.php @@ -42,7 +42,6 @@ private function initTableHeaders(): void { $this->headers = TableHeader::collect([ new TableHeader(key: 'name', label: 'Name',), - new TableHeader(key: 'provider', label: 'Provider'), new TableHeader(key: 'protocol', label: 'Protocol'), new TableHeader(key: 'status', label: 'Status'), ], DataCollection::class); diff --git a/resources/views/pages/dashboards/⚡user.blade.php b/resources/views/pages/dashboards/⚡user.blade.php index 9bf2694..2fcb5ef 100644 --- a/resources/views/pages/dashboards/⚡user.blade.php +++ b/resources/views/pages/dashboards/⚡user.blade.php @@ -242,6 +242,15 @@ class="btn-primary flex-1" @endif + + @if($service->protocol_name === 'oidc') + + @endif
diff --git a/tests/Feature/OidcAppCreationTest.php b/tests/Feature/OidcAppCreationTest.php new file mode 100644 index 0000000..9920576 --- /dev/null +++ b/tests/Feature/OidcAppCreationTest.php @@ -0,0 +1,56 @@ +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(); +}); + +it('successfully creates an OIDC connected app with access URL', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + Livewire::test('pages::apps.oidc.create', ['type' => 'web']) + ->set('form.name', 'My OIDC App') + ->set('form.accessUrl', 'https://oidc-app.example.com') + ->set('form.signInUris', ['https://oidc-app.example.com/callback']) + ->set('form.signOutUris', ['https://oidc-app.example.com/logout']) + ->call('save'); + + $this->assertDatabaseHas('connected_apps', [ + 'name' => 'My OIDC App', + 'connection_protocol_id' => $this->oidcProtocol->id, + ]); + + $app = ConnectedApp::query()->where('name', 'My OIDC App')->firstOrFail(); + expect($app->settings)->toBeArray() + ->and(data_get($app->settings, 'access_url'))->toBe('https://oidc-app.example.com'); +}); + +it('validates access URL format when creating an OIDC app', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + Livewire::test('pages::apps.oidc.create', ['type' => 'web']) + ->set('form.name', 'Invalid OIDC App') + ->set('form.accessUrl', 'not-a-valid-url') + ->call('save') + ->assertHasErrors(['form.accessUrl' => 'url']); +});