feat: add ability to customize session duration of clients (#1641)

This commit is contained in:
Elias Schneider
2026-08-03 23:23:29 +02:00
committed by GitHub
parent 1238bf0f37
commit 5d43c4aaeb
28 changed files with 752 additions and 77 deletions

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import * as ButtonGroup from '$lib/components/ui/button-group';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import type { FormInput } from '$lib/utils/form-util';
type DurationUnit = 'minutes' | 'hours' | 'days';
const minutesPerUnit: Record<DurationUnit, number> = {
minutes: 1,
hours: 60,
days: 24 * 60
};
const minimumMinutes = 1;
const maximumMinutes = 365 * 24 * 60;
let {
id,
label,
description,
input = $bindable()
}: {
id: string;
label: string;
description: string;
input: FormInput<number>;
} = $props();
function preferredUnit(minutes: number): DurationUnit {
if (minutes % minutesPerUnit.days === 0) return 'days';
if (minutes % minutesPerUnit.hours === 0) return 'hours';
return 'minutes';
}
function formatAmount(value: number): string {
return Number(value.toFixed(10)).toString();
}
let unit = $state<DurationUnit>(preferredUnit(input.value));
let amount = $state(formatAmount(input.value / minutesPerUnit[unit]));
function updateAmount(event: Event) {
amount = (event.currentTarget as HTMLInputElement).value;
input.value = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
}
function updateUnit(value: string | undefined) {
if (!value) return;
unit = value as DurationUnit;
if (Number.isFinite(input.value)) {
amount = formatAmount(input.value / minutesPerUnit[unit]);
}
}
function unitLabel(value: DurationUnit): string {
switch (value) {
case 'minutes':
return m.minutes();
case 'hours':
return m.hours();
case 'days':
return m.days();
}
}
</script>
<Field.Field>
<div>
<Field.Label for={id}>{label}</Field.Label>
<Field.Description>{description}</Field.Description>
</div>
<div>
<ButtonGroup.Root class="w-full">
<Input
{id}
type="number"
value={amount}
min={minimumMinutes / minutesPerUnit[unit]}
max={maximumMinutes / minutesPerUnit[unit]}
step={minimumMinutes / minutesPerUnit[unit]}
aria-invalid={!!input.error}
oninput={updateAmount}
/>
<Select.Root type="single" value={unit} onValueChange={updateUnit}>
<Select.Trigger
class="w-32"
aria-label={m.duration_unit_for({ name: label })}
aria-invalid={!!input.error}
>
{unitLabel(unit)}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each ['minutes', 'hours', 'days'] as option (option)}
<Select.Item value={option}>{unitLabel(option as DurationUnit)}</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</ButtonGroup.Root>
{#if input.error}
<Field.Error>{input.error}</Field.Error>
{/if}
</div>
</Field.Field>

View File

@@ -46,8 +46,15 @@ export type OidcClient = OidcClientMetaData & {
launchURL?: string;
isGroupRestricted: boolean;
pkceSupported: boolean;
accessTokenDurationMinutes: number;
refreshTokenDurationMinutes: number;
};
export type OidcClientTokenLifetimes = Pick<
OidcClient,
'accessTokenDurationMinutes' | 'refreshTokenDurationMinutes'
>;
export type OidcClientWithAllowedUserGroups = OidcClient & {
allowedUserGroups: UserGroup[];
};

View File

@@ -13,7 +13,11 @@
import OidcService from '$lib/services/oidc-service';
import ScimService from '$lib/services/scim-service';
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import type {
OidcClientCreateWithLogo,
OidcClientCredentials,
OidcClientTokenLifetimes
} from '$lib/types/oidc.type';
import type { ScimServiceProviderCreate } from '$lib/types/scim.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -29,6 +33,8 @@
import OidcForm from '../oidc-client-form.svelte';
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
import ApiAccessCard from './api-access-card.svelte';
import OidcClientFederatedCredentialsCard from './oidc-client-federated-credentials-card.svelte';
import OidcClientTokenLifetimesCard from './oidc-client-token-lifetimes-card.svelte';
import ScimResourceProviderForm from './scim-resource-provider-form.svelte';
let { data } = $props();
@@ -110,6 +116,23 @@
return success;
}
async function updateTokenLifetimes(lifetimes: OidcClientTokenLifetimes) {
const success = await updateClient({ ...client, ...lifetimes });
if (success) {
client.accessTokenDurationMinutes = lifetimes.accessTokenDurationMinutes;
client.refreshTokenDurationMinutes = lifetimes.refreshTokenDurationMinutes;
}
return success;
}
async function updateFederatedCredentials(credentials: OidcClientCredentials) {
const success = await updateClient({ ...client, credentials });
if (success) {
client.credentials = credentials;
}
return success;
}
async function enableGroupRestriction() {
client.isGroupRestricted = true;
await oidcService
@@ -334,6 +357,10 @@
<OidcForm mode="update" existingClient={client} callback={updateClient} />
</Card.Content>
</Card.Root>
<OidcClientTokenLifetimesCard {client} callback={updateTokenLifetimes} />
<OidcClientFederatedCredentialsCard {client} callback={updateFederatedCredentials} />
</Tabs.Content>
<Tabs.Content value="user-groups" id="allowed-user-groups">

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientCredentials } from '$lib/types/oidc.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { slide } from 'svelte/transition';
import { z } from 'zod/v4';
import FederatedIdentitiesInput from '../federated-identities-input.svelte';
let {
client,
callback
}: {
client: OidcClient;
callback: (credentials: OidcClientCredentials) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
const isCIMDClient = $derived(client.clientType === 'cimd');
const formSchema = z.object({
credentials: z.object({
federatedIdentities: z.array(
z.object({
issuer: z.url(),
subject: z.string().optional(),
audience: z.string().optional(),
jwks: z.url().optional().or(z.literal('')),
replayProtection: z.boolean().default(true)
})
)
})
});
const { inputs, errors, ...form } = createForm(formSchema, {
credentials: {
federatedIdentities:
client.credentials?.federatedIdentities?.map((identity) => ({ ...identity })) ?? []
}
});
const hasFederatedIdentities = $derived($inputs.credentials.value.federatedIdentities.length > 0);
function getFederatedIdentityErrors(errors: z.ZodError<any> | undefined) {
return errors?.issues
.filter((error) =>
['credentials', 'federatedIdentities'].every(
(segment, index) => error.path[index] === segment
)
)
.map((error) => ({ ...error, path: error.path.slice(2) }));
}
function addFederatedIdentity() {
$inputs.credentials.value.federatedIdentities = [
...$inputs.credentials.value.federatedIdentities,
{
issuer: '',
subject: '',
audience: '',
jwks: '',
replayProtection: true
}
];
}
async function onSubmit() {
if (isCIMDClient) return;
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data.credentials).finally(() => (isLoading = false));
}
</script>
<form novalidate onsubmit={preventDefault(onSubmit)}>
<Card.Root data-testid="federated-credentials-card">
<Card.Header>
<div class="flex items-center justify-between gap-4">
<div>
<Card.Title>{m.federated_client_credentials()}</Card.Title>
<Card.Description>
{m.federated_client_credentials_description()}
<a
class="underline underline-offset-4"
href="https://pocket-id.org/docs/guides/oidc-client-authentication"
target="_blank"
rel="noreferrer"
>
{m.docs()}
</a>
</Card.Description>
</div>
{#if !hasFederatedIdentities}
<Button disabled={isCIMDClient} onclick={addFederatedIdentity}>
{m.create()}
</Button>
{/if}
</div>
</Card.Header>
{#if hasFederatedIdentities}
<div transition:slide>
<Card.Content>
<FederatedIdentitiesInput
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
errors={getFederatedIdentityErrors($errors)}
disabled={isCIMDClient}
/>
</Card.Content>
</div>
{/if}
{#if !isCIMDClient && hasFederatedIdentities}
<Card.Footer class="justify-end">
<Button type="submit" disabled={isLoading}>{m.save()}</Button>
</Card.Footer>
{/if}
</Card.Root>
</form>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import DurationInput from '$lib/components/form/duration-input.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientTokenLifetimes } from '$lib/types/oidc.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod/v4';
let {
client,
callback
}: {
client: OidcClient;
callback: (lifetimes: OidcClientTokenLifetimes) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
const durationSchema = z
.number()
.min(1, { message: m.token_lifetime_minimum() })
.max(365 * 24 * 60, { message: m.token_lifetime_maximum() })
.refine((minutes) => Number.isInteger(minutes), {
message: m.token_lifetime_whole_minutes()
});
const formSchema = z.object({
accessTokenDurationMinutes: durationSchema,
refreshTokenDurationMinutes: durationSchema
});
const { inputs, ...form } = createForm(formSchema, {
accessTokenDurationMinutes: client.accessTokenDurationMinutes,
refreshTokenDurationMinutes: client.refreshTokenDurationMinutes
});
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data).finally(() => (isLoading = false));
}
</script>
<form novalidate onsubmit={preventDefault(onSubmit)}>
<Card.Root data-testid="token-lifetimes-card">
<Card.Header>
<Card.Title>{m.token_lifetimes()}</Card.Title>
<Card.Description>{m.token_lifetimes_description()}</Card.Description>
</Card.Header>
<Card.Content>
<div class="md:grid md:grid-cols-2 gap-10 space-y-5 md:space-y-0">
<DurationInput
id="access-token-lifetime"
label={m.access_token_lifetime()}
description={m.access_token_lifetime_description()}
bind:input={$inputs.accessTokenDurationMinutes}
/>
<DurationInput
id="refresh-token-lifetime"
label={m.refresh_token_inactivity_timeout()}
description={m.refresh_token_inactivity_timeout_description()}
bind:input={$inputs.refreshTokenDurationMinutes}
/>
</div>
</Card.Content>
<Card.Footer class="justify-end">
<Button type="submit" disabled={isLoading}>{m.save()}</Button>
</Card.Footer>
</Card.Root>
</form>

View File

@@ -20,7 +20,6 @@
federatedIdentities: OidcClientFederatedIdentity[];
errors?: z.core.$ZodIssue[];
disabled?: boolean;
children?: Snippet;
} = $props();
@@ -60,15 +59,10 @@
</script>
<div {...restProps}>
<FormInput
label={m.federated_client_credentials()}
description={m.federated_client_credentials_description()}
docsLink="https://pocket-id.org/docs/guides/oidc-client-authentication"
{disabled}
>
<div class="space-y-4">
<FormInput {disabled}>
<div class="flex flex-col gap-4">
{#each federatedIdentities as identity, i (identity)}
<div class="space-y-3 rounded-lg border p-4">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<Field.Label>Identity {i + 1}</Field.Label>
{#if federatedIdentities.length > 0}
@@ -79,7 +73,7 @@
aria-label="Remove federated identity"
{disabled}
>
<LucideMinus class="size-4" />
<LucideMinus data-icon="inline-start" />
</Button>
{/if}
</div>
@@ -159,14 +153,14 @@
</FormInput>
<Button
class="mt-3"
class="mt-7"
variant="secondary"
size="sm"
onclick={addFederatedIdentity}
type="button"
{disabled}
>
<LucidePlus class="mr-1 size-4" />
<LucidePlus data-icon="inline-start" />
{federatedIdentities.length === 0
? m.add_federated_client_credential()
: m.add_another_federated_client_credential()}

View File

@@ -18,7 +18,6 @@
import { LucideChevronDown, LucideMoon, LucideSun } from '@lucide/svelte';
import { slide } from 'svelte/transition';
import { z } from 'zod/v4';
import FederatedIdentitiesInput from './federated-identities-input.svelte';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
import OidcClientImageInput from './oidc-client-image-input.svelte';
@@ -56,12 +55,11 @@
existingClient?.requiresPushedAuthorizationRequests || false,
skipConsent: existingClient?.skipConsent || false,
launchURL: existingClient?.launchURL || '',
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
},
logoUrl: '',
darkLogoUrl: '',
pkceSupported: existingClient?.pkceSupported || false
pkceSupported: existingClient?.pkceSupported || false,
accessTokenDurationMinutes: existingClient?.accessTokenDurationMinutes ?? 60,
refreshTokenDurationMinutes: existingClient?.refreshTokenDurationMinutes ?? 30 * 24 * 60
};
const formSchema = z.object({
@@ -87,21 +85,20 @@
launchURL: optionalUrl,
logoUrl: optionalUrl,
darkLogoUrl: optionalUrl,
credentials: z.object({
federatedIdentities: z.array(
z.object({
issuer: z.url(),
subject: z.string().optional(),
audience: z.string().optional(),
jwks: z.url().optional().or(z.literal('')),
replayProtection: z.boolean().default(true)
})
)
})
accessTokenDurationMinutes: z
.number()
.min(1)
.max(365 * 24 * 60)
.int(),
refreshTokenDurationMinutes: z
.number()
.min(1)
.max(365 * 24 * 60)
.int()
});
type FormSchema = typeof formSchema;
const { inputs, errors, ...form } = createForm<FormSchema>(formSchema, client);
const { inputs, ...form } = createForm<FormSchema>(formSchema, client);
const pkcePromptNeeded = $derived(!$inputs.pkceEnabled.value && client.pkceSupported);
@@ -112,6 +109,7 @@
const success = await callback({
...data,
credentials: existingClient?.credentials ?? { federatedIdentities: [] },
logo: $inputs.logoUrl?.value ? undefined : logo,
logoUrl: $inputs.logoUrl?.value,
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,
@@ -177,15 +175,6 @@
}
}
}
function getFederatedIdentityErrors(errors: z.ZodError<any> | undefined) {
return errors?.issues
.filter((e) => e.path[0] == 'credentials' && e.path[1] == 'federatedIdentities')
.map((e) => {
e.path.splice(0, 2);
return e;
});
}
</script>
{#snippet callbackUrlDescription()}
@@ -336,11 +325,6 @@
bind:input={$inputs.id}
/>
{/if}
<FederatedIdentitiesInput
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
errors={getFederatedIdentityErrors($errors)}
disabled={isCIMDClient}
/>
</div>
{/if}