@sourceregistry/sveltekit-oidc 1.6.8 → 1.6.10

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
@@ -298,8 +298,9 @@ Set these environment variables to enable it:
298
298
  - `cookieSecret` should be a strong random secret and must stay stable across instances.
299
299
  - Built-in `returnTo` and logout redirect values are restricted to same-origin paths. Use your own callback `onsuccess` logic if you need custom redirect policy.
300
300
  - `clockSkewSeconds` defaults to `30` and tolerates small clock drift between your app and the identity provider.
301
+ - Local browser sessions expire after 8 hours by default (`sessionMaxAgeSeconds`). They also end when an unrefreshable access token expires; set a shorter value for higher-risk applications.
301
302
  - `createInMemoryBackChannelLogoutStore()` is suitable for local development or single-instance deployments. Use Redis, SQL, or another shared store for production.
302
- - The library validates `id_token` and `logout_token` values through `@sourceregistry/node-jwt` and provider JWKS metadata.
303
+ - The library validates `id_token` and `logout_token` values through `@sourceregistry/node-jwt` and provider JWKS metadata. ID tokens must contain `exp`, `iat`, and a matching nonce; UserInfo subjects must match the validated ID token.
303
304
  - `groups` are normalized onto the session from `groups` and `roles` claims when present.
304
305
  - Use `transformClaims`, `transformUser`, and `transformSession` to project provider-specific claims into your own session shape.
305
306
  - `check_session_iframe` monitoring only runs when `monitorSession` is enabled, the provider advertises that endpoint, and the session includes `session_state`.
@@ -6,7 +6,7 @@
6
6
  </script>
7
7
 
8
8
  <script lang="ts" generics="TClaims extends OIDCUserClaims = OIDCUserClaims">
9
- import {invalidateAll} from '$app/navigation';
9
+ import {invalidateAll, beforeNavigate} from '$app/navigation';
10
10
  import {tick} from 'svelte';
11
11
  import type {Snippet} from 'svelte';
12
12
 
@@ -44,6 +44,7 @@
44
44
 
45
45
  let iframe = $state<HTMLIFrameElement | undefined>(undefined);
46
46
  let status = $state<'authenticated' | 'unauthenticated' | 'expired' | 'revoked'>('unauthenticated');
47
+ let revalidating = $state(false);
47
48
  let handledUnauthenticated = $state(false);
48
49
 
49
50
  const metadata = $derived(config.metadata);
@@ -77,6 +78,9 @@
77
78
  get status() {
78
79
  return status;
79
80
  },
81
+ get revalidating() {
82
+ return revalidating;
83
+ },
80
84
  login: (returnTo?: string) => login(returnTo),
81
85
  logout,
82
86
  revalidate
@@ -110,7 +114,32 @@
110
114
  }
111
115
 
112
116
  async function revalidate() {
113
- await invalidateAll();
117
+ if (revalidating) return;
118
+ revalidating = true;
119
+
120
+ // If the server redirects to the login path during a background
121
+ // revalidation (e.g. session lost, HMR store reset), intercept the
122
+ // SvelteKit router navigation so we can handle it through
123
+ // handleRedirect instead of a jarring mid-page router transition.
124
+ let sessionExpiredDuringRevalidation = false;
125
+ const stopIntercept = beforeNavigate(({ to, cancel }) => {
126
+ if (to?.url.pathname.startsWith(resolvedLoginPath)) {
127
+ cancel();
128
+ sessionExpiredDuringRevalidation = true;
129
+ }
130
+ });
131
+
132
+ try {
133
+ await invalidateAll();
134
+ } finally {
135
+ stopIntercept();
136
+ revalidating = false;
137
+ }
138
+
139
+ if (sessionExpiredDuringRevalidation) {
140
+ status = 'expired';
141
+ void handleRedirect(redirectOnExpired);
142
+ }
114
143
  }
115
144
 
116
145
  async function handleRedirect(mode: RedirectMode) {
@@ -132,6 +161,10 @@
132
161
 
133
162
  $effect(() => {
134
163
  const isAuthenticated = Boolean(session?.isAuthenticated);
164
+ // Suppress authenticated→unauthenticated transition while a revalidation is
165
+ // in-flight — the effect re-runs once revalidating flips false with the
166
+ // final session state, preventing a mid-refresh flicker.
167
+ if (revalidating && !isAuthenticated) return;
135
168
  status = isAuthenticated ? 'authenticated' : status === 'authenticated' ? 'unauthenticated' : status;
136
169
  });
137
170
 
@@ -10,6 +10,7 @@ export type OIDCClientContextValue<TClaims extends OIDCUserClaims = OIDCUserClai
10
10
  groups: OIDCPublicSession<TClaims>['groups'];
11
11
  metadata?: Pick<OIDCDiscoveryDocument, 'issuer' | 'check_session_iframe' | 'end_session_endpoint' | 'backchannel_logout_supported' | 'backchannel_logout_session_supported'>;
12
12
  status: 'authenticated' | 'unauthenticated' | 'expired' | 'revoked';
13
+ revalidating: boolean;
13
14
  login: (returnTo?: string) => void;
14
15
  logout: (clearSessionOnly?: boolean) => Promise<void>;
15
16
  revalidate: () => Promise<void>;
@@ -3,8 +3,8 @@ import { randomBytes } from 'node:crypto';
3
3
  import { decode, fromWeb, verify } from '@sourceregistry/node-jwt/promises';
4
4
  import { createOIDCCookieStore } from './cookies.js';
5
5
  import { asAuthorizationHeader, createClientSecretJwtAssertion, createPrivateKeyJwtAssertion, fetchJson } from './jwt.js';
6
- import { normalizeTokens, shouldRefresh } from './session.js';
7
- import { absoluteUrl, base64UrlEncode, buildCookieOptions, collectGroups, createPKCEPair, internalRedirectPath, normalizeIssuer, normalizeScope, parseProviderError, toPublicSession } from './utils.js';
6
+ import { isSessionExpired, normalizeTokens, shouldRefresh } from './session.js';
7
+ import { absoluteUrl, base64UrlEncode, buildCookieOptions, collectGroups, createPKCEPair, internalRedirectPath, normalizeIssuer, normalizeScope, parseProviderError, toPublicSession, validateIdTokenClaims, validateUserInfoSubject } from './utils.js';
8
8
  export { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
9
9
  import { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
10
10
  function buildLogger(logger) {
@@ -38,12 +38,17 @@ export function createOIDC(options) {
38
38
  const cookieOptions = buildCookieOptions(options.cookieOptions);
39
39
  const clockSkewSeconds = options.clockSkewSeconds ?? 30;
40
40
  const refreshToleranceSeconds = options.refreshToleranceSeconds ?? 30;
41
+ const sessionMaxAgeSeconds = options.sessionMaxAgeSeconds ?? 60 * 60 * 8;
42
+ if (!Number.isFinite(sessionMaxAgeSeconds) || sessionMaxAgeSeconds <= 0) {
43
+ throw new TypeError('sessionMaxAgeSeconds must be a positive finite number');
44
+ }
41
45
  const sessionCookieName = options.sessionCookieName ?? 'oidc_session';
42
46
  const stateCookieName = options.stateCookieName ?? 'oidc_auth_state';
43
47
  const defaultScope = normalizeScope(options.scope);
44
48
  const loginPath = options.loginPath ?? '/auth/login';
45
49
  const redirectPath = options.redirectPath ?? '/auth/callback';
46
50
  const clientAuthMethod = options.clientAuthMethod ?? (options.clientSecret ? 'client_secret_basic' : 'none');
51
+ const fetchImpl = options.fetch ?? fetch;
47
52
  const cookieStore = createOIDCCookieStore(options.cookieSecret, sessionCookieName, stateCookieName, cookieOptions);
48
53
  let metadataPromise;
49
54
  let jwksPromise;
@@ -101,7 +106,7 @@ export function createOIDC(options) {
101
106
  if (!discoveryUrl) {
102
107
  throw error(500, { message: 'OIDC issuer or discoveryUrl must be configured' });
103
108
  }
104
- const document = await fetchJson(discoveryUrl);
109
+ const document = await fetchJson(discoveryUrl, undefined, fetchImpl);
105
110
  return {
106
111
  ...document,
107
112
  ...options.endpoints,
@@ -117,7 +122,7 @@ export function createOIDC(options) {
117
122
  if (!metadata.jwks_uri) {
118
123
  throw error(500, { message: 'OIDC jwks_uri is required to validate id_token values' });
119
124
  }
120
- return fromWeb(metadata.jwks_uri, { overrideEndpointCheck: true });
125
+ return fromWeb(metadata.jwks_uri, { overrideEndpointCheck: true, fetch: fetchImpl });
121
126
  });
122
127
  }
123
128
  return jwksPromise;
@@ -206,7 +211,7 @@ export function createOIDC(options) {
206
211
  method: 'POST',
207
212
  headers: auth.headers,
208
213
  body: auth.body
209
- });
214
+ }, fetchImpl);
210
215
  }
211
216
  async function refreshTokens(refreshToken) {
212
217
  const metadata = await getMetadata();
@@ -217,7 +222,7 @@ export function createOIDC(options) {
217
222
  method: 'POST',
218
223
  headers: auth.headers,
219
224
  body: auth.body
220
- });
225
+ }, fetchImpl);
221
226
  }
222
227
  async function validateIdToken(idToken, nonce) {
223
228
  const metadata = await getMetadata();
@@ -227,15 +232,10 @@ export function createOIDC(options) {
227
232
  algorithms: metadata.id_token_signing_alg_values_supported,
228
233
  clockSkew: clockSkewSeconds
229
234
  });
235
+ validateIdTokenClaims(claims, nonce);
230
236
  const transformedClaims = options.transformClaims
231
237
  ? await options.transformClaims(claims)
232
238
  : claims;
233
- if (transformedClaims.nonce !== nonce) {
234
- throw error(401, { message: 'Invalid id_token nonce' });
235
- }
236
- if (!transformedClaims.sub) {
237
- throw error(401, { message: 'id_token subject is required' });
238
- }
239
239
  return transformedClaims;
240
240
  }
241
241
  async function validateBackChannelLogoutToken(logoutToken) {
@@ -265,7 +265,7 @@ export function createOIDC(options) {
265
265
  headers: {
266
266
  authorization: `Bearer ${accessToken}`
267
267
  }
268
- });
268
+ }, fetchImpl);
269
269
  }
270
270
  async function isRevoked(session) {
271
271
  if (!session || !backChannelLogoutStore) {
@@ -278,6 +278,10 @@ export function createOIDC(options) {
278
278
  if (!session) {
279
279
  return null;
280
280
  }
281
+ if (isSessionExpired(session, sessionMaxAgeSeconds)) {
282
+ await clearPersistedSession(cookies, persisted?.id);
283
+ return null;
284
+ }
281
285
  if (await isRevoked(session)) {
282
286
  await clearPersistedSession(cookies, persisted?.id);
283
287
  return null;
@@ -291,6 +295,9 @@ export function createOIDC(options) {
291
295
  ? await validateIdToken(tokenResponse.id_token, session.nonce)
292
296
  : session.claims;
293
297
  const rawUser = options.fetchUserInfo !== false ? await fetchUserInfo(tokenResponse.access_token) : session.user;
298
+ if (claims) {
299
+ validateUserInfoSubject(claims, rawUser);
300
+ }
294
301
  const user = options.transformUser
295
302
  ? await options.transformUser(rawUser, { claims })
296
303
  : rawUser;
@@ -385,6 +392,7 @@ export function createOIDC(options) {
385
392
  const rawUser = options.fetchUserInfo === false
386
393
  ? undefined
387
394
  : await fetchUserInfo(tokenResponse.access_token).catch(() => undefined);
395
+ validateUserInfoSubject(claims, rawUser);
388
396
  const user = options.transformUser
389
397
  ? await options.transformUser(rawUser, { claims })
390
398
  : rawUser;
@@ -1,5 +1,5 @@
1
1
  import type { OIDCClientAssertionOptions, OIDCClientSecretJwtOptions, OIDCPrivateKeyJwtOptions } from './types.js';
2
- export declare function fetchJson<T>(url: string, init?: RequestInit): Promise<T>;
2
+ export declare function fetchJson<T>(url: string, init?: RequestInit, fetchImpl?: typeof fetch): Promise<T>;
3
3
  export declare function asAuthorizationHeader(clientId: string, clientSecret: string): string;
4
4
  export declare function createClientSecretJwtAssertion(options: OIDCClientAssertionOptions & OIDCClientSecretJwtOptions): Promise<string>;
5
5
  export declare function createPrivateKeyJwtAssertion(options: OIDCClientAssertionOptions & OIDCPrivateKeyJwtOptions): Promise<string>;
@@ -2,8 +2,8 @@ import { error } from '@sveltejs/kit';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { sign } from '@sourceregistry/node-jwt/promises';
4
4
  import { base64UrlEncode } from './utils.js';
5
- export async function fetchJson(url, init) {
6
- const response = await fetch(url, init);
5
+ export async function fetchJson(url, init, fetchImpl = fetch) {
6
+ const response = await fetchImpl(url, init);
7
7
  if (!response.ok) {
8
8
  const body = await response.text().catch(() => '');
9
9
  throw error(response.status, {
@@ -1,3 +1,4 @@
1
1
  import type { OIDCSession, OIDCSessionTokens, OIDCTokenResponse, OIDCUserClaims } from './types.js';
2
2
  export declare function normalizeTokens(tokenResponse: OIDCTokenResponse, defaultScope: string[], existing?: OIDCSessionTokens, now?: number): OIDCSessionTokens;
3
3
  export declare function shouldRefresh<TClaims extends OIDCUserClaims = OIDCUserClaims>(session: OIDCSession<TClaims>, refreshToleranceSeconds: number, now?: number): boolean;
4
+ export declare function isSessionExpired<TClaims extends OIDCUserClaims = OIDCUserClaims>(session: OIDCSession<TClaims>, sessionMaxAgeSeconds: number, now?: number): boolean;
@@ -17,3 +17,13 @@ export function shouldRefresh(session, refreshToleranceSeconds, now = Math.floor
17
17
  }
18
18
  return session.tokens.expiresAt - refreshToleranceSeconds <= now;
19
19
  }
20
+ export function isSessionExpired(session, sessionMaxAgeSeconds, now = Math.floor(Date.now() / 1000)) {
21
+ if (session.createdAt + sessionMaxAgeSeconds <= now) {
22
+ return true;
23
+ }
24
+ // Without a refresh token, an expired access token cannot establish that
25
+ // the user's session is still valid. Do not keep the local session alive.
26
+ return !session.tokens.refreshToken &&
27
+ session.tokens.expiresAt !== undefined &&
28
+ session.tokens.expiresAt <= now;
29
+ }
@@ -17,7 +17,10 @@ export function createInMemoryBackChannelLogoutStore() {
17
17
  };
18
18
  }
19
19
  export function createInMemorySessionStore() {
20
- const sessions = new Map();
20
+ // Persist the Map on globalThis so Vite HMR module re-evaluations don't
21
+ // create a fresh store and orphan all live session cookies.
22
+ const g = globalThis;
23
+ const sessions = (g['__oidc_sessions__'] ??= new Map());
21
24
  return {
22
25
  async get(sessionId) {
23
26
  return sessions.get(sessionId) ?? null;
@@ -53,6 +53,7 @@ export type OIDCUserClaims = Record<string, unknown> & {
53
53
  iss?: string;
54
54
  aud?: string | string[];
55
55
  exp?: number;
56
+ iat?: number;
56
57
  nbf?: number;
57
58
  nonce?: string;
58
59
  };
@@ -159,6 +160,8 @@ export type OIDCOptions<TClaims extends OIDCUserClaims = OIDCUserClaims, TSessio
159
160
  cookieOptions?: Partial<CookieOptions>;
160
161
  clockSkewSeconds?: number;
161
162
  refreshToleranceSeconds?: number;
163
+ /** Maximum lifetime of a local browser session. Defaults to 8 hours. */
164
+ sessionMaxAgeSeconds?: number;
162
165
  defaultLoginRedirect?: string;
163
166
  defaultLogoutRedirect?: string;
164
167
  sessionStore?: OIDCSessionStore<TClaims, TSession> | 'memory';
@@ -176,6 +179,13 @@ export type OIDCOptions<TClaims extends OIDCUserClaims = OIDCUserClaims, TSessio
176
179
  }) => MaybePromise<TSession>;
177
180
  endpoints?: Partial<OIDCDiscoveryDocument>;
178
181
  logger?: OIDCLogger | false;
182
+ /**
183
+ * Custom fetch implementation used for all OIDC network calls
184
+ * (discovery, token, userinfo, JWKS). Useful in dev to work around
185
+ * self-signed certs via a custom https.Agent — do not disable
186
+ * TLS verification in production.
187
+ */
188
+ fetch?: typeof fetch;
179
189
  };
180
190
  export type OIDCLoginOptions = {
181
191
  returnTo?: string;
@@ -22,4 +22,6 @@ export declare function absoluteUrl(event: {
22
22
  export declare function internalRedirectPath(event: {
23
23
  url: URL;
24
24
  }, pathOrUrl: string | undefined, fallback?: string): string;
25
+ export declare function validateIdTokenClaims(claims: OIDCUserClaims, nonce: string): void;
26
+ export declare function validateUserInfoSubject(claims: OIDCUserClaims, user: OIDCUserClaims | undefined): void;
25
27
  export declare function toPublicSession<TClaims extends OIDCUserClaims = OIDCUserClaims>(session: OIDCSession<TClaims> | null): OIDCPublicSession<TClaims> | null;
@@ -119,6 +119,25 @@ export function internalRedirectPath(event, pathOrUrl, fallback = '/') {
119
119
  return fallback;
120
120
  }
121
121
  }
122
+ export function validateIdTokenClaims(claims, nonce) {
123
+ if (!Number.isFinite(claims.exp)) {
124
+ throw error(401, { message: 'id_token expiration is required' });
125
+ }
126
+ if (!Number.isFinite(claims.iat)) {
127
+ throw error(401, { message: 'id_token issued-at time is required' });
128
+ }
129
+ if (claims.nonce !== nonce) {
130
+ throw error(401, { message: 'Invalid id_token nonce' });
131
+ }
132
+ if (!claims.sub) {
133
+ throw error(401, { message: 'id_token subject is required' });
134
+ }
135
+ }
136
+ export function validateUserInfoSubject(claims, user) {
137
+ if (user && user.sub !== claims.sub) {
138
+ throw error(401, { message: 'UserInfo subject does not match id_token subject' });
139
+ }
140
+ }
122
141
  export function toPublicSession(session) {
123
142
  if (!session)
124
143
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sourceregistry/sveltekit-oidc",
3
- "version": "1.6.8",
3
+ "version": "1.6.10",
4
4
  "description": "OIDC authentication helpers for SvelteKit applications",
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
@@ -84,7 +84,7 @@
84
84
  "oauth"
85
85
  ],
86
86
  "dependencies": {
87
- "@sourceregistry/node-jwt": "^1.5.10"
87
+ "@sourceregistry/node-jwt": "^1.5.11"
88
88
  },
89
89
  "release": {
90
90
  "branches": [