@sourceregistry/sveltekit-oidc 2.0.2 → 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,71 +27,100 @@ 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
- await synchronizeUser(session.identity, reason);
60
- },
61
-
62
- loadRequestData: async ({session, event}) => ({
63
- permissions: await loadPermissions(session.sub!, event)
64
- }),
65
-
66
- createPublicSession: ({base, data}) => ({
67
- ...base,
68
- identity: {
69
- ...base.identity,
70
- permissions: data?.permissions ?? []
71
- }
72
- })
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
+ })
73
74
  });
74
75
  ```
75
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
+
76
98
  The extension points have deliberately literal names:
77
99
 
78
- | Extension point | When it runs | Persisted |
79
- | ---------------------- | --------------------------------------------------------------- | ----------------------- |
80
- | `resolveIdentity` | After provider data is validated, on login and refresh | Its result is persisted |
81
- | `beforeSessionPersist` | Immediately before a login or refreshed session is written | Side effects only |
82
- | `loadRequestData` | Once while `handle` builds an authenticated request context | Never |
83
- | `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 |
84
106
 
85
- Both login and refresh are explicit in the callback context:
107
+ Both login and refresh are explicit in the callback context. Returning a session from
108
+ `beforeSessionPersist` is what makes it the right place to provision or enrich application data —
109
+ e.g. upserting a user row — before the very first session for that user is persisted:
86
110
 
87
111
  ```ts
88
112
  beforeSessionPersist: async ({session, reason}) => {
89
- if (reason === 'login') {
90
- await recordLogin(session.identity);
91
- }
113
+ if (reason !== 'login') return;
114
+ const user = await upsertUser(session.identity);
115
+ return {...session, identity: {...session.identity, ...user}};
92
116
  };
93
117
  ```
94
118
 
119
+ `resolveIdentity` runs first and may only be able to _read_ application data (the user may not
120
+ exist yet on a first login). `beforeSessionPersist` runs next, right before the write, so a session
121
+ mutated or replaced there is the one every subsequent read of that session — including the result
122
+ returned from `handleCallback`/`callbackHandler`'s `onsuccess` — actually sees.
123
+
95
124
  ## SvelteKit hook
96
125
 
97
126
  ```ts
@@ -117,11 +146,11 @@ import type {OIDCLocals} from '@sourceregistry/sveltekit-oidc/server';
117
146
  import type {oidc} from '$lib/server/auth';
118
147
 
119
148
  declare global {
120
- namespace App {
121
- interface Locals {
122
- oidc?: OIDCLocals<typeof oidc>;
123
- }
124
- }
149
+ namespace App {
150
+ interface Locals {
151
+ oidc?: OIDCLocals<typeof oidc>;
152
+ }
153
+ }
125
154
  }
126
155
 
127
156
  export {};
@@ -159,6 +188,50 @@ The underlying operations are also available directly when a route needs custom
159
188
  - `handleCallback(event)`
160
189
  - `logout(event, options)`
161
190
  - `handleBackChannelLogout(event)`
191
+
192
+ ### Request flow
193
+
194
+ ```mermaid
195
+ sequenceDiagram
196
+ participant Browser
197
+ participant login as loginHandler
198
+ participant callback as callbackHandler
199
+ participant logout as logoutHandler
200
+ participant bcl as backChannelLogoutHandler
201
+ participant OP as OpenID Provider
202
+
203
+ Browser->>login: GET /auth/login
204
+ login->>login: create PKCE pair, state, nonce
205
+ login-->>Browser: 302 redirect to OP authorize endpoint
206
+ Browser->>OP: authenticate
207
+ OP-->>Browser: 302 redirect with code & state
208
+
209
+ Browser->>callback: GET /auth/callback?code&state
210
+ callback->>OP: POST token endpoint (exchange code)
211
+ OP-->>callback: id_token, access_token, refresh_token
212
+ callback->>OP: verify id_token against JWKS
213
+ callback->>OP: GET userinfo endpoint (optional)
214
+ callback->>callback: resolveIdentity(idTokenClaims, userInfo)
215
+ callback->>callback: beforeSessionPersist(session, reason:'login')
216
+ Note over callback: a returned session here replaces<br/>what gets persisted and returned
217
+ callback->>callback: write session (cookie or sessionStore)
218
+ callback-->>Browser: onsuccess(event, result) or 302 redirect
219
+
220
+ Browser->>logout: POST /auth/logout
221
+ logout->>logout: clear persisted session
222
+ logout-->>Browser: 302 redirect to OP end_session endpoint or local page
223
+
224
+ OP->>bcl: POST /auth/backchannel-logout (logout_token)
225
+ bcl->>OP: verify logout_token against JWKS
226
+ bcl->>bcl: backChannelLogoutStore.revoke(sid/sub)
227
+ bcl-->>OP: 200 OK
228
+ Note over bcl: next getSession()/requireAuth() call<br/>for that sid/sub treats the session as revoked
229
+ ```
230
+
231
+ `handle` (the SvelteKit hook) wraps every request outside of these four routes: it calls
232
+ `getSession`, which transparently refreshes an expiring session — running `resolveIdentity` and
233
+ `beforeSessionPersist` again with `reason: 'refresh'` — before exposing `event.locals.oidc`.
234
+
162
235
  - `getSession(event)`
163
236
  - `requireAuth(event)`
164
237
  - `clearSession(cookies)`
@@ -172,10 +245,10 @@ Load a token-free session for the browser:
172
245
  import {oidc} from '$lib/server/auth';
173
246
 
174
247
  export async function load(event) {
175
- return {
176
- session: oidc.toPublicSession(event.locals.oidc, event.depends),
177
- sessionManagement: await oidc.getSessionManagementConfig()
178
- };
248
+ return {
249
+ session: oidc.toPublicSession(event.locals.oidc, event.depends),
250
+ sessionManagement: await oidc.getSessionManagementConfig()
251
+ };
179
252
  }
180
253
  ```
181
254
 
@@ -192,7 +265,13 @@ returns. `getPublicSession(event)` is available when the hook has not already lo
192
265
  let { data, children } = $props();
193
266
  </script>
194
267
 
195
- <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
+ >
196
275
  {@render children()}
197
276
  </OIDCContext>
198
277
  ```
@@ -211,6 +290,12 @@ returns. `getPublicSession(event)` is available when the hook has not already lo
211
290
  `OIDCContext` supports local expiry handling, targeted SvelteKit revalidation,
212
291
  `check_session_iframe` monitoring, and local or provider logout.
213
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
+
214
299
  When the OP iframe reports `changed`, the component first performs the Session Management 1.0
215
300
  `prompt=none` authorization check in a hidden iframe. The login handler supplies the current ID token
216
301
  as `id_token_hint`; a matching End-User refreshes the local session, while an OP error or a different
@@ -219,34 +304,56 @@ not need an additional endpoint.
219
304
 
220
305
  ## Session stores
221
306
 
222
- 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:
223
310
 
224
311
  ```ts
225
312
  import type {OIDCSessionStore} from '@sourceregistry/sveltekit-oidc/server';
226
313
 
227
314
  const sessionStore: OIDCSessionStore<Identity> = {
228
- get: (id) => redis.get(`session:${id}`),
229
- set: async (id, session) => {
230
- await redis.set(`session:${id}`, session);
231
- },
232
- delete: async (id) => {
233
- await redis.delete(`session:${id}`);
234
- }
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
+ }
235
322
  };
236
323
  ```
237
324
 
238
325
  Use a shared `backChannelLogoutStore` when back-channel logout must work across multiple instances.
239
326
  The built-in `'memory'` stores are intended for local development or single-process deployments.
240
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
+
241
339
  ## Security behavior
242
340
 
243
- - Authorization Code flow uses PKCE, state, and nonce.
244
- - 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.
245
348
  - UserInfo `sub` must match the validated ID token subject.
246
349
  - Cookie sessions use authenticated encryption.
247
350
  - Return and post-logout redirect values are restricted to same-origin paths.
248
351
  - Local sessions have an eight-hour maximum lifetime by default.
249
- - 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.
250
357
  - Client authentication supports `none`, `client_secret_basic`, `client_secret_post`, `client_secret_jwt`, and `private_key_jwt`.
251
358
 
252
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;