mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-31 08:11:27 +02:00
feat: add ability to customize session duration of clients (#1641)
This commit is contained in:
108
frontend/src/lib/components/form/duration-input.svelte
Normal file
108
frontend/src/lib/components/form/duration-input.svelte
Normal 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>
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user