@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 {classifySessionMonitorMessage} from './session-monitor.js';
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 iframe = $state<HTMLIFrameElement | undefined>(undefined);
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(returnTo = `${window.location.pathname}${window.location.search}`) {
117
- return `${resolvedLoginPath}?returnTo=${encodeURIComponent(returnTo)}`;
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 || !iframe) {
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 (!iframe?.contentWindow || !session?.sessionState) {
355
+ if (!sessionIframe?.contentWindow || !session?.sessionState) {
295
356
  return;
296
357
  }
297
358
 
298
- iframe.contentWindow.postMessage(`${config.clientId} ${session.sessionState}`, targetOrigin);
359
+ sessionIframe.contentWindow.postMessage(`${config.clientId} ${session.sessionState}`, targetOrigin);
299
360
  }, checkSessionIntervalMs);
300
361
 
301
362
  const onMessage = (event: MessageEvent) => {
302
- if (event.origin !== targetOrigin) {
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
- status = 'revoked';
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
- bind:this={iframe}
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
+ }
@@ -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 writePersistedSession(event.cookies, session);
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
- return signIn(event, { ...defaults, returnTo });
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;
@@ -244,6 +244,8 @@ export type OIDCStateCookie = {
244
244
  nonce: string;
245
245
  codeVerifier: string;
246
246
  returnTo: string;
247
+ prompt?: OIDCLoginOptions['prompt'];
248
+ originalSub?: string;
247
249
  createdAt: number;
248
250
  };
249
251
  export type MinimalRequestEvent = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sourceregistry/sveltekit-oidc",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "OIDC authentication helpers for SvelteKit applications",
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {