@sourceregistry/sveltekit-oidc 2.0.0 → 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,10 +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 {classifySessionMonitorEvent} from './session-monitor.js';
|
|
14
15
|
|
|
15
16
|
type RedirectMode = 'login' | 'logout' | 'reload' | 'none';
|
|
16
17
|
|
|
@@ -50,7 +51,10 @@
|
|
|
50
51
|
children?: Snippet;
|
|
51
52
|
} = $props();
|
|
52
53
|
|
|
53
|
-
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;
|
|
54
58
|
let status = $state<'authenticated' | 'unauthenticated' | 'expired' | 'revoked'>('unauthenticated');
|
|
55
59
|
let revalidating = $state(false);
|
|
56
60
|
let handledUnauthenticated = $state(false);
|
|
@@ -112,8 +116,14 @@
|
|
|
112
116
|
}
|
|
113
117
|
});
|
|
114
118
|
|
|
115
|
-
function buildLoginUrl(
|
|
116
|
-
|
|
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}`;
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
async function logout(clearSessionOnly = false) {
|
|
@@ -203,6 +213,58 @@
|
|
|
203
213
|
login();
|
|
204
214
|
}
|
|
205
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
|
+
|
|
206
268
|
$effect(() => {
|
|
207
269
|
const isAuthenticated = Boolean(session?.isAuthenticated);
|
|
208
270
|
// Suppress authenticated→unauthenticated transition while a revalidation is
|
|
@@ -284,27 +346,30 @@
|
|
|
284
346
|
});
|
|
285
347
|
|
|
286
348
|
$effect(() => {
|
|
287
|
-
if (!canMonitorIframe || !iframeUrl || !
|
|
349
|
+
if (!canMonitorIframe || !iframeUrl || !sessionIframe) {
|
|
288
350
|
return;
|
|
289
351
|
}
|
|
290
352
|
|
|
291
353
|
const targetOrigin = new URL(iframeUrl).origin;
|
|
292
354
|
const poll = window.setInterval(() => {
|
|
293
|
-
if (!
|
|
355
|
+
if (!sessionIframe?.contentWindow || !session?.sessionState) {
|
|
294
356
|
return;
|
|
295
357
|
}
|
|
296
358
|
|
|
297
|
-
|
|
359
|
+
sessionIframe.contentWindow.postMessage(`${config.clientId} ${session.sessionState}`, targetOrigin);
|
|
298
360
|
}, checkSessionIntervalMs);
|
|
299
361
|
|
|
300
362
|
const onMessage = (event: MessageEvent) => {
|
|
301
|
-
|
|
363
|
+
const result = classifySessionMonitorEvent(event, targetOrigin, sessionIframe?.contentWindow);
|
|
364
|
+
if (result === 'error') {
|
|
365
|
+
// `error` means the OP could not determine session state (for example,
|
|
366
|
+
// transient storage or network denial). It is not proof of revocation.
|
|
367
|
+
debug('iframe_session_error', {origin: event.origin});
|
|
302
368
|
return;
|
|
303
369
|
}
|
|
304
|
-
if (
|
|
305
|
-
debug('iframe_session_event', {result
|
|
306
|
-
|
|
307
|
-
void logout(true).then(() => handleRedirect(redirectOnRevoked));
|
|
370
|
+
if (result === 'changed') {
|
|
371
|
+
debug('iframe_session_event', {result, origin: event.origin});
|
|
372
|
+
startSilentReauthentication();
|
|
308
373
|
}
|
|
309
374
|
};
|
|
310
375
|
|
|
@@ -319,7 +384,8 @@
|
|
|
319
384
|
|
|
320
385
|
{#if canMonitorIframe && iframeUrl}
|
|
321
386
|
<iframe
|
|
322
|
-
|
|
387
|
+
id="oidc-session-monitor"
|
|
388
|
+
bind:this={sessionIframe}
|
|
323
389
|
title="OIDC session monitor"
|
|
324
390
|
src={iframeUrl}
|
|
325
391
|
hidden
|
|
@@ -327,4 +393,15 @@
|
|
|
327
393
|
></iframe>
|
|
328
394
|
{/if}
|
|
329
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
|
+
|
|
330
407
|
{@render children?.()}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type OIDCSessionMonitorMessage = 'changed' | 'unchanged' | 'error' | 'ignored';
|
|
2
|
+
export declare function classifySessionMonitorMessage(data: unknown): OIDCSessionMonitorMessage;
|
|
3
|
+
export declare function classifySessionMonitorEvent(event: Pick<MessageEvent, 'data' | 'origin' | 'source'>, expectedOrigin: string, expectedSource: unknown): OIDCSessionMonitorMessage;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function classifySessionMonitorMessage(data) {
|
|
2
|
+
return data === 'changed' || data === 'unchanged' || data === 'error' ? data : 'ignored';
|
|
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