@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 +125 -69
- package/dist/client/OIDCContext.svelte +217 -5
- package/dist/client/OIDCContext.svelte.d.ts +30 -2
- package/dist/client/idle.d.ts +7 -0
- package/dist/client/idle.js +22 -0
- package/dist/server/cookies.d.ts +1 -1
- package/dist/server/cookies.js +28 -7
- package/dist/server/index.js +346 -91
- package/dist/server/jwt.js +13 -3
- package/dist/server/store.d.ts +3 -1
- package/dist/server/store.js +27 -7
- package/dist/server/types.d.ts +41 -2
- package/dist/server/utils.d.ts +32 -1
- package/dist/server/utils.js +97 -4
- package/package.json +2 -2
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
sub: string;
|
|
31
|
+
email?: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
roles: string[];
|
|
34
|
+
permissions?: string[];
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
type RequestData = {
|
|
38
|
-
|
|
38
|
+
permissions: string[];
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
export const oidc = createOIDC<Identity, RequestData>({
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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,
|
|
295
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/server/cookies.d.ts
CHANGED
|
@@ -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>;
|