@sourceregistry/sveltekit-oidc 1.6.9 → 1.6.11

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`.
@@ -46,6 +46,7 @@
46
46
  let status = $state<'authenticated' | 'unauthenticated' | 'expired' | 'revoked'>('unauthenticated');
47
47
  let revalidating = $state(false);
48
48
  let handledUnauthenticated = $state(false);
49
+ let sessionExpiredDuringRevalidation = false;
49
50
 
50
51
  const metadata = $derived(config.metadata);
51
52
  const resolvedLoginPath = $derived(loginPath ?? config.loginPath ?? '/auth/login');
@@ -88,23 +89,43 @@
88
89
 
89
90
  void context;
90
91
 
92
+ // SvelteKit navigation hooks are registered for the component lifetime.
93
+ // Only intercept login redirects while a background revalidation is active.
94
+ beforeNavigate(({to, cancel}) => {
95
+ if (revalidating && to?.url.pathname.startsWith(resolvedLoginPath)) {
96
+ cancel();
97
+ sessionExpiredDuringRevalidation = true;
98
+ }
99
+ });
100
+
91
101
  function buildLoginUrl(returnTo = `${window.location.pathname}${window.location.search}`) {
92
102
  return `${resolvedLoginPath}?returnTo=${encodeURIComponent(returnTo)}`;
93
103
  }
94
104
 
95
105
  async function logout(clearSessionOnly = false) {
96
- const body = new URLSearchParams();
97
106
  if (clearSessionOnly) {
98
- body.set('clearSessionOnly', '1');
107
+ // Session-monitor events must clear only this application's session.
108
+ // Keep the flag in the URL because logoutHandler also supports callers
109
+ // that do not send a form body.
110
+ const url = new URL(logoutPath, window.location.href);
111
+ url.searchParams.set('clearSessionOnly', '1');
112
+
113
+ await fetch(url, {
114
+ method: 'POST',
115
+ redirect: 'manual'
116
+ });
117
+ return;
99
118
  }
100
119
 
101
- await fetch(logoutPath, {
102
- method: 'POST',
103
- headers: {
104
- 'content-type': 'application/x-www-form-urlencoded'
105
- },
106
- body
107
- });
120
+ // Provider logout can include an interactive confirmation and redirects.
121
+ // A fetch would follow that flow in the background, leaving the provider
122
+ // session intact and allowing the app to immediately sign in again.
123
+ const form = document.createElement('form');
124
+ form.method = 'POST';
125
+ form.action = logoutPath;
126
+ form.style.display = 'none';
127
+ document.body.append(form);
128
+ form.submit();
108
129
  }
109
130
 
110
131
  function login(returnTo?: string) {
@@ -118,21 +139,13 @@
118
139
  revalidating = true;
119
140
 
120
141
  // 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
- });
142
+ // revalidation (e.g. session lost, HMR store reset), the component's
143
+ // navigation hook records it so we can handle the redirect below.
144
+ sessionExpiredDuringRevalidation = false;
131
145
 
132
146
  try {
133
147
  await invalidateAll();
134
148
  } finally {
135
- stopIntercept();
136
149
  revalidating = false;
137
150
  }
138
151
 
@@ -1,4 +1,4 @@
1
- import type { OIDCOptions, OIDCInstance, OIDCSession, OIDCUserClaims } from './types.js';
1
+ import type { OIDCInstance, OIDCOptions, OIDCSession, OIDCUserClaims } from './types.js';
2
2
  export type * from './types.js';
3
3
  export { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
4
4
  export declare function createOIDC<TClaims extends OIDCUserClaims = OIDCUserClaims, TSession extends OIDCSession<TClaims> = OIDCSession<TClaims>>(options: OIDCOptions<TClaims, TSession>): OIDCInstance<TClaims, TSession>;
@@ -3,10 +3,10 @@ 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';
8
- export { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.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';
9
8
  import { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
9
+ export { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
10
10
  function buildLogger(logger) {
11
11
  const noop = () => {
12
12
  };
@@ -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,16 +232,10 @@ export function createOIDC(options) {
227
232
  algorithms: metadata.id_token_signing_alg_values_supported,
228
233
  clockSkew: clockSkewSeconds
229
234
  });
230
- const transformedClaims = options.transformClaims
235
+ validateIdTokenClaims(claims, nonce);
236
+ return 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
- return transformedClaims;
240
239
  }
241
240
  async function validateBackChannelLogoutToken(logoutToken) {
242
241
  const metadata = await getMetadata();
@@ -265,7 +264,7 @@ export function createOIDC(options) {
265
264
  headers: {
266
265
  authorization: `Bearer ${accessToken}`
267
266
  }
268
- });
267
+ }, fetchImpl);
269
268
  }
270
269
  async function isRevoked(session) {
271
270
  if (!session || !backChannelLogoutStore) {
@@ -278,6 +277,10 @@ export function createOIDC(options) {
278
277
  if (!session) {
279
278
  return null;
280
279
  }
280
+ if (isSessionExpired(session, sessionMaxAgeSeconds)) {
281
+ await clearPersistedSession(cookies, persisted?.id);
282
+ return null;
283
+ }
281
284
  if (await isRevoked(session)) {
282
285
  await clearPersistedSession(cookies, persisted?.id);
283
286
  return null;
@@ -291,6 +294,9 @@ export function createOIDC(options) {
291
294
  ? await validateIdToken(tokenResponse.id_token, session.nonce)
292
295
  : session.claims;
293
296
  const rawUser = options.fetchUserInfo !== false ? await fetchUserInfo(tokenResponse.access_token) : session.user;
297
+ if (claims) {
298
+ validateUserInfoSubject(claims, rawUser);
299
+ }
294
300
  const user = options.transformUser
295
301
  ? await options.transformUser(rawUser, { claims })
296
302
  : rawUser;
@@ -385,6 +391,7 @@ export function createOIDC(options) {
385
391
  const rawUser = options.fetchUserInfo === false
386
392
  ? undefined
387
393
  : await fetchUserInfo(tokenResponse.access_token).catch(() => undefined);
394
+ validateUserInfoSubject(claims, rawUser);
388
395
  const user = options.transformUser
389
396
  ? await options.transformUser(rawUser, { claims })
390
397
  : rawUser;
@@ -526,9 +533,14 @@ export function createOIDC(options) {
526
533
  if (event.request.method !== 'POST') {
527
534
  throw error(405, { message: 'Logout requires POST' });
528
535
  }
529
- const postLogoutRedirectUri = event.url.searchParams.get('postLogoutRedirectUri') ?? defaults.postLogoutRedirectUri;
536
+ const form = await event.request.formData().catch(() => null);
537
+ const postLogoutRedirectUri = event.url.searchParams.get('postLogoutRedirectUri') ??
538
+ form?.get('postLogoutRedirectUri')?.toString() ??
539
+ defaults.postLogoutRedirectUri;
530
540
  const clearSessionOnly = event.url.searchParams.get('clearSessionOnly') === '1' ||
531
541
  event.url.searchParams.get('clearSessionOnly') === 'true' ||
542
+ form?.get('clearSessionOnly')?.toString() === '1' ||
543
+ form?.get('clearSessionOnly')?.toString() === 'true' ||
532
544
  defaults.clearSessionOnly;
533
545
  return signOut(event, { ...defaults, postLogoutRedirectUri, clearSessionOnly });
534
546
  };
@@ -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
+ }
@@ -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.9",
3
+ "version": "1.6.11",
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": [