mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-10 21:54:19 +00:00
feat: add option to OIDC client to require re-authentication (#747)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us> Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -19,7 +19,8 @@ class OidcService extends APIService {
|
||||
callbackURL: string,
|
||||
nonce?: string,
|
||||
codeChallenge?: string,
|
||||
codeChallengeMethod?: string
|
||||
codeChallengeMethod?: string,
|
||||
reauthenticationToken?: string
|
||||
) {
|
||||
const res = await this.api.post('/oidc/authorize', {
|
||||
scope,
|
||||
@@ -27,7 +28,8 @@ class OidcService extends APIService {
|
||||
callbackURL,
|
||||
clientId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod
|
||||
codeChallengeMethod,
|
||||
reauthenticationToken
|
||||
});
|
||||
|
||||
return res.data as AuthorizeResponse;
|
||||
|
||||
@@ -37,6 +37,11 @@ class WebAuthnService extends APIService {
|
||||
async updateCredentialName(id: string, name: string) {
|
||||
await this.api.patch(`/webauthn/credentials/${id}`, { name });
|
||||
}
|
||||
|
||||
async reauthenticate(body?: AuthenticationResponseJSON) {
|
||||
const res = await this.api.post('/webauthn/reauthenticate', body);
|
||||
return res.data.reauthenticationToken as string;
|
||||
}
|
||||
}
|
||||
|
||||
export default WebAuthnService;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type OidcClientMetaData = {
|
||||
id: string;
|
||||
name: string;
|
||||
hasLogo: boolean;
|
||||
requiresReauthentication: boolean;
|
||||
launchURL?: string;
|
||||
};
|
||||
|
||||
@@ -23,6 +24,7 @@ export type OidcClient = OidcClientMetaData & {
|
||||
logoutCallbackURLs: string[];
|
||||
isPublic: boolean;
|
||||
pkceEnabled: boolean;
|
||||
requiresReauthentication: boolean;
|
||||
credentials?: OidcClientCredentials;
|
||||
launchURL?: string;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { startAuthentication, type AuthenticationResponseJSON } from '@simplewebauthn/browser';
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import type { PageProps } from './$types';
|
||||
@@ -29,6 +29,7 @@
|
||||
let errorMessage: string | null = $state(null);
|
||||
let authorizationRequired = $state(false);
|
||||
let authorizationConfirmed = $state(false);
|
||||
let userSignedInAt: Date | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if ($userStore) {
|
||||
@@ -38,13 +39,16 @@
|
||||
|
||||
async function authorize() {
|
||||
isLoading = true;
|
||||
|
||||
let authResponse: AuthenticationResponseJSON | undefined;
|
||||
|
||||
try {
|
||||
// Get access token if not signed in
|
||||
if (!$userStore?.id) {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
const user = await webauthnService.finishLogin(authResponse);
|
||||
await userStore.setUser(user);
|
||||
userStore.setUser(user);
|
||||
userSignedInAt = new Date();
|
||||
}
|
||||
|
||||
if (!authorizationConfirmed) {
|
||||
@@ -56,8 +60,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
let reauthToken: string | undefined;
|
||||
if (client?.requiresReauthentication) {
|
||||
let authResponse;
|
||||
const signedInRecently =
|
||||
userSignedInAt && userSignedInAt.getTime() > Date.now() - 60 * 1000;
|
||||
if (!signedInRecently) {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||
}
|
||||
reauthToken = await webauthnService.reauthenticate(authResponse);
|
||||
}
|
||||
|
||||
await oidService
|
||||
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||
.authorize(
|
||||
client!.id,
|
||||
scope,
|
||||
callbackURL,
|
||||
nonce,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
reauthToken
|
||||
)
|
||||
.then(async ({ code, callbackURL, issuer }) => {
|
||||
onSuccess(code, callbackURL, issuer);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
[m.userinfo_url()]: `https://${page.url.hostname}/api/oidc/userinfo`,
|
||||
[m.logout_url()]: `https://${page.url.hostname}/api/oidc/end-session`,
|
||||
[m.certificate_url()]: `https://${page.url.hostname}/.well-known/jwks.json`,
|
||||
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled()
|
||||
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled(),
|
||||
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled()
|
||||
});
|
||||
|
||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||
@@ -49,6 +50,9 @@
|
||||
|
||||
client.isPublic = updatedClient.isPublic;
|
||||
setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled();
|
||||
setupDetails[m.requires_reauthentication()] = updatedClient.requiresReauthentication
|
||||
? m.enabled()
|
||||
: m.disabled();
|
||||
|
||||
await Promise.all([dataPromise, imagePromise])
|
||||
.then(() => {
|
||||
@@ -120,14 +124,14 @@
|
||||
<Card.Content>
|
||||
<div class="flex flex-col">
|
||||
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
|
||||
<Label class="mb-0 w-44">{m.client_id()}</Label>
|
||||
<Label class="mb-0 w-50">{m.client_id()}</Label>
|
||||
<CopyToClipboard value={client.id}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
{#if !client.isPublic}
|
||||
<div class="mt-1 mb-2 flex flex-col sm:flex-row sm:items-center">
|
||||
<Label class="mb-0 w-44">{m.client_secret()}</Label>
|
||||
<Label class="mb-0 w-50">{m.client_secret()}</Label>
|
||||
{#if $clientSecretStore}
|
||||
<CopyToClipboard value={$clientSecretStore}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||
@@ -154,7 +158,7 @@
|
||||
<div transition:slide>
|
||||
{#each Object.entries(setupDetails) as [key, value]}
|
||||
<div class="mb-5 flex flex-col sm:flex-row sm:items-center">
|
||||
<Label class="mb-0 w-44">{key}</Label>
|
||||
<Label class="mb-0 w-50">{key}</Label>
|
||||
<CopyToClipboard {value}>
|
||||
<span class="text-muted-foreground text-sm">{value}</span>
|
||||
</CopyToClipboard>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
|
||||
isPublic: existingClient?.isPublic || false,
|
||||
pkceEnabled: existingClient?.pkceEnabled || false,
|
||||
requiresReauthentication: existingClient?.requiresReauthentication || false,
|
||||
launchURL: existingClient?.launchURL || '',
|
||||
credentials: {
|
||||
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
||||
@@ -51,6 +52,7 @@
|
||||
logoutCallbackURLs: z.array(z.string().nonempty()),
|
||||
isPublic: z.boolean(),
|
||||
pkceEnabled: z.boolean(),
|
||||
requiresReauthentication: z.boolean(),
|
||||
launchURL: optionalUrl,
|
||||
credentials: z.object({
|
||||
federatedIdentities: z.array(
|
||||
@@ -147,6 +149,12 @@
|
||||
description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
|
||||
bind:checked={$inputs.pkceEnabled.value}
|
||||
/>
|
||||
<SwitchWithLabel
|
||||
id="requires-reauthentication"
|
||||
label={m.requires_reauthentication()}
|
||||
description={m.requires_users_to_authenticate_again_on_each_authorization()}
|
||||
bind:checked={$inputs.requiresReauthentication.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<Label for="logo">{m.logo()}</Label>
|
||||
|
||||
Reference in New Issue
Block a user