mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-11 05:59:15 +00:00
feat: JWT bearer assertions for client authentication (#566)
Co-authored-by: Kyle Mendell <ksm@ofkm.us> Co-authored-by: Kyle Mendell <kmendell@ofkm.us> Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
committed by
GitHub
parent
035b2c022b
commit
05bfe00924
@@ -6,11 +6,23 @@ export type OidcClientMetaData = {
|
||||
hasLogo: boolean;
|
||||
};
|
||||
|
||||
export type OidcClientFederatedIdentity = {
|
||||
issuer: string;
|
||||
subject?: string;
|
||||
audience?: string;
|
||||
jwks?: string;
|
||||
};
|
||||
|
||||
export type OidcClientCredentials = {
|
||||
federatedIdentities: OidcClientFederatedIdentity[];
|
||||
};
|
||||
|
||||
export type OidcClient = OidcClientMetaData & {
|
||||
callbackURLs: string[]; // No longer requires at least one URL
|
||||
logoutCallbackURLs: string[];
|
||||
isPublic: boolean;
|
||||
pkceEnabled: boolean;
|
||||
credentials?: OidcClientCredentials;
|
||||
};
|
||||
|
||||
export type OidcClientWithAllowedUserGroups = OidcClient & {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import OidcService from '$lib/services/oidc-service';
|
||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||
@@ -16,7 +17,6 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { slide } from 'svelte/transition';
|
||||
import OidcForm from '../oidc-client-form.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
let { data } = $props();
|
||||
let client = $state({
|
||||
@@ -166,7 +166,7 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Content class="p-5">
|
||||
<Card.Content>
|
||||
<OidcForm existingClient={client} callback={updateClient} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { OidcClient, OidcClientFederatedIdentity } from '$lib/types/oidc.type';
|
||||
import { LucideMinus, LucidePlus } from '@lucide/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
client,
|
||||
federatedIdentities = $bindable([]),
|
||||
error = $bindable(null),
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
client?: OidcClient;
|
||||
federatedIdentities: OidcClientFederatedIdentity[];
|
||||
error?: string | null;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
function addFederatedIdentity() {
|
||||
federatedIdentities = [
|
||||
...federatedIdentities,
|
||||
{
|
||||
issuer: '',
|
||||
subject: '',
|
||||
audience: '',
|
||||
jwks: ''
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function removeFederatedIdentity(index: number) {
|
||||
federatedIdentities = federatedIdentities.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function updateFederatedIdentity(
|
||||
index: number,
|
||||
field: keyof OidcClientFederatedIdentity,
|
||||
value: string
|
||||
) {
|
||||
federatedIdentities[index] = {
|
||||
...federatedIdentities[index],
|
||||
[field]: value
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div {...restProps}>
|
||||
<FormInput label={m.federated_identities()} description={m.federated_identities_description()}>
|
||||
<div class="space-y-4">
|
||||
{#each federatedIdentities as identity, i}
|
||||
<div class="space-y-3 rounded-lg border p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-sm font-medium">Identity {i + 1}</Label>
|
||||
{#if federatedIdentities.length > 0}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => removeFederatedIdentity(i)}
|
||||
aria-label="Remove federated identity"
|
||||
>
|
||||
<LucideMinus class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<Label for="issuer-{i}" class="text-xs">Issuer (Required)</Label>
|
||||
<Input
|
||||
id="issuer-{i}"
|
||||
placeholder="https://example.com/"
|
||||
value={identity.issuer}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'issuer', e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="subject-{i}" class="text-xs">Subject (Optional)</Label>
|
||||
<Input
|
||||
id="subject-{i}"
|
||||
placeholder="Defaults to the client ID: {client?.id}"
|
||||
value={identity.subject || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'subject', e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="audience-{i}" class="text-xs">Audience (Optional)</Label>
|
||||
<Input
|
||||
id="audience-{i}"
|
||||
placeholder="Defaults to the Pocket ID URL"
|
||||
value={identity.audience || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'audience', e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="jwks-{i}" class="text-xs">JWKS URL (Optional)</Label>
|
||||
<Input
|
||||
id="jwks-{i}"
|
||||
placeholder="Defaults to {identity.issuer || '<issuer>'}/.well-known/jwks.json"
|
||||
value={identity.jwks || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'jwks', e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</FormInput>
|
||||
|
||||
{#if error}
|
||||
<p class="text-destructive mt-1 text-xs">{error}</p>
|
||||
{/if}
|
||||
|
||||
<Button class="mt-3" variant="secondary" size="sm" onclick={addFederatedIdentity} type="button">
|
||||
<LucidePlus class="mr-1 size-4" />
|
||||
{federatedIdentities.length === 0
|
||||
? m.add_federated_identity()
|
||||
: m.add_another_federated_identity()}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -5,14 +5,14 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type {
|
||||
OidcClient,
|
||||
OidcClientCreate,
|
||||
OidcClientCreateWithLogo
|
||||
} from '$lib/types/oidc.type';
|
||||
import type { OidcClient, OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { LucideChevronDown } from '@lucide/svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { z } from 'zod';
|
||||
import FederatedIdentitiesInput from './federated-identities-input.svelte';
|
||||
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
||||
|
||||
let {
|
||||
@@ -24,17 +24,21 @@
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let showAdvancedOptions = $state(false);
|
||||
let logo = $state<File | null | undefined>();
|
||||
let logoDataURL: string | null = $state(
|
||||
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
|
||||
);
|
||||
|
||||
const client: OidcClientCreate = {
|
||||
const client = {
|
||||
name: existingClient?.name || '',
|
||||
callbackURLs: existingClient?.callbackURLs || [],
|
||||
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
|
||||
isPublic: existingClient?.isPublic || false,
|
||||
pkceEnabled: existingClient?.pkceEnabled || false
|
||||
pkceEnabled: existingClient?.pkceEnabled || false,
|
||||
credentials: {
|
||||
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
||||
}
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -42,7 +46,17 @@
|
||||
callbackURLs: z.array(z.string().nonempty()).default([]),
|
||||
logoutCallbackURLs: z.array(z.string().nonempty()),
|
||||
isPublic: z.boolean(),
|
||||
pkceEnabled: z.boolean()
|
||||
pkceEnabled: z.boolean(),
|
||||
credentials: z.object({
|
||||
federatedIdentities: z.array(
|
||||
z.object({
|
||||
issuer: z.string().url(),
|
||||
subject: z.string().optional(),
|
||||
audience: z.string().optional(),
|
||||
jwks: z.string().url().optional().or(z.literal(''))
|
||||
})
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
type FormSchema = typeof formSchema;
|
||||
@@ -139,8 +153,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full"></div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button {isLoading} type="submit">{m.save()}</Button>
|
||||
|
||||
{#if showAdvancedOptions}
|
||||
<div class="mt-5 md:col-span-2" transition:slide={{ duration: 200 }}>
|
||||
<FederatedIdentitiesInput
|
||||
client={existingClient}
|
||||
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
|
||||
bind:error={$inputs.credentials.error}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="relative mt-5 flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-muted-foregroun"
|
||||
onclick={() => (showAdvancedOptions = !showAdvancedOptions)}
|
||||
>
|
||||
{showAdvancedOptions ? m.hide_advanced_options() : m.show_advanced_options()}
|
||||
<LucideChevronDown
|
||||
class={cn(
|
||||
'size-5 transition-transform duration-200',
|
||||
showAdvancedOptions && 'rotate-180 transform'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button {isLoading} type="submit" class="absolute right-0">{m.save()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user