@sourceregistry/sveltekit-oidc 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -211,6 +211,12 @@ returns. `getPublicSession(event)` is available when the hook has not already lo
|
|
|
211
211
|
`OIDCContext` supports local expiry handling, targeted SvelteKit revalidation,
|
|
212
212
|
`check_session_iframe` monitoring, and local or provider logout.
|
|
213
213
|
|
|
214
|
+
When the OP iframe reports `changed`, the component first performs the Session Management 1.0
|
|
215
|
+
`prompt=none` authorization check in a hidden iframe. The login handler supplies the current ID token
|
|
216
|
+
as `id_token_hint`; a matching End-User refreshes the local session, while an OP error or a different
|
|
217
|
+
End-User clears it. Applications using the standard `loginHandler()` and `callbackHandler()` routes do
|
|
218
|
+
not need an additional endpoint.
|
|
219
|
+
|
|
214
220
|
## Session stores
|
|
215
221
|
|
|
216
222
|
Without `sessionStore`, the encrypted session is stored in the cookie. For server-side sessions:
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
<script lang="ts" generics="TIdentity extends OIDCUserClaims = OIDCUserClaims">
|
|
9
9
|
import {beforeNavigate, invalidate, invalidateAll} from '$app/navigation';
|
|
10
|
-
import {tick} from 'svelte';
|
|
10
|
+
import {onDestroy, tick} from 'svelte';
|
|
11
11
|
import type {Snippet} from 'svelte';
|
|
12
12
|
|
|
13
13
|
import {setOIDCContext} from './context.js';
|
|
14
|
-
import {
|
|
14
|
+
import {classifySessionMonitorEvent} from './session-monitor.js';
|
|
15
15
|
|
|
16
16
|
type RedirectMode = 'login' | 'logout' | 'reload' | 'none';
|
|
17
17
|
|
|
@@ -51,7 +51,10 @@
|
|
|
51
51
|
children?: Snippet;
|
|
52
52
|
} = $props();
|
|
53
53
|
|
|
54
|
-
let
|
|
54
|
+
let sessionIframe = $state<HTMLIFrameElement | undefined>(undefined);
|
|
55
|
+
let silentReauthenticationIframe = $state<HTMLIFrameElement | undefined>(undefined);
|
|
56
|
+
let silentReauthenticationUrl = $state<string | undefined>(undefined);
|
|
57
|
+
let silentReauthenticationTimeout: number | undefined;
|
|
55
58
|
let status = $state<'authenticated' | 'unauthenticated' | 'expired' | 'revoked'>('unauthenticated');
|
|
56
59
|
let revalidating = $state(false);
|
|
57
60
|
let handledUnauthenticated = $state(false);
|
|
@@ -113,8 +116,14 @@
|
|
|
113
116
|
}
|
|
114
117
|
});
|
|
115
118
|
|
|
116
|
-
function buildLoginUrl(
|
|
117
|
-
|
|
119
|
+
function buildLoginUrl(
|
|
120
|
+
returnTo = `${window.location.pathname}${window.location.search}`,
|
|
121
|
+
prompt?: 'none'
|
|
122
|
+
) {
|
|
123
|
+
const url = new URL(resolvedLoginPath, window.location.href);
|
|
124
|
+
url.searchParams.set('returnTo', returnTo);
|
|
125
|
+
if (prompt) url.searchParams.set('prompt', prompt);
|
|
126
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
async function logout(clearSessionOnly = false) {
|
|
@@ -204,6 +213,58 @@
|
|
|
204
213
|
login();
|
|
205
214
|
}
|
|
206
215
|
|
|
216
|
+
async function finishSilentReauthentication(result: 'authenticated' | 'logged_out') {
|
|
217
|
+
if (silentReauthenticationTimeout) {
|
|
218
|
+
window.clearTimeout(silentReauthenticationTimeout);
|
|
219
|
+
silentReauthenticationTimeout = undefined;
|
|
220
|
+
}
|
|
221
|
+
silentReauthenticationUrl = undefined;
|
|
222
|
+
await revalidate();
|
|
223
|
+
await tick();
|
|
224
|
+
|
|
225
|
+
if (result === 'authenticated' && session?.isAuthenticated) {
|
|
226
|
+
status = 'authenticated';
|
|
227
|
+
debug('silent_reauthentication_completed', {sub: session.sub});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
status = 'revoked';
|
|
232
|
+
debug('silent_reauthentication_logged_out');
|
|
233
|
+
await handleRedirect(redirectOnRevoked);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function startSilentReauthentication() {
|
|
237
|
+
if (silentReauthenticationUrl) return;
|
|
238
|
+
|
|
239
|
+
debug('silent_reauthentication_started');
|
|
240
|
+
silentReauthenticationUrl = buildLoginUrl(
|
|
241
|
+
`${window.location.pathname}${window.location.search}`,
|
|
242
|
+
'none'
|
|
243
|
+
);
|
|
244
|
+
silentReauthenticationTimeout = window.setTimeout(() => {
|
|
245
|
+
debug('silent_reauthentication_timed_out');
|
|
246
|
+
void logout(true).then(() => finishSilentReauthentication('logged_out'));
|
|
247
|
+
}, 30_000);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function handleSilentReauthenticationLoad() {
|
|
251
|
+
if (!silentReauthenticationUrl || !silentReauthenticationIframe?.contentWindow) return;
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const documentElement = silentReauthenticationIframe.contentWindow.document.documentElement;
|
|
255
|
+
const result = documentElement.dataset.oidcSilentReauth;
|
|
256
|
+
if (result === 'authenticated' || result === 'logged_out') {
|
|
257
|
+
void finishSilentReauthentication(result);
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// The authorization request is currently displaying the cross-origin OP document.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
onDestroy(() => {
|
|
265
|
+
if (silentReauthenticationTimeout) window.clearTimeout(silentReauthenticationTimeout);
|
|
266
|
+
});
|
|
267
|
+
|
|
207
268
|
$effect(() => {
|
|
208
269
|
const isAuthenticated = Boolean(session?.isAuthenticated);
|
|
209
270
|
// Suppress authenticated→unauthenticated transition while a revalidation is
|
|
@@ -285,25 +346,21 @@
|
|
|
285
346
|
});
|
|
286
347
|
|
|
287
348
|
$effect(() => {
|
|
288
|
-
if (!canMonitorIframe || !iframeUrl || !
|
|
349
|
+
if (!canMonitorIframe || !iframeUrl || !sessionIframe) {
|
|
289
350
|
return;
|
|
290
351
|
}
|
|
291
352
|
|
|
292
353
|
const targetOrigin = new URL(iframeUrl).origin;
|
|
293
354
|
const poll = window.setInterval(() => {
|
|
294
|
-
if (!
|
|
355
|
+
if (!sessionIframe?.contentWindow || !session?.sessionState) {
|
|
295
356
|
return;
|
|
296
357
|
}
|
|
297
358
|
|
|
298
|
-
|
|
359
|
+
sessionIframe.contentWindow.postMessage(`${config.clientId} ${session.sessionState}`, targetOrigin);
|
|
299
360
|
}, checkSessionIntervalMs);
|
|
300
361
|
|
|
301
362
|
const onMessage = (event: MessageEvent) => {
|
|
302
|
-
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const result = classifySessionMonitorMessage(event.data);
|
|
363
|
+
const result = classifySessionMonitorEvent(event, targetOrigin, sessionIframe?.contentWindow);
|
|
307
364
|
if (result === 'error') {
|
|
308
365
|
// `error` means the OP could not determine session state (for example,
|
|
309
366
|
// transient storage or network denial). It is not proof of revocation.
|
|
@@ -312,8 +369,7 @@
|
|
|
312
369
|
}
|
|
313
370
|
if (result === 'changed') {
|
|
314
371
|
debug('iframe_session_event', {result, origin: event.origin});
|
|
315
|
-
|
|
316
|
-
void logout(true).then(() => handleRedirect(redirectOnRevoked));
|
|
372
|
+
startSilentReauthentication();
|
|
317
373
|
}
|
|
318
374
|
};
|
|
319
375
|
|
|
@@ -328,7 +384,8 @@
|
|
|
328
384
|
|
|
329
385
|
{#if canMonitorIframe && iframeUrl}
|
|
330
386
|
<iframe
|
|
331
|
-
|
|
387
|
+
id="oidc-session-monitor"
|
|
388
|
+
bind:this={sessionIframe}
|
|
332
389
|
title="OIDC session monitor"
|
|
333
390
|
src={iframeUrl}
|
|
334
391
|
hidden
|
|
@@ -336,4 +393,15 @@
|
|
|
336
393
|
></iframe>
|
|
337
394
|
{/if}
|
|
338
395
|
|
|
396
|
+
{#if silentReauthenticationUrl}
|
|
397
|
+
<iframe
|
|
398
|
+
bind:this={silentReauthenticationIframe}
|
|
399
|
+
title="OIDC silent re-authentication"
|
|
400
|
+
src={silentReauthenticationUrl}
|
|
401
|
+
onload={handleSilentReauthenticationLoad}
|
|
402
|
+
hidden
|
|
403
|
+
aria-hidden="true"
|
|
404
|
+
></iframe>
|
|
405
|
+
{/if}
|
|
406
|
+
|
|
339
407
|
{@render children?.()}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export type OIDCSessionMonitorMessage = 'changed' | 'unchanged' | 'error' | 'ignored';
|
|
2
2
|
export declare function classifySessionMonitorMessage(data: unknown): OIDCSessionMonitorMessage;
|
|
3
|
+
export declare function classifySessionMonitorEvent(event: Pick<MessageEvent, 'data' | 'origin' | 'source'>, expectedOrigin: string, expectedSource: unknown): OIDCSessionMonitorMessage;
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export function classifySessionMonitorMessage(data) {
|
|
2
2
|
return data === 'changed' || data === 'unchanged' || data === 'error' ? data : 'ignored';
|
|
3
3
|
}
|
|
4
|
+
export function classifySessionMonitorEvent(event, expectedOrigin, expectedSource) {
|
|
5
|
+
if (event.origin !== expectedOrigin || !expectedSource || event.source !== expectedSource) {
|
|
6
|
+
return 'ignored';
|
|
7
|
+
}
|
|
8
|
+
return classifySessionMonitorMessage(event.data);
|
|
9
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -8,6 +8,21 @@ import { absoluteUrl, base64UrlEncode, buildCookieOptions, collectGroups, create
|
|
|
8
8
|
import { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
|
|
9
9
|
export { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
|
|
10
10
|
const OIDC_SESSION_REVALIDATION_DEPENDENCY = 'oidc:session';
|
|
11
|
+
const LOGIN_PROMPTS = new Set([
|
|
12
|
+
'login',
|
|
13
|
+
'consent',
|
|
14
|
+
'none',
|
|
15
|
+
'select_account'
|
|
16
|
+
]);
|
|
17
|
+
function silentReauthenticationResponse(status) {
|
|
18
|
+
return new Response(`<!doctype html><html lang="en" data-oidc-silent-reauth="${status}"><head><meta charset="utf-8"><title>OIDC session check</title></head><body></body></html>`, {
|
|
19
|
+
headers: {
|
|
20
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
21
|
+
'Cache-Control': 'no-store',
|
|
22
|
+
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'self'"
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
11
26
|
function buildLogger(logger) {
|
|
12
27
|
const noop = () => { };
|
|
13
28
|
if (logger === false)
|
|
@@ -395,11 +410,14 @@ export function createOIDC(options) {
|
|
|
395
410
|
const state = base64UrlEncode(randomBytes(24));
|
|
396
411
|
const nonce = base64UrlEncode(randomBytes(24));
|
|
397
412
|
const returnTo = internalRedirectPath(event, loginOptions.returnTo ?? options.defaultLoginRedirect, '/');
|
|
413
|
+
const existingSession = loginOptions.prompt === 'none' ? await readPersistedSession(event.cookies) : null;
|
|
398
414
|
cookieStore.writeState(event.cookies, {
|
|
399
415
|
state,
|
|
400
416
|
nonce,
|
|
401
417
|
codeVerifier: pkce.verifier,
|
|
402
418
|
returnTo,
|
|
419
|
+
prompt: loginOptions.prompt,
|
|
420
|
+
originalSub: existingSession?.session.sub,
|
|
403
421
|
createdAt: Math.floor(Date.now() / 1000)
|
|
404
422
|
});
|
|
405
423
|
const redirectUri = absoluteUrl(event, redirectPath);
|
|
@@ -418,6 +436,9 @@ export function createOIDC(options) {
|
|
|
418
436
|
if (loginOptions.prompt) {
|
|
419
437
|
authorizationUrl.searchParams.set('prompt', loginOptions.prompt);
|
|
420
438
|
}
|
|
439
|
+
if (loginOptions.prompt === 'none' && existingSession?.session.tokens.idToken) {
|
|
440
|
+
authorizationUrl.searchParams.set('id_token_hint', existingSession.session.tokens.idToken);
|
|
441
|
+
}
|
|
421
442
|
for (const [key, value] of Object.entries(loginOptions.extraParams ?? {})) {
|
|
422
443
|
authorizationUrl.searchParams.set(key, value);
|
|
423
444
|
}
|
|
@@ -448,6 +469,9 @@ export function createOIDC(options) {
|
|
|
448
469
|
});
|
|
449
470
|
}
|
|
450
471
|
const idTokenClaims = await validateIdToken(tokenResponse.id_token, stateCookie.nonce);
|
|
472
|
+
if (stateCookie.prompt === 'none' && stateCookie.originalSub && idTokenClaims.sub !== stateCookie.originalSub) {
|
|
473
|
+
throw error(401, { message: 'Silent re-authentication returned a different End-User' });
|
|
474
|
+
}
|
|
451
475
|
const userInfo = options.fetchUserInfo === false
|
|
452
476
|
? undefined
|
|
453
477
|
: await fetchUserInfo(tokenResponse.access_token).catch(() => undefined);
|
|
@@ -476,7 +500,8 @@ export function createOIDC(options) {
|
|
|
476
500
|
event: event,
|
|
477
501
|
tokenResponse
|
|
478
502
|
});
|
|
479
|
-
await
|
|
503
|
+
const existingSession = stateCookie.prompt === 'none' ? await readPersistedSession(event.cookies) : null;
|
|
504
|
+
await writePersistedSession(event.cookies, session, existingSession?.id);
|
|
480
505
|
return {
|
|
481
506
|
session,
|
|
482
507
|
returnTo: stateCookie.returnTo
|
|
@@ -566,19 +591,36 @@ export function createOIDC(options) {
|
|
|
566
591
|
function loginHandler(defaults = {}) {
|
|
567
592
|
return async (event) => {
|
|
568
593
|
const returnTo = event.url.searchParams.get('returnTo') ?? defaults.returnTo;
|
|
569
|
-
|
|
594
|
+
const requestedPrompt = event.url.searchParams.get('prompt');
|
|
595
|
+
const prompt = requestedPrompt && LOGIN_PROMPTS.has(requestedPrompt)
|
|
596
|
+
? requestedPrompt
|
|
597
|
+
: defaults.prompt;
|
|
598
|
+
return signIn(event, { ...defaults, returnTo, prompt });
|
|
570
599
|
};
|
|
571
600
|
}
|
|
572
601
|
function callbackHandler(handlerOptions = {}) {
|
|
573
602
|
return async (event) => {
|
|
603
|
+
const stateCookie = cookieStore.readState(event.cookies);
|
|
604
|
+
const callbackState = event.url.searchParams.get('state');
|
|
605
|
+
const isSilentReauthentication = Boolean(stateCookie?.prompt === 'none' && callbackState && callbackState === stateCookie.state);
|
|
574
606
|
try {
|
|
575
607
|
const result = await handleCallback(event);
|
|
608
|
+
if (isSilentReauthentication) {
|
|
609
|
+
return silentReauthenticationResponse('authenticated');
|
|
610
|
+
}
|
|
576
611
|
const response = await handlerOptions.onsuccess?.(event, result);
|
|
577
612
|
if (response)
|
|
578
613
|
return response;
|
|
579
614
|
throw redirect(302, internalRedirectPath(event, handlerOptions.redirectTo ?? result.returnTo, '/'));
|
|
580
615
|
}
|
|
581
616
|
catch (err) {
|
|
617
|
+
if (isSilentReauthentication) {
|
|
618
|
+
cookieStore.clearState(event.cookies);
|
|
619
|
+
const persisted = await readPersistedSession(event.cookies);
|
|
620
|
+
await clearPersistedSession(event.cookies, persisted?.id);
|
|
621
|
+
log.debug('Silent OIDC re-authentication failed — clearing local session');
|
|
622
|
+
return silentReauthenticationResponse('logged_out');
|
|
623
|
+
}
|
|
582
624
|
const response = await handlerOptions.onfailure?.(event, err);
|
|
583
625
|
if (response) {
|
|
584
626
|
return response;
|
package/dist/server/types.d.ts
CHANGED