@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.
@@ -1,18 +1,24 @@
1
1
  import { error, redirect } from '@sveltejs/kit';
2
- import { randomBytes } from 'node:crypto';
3
- import { decode, fromWeb, verify } from '@sourceregistry/node-jwt/promises';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { fromWeb } from '@sourceregistry/node-jwt/promises';
4
+ import { decode as decodeJwt, verify as verifyJwt } from '@sourceregistry/node-jwt';
4
5
  import { createOIDCCookieStore } from './cookies.js';
5
6
  import { asAuthorizationHeader, createClientSecretJwtAssertion, createPrivateKeyJwtAssertion, fetchJson } from './jwt.js';
6
7
  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
+ import { absoluteUrl, base64UrlEncode, buildCookieOptions, collectGroups, createPKCEPair, decodeOAuthState, encodeOAuthState, internalRedirectPath, normalizeIssuer, normalizeScope, parseProviderError, toPublicSession, validateIdTokenClaims, validateRefreshedIdTokenClaims, validateUserInfoSubject } from './utils.js';
8
9
  import { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
9
10
  export { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
10
11
  const OIDC_SESSION_REVALIDATION_DEPENDENCY = 'oidc:session';
11
- const LOGIN_PROMPTS = new Set([
12
- 'login',
13
- 'consent',
14
- 'none',
15
- 'select_account'
12
+ const LOGIN_PROMPTS = new Set(['login', 'consent', 'none', 'select_account']);
13
+ const RESERVED_AUTHORIZATION_PARAMETERS = new Set([
14
+ 'client_id',
15
+ 'response_type',
16
+ 'redirect_uri',
17
+ 'scope',
18
+ 'state',
19
+ 'nonce',
20
+ 'code_challenge',
21
+ 'code_challenge_method'
16
22
  ]);
17
23
  function silentReauthenticationResponse(status) {
18
24
  return new Response(`<!doctype html><html lang="en" data-oidc-silent-reauth="${status}"><head><meta charset="utf-8"><title>OIDC session check</title></head><body></body></html>`, {
@@ -44,17 +50,36 @@ function buildLogger(logger) {
44
50
  }
45
51
  export function createOIDC(options) {
46
52
  const log = buildLogger(options.logger);
47
- const sessionStore = options.sessionStore === 'memory' ? createInMemorySessionStore() : options.sessionStore;
48
- const backChannelLogoutStore = options.backChannelLogoutStore === 'memory'
49
- ? createInMemoryBackChannelLogoutStore()
50
- : options.backChannelLogoutStore;
51
53
  const cookieOptions = buildCookieOptions(options.cookieOptions);
52
54
  const clockSkewSeconds = options.clockSkewSeconds ?? 30;
53
55
  const refreshToleranceSeconds = options.refreshToleranceSeconds ?? 30;
54
56
  const sessionMaxAgeSeconds = options.sessionMaxAgeSeconds ?? 60 * 60 * 8;
57
+ if (Buffer.byteLength(options.cookieSecret, 'utf8') < 32) {
58
+ throw new TypeError('cookieSecret must contain at least 32 bytes of entropy');
59
+ }
60
+ if (!Number.isFinite(clockSkewSeconds) || clockSkewSeconds < 0) {
61
+ throw new TypeError('clockSkewSeconds must be a non-negative finite number');
62
+ }
63
+ if (!Number.isFinite(refreshToleranceSeconds) || refreshToleranceSeconds < 0) {
64
+ throw new TypeError('refreshToleranceSeconds must be a non-negative finite number');
65
+ }
55
66
  if (!Number.isFinite(sessionMaxAgeSeconds) || sessionMaxAgeSeconds <= 0) {
56
67
  throw new TypeError('sessionMaxAgeSeconds must be a positive finite number');
57
68
  }
69
+ const stateMaxAgeSeconds = options.stateMaxAgeSeconds ?? 60 * 10;
70
+ if (!Number.isFinite(stateMaxAgeSeconds) || stateMaxAgeSeconds <= 0) {
71
+ throw new TypeError('stateMaxAgeSeconds must be a positive finite number');
72
+ }
73
+ const maxCookieSizeBytes = options.maxCookieSizeBytes ?? 3800;
74
+ if (!Number.isFinite(maxCookieSizeBytes) || maxCookieSizeBytes <= 0) {
75
+ throw new TypeError('maxCookieSizeBytes must be a positive finite number');
76
+ }
77
+ const sessionStore = options.sessionStore === 'memory' ? createInMemorySessionStore() : options.sessionStore;
78
+ const backChannelLogoutStore = options.backChannelLogoutStore === 'memory'
79
+ ? createInMemoryBackChannelLogoutStore({
80
+ retentionSeconds: sessionMaxAgeSeconds
81
+ })
82
+ : options.backChannelLogoutStore;
58
83
  const sessionCookieName = options.sessionCookieName ?? 'oidc_session';
59
84
  const stateCookieName = options.stateCookieName ?? 'oidc_auth_state';
60
85
  const defaultScope = normalizeScope(options.scope);
@@ -63,9 +88,45 @@ export function createOIDC(options) {
63
88
  const redirectPath = options.redirectPath ?? '/auth/callback';
64
89
  const clientAuthMethod = options.clientAuthMethod ?? (options.clientSecret ? 'client_secret_basic' : 'none');
65
90
  const fetchImpl = options.fetch ?? fetch;
66
- const cookieStore = createOIDCCookieStore(options.cookieSecret, sessionCookieName, stateCookieName, cookieOptions);
91
+ const cookieStore = createOIDCCookieStore(options.cookieSecret, sessionCookieName, stateCookieName, cookieOptions, stateMaxAgeSeconds, maxCookieSizeBytes);
67
92
  let metadataPromise;
68
93
  let jwksPromise;
94
+ const configuredIssuer = options.issuer ? normalizeIssuer(options.issuer) : undefined;
95
+ function validateProtocolUrl(name, value, issuer = false) {
96
+ let url;
97
+ try {
98
+ url = new URL(value);
99
+ }
100
+ catch {
101
+ throw error(500, { message: `OIDC ${name} must be an absolute URL` });
102
+ }
103
+ if (!options.allowInsecureHttp && url.protocol !== 'https:') {
104
+ throw error(500, { message: `OIDC ${name} must use HTTPS` });
105
+ }
106
+ if (url.hash || (issuer && url.search)) {
107
+ throw error(500, {
108
+ message: `OIDC ${name} must not contain a query or fragment`
109
+ });
110
+ }
111
+ }
112
+ function validateMetadata(document, expectedIssuer) {
113
+ const issuer = normalizeIssuer(document.issuer ?? '');
114
+ if (!issuer || (expectedIssuer && issuer !== expectedIssuer)) {
115
+ throw error(500, {
116
+ message: 'OIDC discovery issuer does not match the configured issuer'
117
+ });
118
+ }
119
+ validateProtocolUrl('issuer', issuer, true);
120
+ validateProtocolUrl('authorization_endpoint', document.authorization_endpoint);
121
+ validateProtocolUrl('token_endpoint', document.token_endpoint);
122
+ if (document.jwks_uri)
123
+ validateProtocolUrl('jwks_uri', document.jwks_uri);
124
+ if (document.userinfo_endpoint)
125
+ validateProtocolUrl('userinfo_endpoint', document.userinfo_endpoint);
126
+ if (document.end_session_endpoint)
127
+ validateProtocolUrl('end_session_endpoint', document.end_session_endpoint);
128
+ return { ...document, issuer };
129
+ }
69
130
  function hasCurrentSessionShape(session) {
70
131
  return Boolean(session?.identity?.sub && session.idTokenClaims?.sub);
71
132
  }
@@ -127,32 +188,37 @@ export function createOIDC(options) {
127
188
  if (!metadataPromise) {
128
189
  metadataPromise = (async () => {
129
190
  if (options.endpoints?.authorization_endpoint && options.endpoints?.token_endpoint) {
130
- return {
131
- issuer: normalizeIssuer(options.issuer ?? options.endpoints.issuer ?? ''),
191
+ return validateMetadata({
132
192
  jwks_uri: options.endpoints.jwks_uri ?? '',
133
- ...options.endpoints
134
- };
193
+ ...options.endpoints,
194
+ issuer: configuredIssuer ?? normalizeIssuer(options.endpoints.issuer ?? '')
195
+ }, configuredIssuer);
135
196
  }
136
- const issuer = options.issuer ? normalizeIssuer(options.issuer) : undefined;
137
- const discoveryUrl = options.discoveryUrl ?? (issuer ? `${issuer}/.well-known/openid-configuration` : undefined);
197
+ const discoveryUrl = options.discoveryUrl ??
198
+ (configuredIssuer ? `${configuredIssuer}/.well-known/openid-configuration` : undefined);
138
199
  if (!discoveryUrl) {
139
200
  throw error(500, {
140
201
  message: 'OIDC issuer or discoveryUrl must be configured'
141
202
  });
142
203
  }
204
+ validateProtocolUrl('discoveryUrl', discoveryUrl);
143
205
  const document = await fetchJson(discoveryUrl, undefined, fetchImpl);
144
- return {
206
+ return validateMetadata({
145
207
  ...document,
146
208
  ...options.endpoints,
147
209
  issuer: normalizeIssuer(document.issuer)
148
- };
149
- })();
210
+ }, configuredIssuer);
211
+ })().catch((err) => {
212
+ metadataPromise = undefined;
213
+ throw err;
214
+ });
150
215
  }
151
216
  return metadataPromise;
152
217
  }
153
218
  async function getJwks() {
154
219
  if (!jwksPromise) {
155
- jwksPromise = getMetadata().then((metadata) => {
220
+ jwksPromise = getMetadata()
221
+ .then((metadata) => {
156
222
  if (!metadata.jwks_uri) {
157
223
  throw error(500, {
158
224
  message: 'OIDC jwks_uri is required to validate id_token values'
@@ -162,32 +228,74 @@ export function createOIDC(options) {
162
228
  overrideEndpointCheck: true,
163
229
  fetch: fetchImpl
164
230
  });
231
+ })
232
+ .catch((err) => {
233
+ jwksPromise = undefined;
234
+ throw err;
165
235
  });
166
236
  }
167
237
  return jwksPromise;
168
238
  }
169
239
  async function verifyJwtWithJwks(token, verifyOptions) {
170
- let decoded;
240
+ let header;
171
241
  try {
172
- decoded = await decode(token);
242
+ header = decodeJwt(token).header;
173
243
  }
174
244
  catch {
175
245
  throw error(400, { message: 'Invalid JWT format' });
176
246
  }
177
- const jwks = await getJwks();
178
- const key = decoded.header.kid ? await jwks.key(decoded.header.kid) : (await jwks.list())[0];
179
- if (!key) {
247
+ const algorithms = verifyOptions.algorithms?.length
248
+ ? verifyOptions.algorithms
249
+ : ['RS256'];
250
+ if (!header.alg || !algorithms.includes(header.alg)) {
180
251
  throw error(401, {
181
- message: 'Unable to resolve a signing key from JWKS'
252
+ message: `JWT algorithm '${header.alg ?? 'missing'}' is not allowed`
182
253
  });
183
254
  }
184
- try {
185
- const result = await verify(token, key.toKeyObject(), verifyOptions);
255
+ if (header.typ && verifyOptions.types && !verifyOptions.types.includes(header.typ)) {
256
+ throw error(401, { message: `JWT type '${header.typ}' is not allowed` });
257
+ }
258
+ const nodeJwtOptions = {
259
+ issuer: verifyOptions.issuer,
260
+ audience: verifyOptions.audience,
261
+ algorithms,
262
+ clockSkew: verifyOptions.clockSkew ?? 0,
263
+ tokenTypes: verifyOptions.types ?? ['JWT']
264
+ };
265
+ const verifyWithKey = (key) => {
266
+ const result = verifyJwt(token, key, nodeJwtOptions);
267
+ if (!result.valid)
268
+ throw new Error(result.error.reason);
186
269
  return result.payload;
270
+ };
271
+ try {
272
+ if (header.alg.startsWith('HS')) {
273
+ if (!options.clientSecret)
274
+ throw new Error('clientSecret is required for HMAC-signed ID tokens');
275
+ return verifyWithKey(options.clientSecret);
276
+ }
277
+ const jwks = await getJwks();
278
+ let keys = header.kid ? [await jwks.key(header.kid)] : await jwks.list();
279
+ if (header.kid && !keys[0]) {
280
+ await jwks.refresh();
281
+ keys = [await jwks.key(header.kid)];
282
+ }
283
+ let lastError;
284
+ for (const key of keys) {
285
+ if (!key)
286
+ continue;
287
+ try {
288
+ return verifyWithKey(key.toKeyObject());
289
+ }
290
+ catch (err) {
291
+ lastError = err;
292
+ }
293
+ }
294
+ throw lastError ?? new Error('Unable to resolve a signing key from JWKS');
187
295
  }
188
296
  catch (err) {
189
297
  throw error(401, {
190
- message: typeof err === 'object' && err && 'reason' in err ? String(err.reason) : 'JWT verification failed'
298
+ message: err instanceof Error ? err.message : 'JWT verification failed'
191
299
  });
192
300
  }
193
301
  }
@@ -271,15 +379,56 @@ export function createOIDC(options) {
271
379
  body: auth.body
272
380
  }, fetchImpl);
273
381
  }
382
+ function idTokenAlgorithms(metadata) {
383
+ const supported = new Set([
384
+ 'HS256',
385
+ 'HS384',
386
+ 'HS512',
387
+ 'RS256',
388
+ 'RS384',
389
+ 'RS512',
390
+ 'ES256',
391
+ 'ES384',
392
+ 'ES512',
393
+ 'ES256K',
394
+ 'PS256',
395
+ 'PS384',
396
+ 'PS512',
397
+ 'EdDSA'
398
+ ]);
399
+ const configured = options.idTokenSigningAlgorithms ?? metadata.id_token_signing_alg_values_supported;
400
+ if (!configured?.length)
401
+ return ['RS256'];
402
+ const algorithms = configured.filter((algorithm) => supported.has(algorithm));
403
+ if (!algorithms.length)
404
+ throw error(500, {
405
+ message: 'Provider has no supported ID-token signing algorithm'
406
+ });
407
+ return algorithms;
408
+ }
274
409
  async function validateIdToken(idToken, nonce) {
275
410
  const metadata = await getMetadata();
276
411
  const claims = await verifyJwtWithJwks(idToken, {
277
412
  issuer: metadata.issuer,
278
413
  audience: options.clientId,
279
- algorithms: metadata.id_token_signing_alg_values_supported,
414
+ algorithms: idTokenAlgorithms(metadata),
415
+ types: ['JWT'],
416
+ clockSkew: clockSkewSeconds
417
+ });
418
+ validateIdTokenClaims(claims, nonce, options.clientId, options.trustedIdTokenAudiences);
419
+ return claims;
420
+ }
421
+ async function validateRefreshedIdToken(idToken, previous) {
422
+ const metadata = await getMetadata();
423
+ const claims = await verifyJwtWithJwks(idToken, {
424
+ issuer: metadata.issuer,
425
+ audience: options.clientId,
426
+ algorithms: idTokenAlgorithms(metadata),
427
+ types: ['JWT'],
280
428
  clockSkew: clockSkewSeconds
281
429
  });
282
- validateIdTokenClaims(claims, nonce);
430
+ validateIdTokenClaims(claims, previous.nonce, options.clientId, options.trustedIdTokenAudiences, false);
431
+ validateRefreshedIdTokenClaims(previous, claims);
283
432
  return claims;
284
433
  }
285
434
  async function resolveIdentity(idTokenClaims, userInfo, reason) {
@@ -297,9 +446,20 @@ export function createOIDC(options) {
297
446
  const claims = await verifyJwtWithJwks(logoutToken, {
298
447
  issuer: metadata.issuer,
299
448
  audience: options.clientId,
449
+ algorithms: idTokenAlgorithms(metadata),
450
+ types: ['JWT', 'logout+jwt'],
300
451
  clockSkew: clockSkewSeconds
301
452
  });
302
- if (!claims.events?.['http://schemas.openid.net/event/backchannel-logout']) {
453
+ if (!Number.isFinite(claims.iat) ||
454
+ !Number.isFinite(claims.exp) ||
455
+ typeof claims.jti !== 'string' ||
456
+ !claims.jti) {
457
+ throw error(400, {
458
+ message: 'logout_token must contain valid iat, exp, and jti claims'
459
+ });
460
+ }
461
+ const event = claims.events?.['http://schemas.openid.net/event/backchannel-logout'];
462
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
303
463
  throw error(400, { message: 'Invalid logout_token events claim' });
304
464
  }
305
465
  if (!claims.sid && !claims.sub) {
@@ -327,6 +487,36 @@ export function createOIDC(options) {
327
487
  }
328
488
  return backChannelLogoutStore.isRevoked(session);
329
489
  }
490
+ const refreshTasks = new Map();
491
+ async function refreshSessionTokens(session, event) {
492
+ const tokenResponse = await refreshTokens(session.tokens.refreshToken);
493
+ const idTokenClaims = tokenResponse.id_token
494
+ ? await validateRefreshedIdToken(tokenResponse.id_token, session.idTokenClaims)
495
+ : session.idTokenClaims;
496
+ const userInfo = options.fetchUserInfo !== false && tokenResponse.access_token
497
+ ? await fetchUserInfo(tokenResponse.access_token)
498
+ : session.userInfo;
499
+ validateUserInfoSubject(idTokenClaims, userInfo);
500
+ const identity = await resolveIdentity(idTokenClaims, userInfo, 'refresh');
501
+ const nextSession = {
502
+ ...session,
503
+ sub: idTokenClaims.sub,
504
+ sid: idTokenClaims.sid ?? session.sid,
505
+ groups: collectGroups(idTokenClaims, userInfo, identity),
506
+ idTokenClaims,
507
+ userInfo,
508
+ identity,
509
+ sessionState: tokenResponse.session_state ?? session.sessionState,
510
+ tokens: normalizeTokens(tokenResponse, defaultScope, session.tokens),
511
+ refreshedAt: Math.floor(Date.now() / 1000)
512
+ };
513
+ return ((await options.beforeSessionPersist?.({
514
+ session: nextSession,
515
+ reason: 'refresh',
516
+ event,
517
+ tokenResponse
518
+ })) ?? nextSession);
519
+ }
330
520
  async function maybeRefreshSession(cookies, persisted, event) {
331
521
  const session = persisted?.session ?? null;
332
522
  if (!session) {
@@ -357,49 +547,61 @@ export function createOIDC(options) {
357
547
  if (!shouldRefresh(session, refreshToleranceSeconds)) {
358
548
  return session;
359
549
  }
360
- try {
361
- log.debug('Refreshing OIDC session tokens', {
362
- expiresAt: session.tokens.expiresAt,
363
- refreshExpiresAt: session.tokens.refreshExpiresAt
364
- });
365
- const tokenResponse = await refreshTokens(session.tokens.refreshToken);
366
- const idTokenClaims = tokenResponse.id_token
367
- ? await validateIdToken(tokenResponse.id_token, session.nonce)
368
- : session.idTokenClaims;
369
- const userInfo = options.fetchUserInfo !== false ? await fetchUserInfo(tokenResponse.access_token) : session.userInfo;
370
- validateUserInfoSubject(idTokenClaims, userInfo);
371
- const identity = await resolveIdentity(idTokenClaims, userInfo, 'refresh');
372
- const nextSession = {
373
- ...session,
374
- sub: idTokenClaims.sub,
375
- sid: idTokenClaims.sid ?? session.sid,
376
- groups: collectGroups(idTokenClaims, userInfo, identity),
377
- idTokenClaims,
378
- userInfo,
379
- identity,
380
- sessionState: tokenResponse.session_state ?? session.sessionState,
381
- tokens: normalizeTokens(tokenResponse, defaultScope, session.tokens),
382
- refreshedAt: Math.floor(Date.now() / 1000)
550
+ const refreshKey = persisted?.id ??
551
+ createHash('sha256')
552
+ .update(session.tokens.refreshToken)
553
+ .digest('base64url');
554
+ let task = refreshTasks.get(refreshKey);
555
+ if (!task) {
556
+ const execute = async () => {
557
+ let current = session;
558
+ if (sessionStore && persisted?.id) {
559
+ current = (await sessionStore.get(persisted.id)) ?? session;
560
+ if (!shouldRefresh(current, refreshToleranceSeconds))
561
+ return current;
562
+ }
563
+ try {
564
+ log.debug('Refreshing OIDC session tokens', {
565
+ expiresAt: current.tokens.expiresAt,
566
+ refreshExpiresAt: current.tokens.refreshExpiresAt
567
+ });
568
+ const refreshed = await refreshSessionTokens(current, event);
569
+ if (sessionStore)
570
+ await writePersistedSession(cookies, refreshed, persisted?.id);
571
+ return refreshed;
572
+ }
573
+ catch (err) {
574
+ log.error('Token refresh failed — clearing session', err);
575
+ if (sessionStore && persisted?.id) {
576
+ const latest = await sessionStore.get(persisted.id);
577
+ if (latest?.tokens.refreshToken !== current.tokens.refreshToken)
578
+ return latest;
579
+ await sessionStore.delete(persisted.id);
580
+ }
581
+ return null;
582
+ }
383
583
  };
384
- await options.beforeSessionPersist?.({
385
- session: nextSession,
386
- reason: 'refresh',
387
- event,
388
- tokenResponse
389
- });
390
- await writePersistedSession(cookies, nextSession, persisted?.id);
391
- log.debug('OIDC session tokens refreshed', {
392
- expiresAt: nextSession.tokens.expiresAt,
393
- refreshExpiresAt: nextSession.tokens.refreshExpiresAt,
394
- hasRefreshToken: Boolean(nextSession.tokens.refreshToken)
584
+ task = (options.refreshLock ? options.refreshLock.runExclusive(refreshKey, execute) : execute()).finally(() => {
585
+ refreshTasks.delete(refreshKey);
395
586
  });
396
- return nextSession;
587
+ refreshTasks.set(refreshKey, task);
397
588
  }
398
- catch (err) {
399
- log.error('Token refresh failed — clearing session', err);
400
- await clearPersistedSession(cookies, persisted?.id);
589
+ const refreshed = await task;
590
+ if (!refreshed) {
591
+ if (sessionStore)
592
+ cookieStore.clearSessionReference(cookies);
593
+ else
594
+ cookieStore.clearSession(cookies);
401
595
  return null;
402
596
  }
597
+ if (!sessionStore)
598
+ await writePersistedSession(cookies, refreshed);
599
+ log.debug('OIDC session tokens refreshed', {
600
+ expiresAt: refreshed.tokens.expiresAt,
601
+ refreshExpiresAt: refreshed.tokens.refreshExpiresAt,
602
+ hasRefreshToken: Boolean(refreshed.tokens.refreshToken)
603
+ });
604
+ return refreshed;
403
605
  }
404
606
  async function getSession(event) {
405
607
  return maybeRefreshSession(event.cookies, await readPersistedSession(event.cookies), 'url' in event && 'request' in event ? event : undefined);
@@ -407,12 +609,19 @@ export function createOIDC(options) {
407
609
  async function signIn(event, loginOptions = {}) {
408
610
  const metadata = await getMetadata();
409
611
  const pkce = createPKCEPair();
410
- const state = base64UrlEncode(randomBytes(24));
612
+ // `token` is the value actually compared against the state cookie on
613
+ // callback (unchanged from before). `state` - what's sent to the
614
+ // provider - additionally carries an encrypted `returnTo`, so it survives
615
+ // purely as a round-tripped query parameter even if the state cookie
616
+ // itself doesn't make it back (expired or removed by browser policy).
617
+ // See handleCallback's restart-on-mismatch branch.
618
+ const token = base64UrlEncode(randomBytes(24));
411
619
  const nonce = base64UrlEncode(randomBytes(24));
412
620
  const returnTo = internalRedirectPath(event, loginOptions.returnTo ?? options.defaultLoginRedirect, '/');
621
+ const state = encodeOAuthState(token, returnTo, options.cookieSecret);
413
622
  const existingSession = loginOptions.prompt === 'none' ? await readPersistedSession(event.cookies) : null;
414
623
  cookieStore.writeState(event.cookies, {
415
- state,
624
+ state: token,
416
625
  nonce,
417
626
  codeVerifier: pkce.verifier,
418
627
  returnTo,
@@ -440,29 +649,61 @@ export function createOIDC(options) {
440
649
  authorizationUrl.searchParams.set('id_token_hint', existingSession.session.tokens.idToken);
441
650
  }
442
651
  for (const [key, value] of Object.entries(loginOptions.extraParams ?? {})) {
652
+ if (RESERVED_AUTHORIZATION_PARAMETERS.has(key)) {
653
+ throw error(500, {
654
+ message: `extraParams must not override reserved parameter '${key}'`
655
+ });
656
+ }
443
657
  authorizationUrl.searchParams.set(key, value);
444
658
  }
445
659
  throw redirect(302, authorizationUrl.toString());
446
660
  }
447
661
  async function handleCallback(event) {
662
+ const rawState = event.url.searchParams.get('state');
663
+ const code = event.url.searchParams.get('code');
664
+ const decodedState = decodeOAuthState(rawState, options.cookieSecret);
665
+ const stateCookie = cookieStore.readState(event.cookies, decodedState.token);
666
+ const now = Math.floor(Date.now() / 1000);
667
+ const stateMatches = Boolean(stateCookie &&
668
+ rawState &&
669
+ stateCookie.state === decodedState.token &&
670
+ Number.isFinite(stateCookie.createdAt) &&
671
+ stateCookie.createdAt <= now + clockSkewSeconds &&
672
+ stateCookie.createdAt + stateMaxAgeSeconds >= now);
448
673
  const providerError = parseProviderError(event);
449
674
  if (providerError) {
675
+ if (!stateMatches)
676
+ throw error(400, { message: 'Invalid or expired callback state' });
677
+ cookieStore.clearState(event.cookies, decodedState.token);
450
678
  throw providerError;
451
679
  }
452
- const stateCookie = cookieStore.readState(event.cookies);
453
- const state = event.url.searchParams.get('state');
454
- const code = event.url.searchParams.get('code');
455
- if (!stateCookie || !state || !code || stateCookie.state !== state) {
456
- cookieStore.clearState(event.cookies);
457
- log.warn('Invalid or expired callback state restarting login flow');
458
- return signIn(event);
680
+ if (!stateCookie || !code || !stateMatches) {
681
+ cookieStore.clearState(event.cookies, decodedState.token);
682
+ // The state cookie didn't make it back (expired past
683
+ // stateMaxAgeSeconds or removed by browser policy). Restarting is
684
+ // still correct (there's nothing left to
685
+ // verify a code exchange against), but `returnTo` doesn't have
686
+ // to be lost: it travels encrypted inside `state` itself
687
+ // (see encodeOAuthState), independent of the cookie.
688
+ log.warn('Invalid or expired callback state — restarting login flow', {
689
+ recoveredReturnTo: decodedState.returnTo !== undefined
690
+ });
691
+ return signIn(event, decodedState.returnTo !== undefined ? { returnTo: decodedState.returnTo } : undefined);
459
692
  }
460
- cookieStore.clearState(event.cookies);
693
+ cookieStore.clearState(event.cookies, decodedState.token);
461
694
  const tokenResponse = await exchangeCode({
462
695
  code,
463
696
  redirectUri: absoluteUrl(event, redirectPath),
464
697
  codeVerifier: stateCookie.codeVerifier
465
698
  });
699
+ if (typeof tokenResponse.access_token !== 'string' ||
700
+ !tokenResponse.access_token ||
701
+ typeof tokenResponse.token_type !== 'string' ||
702
+ !tokenResponse.token_type) {
703
+ throw error(401, {
704
+ message: 'OIDC callback response must include access_token and token_type'
705
+ });
706
+ }
466
707
  if (!tokenResponse.id_token) {
467
708
  throw error(401, {
468
709
  message: 'OIDC callback response must include an id_token'
@@ -470,7 +711,9 @@ export function createOIDC(options) {
470
711
  }
471
712
  const idTokenClaims = await validateIdToken(tokenResponse.id_token, stateCookie.nonce);
472
713
  if (stateCookie.prompt === 'none' && stateCookie.originalSub && idTokenClaims.sub !== stateCookie.originalSub) {
473
- throw error(401, { message: 'Silent re-authentication returned a different End-User' });
714
+ throw error(401, {
715
+ message: 'Silent re-authentication returned a different End-User'
716
+ });
474
717
  }
475
718
  const userInfo = options.fetchUserInfo === false
476
719
  ? undefined
@@ -478,7 +721,6 @@ export function createOIDC(options) {
478
721
  validateUserInfoSubject(idTokenClaims, userInfo);
479
722
  const identity = await resolveIdentity(idTokenClaims, userInfo, 'login');
480
723
  const metadata = await getMetadata();
481
- const now = Math.floor(Date.now() / 1000);
482
724
  const session = {
483
725
  issuer: metadata.issuer,
484
726
  clientId: options.clientId,
@@ -494,29 +736,38 @@ export function createOIDC(options) {
494
736
  createdAt: now,
495
737
  refreshedAt: now
496
738
  };
497
- await options.beforeSessionPersist?.({
739
+ const persistedSession = (await options.beforeSessionPersist?.({
498
740
  session,
499
741
  reason: 'login',
500
742
  event: event,
501
743
  tokenResponse
502
- });
744
+ })) ?? session;
503
745
  const existingSession = stateCookie.prompt === 'none' ? await readPersistedSession(event.cookies) : null;
504
- await writePersistedSession(event.cookies, session, existingSession?.id);
746
+ await writePersistedSession(event.cookies, persistedSession, existingSession?.id);
505
747
  return {
506
- session,
748
+ session: persistedSession,
507
749
  returnTo: stateCookie.returnTo
508
750
  };
509
751
  }
510
752
  async function signOut(event, logoutOptions = {}) {
511
- const metadata = await getMetadata();
512
753
  const persisted = await readPersistedSession(event.cookies);
513
754
  const session = persisted?.session ?? null;
514
755
  cookieStore.clearState(event.cookies);
515
756
  await clearPersistedSession(event.cookies, persisted?.id);
516
757
  const postLogoutRedirectPath = internalRedirectPath(event, logoutOptions.postLogoutRedirectUri ?? options.defaultLogoutRedirect ?? options.postLogoutRedirectUri, '/');
517
- if (logoutOptions.clearSessionOnly || !metadata.end_session_endpoint) {
758
+ if (logoutOptions.clearSessionOnly) {
518
759
  throw redirect(302, postLogoutRedirectPath);
519
760
  }
761
+ let metadata;
762
+ try {
763
+ metadata = await getMetadata();
764
+ }
765
+ catch (err) {
766
+ log.warn('Provider metadata unavailable during logout; local session was cleared', err);
767
+ throw redirect(302, postLogoutRedirectPath);
768
+ }
769
+ if (!metadata.end_session_endpoint)
770
+ throw redirect(302, postLogoutRedirectPath);
520
771
  const url = new URL(metadata.end_session_endpoint);
521
772
  if (session?.tokens.idToken) {
522
773
  url.searchParams.set('id_token_hint', session.tokens.idToken);
@@ -600,9 +851,13 @@ export function createOIDC(options) {
600
851
  }
601
852
  function callbackHandler(handlerOptions = {}) {
602
853
  return async (event) => {
603
- const stateCookie = cookieStore.readState(event.cookies);
604
- const callbackState = event.url.searchParams.get('state');
605
- const isSilentReauthentication = Boolean(stateCookie?.prompt === 'none' && callbackState && callbackState === stateCookie.state);
854
+ // `state` now carries a signed `returnTo` suffix (see
855
+ // encodeOAuthState in signIn), so it's no longer directly equal
856
+ // to the bare token stored in the cookie - decode it the same
857
+ // way handleCallback does before comparing.
858
+ const callbackToken = decodeOAuthState(event.url.searchParams.get('state'), options.cookieSecret).token;
859
+ const stateCookie = cookieStore.readState(event.cookies, callbackToken);
860
+ const isSilentReauthentication = Boolean(stateCookie?.prompt === 'none' && callbackToken && callbackToken === stateCookie.state);
606
861
  try {
607
862
  const result = await handleCallback(event);
608
863
  if (isSilentReauthentication) {
@@ -615,7 +870,7 @@ export function createOIDC(options) {
615
870
  }
616
871
  catch (err) {
617
872
  if (isSilentReauthentication) {
618
- cookieStore.clearState(event.cookies);
873
+ cookieStore.clearState(event.cookies, callbackToken);
619
874
  const persisted = await readPersistedSession(event.cookies);
620
875
  await clearPersistedSession(event.cookies, persisted?.id);
621
876
  log.debug('Silent OIDC re-authentication failed — clearing local session');