feat: add PKCE support

This commit is contained in:
Elias Schneider
2024-11-15 15:00:25 +01:00
parent 760c8e83bb
commit 3613ac261c
15 changed files with 188 additions and 86 deletions

View File

@@ -3,23 +3,27 @@ import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
class OidcService extends APIService {
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string) {
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
const res = await this.api.post('/oidc/authorize', {
scope,
nonce,
callbackURL,
clientId
clientId,
codeChallenge,
codeChallengeMethod
});
return res.data as AuthorizeResponse;
}
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string) {
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
const res = await this.api.post('/oidc/authorize/new-client', {
scope,
nonce,
callbackURL,
clientId
clientId,
codeChallenge,
codeChallengeMethod
});
return res.data as AuthorizeResponse;

View File

@@ -4,6 +4,7 @@ export type OidcClient = {
logoURL: string;
callbackURLs: [string, ...string[]];
hasLogo: boolean;
isPublic: boolean;
};
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;

View File

@@ -12,6 +12,8 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
nonce: url.searchParams.get('nonce') || undefined,
state: url.searchParams.get('state')!,
callbackURL: url.searchParams.get('redirect_uri')!,
client
client,
codeChallenge: url.searchParams.get('code_challenge')!,
codeChallengeMethod: url.searchParams.get('code_challenge_method')!
};
};

View File

@@ -24,7 +24,7 @@
let authorizationRequired = false;
export let data: PageData;
let { scope, nonce, client, state, callbackURL } = data;
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
async function authorize() {
isLoading = true;
@@ -37,7 +37,7 @@
}
await oidService
.authorize(client!.id, scope, callbackURL, nonce)
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL);
});
@@ -55,7 +55,7 @@
isLoading = true;
try {
await oidService
.authorizeNewClient(client!.id, scope, callbackURL, nonce)
.authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL);
});

View File

@@ -26,7 +26,8 @@
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: client.isPublic ? 'Enabled' : 'Disabled'
};
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -34,6 +35,8 @@
const dataPromise = oidcService.updateClient(client.id, updatedClient);
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
client.isPublic = updatedClient.isPublic;
await Promise.all([dataPromise, imagePromise])
.then(() => {
toast.success('OIDC client updated successfully');
@@ -93,27 +96,29 @@
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard>
</div>
<div class="mb-2 mt-1 flex items-center">
<Label class="w-44">Client secret</Label>
{#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret">
{$clientSecretStore}
</span>
</CopyToClipboard>
{:else}
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>••••••••••••••••••••••••••••••••</span
>
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
>
{/if}
</div>
{#if !client.isPublic}
<div class="mb-2 mt-1 flex items-center">
<Label class="w-44">Client secret</Label>
{#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret">
{$clientSecretStore}
</span>
</CopyToClipboard>
{:else}
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>••••••••••••••••••••••••••••••••</span
>
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
>
{/if}
</div>
{/if}
{#if showAllDetails}
<div transition:slide>
{#each Object.entries(setupDetails) as [key, value]}

View File

@@ -2,6 +2,7 @@
import FileInput from '$lib/components/file-input.svelte';
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import Label from '$lib/components/ui/label/label.svelte';
import type {
OidcClient,
@@ -28,12 +29,14 @@
const client: OidcClientCreate = {
name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [""]
callbackURLs: existingClient?.callbackURLs || [''],
isPublic: existingClient?.isPublic || false
};
const formSchema = z.object({
name: z.string().min(2).max(50),
callbackURLs: z.array(z.string().url()).nonempty()
callbackURLs: z.array(z.string().url()).nonempty(),
isPublic: z.boolean()
});
type FormSchema = typeof formSchema;
@@ -71,15 +74,27 @@
</script>
<form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="grid grid-cols-2 gap-3 sm:flex-row">
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
<OidcCallbackUrlInput
class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error}
/>
<div class="items-top flex space-x-2">
<Checkbox id="admin-privileges" bind:checked={$inputs.isPublic.value} />
<div class="grid gap-1.5 leading-none">
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
Public Client
</Label>
<p class="text-muted-foreground text-[0.8rem]">
Public clients do not have a client secret and use PKCE instead. Enable this if your
client is a SPA or mobile app.
</p>
</div>
</div>
</div>
<div class="mt-3">
<div class="mt-8">
<Label for="logo">Logo</Label>
<div class="mt-2 flex items-end gap-3">
{#if logoDataURL}