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:
parent
882538130b
commit
7f02209631
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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'),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@ -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 -->
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
56
tests/Feature/OidcAppCreationTest.php
Normal file
56
tests/Feature/OidcAppCreationTest.php
Normal 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']);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user