feat: add explicit public keys for federated client credentials (#1702)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Alessandro (Ale) Segala
2026-08-28 21:12:15 +00:00
committed by GitHub
co-authored by copilot-swe-agent[bot]
parent 6ff9a5ba7e
commit 7c79a9e14b
14 changed files with 846 additions and 44 deletions
+2
View File
@@ -1,3 +1,4 @@
import type { Jwk } from '$lib/utils/jwk-util';
import type { UserGroup, UserGroupMinimal } from './user-group.type';
export type OidcClientType = 'standard' | 'cimd';
@@ -18,6 +19,7 @@ export type OidcClientFederatedIdentity = {
subject?: string;
audience?: string;
jwks?: string | undefined;
publicKeys?: Jwk[];
replayProtection: boolean;
};
+72
View File
@@ -0,0 +1,72 @@
import { m } from '$lib/paraglide/messages';
export type Jwk = {
kty?: unknown;
kid?: unknown;
alg?: unknown;
use?: unknown;
crv?: unknown;
[key: string]: unknown;
};
export type ParseJwkResult = { ok: true; keys: Jwk[] } | { ok: false; error: string };
/**
* Parses pasted JWK input into a list of keys.
* The input can either be a single JWK or a JWKS, in which case each key it contains is imported separately.
* Full validation is performed by the backend.
*/
export function parseJwkInput(input: string): ParseJwkResult {
const trimmed = input.trim();
if (!trimmed) {
return { ok: false, error: m.paste_a_public_key_or_a_jwks() };
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return { ok: false, error: m.the_value_is_not_valid_json() };
}
if (!isJsonObject(parsed)) {
return { ok: false, error: m.the_value_is_not_a_jwk_or_a_jwks() };
}
// A JWKS holds its keys in a "keys" array, everything else is treated as a single key
if (!('keys' in parsed)) {
return { ok: true, keys: [parsed] };
}
if (!Array.isArray(parsed.keys) || parsed.keys.length === 0) {
return { ok: false, error: m.the_jwks_does_not_contain_any_key() };
}
const keys: Jwk[] = [];
for (const [index, key] of parsed.keys.entries()) {
const number = index + 1;
if (!isJsonObject(key)) {
return { ok: false, error: m.jwks_key_is_not_a_jwk({ number }) };
}
keys.push(key);
}
return { ok: true, keys };
}
/**
* Returns a short description of a key, such as "RSA · RS256", to show next to its key ID.
*/
export function describeJwk(key: Jwk): string {
return [key.kty, key.crv, key.alg]
.filter((part): part is string => typeof part === 'string' && !!part)
.join(' · ');
}
export function getJwkKeyId(key: Jwk): string {
return typeof key.kid === 'string' ? key.kid : '';
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -28,6 +28,7 @@
subject: z.string().optional(),
audience: z.string().optional(),
jwks: z.url().optional().or(z.literal('')),
publicKeys: z.array(z.record(z.string(), z.unknown())).optional(),
replayProtection: z.boolean().default(true)
})
)
@@ -60,6 +61,7 @@
subject: '',
audience: '',
jwks: '',
publicKeys: [],
replayProtection: true
}
];
@@ -4,12 +4,19 @@
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { OidcClientFederatedIdentity } from '$lib/types/oidc.type';
import { LucideMinus, LucidePlus } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { z } from 'zod/v4';
import FederatedIdentityKeysInput from './federated-identity-keys-input.svelte';
// An identity is verified either against the keys of a JWKS endpoint, or against the public keys configured here
type KeySource = 'jwks' | 'publicKeys';
let {
federatedIdentities = $bindable([]),
@@ -23,6 +30,13 @@
children?: Snippet;
} = $props();
// The source can't be derived from the identity alone: it stays on "Public keys" while no key has been added yet
let keySources = $state<KeySource[]>([]);
function keySourceFor(index: number, identity: OidcClientFederatedIdentity): KeySource {
return keySources[index] ?? (identity.publicKeys?.length ? 'publicKeys' : 'jwks');
}
function addFederatedIdentity() {
federatedIdentities = [
...federatedIdentities,
@@ -31,6 +45,7 @@
subject: '',
audience: '',
jwks: '',
publicKeys: [],
replayProtection: true
}
];
@@ -38,6 +53,7 @@
function removeFederatedIdentity(index: number) {
federatedIdentities = federatedIdentities.filter((_, i) => i !== index);
keySources = keySources.filter((_, i) => i !== index);
}
function updateFederatedIdentity<K extends keyof OidcClientFederatedIdentity>(
@@ -51,6 +67,19 @@
};
}
// Only one of the two sources is ever submitted, so the one that is not selected is cleared
function updateKeySource(index: number, source: KeySource) {
// The list is rebuilt in full so it stays aligned with the identities when one of them is removed
keySources = federatedIdentities.map((identity, i) =>
i === index ? source : keySourceFor(i, identity)
);
if (source === 'jwks') {
updateFederatedIdentity(index, 'publicKeys', []);
} else {
updateFederatedIdentity(index, 'jwks', '');
}
}
function getFieldError(index: number, field: keyof OidcClientFederatedIdentity): string | null {
if (!errors) return null;
const path = [index, field];
@@ -61,16 +90,16 @@
<div {...restProps}>
<FormInput {disabled}>
<div class="flex flex-col gap-4">
{#each federatedIdentities as identity, i (identity)}
{#each federatedIdentities as identity, i (i)}
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<Field.Label>Identity {i + 1}</Field.Label>
<Field.Label>{m.federated_identity_number({ number: i + 1 })}</Field.Label>
{#if federatedIdentities.length > 0}
<Button
variant="outline"
size="sm"
onclick={() => removeFederatedIdentity(i)}
aria-label="Remove federated identity"
aria-label={m.remove_federated_identity()}
{disabled}
>
<LucideMinus data-icon="inline-start" />
@@ -80,7 +109,7 @@
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<Field.Field>
<Field.Label required for="issuer-{i}">Issuer</Field.Label>
<Field.Label required for="issuer-{i}">{m.issuer()}</Field.Label>
<Input
id="issuer-{i}"
placeholder="https://example.com/"
@@ -95,10 +124,10 @@
</Field.Field>
<Field.Field>
<Field.Label for="subject-{i}">Subject</Field.Label>
<Field.Label for="subject-{i}">{m.subject()}</Field.Label>
<Input
id="subject-{i}"
placeholder="Defaults to the client ID"
placeholder={m.defaults_to_the_client_id()}
value={identity.subject || ''}
oninput={(e) => updateFederatedIdentity(i, 'subject', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'subject')}
@@ -110,10 +139,10 @@
</Field.Field>
<Field.Field>
<Field.Label for="audience-{i}">Audience</Field.Label>
<Field.Label for="audience-{i}">{m.audience()}</Field.Label>
<Input
id="audience-{i}"
placeholder="Defaults to the Pocket ID URL"
placeholder={m.defaults_to_the_appname_url({ appName: $appConfigStore.appName })}
value={identity.audience || ''}
oninput={(e) => updateFederatedIdentity(i, 'audience', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'audience')}
@@ -124,20 +153,54 @@
{/if}
</Field.Field>
<Field.Field>
<Field.Label for="jwks-{i}">JWKS URL</Field.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)}
aria-invalid={!!getFieldError(i, 'jwks')}
<Field.Field class="md:col-span-2">
<Field.Label>{m.signing_keys()}</Field.Label>
<RadioGroup.Root
class="flex flex-wrap gap-x-6 gap-y-3"
value={keySourceFor(i, identity)}
onValueChange={(value) => updateKeySource(i, value as KeySource)}
{disabled}
/>
{#if getFieldError(i, 'jwks')}
<Field.Error>{getFieldError(i, 'jwks')}</Field.Error>
>
<div class="flex items-center gap-2">
<RadioGroup.Item value="jwks" id="key-source-jwks-{i}" />
<Label for="key-source-jwks-{i}" class="mb-0 font-normal">{m.jwks_url()}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="publicKeys" id="key-source-public-keys-{i}" />
<Label for="key-source-public-keys-{i}" class="mb-0 font-normal">
{m.public_keys()}
</Label>
</div>
</RadioGroup.Root>
{#if keySourceFor(i, identity) === 'publicKeys'}
<FederatedIdentityKeysInput
id="public-keys-{i}"
publicKeys={identity.publicKeys ?? []}
onChange={(publicKeys) => updateFederatedIdentity(i, 'publicKeys', publicKeys)}
{disabled}
/>
{#if getFieldError(i, 'publicKeys')}
<Field.Error>{getFieldError(i, 'publicKeys')}</Field.Error>
{/if}
{:else}
<Input
id="jwks-{i}"
aria-label={m.jwks_url()}
placeholder={m.defaults_to_the_issuer_jwks_url({
issuer: identity.issuer || '<issuer>'
})}
value={identity.jwks || ''}
oninput={(e) => updateFederatedIdentity(i, 'jwks', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'jwks')}
{disabled}
/>
{#if getFieldError(i, 'jwks')}
<Field.Error>{getFieldError(i, 'jwks')}</Field.Error>
{/if}
{/if}
</Field.Field>
<SwitchWithLabel
id="replay-protection-{i}"
label={m.replay_protection()}
@@ -0,0 +1,111 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import { Textarea } from '$lib/components/ui/textarea';
import { m } from '$lib/paraglide/messages';
import { describeJwk, getJwkKeyId, parseJwkInput, type Jwk } from '$lib/utils/jwk-util';
import { LucideTrash2 } from '@lucide/svelte';
let {
publicKeys,
onChange,
id,
disabled = false
}: {
publicKeys: Jwk[];
onChange: (publicKeys: Jwk[]) => void;
id: string;
disabled?: boolean;
} = $props();
// The list is owned by this component: the identity it belongs to lives in a store, whose
// updates don't propagate back down to this input
let keys = $state<Jwk[]>(publicKeys);
let pastedKey = $state('');
let error = $state<string | null>(null);
function addKeys() {
const result = parseJwkInput(pastedKey);
if (!result.ok) {
error = result.error;
return;
}
// A JWKS may repeat a key that was already added, and duplicate key IDs would make the key
// to verify an assertion with ambiguous
const existingKeyIds = new Set(keys.map(getJwkKeyId));
const duplicate = result.keys.find((key) => existingKeyIds.has(getJwkKeyId(key)));
if (duplicate) {
error = m.public_key_already_added({ keyId: getJwkKeyId(duplicate) });
return;
}
keys = [...keys, ...result.keys];
onChange(keys);
pastedKey = '';
error = null;
}
function removeKey(index: number) {
keys = keys.filter((_, i) => i !== index);
onChange(keys);
}
</script>
<div class="flex flex-col gap-3">
{#if keys.length > 0}
<ul class="flex flex-col gap-2" data-testid="federated-identity-public-keys">
{#each keys as key, i (getJwkKeyId(key))}
<li
class="bg-muted/40 flex items-center justify-between gap-3 rounded-2xl px-4 py-2"
data-testid="federated-identity-public-key"
>
<div class="flex min-w-0 flex-col">
<span class="truncate font-mono text-sm">{getJwkKeyId(key)}</span>
<span class="text-muted-foreground text-[0.8rem]">{describeJwk(key)}</span>
</div>
<Button
variant="ghost"
size="sm"
onclick={() => removeKey(i)}
aria-label={m.remove_public_key({ keyId: getJwkKeyId(key) })}
{disabled}
>
<LucideTrash2 />
</Button>
</li>
{/each}
</ul>
{/if}
<Field.Field>
<Field.Label for={id}>{m.public_key()}</Field.Label>
<Textarea
{id}
class="font-mono text-xs"
rows={4}
placeholder={'{"kty": "RSA", "kid": "...", "n": "...", "e": "AQAB"}'}
bind:value={pastedKey}
aria-invalid={!!error}
oninput={() => (error = null)}
{disabled}
/>
{#if error}
<Field.Error>{error}</Field.Error>
{:else}
<Field.Description>{m.paste_public_key_description()}</Field.Description>
{/if}
</Field.Field>
<div>
<Button
variant="secondary"
size="sm"
type="button"
onclick={addKeys}
disabled={disabled || !pastedKey.trim()}
>
{m.add_public_key()}
</Button>
</div>
</div>