@sourceregistry/sveltekit-oidc 2.1.0 → 3.0.0

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
@@ -27,61 +27,82 @@ npm install @sourceregistry/sveltekit-oidc
27
27
  import {createOIDC} from '@sourceregistry/sveltekit-oidc/server';
28
28
 
29
29
  type Identity = {
30
- sub: string;
31
- email?: string;
32
- name?: string;
33
- roles: string[];
34
- permissions?: string[];
30
+ sub: string;
31
+ email?: string;
32
+ name?: string;
33
+ roles: string[];
34
+ permissions?: string[];
35
35
  };
36
36
 
37
37
  type RequestData = {
38
- permissions: string[];
38
+ permissions: string[];
39
39
  };
40
40
 
41
41
  export const oidc = createOIDC<Identity, RequestData>({
42
- issuer: 'https://identity.example.com',
43
- clientId: process.env.OIDC_CLIENT_ID!,
44
- clientSecret: process.env.OIDC_CLIENT_SECRET!,
45
- clientAuthMethod: 'client_secret_basic',
46
- cookieSecret: process.env.OIDC_COOKIE_SECRET!,
47
- scope: ['openid', 'profile', 'email', 'offline_access'],
48
-
49
- resolveIdentity: ({idTokenClaims, userInfo}) => ({
50
- sub: idTokenClaims.sub,
51
- email: userInfo?.email ?? idTokenClaims.email,
52
- name: userInfo?.name ?? idTokenClaims.name,
53
- roles: Array.isArray(userInfo?.roles ?? idTokenClaims.roles)
54
- ? ((userInfo?.roles ?? idTokenClaims.roles) as string[])
55
- : []
56
- }),
57
-
58
- beforeSessionPersist: async ({session, reason}) => {
59
- const identity = await synchronizeUser(session.identity, reason);
60
- return {...session, identity};
61
- },
62
-
63
- loadRequestData: async ({session, event}) => ({
64
- permissions: await loadPermissions(session.sub!, event)
65
- }),
66
-
67
- createPublicSession: ({base, data}) => ({
68
- ...base,
69
- identity: {
70
- ...base.identity,
71
- permissions: data?.permissions ?? []
72
- }
73
- })
42
+ issuer: 'https://identity.example.com',
43
+ clientId: process.env.OIDC_CLIENT_ID!,
44
+ clientSecret: process.env.OIDC_CLIENT_SECRET!,
45
+ clientAuthMethod: 'client_secret_basic',
46
+ cookieSecret: process.env.OIDC_COOKIE_SECRET!,
47
+ scope: ['openid', 'profile', 'email', 'offline_access'],
48
+
49
+ resolveIdentity: ({idTokenClaims, userInfo}) => ({
50
+ sub: idTokenClaims.sub,
51
+ email: userInfo?.email ?? idTokenClaims.email,
52
+ name: userInfo?.name ?? idTokenClaims.name,
53
+ roles: Array.isArray(userInfo?.roles ?? idTokenClaims.roles)
54
+ ? ((userInfo?.roles ?? idTokenClaims.roles) as string[])
55
+ : []
56
+ }),
57
+
58
+ beforeSessionPersist: async ({session, reason}) => {
59
+ const identity = await synchronizeUser(session.identity, reason);
60
+ return {...session, identity};
61
+ },
62
+
63
+ loadRequestData: async ({session, event}) => ({
64
+ permissions: await loadPermissions(session.sub!, event)
65
+ }),
66
+
67
+ createPublicSession: ({base, data}) => ({
68
+ ...base,
69
+ identity: {
70
+ ...base.identity,
71
+ permissions: data?.permissions ?? []
72
+ }
73
+ })
74
74
  });
75
75
  ```
76
76
 
77
+ `cookieSecret` must contain at least 32 bytes of entropy. For example:
78
+
79
+ ```sh
80
+ openssl rand -base64 32
81
+ ```
82
+
83
+ Discovery and protocol endpoints must use HTTPS by default. Set `allowInsecureHttp: true` only for
84
+ local development providers. `openid` is always included in the requested scope, and values in
85
+ `extraParams` cannot replace security-sensitive authorization parameters such as `state`, `nonce`,
86
+ PKCE, `redirect_uri`, or `client_id`.
87
+
88
+ If the client registration fixes an ID-token signing algorithm, pin it explicitly:
89
+
90
+ ```ts
91
+ idTokenSigningAlgorithms: ['RS256'],
92
+ trustedIdTokenAudiences: ['https://api.example.com']
93
+ ```
94
+
95
+ The client ID is always required in `aud`. `trustedIdTokenAudiences` permits only explicitly trusted
96
+ additional audience values; it does not replace the client ID.
97
+
77
98
  The extension points have deliberately literal names:
78
99
 
79
- | Extension point | When it runs | Persisted |
80
- | ----------------------- | ----------------------------------------------------------------- | ------------------------------------------------ |
81
- | `resolveIdentity` | After provider data is validated, on login and refresh | Its result is persisted |
82
- | `beforeSessionPersist` | Immediately before a login or refreshed session is written | Returned session replaces it; `void` keeps it |
83
- | `loadRequestData` | Once while `handle` builds an authenticated request context | Never |
84
- | `createPublicSession` | When `getPublicSession` or `toPublicSession` projects a session | Never |
100
+ | Extension point | When it runs | Persisted |
101
+ | ---------------------- | --------------------------------------------------------------- | --------------------------------------------- |
102
+ | `resolveIdentity` | After provider data is validated, on login and refresh | Its result is persisted |
103
+ | `beforeSessionPersist` | Immediately before a login or refreshed session is written | Returned session replaces it; `void` keeps it |
104
+ | `loadRequestData` | Once while `handle` builds an authenticated request context | Never |
105
+ | `createPublicSession` | When `getPublicSession` or `toPublicSession` projects a session | Never |
85
106
 
86
107
  Both login and refresh are explicit in the callback context. Returning a session from
87
108
  `beforeSessionPersist` is what makes it the right place to provision or enrich application data —
@@ -89,13 +110,13 @@ e.g. upserting a user row — before the very first session for that user is per
89
110
 
90
111
  ```ts
91
112
  beforeSessionPersist: async ({session, reason}) => {
92
- if (reason !== 'login') return;
93
- const user = await upsertUser(session.identity);
94
- return {...session, identity: {...session.identity, ...user}};
113
+ if (reason !== 'login') return;
114
+ const user = await upsertUser(session.identity);
115
+ return {...session, identity: {...session.identity, ...user}};
95
116
  };
96
117
  ```
97
118
 
98
- `resolveIdentity` runs first and may only be able to *read* application data (the user may not
119
+ `resolveIdentity` runs first and may only be able to _read_ application data (the user may not
99
120
  exist yet on a first login). `beforeSessionPersist` runs next, right before the write, so a session
100
121
  mutated or replaced there is the one every subsequent read of that session — including the result
101
122
  returned from `handleCallback`/`callbackHandler`'s `onsuccess` — actually sees.
@@ -125,11 +146,11 @@ import type {OIDCLocals} from '@sourceregistry/sveltekit-oidc/server';
125
146
  import type {oidc} from '$lib/server/auth';
126
147
 
127
148
  declare global {
128
- namespace App {
129
- interface Locals {
130
- oidc?: OIDCLocals<typeof oidc>;
131
- }
132
- }
149
+ namespace App {
150
+ interface Locals {
151
+ oidc?: OIDCLocals<typeof oidc>;
152
+ }
153
+ }
133
154
  }
134
155
 
135
156
  export {};
@@ -210,6 +231,7 @@ sequenceDiagram
210
231
  `handle` (the SvelteKit hook) wraps every request outside of these four routes: it calls
211
232
  `getSession`, which transparently refreshes an expiring session — running `resolveIdentity` and
212
233
  `beforeSessionPersist` again with `reason: 'refresh'` — before exposing `event.locals.oidc`.
234
+
213
235
  - `getSession(event)`
214
236
  - `requireAuth(event)`
215
237
  - `clearSession(cookies)`
@@ -223,10 +245,10 @@ Load a token-free session for the browser:
223
245
  import {oidc} from '$lib/server/auth';
224
246
 
225
247
  export async function load(event) {
226
- return {
227
- session: oidc.toPublicSession(event.locals.oidc, event.depends),
228
- sessionManagement: await oidc.getSessionManagementConfig()
229
- };
248
+ return {
249
+ session: oidc.toPublicSession(event.locals.oidc, event.depends),
250
+ sessionManagement: await oidc.getSessionManagementConfig()
251
+ };
230
252
  }
231
253
  ```
232
254
 
@@ -243,7 +265,13 @@ returns. `getPublicSession(event)` is available when the hook has not already lo
243
265
  let { data, children } = $props();
244
266
  </script>
245
267
 
246
- <OIDCContext session={data.session} config={data.sessionManagement}>
268
+ <OIDCContext
269
+ session={data.session}
270
+ config={data.sessionManagement}
271
+ idleTimeoutMs={30 * 60 * 1000}
272
+ idleWarningMs={60 * 1000}
273
+ heartbeatUrl="/auth/heartbeat"
274
+ >
247
275
  {@render children()}
248
276
  </OIDCContext>
249
277
  ```
@@ -262,6 +290,12 @@ returns. `getPublicSession(event)` is available when the hook has not already lo
262
290
  `OIDCContext` supports local expiry handling, targeted SvelteKit revalidation,
263
291
  `check_session_iframe` monitoring, and local or provider logout.
264
292
 
293
+ Idle deadlines use absolute timestamps, synchronize activity across tabs, and remain correct after a
294
+ tab or device resumes from sleep. The default idle action performs provider logout; set
295
+ `redirectOnIdle="logout"` only when clearing the application session without ending the OP
296
+ session is intentional. `heartbeatUrl` is application-owned and should be a same-origin,
297
+ CSRF-protected endpoint that returns `401` or `403` when the session is no longer valid.
298
+
265
299
  When the OP iframe reports `changed`, the component first performs the Session Management 1.0
266
300
  `prompt=none` authorization check in a hidden iframe. The login handler supplies the current ID token
267
301
  as `id_token_hint`; a matching End-User refreshes the local session, while an OP error or a different
@@ -270,34 +304,56 @@ not need an additional endpoint.
270
304
 
271
305
  ## Session stores
272
306
 
273
- Without `sessionStore`, the encrypted session is stored in the cookie. For server-side sessions:
307
+ Without `sessionStore`, the encrypted session is stored in the cookie. The default maximum serialized
308
+ cookie size is 3800 bytes so oversized sessions fail explicitly instead of being silently truncated by
309
+ a browser or proxy. Use a server-side store for large tokens or identities:
274
310
 
275
311
  ```ts
276
312
  import type {OIDCSessionStore} from '@sourceregistry/sveltekit-oidc/server';
277
313
 
278
314
  const sessionStore: OIDCSessionStore<Identity> = {
279
- get: (id) => redis.get(`session:${id}`),
280
- set: async (id, session) => {
281
- await redis.set(`session:${id}`, session);
282
- },
283
- delete: async (id) => {
284
- await redis.delete(`session:${id}`);
285
- }
315
+ get: (id) => redis.get(`session:${id}`),
316
+ set: async (id, session) => {
317
+ await redis.set(`session:${id}`, session);
318
+ },
319
+ delete: async (id) => {
320
+ await redis.delete(`session:${id}`);
321
+ }
286
322
  };
287
323
  ```
288
324
 
289
325
  Use a shared `backChannelLogoutStore` when back-channel logout must work across multiple instances.
290
326
  The built-in `'memory'` stores are intended for local development or single-process deployments.
291
327
 
328
+ Providers that rotate refresh tokens also need a distributed `refreshLock` in multi-instance
329
+ deployments. The built-in promise coalescing prevents duplicate refreshes within one process; the
330
+ lock must serialize the supplied operation by session ID across every application instance:
331
+
332
+ ```ts
333
+ const refreshLock = {
334
+ runExclusive: <T>(sessionId: string, operation: () => Promise<T>) =>
335
+ redlock.using([`oidc-refresh:${sessionId}`], 10_000, operation)
336
+ };
337
+ ```
338
+
292
339
  ## Security behavior
293
340
 
294
- - Authorization Code flow uses PKCE, state, and nonce.
295
- - ID tokens are verified against provider JWKS and require matching issuer, audience, nonce, `exp`, and `iat`.
341
+ - Authorization Code flow uses PKCE, nonce, and an encrypted state value; each pending authorization
342
+ transaction has its own cookie, so concurrent logins in separate tabs do not overwrite each other.
343
+ - Discovery metadata is bound to the configured issuer, and HTTPS is required unless explicitly
344
+ disabled for local development.
345
+ - Initial ID tokens require matching issuer, client audience, nonce, `exp`, and `iat`. Refreshed ID
346
+ tokens may omit nonce as allowed by OIDC Core, but must preserve subject, audiences, authorized
347
+ party, and authentication time.
296
348
  - UserInfo `sub` must match the validated ID token subject.
297
349
  - Cookie sessions use authenticated encryption.
298
350
  - Return and post-logout redirect values are restricted to same-origin paths.
299
351
  - Local sessions have an eight-hour maximum lifetime by default.
300
- - Refresh is automatic while a valid refresh token is available.
352
+ - Refresh is automatic while a valid refresh token is available and is coalesced per session within a
353
+ process.
354
+ - Back-channel logout tokens require the logout event, `iat`, `exp`, `jti`, and exactly one or both of
355
+ `sid` and `sub`; `nonce` is rejected. Revocations expire and do not revoke later logins permanently.
356
+ - Local session clearing does not depend on provider discovery being available.
301
357
  - Client authentication supports `none`, `client_secret_basic`, `client_secret_post`, `client_secret_jwt`, and `private_key_jwt`.
302
358
 
303
359
  Application code can normalize provider-specific data in `resolveIdentity`, but cannot replace the
@@ -11,9 +11,10 @@
11
11
  import type {Snippet} from 'svelte';
12
12
 
13
13
  import {setOIDCContext} from './context.js';
14
+ import {getIdlePhase, normalizeIdleDuration} from './idle.js';
14
15
  import {classifySessionMonitorEvent} from './session-monitor.js';
15
16
 
16
- type RedirectMode = 'login' | 'logout' | 'reload' | 'none';
17
+ type RedirectMode = 'login' | 'logout' | 'provider-logout' | 'reload' | 'none';
17
18
 
18
19
  type OIDCClientDebugEvent = {
19
20
  type: string;
@@ -32,6 +33,14 @@
32
33
  redirectOnExpired = 'login',
33
34
  redirectOnRevoked = 'login',
34
35
  redirectIfUnauthenticated = false,
36
+ idleTimeoutMs = 0,
37
+ redirectOnIdle = 'provider-logout',
38
+ idleEvents = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart', 'wheel'],
39
+ idleActivityThrottleMs = 1000,
40
+ idleWarningMs = 0,
41
+ idleWarning,
42
+ heartbeatUrl,
43
+ heartbeatIntervalMs = 60_000,
35
44
  onDebug,
36
45
  children
37
46
  }: {
@@ -46,6 +55,30 @@
46
55
  redirectOnExpired?: RedirectMode;
47
56
  redirectOnRevoked?: RedirectMode;
48
57
  redirectIfUnauthenticated?: boolean;
58
+ /** Milliseconds of no user activity before forcing redirectOnIdle. 0 disables idle tracking. */
59
+ idleTimeoutMs?: number;
60
+ redirectOnIdle?: RedirectMode;
61
+ /** window events treated as activity; reset the idle timer, throttled by idleActivityThrottleMs. */
62
+ idleEvents?: string[];
63
+ idleActivityThrottleMs?: number;
64
+ /**
65
+ * How long before idleTimeoutMs to show idleWarning instead of redirecting immediately.
66
+ * 0 (default) skips the warning and redirects at idleTimeoutMs, same as before.
67
+ * Must be less than idleTimeoutMs.
68
+ */
69
+ idleWarningMs?: number;
70
+ /** Rendered while the idle warning is active. Passed secondsRemaining plus stayLoggedIn/logout callbacks. */
71
+ idleWarning?: Snippet<[{secondsRemaining: number; stayLoggedIn: () => void; logout: () => void}]>;
72
+ /**
73
+ * Server endpoint touched on activity to reset the *server-side* idle timer
74
+ * (e.g. the identity service's session_idle_timeout). Undefined disables this —
75
+ * revalidate()/stayLoggedIn() alone only reach the server when the access token
76
+ * is near expiry, so without this the server session can idle out independently
77
+ * of what this component shows.
78
+ */
79
+ heartbeatUrl?: string;
80
+ /** Minimum gap between heartbeat requests while active. Match the server's own touch throttle. */
81
+ heartbeatIntervalMs?: number;
49
82
  /** Receives token-safe lifecycle events for diagnosing session changes. */
50
83
  onDebug?: (event: OIDCClientDebugEvent) => void;
51
84
  children?: Snippet;
@@ -58,9 +91,13 @@
58
91
  let status = $state<'authenticated' | 'unauthenticated' | 'expired' | 'revoked'>('unauthenticated');
59
92
  let revalidating = $state(false);
60
93
  let handledUnauthenticated = $state(false);
94
+ let idleWarningVisible = $state(false);
95
+ let idleSecondsRemaining = $state(0);
96
+ let idleStayLoggedIn = () => {};
97
+ let idleLogoutNow = () => {};
61
98
  let sessionExpiredDuringRevalidation = false;
62
99
 
63
- function debug(type: string, details?: Record<string, unknown>) {
100
+ function debug(type: string, details: Record<string, unknown> | undefined = undefined) {
64
101
  onDebug?.({type, details});
65
102
  }
66
103
 
@@ -73,6 +110,11 @@
73
110
  const canMonitorIframe = $derived(
74
111
  Boolean(monitorSession && session?.isAuthenticated && session?.sessionState && iframeUrl)
75
112
  );
113
+ const effectiveIdleTimeoutMs = $derived(normalizeIdleDuration(idleTimeoutMs));
114
+ const effectiveIdleWarningMs = $derived(normalizeIdleDuration(idleWarningMs));
115
+ const effectiveIdleActivityThrottleMs = $derived(normalizeIdleDuration(idleActivityThrottleMs, 1000));
116
+ const effectiveHeartbeatIntervalMs = $derived(normalizeIdleDuration(heartbeatIntervalMs, 60_000));
117
+ const idleActivityStorageKey = $derived(`sveltekit-oidc:activity:${config.issuer}:${config.clientId}`);
76
118
 
77
119
  const context = setOIDCContext<TIdentity>({
78
120
  get isAuthenticated() {
@@ -99,7 +141,7 @@
99
141
  get revalidating() {
100
142
  return revalidating;
101
143
  },
102
- login: (returnTo?: string) => login(returnTo),
144
+ login: (returnTo: string | undefined = undefined) => login(returnTo),
103
145
  logout,
104
146
  revalidate
105
147
  });
@@ -118,7 +160,7 @@
118
160
 
119
161
  function buildLoginUrl(
120
162
  returnTo = `${window.location.pathname}${window.location.search}`,
121
- prompt?: 'none'
163
+ prompt: 'none' | undefined = undefined
122
164
  ) {
123
165
  const url = new URL(resolvedLoginPath, window.location.href);
124
166
  url.searchParams.set('returnTo', returnTo);
@@ -153,7 +195,24 @@
153
195
  form.submit();
154
196
  }
155
197
 
156
- function login(returnTo?: string) {
198
+ async function sendHeartbeat(): Promise<boolean> {
199
+ if (!heartbeatUrl) return true;
200
+ try {
201
+ const response = await fetch(heartbeatUrl, {method: 'POST', credentials: 'same-origin'});
202
+ if (!response.ok) {
203
+ debug('heartbeat_failed', {status: response.status});
204
+ if (response.status === 401 || response.status === 403) void handleRedirect('logout');
205
+ return false;
206
+ }
207
+ debug('heartbeat_sent');
208
+ return true;
209
+ } catch (err) {
210
+ debug('heartbeat_failed', {error: err instanceof Error ? err.message : String(err)});
211
+ return false;
212
+ }
213
+ }
214
+
215
+ function login(returnTo: string | undefined = undefined) {
157
216
  debug('login_redirect_requested', {returnTo: returnTo ?? `${window.location.pathname}${window.location.search}`});
158
217
  // loginPath is a plain +server.ts redirect endpoint, not a routable
159
218
  // page — use a full browser navigation, not SvelteKit's goto().
@@ -209,6 +268,10 @@
209
268
  window.location.reload();
210
269
  return;
211
270
  }
271
+ if (mode === 'provider-logout') {
272
+ await logout(false);
273
+ return;
274
+ }
212
275
 
213
276
  login();
214
277
  }
@@ -331,6 +394,147 @@
331
394
  return () => window.clearInterval(timer);
332
395
  });
333
396
 
397
+ $effect(() => {
398
+ if (!heartbeatUrl || !session?.isAuthenticated) {
399
+ return;
400
+ }
401
+
402
+ let lastHeartbeatAt = 0;
403
+ let heartbeatInFlight = false;
404
+
405
+ function onActivity() {
406
+ const now = Date.now();
407
+ if (heartbeatInFlight || now - lastHeartbeatAt < effectiveHeartbeatIntervalMs) return;
408
+ lastHeartbeatAt = now;
409
+ heartbeatInFlight = true;
410
+ void sendHeartbeat().finally(() => {
411
+ heartbeatInFlight = false;
412
+ });
413
+ }
414
+
415
+ for (const eventName of idleEvents) {
416
+ window.addEventListener(eventName, onActivity, {passive: true});
417
+ }
418
+
419
+ return () => {
420
+ for (const eventName of idleEvents) {
421
+ window.removeEventListener(eventName, onActivity);
422
+ }
423
+ };
424
+ });
425
+
426
+ $effect(() => {
427
+ if (!effectiveIdleTimeoutMs || !session?.isAuthenticated) {
428
+ idleWarningVisible = false;
429
+ return;
430
+ }
431
+
432
+ let timer: number | undefined;
433
+ let lastActivityAt = Date.now();
434
+ let lastLocalActivityAt = 0;
435
+ let logoutTriggered = false;
436
+ try {
437
+ const sharedActivity = Number(window.localStorage.getItem(idleActivityStorageKey));
438
+ if (Number.isFinite(sharedActivity)) lastActivityAt = Math.max(lastActivityAt, sharedActivity);
439
+ } catch {
440
+ // Storage can be denied in privacy modes; per-tab idle tracking still works.
441
+ }
442
+
443
+ function shareActivity(timestamp: number) {
444
+ try {
445
+ window.localStorage.setItem(idleActivityStorageKey, String(timestamp));
446
+ } catch {
447
+ // See storage-read fallback above.
448
+ }
449
+ }
450
+
451
+ function schedule() {
452
+ if (timer) window.clearTimeout(timer);
453
+ const phase = getIdlePhase(
454
+ lastActivityAt,
455
+ Date.now(),
456
+ effectiveIdleTimeoutMs,
457
+ effectiveIdleWarningMs
458
+ );
459
+ idleSecondsRemaining = phase.secondsRemaining;
460
+ if (phase.phase === 'idle') {
461
+ if (!logoutTriggered) {
462
+ logoutTriggered = true;
463
+ idleWarningVisible = false;
464
+ debug('idle_timeout_reached', {idleTimeoutMs: effectiveIdleTimeoutMs});
465
+ void handleRedirect(redirectOnIdle);
466
+ }
467
+ return;
468
+ }
469
+ if (phase.phase === 'warning' && !idleWarningVisible) {
470
+ idleWarningVisible = true;
471
+ debug('idle_warning_shown', {idleWarningMs: effectiveIdleWarningMs});
472
+ } else if (phase.phase === 'active') {
473
+ idleWarningVisible = false;
474
+ }
475
+ const untilTransition = Math.max(1, (phase.nextTransitionAt ?? Date.now() + 1000) - Date.now());
476
+ timer = window.setTimeout(schedule, phase.phase === 'warning' ? Math.min(1000, untilTransition) : untilTransition);
477
+ }
478
+
479
+ function onActivity() {
480
+ // Once the warning is up, passive activity (e.g. moving the mouse while
481
+ // reading the modal) must not silently dismiss it — only an explicit
482
+ // stayLoggedIn() response should reset the timer.
483
+ if (idleWarningVisible) return;
484
+ const now = Date.now();
485
+ if (now - lastLocalActivityAt < effectiveIdleActivityThrottleMs) return;
486
+ lastLocalActivityAt = now;
487
+ lastActivityAt = now;
488
+ shareActivity(now);
489
+ schedule();
490
+ }
491
+
492
+ function onSharedActivity(event: StorageEvent) {
493
+ if (event.key !== idleActivityStorageKey) return;
494
+ const timestamp = Number(event.newValue);
495
+ if (!Number.isFinite(timestamp) || timestamp <= lastActivityAt) return;
496
+ lastActivityAt = timestamp;
497
+ logoutTriggered = false;
498
+ schedule();
499
+ }
500
+
501
+ idleStayLoggedIn = () => {
502
+ debug('idle_warning_dismissed_stay_logged_in');
503
+ void (async () => {
504
+ if (!(await sendHeartbeat())) return;
505
+ await revalidate();
506
+ await tick();
507
+ if (!session?.isAuthenticated) return;
508
+ lastActivityAt = Date.now();
509
+ logoutTriggered = false;
510
+ shareActivity(lastActivityAt);
511
+ schedule();
512
+ })();
513
+ };
514
+ idleLogoutNow = () => {
515
+ if (timer) window.clearTimeout(timer);
516
+ idleWarningVisible = false;
517
+ void logout();
518
+ };
519
+
520
+ shareActivity(lastActivityAt);
521
+ schedule();
522
+ for (const eventName of idleEvents) {
523
+ window.addEventListener(eventName, onActivity, {passive: true});
524
+ }
525
+ window.addEventListener('storage', onSharedActivity);
526
+ document.addEventListener('visibilitychange', schedule);
527
+
528
+ return () => {
529
+ if (timer) window.clearTimeout(timer);
530
+ for (const eventName of idleEvents) {
531
+ window.removeEventListener(eventName, onActivity);
532
+ }
533
+ window.removeEventListener('storage', onSharedActivity);
534
+ document.removeEventListener('visibilitychange', schedule);
535
+ };
536
+ });
537
+
334
538
  $effect(() => {
335
539
  if (session?.isAuthenticated) {
336
540
  handledUnauthenticated = false;
@@ -404,4 +608,12 @@
404
608
  ></iframe>
405
609
  {/if}
406
610
 
611
+ {#if idleWarningVisible && idleWarning}
612
+ {@render idleWarning({
613
+ secondsRemaining: idleSecondsRemaining,
614
+ stayLoggedIn: () => idleStayLoggedIn(),
615
+ logout: () => idleLogoutNow()
616
+ })}
617
+ {/if}
618
+
407
619
  {@render children?.()}
@@ -10,9 +10,37 @@ declare function $$render<TIdentity extends OIDCUserClaims = OIDCUserClaims>():
10
10
  monitorSession?: boolean;
11
11
  revalidateIntervalMs?: number;
12
12
  renewalLeadTimeMs?: number;
13
- redirectOnExpired?: "login" | "none" | "logout" | "reload";
14
- redirectOnRevoked?: "login" | "none" | "logout" | "reload";
13
+ redirectOnExpired?: "login" | "none" | "logout" | "provider-logout" | "reload";
14
+ redirectOnRevoked?: "login" | "none" | "logout" | "provider-logout" | "reload";
15
15
  redirectIfUnauthenticated?: boolean;
16
+ /** Milliseconds of no user activity before forcing redirectOnIdle. 0 disables idle tracking. */
17
+ idleTimeoutMs?: number;
18
+ redirectOnIdle?: "login" | "none" | "logout" | "provider-logout" | "reload";
19
+ /** window events treated as activity; reset the idle timer, throttled by idleActivityThrottleMs. */
20
+ idleEvents?: string[];
21
+ idleActivityThrottleMs?: number;
22
+ /**
23
+ * How long before idleTimeoutMs to show idleWarning instead of redirecting immediately.
24
+ * 0 (default) skips the warning and redirects at idleTimeoutMs, same as before.
25
+ * Must be less than idleTimeoutMs.
26
+ */
27
+ idleWarningMs?: number;
28
+ /** Rendered while the idle warning is active. Passed secondsRemaining plus stayLoggedIn/logout callbacks. */
29
+ idleWarning?: Snippet<[{
30
+ secondsRemaining: number;
31
+ stayLoggedIn: () => void;
32
+ logout: () => void;
33
+ }]>;
34
+ /**
35
+ * Server endpoint touched on activity to reset the *server-side* idle timer
36
+ * (e.g. the identity service's session_idle_timeout). Undefined disables this —
37
+ * revalidate()/stayLoggedIn() alone only reach the server when the access token
38
+ * is near expiry, so without this the server session can idle out independently
39
+ * of what this component shows.
40
+ */
41
+ heartbeatUrl?: string;
42
+ /** Minimum gap between heartbeat requests while active. Match the server's own touch throttle. */
43
+ heartbeatIntervalMs?: number;
16
44
  /** Receives token-safe lifecycle events for diagnosing session changes. */
17
45
  onDebug?: (event: {
18
46
  type: string;
@@ -0,0 +1,7 @@
1
+ export type IdlePhase = {
2
+ phase: 'active' | 'warning' | 'idle';
3
+ secondsRemaining: number;
4
+ nextTransitionAt?: number;
5
+ };
6
+ export declare function normalizeIdleDuration(value: number, fallback?: number): number;
7
+ export declare function getIdlePhase(lastActivityAt: number, now: number, idleTimeoutMs: number, idleWarningMs: number): IdlePhase;
@@ -0,0 +1,22 @@
1
+ export function normalizeIdleDuration(value, fallback = 0) {
2
+ return Number.isFinite(value) && value > 0 ? value : fallback;
3
+ }
4
+ export function getIdlePhase(lastActivityAt, now, idleTimeoutMs, idleWarningMs) {
5
+ const deadline = lastActivityAt + idleTimeoutMs;
6
+ if (now >= deadline)
7
+ return { phase: 'idle', secondsRemaining: 0 };
8
+ const warningMs = idleWarningMs > 0 && idleWarningMs < idleTimeoutMs ? idleWarningMs : 0;
9
+ const warningAt = deadline - warningMs;
10
+ if (warningMs && now >= warningAt) {
11
+ return {
12
+ phase: 'warning',
13
+ secondsRemaining: Math.max(0, Math.ceil((deadline - now) / 1000)),
14
+ nextTransitionAt: deadline
15
+ };
16
+ }
17
+ return {
18
+ phase: 'active',
19
+ secondsRemaining: Math.max(0, Math.ceil((deadline - now) / 1000)),
20
+ nextTransitionAt: warningMs ? warningAt : deadline
21
+ };
22
+ }
@@ -1,2 +1,2 @@
1
1
  import type { CookieOptions, OIDCCookies, OIDCUserClaims } from './types.js';
2
- export declare function createOIDCCookieStore<TIdentity extends OIDCUserClaims = OIDCUserClaims>(cookieSecret: string, sessionCookieName: string, stateCookieName: string, cookieOptions: CookieOptions): OIDCCookies<TIdentity>;
2
+ export declare function createOIDCCookieStore<TIdentity extends OIDCUserClaims = OIDCUserClaims>(cookieSecret: string, sessionCookieName: string, stateCookieName: string, cookieOptions: CookieOptions, stateMaxAgeSeconds: number, maxCookieSizeBytes: number): OIDCCookies<TIdentity>;