Feat: Add modular OIDC app creation with logo support

- Introduced new components and services for OIDC app creation, enabling dynamic handling of app types, URIs, and user roles.
- Added support for uploading and managing logos for connected apps.
- Implemented `ClientCreationFactory` and related factories for modular OIDC client generation.
- Updated UI to enhance app creation flow with post-creation credential display and improved validations.
- Refactored backend with `OidcService` for streamlined OIDC app creation and management.
This commit is contained in:
= 2026-06-18 13:35:23 +00:00
parent fb7b1e3721
commit 0b77f69818
15 changed files with 456 additions and 110 deletions

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Data\Application;
use Spatie\LaravelData\Data;
final class ClientCredentialsData extends Data
{
public function __construct(
public string $clientId,
public string $grantType,
public ?string $clientSecret = null,
public ?array $redirectUris = null,
public ?array $scopes = null,
) {}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Data\Application;
use App\Data\ConnectedApp\ConnectedAppData;
use Spatie\LaravelData\Data;
class CreatedApplicationData extends Data
{
public function __construct(
public ConnectedAppData $application,
public ClientCredentialsData $clientCredentials,
) {}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Data\Application;
use App\Enums\{ApplicationTypeEnum, UserAccessTypeEnum};
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Spatie\LaravelData\Data;
class StoreOIDCAppData extends Data
{
public function __construct(
public ApplicationTypeEnum $type,
public string $name,
public UserAccessTypeEnum $userAccessType,
/** @var array<string> */
public array $signInUris = [],
/** @var array<string> */
public array $signOutUris = [],
public ?TemporaryUploadedFile $logo = null,
/** @var array<int> */
public array $roleIds = [],
) {}
public static function fromArray(ApplicationTypeEnum $applicationType, array $value): StoreOIDCAppData
{
return new self(
type: $applicationType,
name: $value['name'],
userAccessType: UserAccessTypeEnum::from($value['userAccessOption']),
signInUris: $value['signInUris'] ?? [],
signOutUris: $value['signOutUris'] ?? [],
logo: $value['logo'] ?? null,
roleIds: $value['roleIds'] ?? [],
);
}
}

View File

@ -13,10 +13,11 @@ class ConnectAppRequest extends Data
{ {
public function __construct( public function __construct(
public string $name, public string $name,
public int $connectionProviderId,
public int $connectionProtocolId, public int $connectionProtocolId,
public ?int $connectionProviderId = null,
public ?string $slug = null, public ?string $slug = null,
public ?int $connectionStatusId = null, public ?int $connectionStatusId = null,
public ?array $settings = null, public ?array $settings = null,
public ?string $logo = null,
) {} ) {}
} }

View File

@ -20,5 +20,6 @@ public function __construct(
public int $statusId, public int $statusId,
public ?string $slug = null, public ?string $slug = null,
public ?array $settings = null, public ?array $settings = null,
public ?string $logo = null
) {} ) {}
} }

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
final readonly class ApplicationLogoUploader
{
public function store(?UploadedFile $logo): ?string
{
return $logo?->store('application-logos', 'public');
}
public function delete(?string $path): bool
{
if (null !== $path) {
return Storage::disk('public')->delete($path);
}
return false;
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC\Factory;
use App\Data\Application\{ClientCredentialsData, StoreOIDCAppData};
use App\Enums\ApplicationTypeEnum;
use Exception;
use Laravel\Passport\ClientRepository;
readonly class AuthorizationCodeClientFactory implements ClientCreationContract
{
public function __construct(
private ClientRepository $client,
) {}
/**
* @throws Exception
*/
public function create(
StoreOIDCAppData $data
): ClientCredentialsData {
$client = $this->client->createAuthorizationCodeGrantClient(
name: $data->name,
redirectUris: $data->signInUris,
confidential: ApplicationTypeEnum::Web === $data->type,
);
return new ClientCredentialsData(
clientId: $client->id,
grantType: 'authorization_code',
clientSecret: $client->plainSecret,
redirectUris: $client->getAttribute('redirect_uris'),
);
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC\Factory;
use App\Data\Application\{ClientCredentialsData, StoreOIDCAppData};
interface ClientCreationContract
{
public function create(StoreOIDCAppData $data): ClientCredentialsData;
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC\Factory;
use App\Enums\ApplicationTypeEnum;
class ClientCreationFactory
{
public function __construct(
private readonly ClientCredentialsClientFactory $clientCredentialsClient,
private readonly AuthorizationCodeClientFactory $authorizationCodeClient,
) {}
public function for(ApplicationTypeEnum $applicationType): ClientCreationContract
{
return match ($applicationType) {
ApplicationTypeEnum::Web, ApplicationTypeEnum::SPA => $this->authorizationCodeClient,
ApplicationTypeEnum::Machine => $this->clientCredentialsClient,
};
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC\Factory;
use App\Data\Application\{ClientCredentialsData, StoreOIDCAppData};
use Laravel\Passport\ClientRepository;
class ClientCredentialsClientFactory implements ClientCreationContract
{
public function __construct(
private readonly ClientRepository $client,
) {}
public function create(StoreOIDCAppData $data): ClientCredentialsData
{
$client = $this->client->createClientCredentialsGrantClient(
name: $data->name,
);
return new ClientCredentialsData(
clientId: $client->id,
grantType: 'client_credentials',
clientSecret: $client->plainSecret,
redirectUris: null,
);
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC;
use App\Data\Application\{CreatedApplicationData, StoreOIDCAppData};
use App\Data\ConnectedApp\ConnectAppRequest;
use App\Enums\{ConnectionProtocolEnum, ConnectionStatusEnum};
use App\Models\{ConnectionProtocol, ConnectionStatus};
use App\Services\{ApplicationLogoUploader, ConnectedAppService};
use App\Services\Applications\OIDC\Factory\ClientCreationFactory;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
readonly class OidcService
{
public function __construct(
private ClientCreationFactory $clientCreationFactory,
private ApplicationLogoUploader $logoUploader,
private ConnectedAppService $connectedAppService,
) {}
/**
* @throws Throwable
*/
public function createApp(StoreOIDCAppData $data): CreatedApplicationData
{
try {
DB::beginTransaction();
$logo = $this->logoUploader->store($data->logo);
$appData = $this->connectedAppService->create(
new ConnectAppRequest(
name: $data->name,
connectionProtocolId: ConnectionProtocol::query()->where('name', ConnectionProtocolEnum::OIDC->value)->first()->id,
connectionProviderId: 0,
slug: Str::slug($data->name),
connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id,
logo: $logo,
)
);
$clientData = $this->clientCreationFactory->for($data->type)->create($data);
DB::commit();
// remove sensitive data
$appData->settings = null;
return new CreatedApplicationData(
application: $appData,
clientCredentials: $clientData,
);
} catch (Throwable $e) {
DB::rollBack();
throw $e;
}
}
}

View File

@ -26,15 +26,17 @@ public function __construct(
* *
* @throws Throwable when an error occurs during the creation. * @throws Throwable when an error occurs during the creation.
*/ */
public function create(ConnectAppRequest $data): void public function create(ConnectAppRequest $data): ConnectedAppData
{ {
DB::transaction( return DB::transaction(
function () use ($data): void { function () use ($data): ConnectedAppData {
$connectedApp = ConnectedApp::query()->create( $connectedApp = ConnectedApp::query()->create(
$data->toArray() $data->toArray()
); );
$this->permissionService->add(ConnectedAppData::from($connectedApp), ConnectedAppPermissonEnum::Use); $this->permissionService->add(ConnectedAppData::from($connectedApp), ConnectedAppPermissonEnum::Use);
return ConnectedAppData::from($connectedApp);
} }
); );
} }
@ -95,7 +97,7 @@ public function getConnectedApp(int $id): ConnectedAppData
if ($id <= 0) { if ($id <= 0) {
throw new InvalidArgumentException('Invalid id'); throw new InvalidArgumentException('Invalid id');
} }
$data = ConnectedApp::with('provider:id', 'protocol:id', 'status:id') $data = ConnectedApp::with(['provider:id', 'protocol:id', 'status:id'])
->findOrFail($id); ->findOrFail($id);
return ConnectedAppData::from($data); return ConnectedAppData::from($data);

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
public function up(): void
{
Schema::table('connected_apps', function (Blueprint $table): void {
$table->string('logo')->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('connected_apps', function (Blueprint $table): void {
$table->dropColumn('logo');
});
}
};

View File

@ -10,7 +10,7 @@
<div> <div>
<x-shared.card title="Connected Apps" icon="lucide-grid-2x2-plus" class="p-0"> <x-shared.card title="Connected Apps" icon="lucide-grid-2x2-plus" class="p-0">
<x-slot:actions> <x-slot:actions>
<x-shared.button :link="route('apps.create')"> <x-shared.button>
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
<x-lucide-plus class="w-5 h-5"/> <x-lucide-plus class="w-5 h-5"/>
{{ __('Connect App') }} {{ __('Connect App') }}

View File

@ -1,26 +1,19 @@
<?php <?php
use App\Concerns\HandlesOperations; use App\Concerns\HandlesOperations;
use App\Data\ConnectedApp\{ConnectAppRequest, ConnectionStatusData}; use App\Data\Application\StoreOIDCAppData;
use App\Enums\{ApplicationTypeEnum, ConnectionProtocolEnum, ConnectionStatusEnum, UserAccessTypeEnum}; use App\Data\ConnectedApp\ConnectedAppData;
use App\Livewire\Forms\StoreOIDCAppForm; use App\Enums\{ApplicationTypeEnum, ConnectionProtocolEnum, UserAccessTypeEnum};
use App\Livewire\Concerns\Choices\{ChoiceField, WithSearchableChoices}; use App\Livewire\Concerns\Choices\{ChoiceField, WithSearchableChoices};
use App\Livewire\Concerns\WithRepeaterFields; use App\Livewire\Concerns\WithRepeaterFields;
use App\Models\{ConnectionProtocol, ConnectionProvider, ConnectionStatus}; use App\Livewire\Forms\StoreOIDCAppForm;
use App\Services\{ConnectedAppService, use App\Services\{Applications\OIDC\OidcService, RoleService};
ConnectionProtocolService, use Livewire\Attributes\{Url};
ConnectionProviderService,
ConnectionStatusService,
RoleService
};
use Illuminate\Support\Collection;
use Livewire\Attributes\{Computed, Title, Url};
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use Mary\Traits\Toast;
use Spatie\LaravelData\DataCollection;
new #[Title("Create a service")]
new
class extends Component { class extends Component {
use HandlesOperations, WithRepeaterFields, WithSearchableChoices, WithFileUploads; use HandlesOperations, WithRepeaterFields, WithSearchableChoices, WithFileUploads;
@ -30,25 +23,49 @@ class extends Component {
#[Url] #[Url]
public ?string $type = null; public ?string $type = null;
// For creation states
public bool $created = false;
public ?string $clientId = null;
public ?string $clientSecret = null;
public ?ConnectedAppData $app = null;
public StoreOIDCAppForm $form; public StoreOIDCAppForm $form;
public ConnectionProtocolEnum $connectionProtocol = ConnectionProtocolEnum::OIDC; public ConnectionProtocolEnum $connectionProtocol = ConnectionProtocolEnum::OIDC;
public ?ApplicationTypeEnum $applicationType = null; public ApplicationTypeEnum $applicationType;
private RoleService $roleService; private RoleService $roleService;
private OidcService $oidcService;
public function boot(RoleService $roleService): void
public function boot(RoleService $roleService, OidcService $oidcService): void
{ {
$this->roleService = $roleService; $this->roleService = $roleService;
$this->oidcService = $oidcService;
} }
public function mount(): void public function mount(): void
{ {
$this->applicationType = ApplicationTypeEnum::tryFrom($this->type); $this->applicationType = ApplicationTypeEnum::from($this->type);
}
public function render()
{
/** @phpstan-ignore-next-line */
return $this->view()->title($this->title());
} }
public function save(bool $goBack = true): void public function save(bool $goBack = true): void
{ {
$this->form->validate(); $this->form->validate();
ds($this->form->pull()); $this->attempt(
function () {
$data = StoreOIDCAppData::fromArray($this->applicationType, $this->form->pull());
$appdata = $this->oidcService->createApp($data);
$this->created = true;
$this->clientId = $appdata->clientCredentials->clientId;
$this->clientSecret = $appdata->clientCredentials->clientSecret;
$this->app = $appdata->application;
}
);
} }
@ -64,7 +81,6 @@ public function title(): string
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 ",
null => ""
}; };
$title .= "Integration"; $title .= "Integration";
return $title; return $title;
@ -99,98 +115,143 @@ protected function choiceFields(): array
?> ?>
<div> <div>
<x-shared.card :title="$this->title()" icon="lucide-package-plus"> @if($created && $clientId && $clientSecret)
<x-slot:actions> <x-shared.card
<x-mary-button wire:click="goBack" icon="lucide.corner-up-left" class="btn-ghost">Go Back title="Application Created"
</x-mary-button> icon="lucide-check-circle"
</x-slot:actions> >
<x-mary-form wire:submit="save" class="p-4 gap-y-8"> <div class="flex w-full justify-center items-center pt-6">
<!-- General settings start --> <article class="mx-auto flex items-center gap-4">
<div class="flex gap-4 flex-col w-full md:flex-row"> <x-mary-avatar :image="$app->logo" alt="App logo" class="w-14! rounded-lg! p-2"/>
<article class="w-full md:w-2/5"> <h1 class="text-2xl font-medium">{{$app->name}}</h1>
<h2 class="font-bold">General Settings</h2>
</article> </article>
<div class="w-full md:w-3/5">
<x-mary-input required label="App Name" wire:model="form.name" placeholder="Keka, MS 365 etc."
hint="A basic name that matches the actual application"/>
<x-mary-file wire:model="form.logo" label="Logo" hint="Optional"
accept="application/jpg, application/png, image/jpeg, image/png"/>
</div>
</div> </div>
<!-- General settings end --> <div class="space-y-6 p-6">
<!-- Sign-in uris start --> <x-mary-alert
<hr> title="Save these credentials"
<div class="flex gap-4 flex-col w-full md:flex-row"> description="The client secret will only be displayed once."
<article class="w-full md:w-2/5"> class="alert-warning alert-soft text-yellow-700"
<h2 class="font-bold">Sign In Redirect URIs</h2> />
<p class="text-sm text-gray-500 max-w-70 mt-4">
{{config('app.name')}} sends response and ID Token back to these URIs.
</p>
</article>
<div class="w-full md:w-3/5">
<x-shared.repeater-input model="form.signInUris" :items="$form->signInUris"/>
</div>
</div>
<!-- Sign-in uris end -->
<!-- Sign-out uris start --> <x-mary-input
<hr> label="Client ID"
<div class="flex gap-4 flex-col w-full md:flex-row"> :value="$clientId"
<article class="w-full md:w-2/5"> readonly
<h2 class="font-bold">Sign out Redirect URIs</h2> />
<p class="text-sm text-gray-500 max-w-70 mt-4">
{{config('app.name')}} sends back the user to these URIs after logout.
</p>
</article>
<div class="w-full md:w-3/5">
<x-shared.repeater-input model="form.signOutUris" :items="$form->signOutUris"/>
</div>
</div>
<!-- Sign-out uris end -->
<!-- Access start --> <x-mary-input
<hr> label="Client Secret"
<div class="flex gap-4 flex-col w-full md:flex-row"> :value="$clientSecret"
<article class="w-full md:w-2/5"> readonly
<h2 class="font-bold">User Access</h2> />
<p class="text-sm text-gray-500 max-w-70 mt-4">
Choose whether to allow all users be able to connect this app or selected ones. <div class="flex justify-end gap-4">
</p> <x-mary-button
</article> wire:click="goBack"
<div class="w-full md:w-3/5"> class="btn-primary"
<x-mary-radio
:options="UserAccessTypeEnum::asOptions()"
option-value="value"
wire:model.change.live="form.userAccessOption"
/>
<div class=""
x-show="$wire.form.userAccessOption === '{{UserAccessTypeEnum::Role->value}}'"
x-cloak
> >
<x-shared.choices Done
wire:model.live="form.roleIds" </x-mary-button>
:options="$choicesSearchable['roles'] ?? []" </div>
search-function="searchRoles"
select-all-action="selectAllRoles" </div>
clear-action="clearRoles" </x-shared.card>
label="Roles" @else
/> <x-shared.card :title="$this->title()" icon="lucide-package-plus">
<x-slot:actions>
<x-mary-button wire:click="goBack" icon="lucide.corner-up-left" class="btn-ghost">Go Back
</x-mary-button>
</x-slot:actions>
<x-mary-form wire:submit="save" class="p-4 gap-y-8">
<!-- General settings start -->
<div class="flex gap-4 flex-col w-full md:flex-row">
<article class="w-full md:w-2/5">
<h2 class="font-bold">General Settings</h2>
</article>
<div class="w-full md:w-3/5">
<x-mary-input required label="App Name" wire:model="form.name"
placeholder="Keka, MS 365 etc."
hint="A basic name that matches the actual application"/>
<x-mary-file wire:model="form.logo" label="Logo" hint="Optional"
accept="application/jpg, application/png, image/jpeg, image/png"/>
</div> </div>
</div> </div>
<!-- General settings end -->
</div> <!-- Sign-in uris start -->
<!-- Access end --> <hr>
<div class="flex gap-4 flex-col w-full md:flex-row">
<article class="w-full md:w-2/5">
<h2 class="font-bold">Sign In Redirect URIs</h2>
<p class="text-sm text-gray-500 max-w-70 mt-4">
{{config('app.name')}} sends response and ID Token back to these URIs.
</p>
</article>
<div class="w-full md:w-3/5">
<x-shared.repeater-input model="form.signInUris" :items="$form->signInUris"/>
</div>
</div>
<!-- Sign-in uris end -->
<div class="flex gap-x-4 font-medium justify-end"> <!-- Sign-out uris start -->
<x-mary-button wire:click.prevent="goBack" spinner="goBack">Cancel</x-mary-button> <hr>
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button> <div class="flex gap-4 flex-col w-full md:flex-row">
<x-mary-button type="submit" <article class="w-full md:w-2/5">
class="btn-primary"> <h2 class="font-bold">Sign out Redirect URIs</h2>
Save <p class="text-sm text-gray-500 max-w-70 mt-4">
</x-mary-button> {{config('app.name')}} sends back the user to these URIs after logout.
</div> </p>
</x-mary-form> </article>
</x-shared.card> <div class="w-full md:w-3/5">
<x-shared.repeater-input model="form.signOutUris" :items="$form->signOutUris"/>
</div>
</div>
<!-- Sign-out uris end -->
<!-- Access start -->
<hr>
<div class="flex gap-4 flex-col w-full md:flex-row">
<article class="w-full md:w-2/5">
<h2 class="font-bold">User Access</h2>
<p class="text-sm text-gray-500 max-w-70 mt-4">
Choose whether to allow all users be able to connect this app or selected ones.
</p>
</article>
<div class="w-full md:w-3/5">
<x-mary-radio
:options="UserAccessTypeEnum::asOptions()"
option-value="value"
wire:model.change.live="form.userAccessOption"
/>
<div class=""
x-show="$wire.form.userAccessOption === '{{UserAccessTypeEnum::Role->value}}'"
x-cloak
>
<x-shared.choices
wire:model.live="form.roleIds"
:options="$choicesSearchable['roles'] ?? []"
search-function="searchRoles"
select-all-action="selectAllRoles"
clear-action="clearRoles"
label="Roles"
/>
</div>
</div>
</div>
<!-- Access end -->
<div class="flex gap-x-4 font-medium justify-end">
<x-mary-button wire:click.prevent="goBack" spinner="goBack">Cancel</x-mary-button>
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>
<x-mary-button type="submit"
class="btn-primary">
Save
</x-mary-button>
</div>
</x-mary-form>
</x-shared.card>
@endif
</div> </div>