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.
This commit is contained in:
= 2026-06-22 05:54:36 +00:00
parent 882538130b
commit 7f02209631
15 changed files with 120 additions and 41 deletions

View File

@ -14,6 +14,7 @@ public function __construct(
public ApplicationTypeEnum $type, public ApplicationTypeEnum $type,
public string $name, public string $name,
public UserAccessTypeEnum $userAccessType, public UserAccessTypeEnum $userAccessType,
public string $accessUrl,
/** @var array<string> */ /** @var array<string> */
public array $signInUris = [], public array $signInUris = [],
/** @var array<string> */ /** @var array<string> */
@ -29,6 +30,7 @@ public static function fromArray(ApplicationTypeEnum $applicationType, array $va
type: $applicationType, type: $applicationType,
name: $value['name'], name: $value['name'],
userAccessType: UserAccessTypeEnum::from($value['userAccessOption']), userAccessType: UserAccessTypeEnum::from($value['userAccessOption']),
accessUrl: $value['accessUrl'],
signInUris: $value['signInUris'] ?? [], signInUris: $value['signInUris'] ?? [],
signOutUris: $value['signOutUris'] ?? [], signOutUris: $value['signOutUris'] ?? [],
logo: $value['logo'] ?? null, logo: $value['logo'] ?? null,

View File

@ -17,5 +17,6 @@ public function __construct(
public bool $is_warning, public bool $is_warning,
public ?string $protocol_name = null, public ?string $protocol_name = null,
public ?string $provider_slug = null, public ?string $provider_slug = null,
public ?string $accessUrl = null,
) {} ) {}
} }

View File

@ -10,7 +10,7 @@ enum ApplicationTypeEnum: string implements EnumMorphsToOptionsContract
{ {
case Web = 'web'; case Web = 'web';
case SPA = 'spa'; case SPA = 'spa';
case Machine = 'machine'; // case Machine = 'machine';
public static function asOptions(): array public static function asOptions(): array
{ {
@ -26,7 +26,7 @@ public function toLabel(): string
return match ($this) { return match ($this) {
self::Web => 'Web Applications', self::Web => 'Web Applications',
self::SPA => 'Single Page Applications', self::SPA => 'Single Page Applications',
self::Machine => 'Microservices', // self::Machine => 'Microservices',
}; };
} }
@ -35,7 +35,7 @@ public function toHint(): string
return match ($this) { return match ($this) {
self::Web => 'Server-side rendered web application, where authentication is handled by the server.', 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::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.',
}; };
} }
} }

View File

@ -12,7 +12,7 @@ enum ConnectionProtocolEnum: string implements EnumMorphsToOptionsContract
use EnumValuesAsArray; use EnumValuesAsArray;
case OIDC = 'oidc'; case OIDC = 'oidc';
case SAML = 'saml'; // case SAML = 'saml';
case URL = 'url'; case URL = 'url';
public static function asOptions(): array public static function asOptions(): array
@ -28,7 +28,7 @@ public function toLabel(): string
{ {
return match ($this) { return match ($this) {
self::OIDC => 'OIDC - OpenID Connect', self::OIDC => 'OIDC - OpenID Connect',
self::SAML => 'SAML 2.0', // self::SAML => 'SAML 2.0',
self::URL => 'Redirect URL', self::URL => 'Redirect URL',
}; };
} }
@ -37,8 +37,8 @@ public function toHint(): string
{ {
return match ($this) { 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::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::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::URL => 'Simple URL that will redirect the user to the authentication page. E.g. Keka, Outlook, etc.',
}; };
} }
} }

View File

@ -5,8 +5,7 @@
namespace App\Livewire\Forms; namespace App\Livewire\Forms;
use App\Data\ConnectedApp\ConnectedAppData; use App\Data\ConnectedApp\ConnectedAppData;
use App\Enums\ConnectionProtocolEnum; use App\Models\{ConnectionProtocol, ConnectionProvider};
use App\Models\ConnectionProtocol;
use App\Services\ConnectedAppService; use App\Services\ConnectedAppService;
use Livewire\Form; use Livewire\Form;
@ -56,18 +55,18 @@ public function rules(): array
$protocol = ConnectionProtocol::query()->find($this->protocolId); $protocol = ConnectionProtocol::query()->find($this->protocolId);
if ($protocol) { if ($protocol) {
$protocolName = mb_strtolower($protocol->name); $protocolName = mb_strtolower($protocol->name);
if (ConnectionProtocolEnum::SAML->value === $protocolName) { // if (ConnectionProtocolEnum::SAML->value === $protocolName) {
$rules['samlEntityId'] = 'required|string|min:3'; // $rules['samlEntityId'] = 'required|string|min:3';
$rules['samlAcsUrl'] = 'required|url'; // $rules['samlAcsUrl'] = 'required|url';
$rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified'; // $rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified';
$rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id'; // $rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id';
$rules['samlAttributes'] = 'array'; // $rules['samlAttributes'] = 'array';
$rules['samlAttributes.*.saml_name'] = 'required|string|min:1'; // $rules['samlAttributes.*.saml_name'] = 'required|string|min:1';
$rules['samlAttributes.*.user_field'] = 'required|string|in:email,name,immutable_id,id'; // $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) { if ($provider && 'keka-hr' === $provider->slug) {
$rules['kekaUrl'] = 'required|url'; $rules['kekaUrl'] = 'required|url';
} }

View File

@ -14,6 +14,9 @@ class StoreOIDCAppForm extends Form
#[Validate('required|string|max:255|min:3')] #[Validate('required|string|max:255|min:3')]
public string $name = ''; public string $name = '';
#[Validate('required|url|max:255')]
public string $accessUrl = '';
#[Validate('nullable|image|max:1024')] #[Validate('nullable|image|max:1024')]
public ?TemporaryUploadedFile $logo = null; public ?TemporaryUploadedFile $logo = null;
@ -46,6 +49,7 @@ class StoreOIDCAppForm extends Form
public function validationAttributes(): array public function validationAttributes(): array
{ {
return [ return [
'accessUrl' => 'Access URL',
'signInUris.*' => 'Sign-in URI', 'signInUris.*' => 'Sign-in URI',
'signOutUris.*' => 'Sign-out URI', 'signOutUris.*' => 'Sign-out URI',
'roleIds.*' => 'Role', 'roleIds.*' => 'Role',

View File

@ -9,7 +9,7 @@
class ClientCreationFactory class ClientCreationFactory
{ {
public function __construct( public function __construct(
private readonly ClientCredentialsClientFactory $clientCredentialsClient, // private readonly ClientCredentialsClientFactory $clientCredentialsClient,
private readonly AuthorizationCodeClientFactory $authorizationCodeClient, private readonly AuthorizationCodeClientFactory $authorizationCodeClient,
) {} ) {}
@ -17,7 +17,7 @@ public function for(ApplicationTypeEnum $applicationType): ClientCreationContrac
{ {
return match ($applicationType) { return match ($applicationType) {
ApplicationTypeEnum::Web, ApplicationTypeEnum::SPA => $this->authorizationCodeClient, ApplicationTypeEnum::Web, ApplicationTypeEnum::SPA => $this->authorizationCodeClient,
ApplicationTypeEnum::Machine => $this->clientCredentialsClient, // ApplicationTypeEnum::Machine => $this->clientCredentialsClient,
}; };
} }
} }

View File

@ -24,13 +24,13 @@ public function hasClientSecret(): bool
public function needSignInUris(): bool public function needSignInUris(): bool
{ {
return true;
return ApplicationTypeEnum::Machine !== $this->applicationType; // return ApplicationTypeEnum::Machine !== $this->applicationType;
} }
public function needSignOutUris(): bool public function needSignOutUris(): bool
{ {
return true;
return ApplicationTypeEnum::Machine !== $this->applicationType; // return ApplicationTypeEnum::Machine !== $this->applicationType;
} }
} }

View File

@ -41,6 +41,9 @@ public function createApp(StoreOIDCAppData $data): CreatedApplicationData
connectionProviderId: 0, connectionProviderId: 0,
slug: Str::slug($data->name), slug: Str::slug($data->name),
connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id, connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id,
settings: [
'access_url' => $data->accessUrl,
],
logo: $logo, logo: $logo,
) )
); );

View File

@ -136,6 +136,7 @@ private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, str
is_warning: $daysRemaining <= 3, is_warning: $daysRemaining <= 3,
protocol_name: $protocolName, protocol_name: $protocolName,
provider_slug: $providerSlug, provider_slug: $providerSlug,
accessUrl: data_get($app, 'settings.access_url'),
)); ));
} }
} }

View File

@ -8,7 +8,8 @@
RoleAndAppsPermissionEnum, RoleAndAppsPermissionEnum,
SecurityPermissionEnum, SecurityPermissionEnum,
UserPermissionEnum, UserPermissionEnum,
PermissionPermissionEnum}; PermissionPermissionEnum
};
use Livewire\Component; use Livewire\Component;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
@ -51,18 +52,18 @@ public function navItems(): DataCollection
active_pattern: 'apps.index', active_pattern: 'apps.index',
permission: AppPermissionEnum::Access permission: AppPermissionEnum::Access
), ),
new NavSubItemData( // new NavSubItemData(
label: 'Providers', // label: 'Providers',
route: 'apps.connectionProviders.index', // route: 'apps.connectionProviders.index',
active_pattern: 'apps.connectionProviders.*', // active_pattern: 'apps.connectionProviders.*',
permission: ConnectionProviderPermissionEnum::Access // permission: ConnectionProviderPermissionEnum::Access
), // ),
new NavSubItemData( // new NavSubItemData(
label: 'Microsoft Graph', // label: 'Microsoft Graph',
route: 'apps.microsoft-federation', // route: 'apps.microsoft-federation',
active_pattern: 'apps.microsoft-federation', // active_pattern: 'apps.microsoft-federation',
permission: ConnectionProviderPermissionEnum::Access // permission: ConnectionProviderPermissionEnum::Access
) // )
], DataCollection::class) ], DataCollection::class)
), ),

View File

@ -82,7 +82,7 @@ public function title(): string
$title .= match ($this->applicationType) { $title .= match ($this->applicationType) {
ApplicationTypeEnum::Web => "Web Application ", ApplicationTypeEnum::Web => "Web Application ",
ApplicationTypeEnum::SPA => "Single Page Application ", ApplicationTypeEnum::SPA => "Single Page Application ",
ApplicationTypeEnum::Machine => "Microservice Application ", // ApplicationTypeEnum::Machine => "Microservice Application ",
}; };
$title .= "Integration"; $title .= "Integration";
return $title; return $title;
@ -198,9 +198,13 @@ class="btn-primary"
<x-mary-input required label="App Name" wire:model="form.name" <x-mary-input required label="App Name" wire:model="form.name"
placeholder="Keka, MS 365 etc." placeholder="Keka, MS 365 etc."
hint="A basic name that matches the actual application"/> hint="A basic name that matches the actual application"/>
<x-mary-input required label="Access Url" wire:model="form.accessUrl"
placeholder="https://app.example.com"
hint="The URL by which user will access this application. We will show this url in user dashboard."/>
<x-mary-file wire:model="form.logo" label="Logo" hint="Optional" <x-mary-file wire:model="form.logo" label="Logo" hint="Optional"
accept="application/jpg, application/png, image/jpeg, image/png"/> accept="application/jpg, application/png, image/jpeg, image/png"/>
</div> </div>
</div> </div>
<!-- General settings end --> <!-- General settings end -->

View File

@ -42,7 +42,6 @@ private function initTableHeaders(): void
{ {
$this->headers = TableHeader::collect([ $this->headers = TableHeader::collect([
new TableHeader(key: 'name', label: 'Name',), new TableHeader(key: 'name', label: 'Name',),
new TableHeader(key: 'provider', label: 'Provider'),
new TableHeader(key: 'protocol', label: 'Protocol'), new TableHeader(key: 'protocol', label: 'Protocol'),
new TableHeader(key: 'status', label: 'Status'), new TableHeader(key: 'status', label: 'Status'),
], DataCollection::class); ], DataCollection::class);

View File

@ -242,6 +242,15 @@ class="btn-primary flex-1"
</div> </div>
</div> </div>
@endif @endif
@if($service->protocol_name === 'oidc')
<x-mary-button
external
:link="$service->accessUrl"
class="btn-primary w-full"
label="Open"
/>
@endif
</div> </div>
<div class="pt-4 border-t border-gray-200 flex items-center justify-between gap-4"> <div class="pt-4 border-t border-gray-200 flex items-center justify-between gap-4">

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionStatus, User};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->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']);
});