@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 +175 -68
- 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 +350 -95
- 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 +49 -4
- package/dist/server/utils.d.ts +32 -1
- package/dist/server/utils.js +97 -4
- package/package.json +8 -8
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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,
|
|
244
|
-
|
|
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
|
-
|
|
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;
|