@sourceregistry/sveltekit-oidc 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -69
- package/dist/client/OIDCContext.svelte +217 -5
- package/dist/client/OIDCContext.svelte.d.ts +30 -2
- package/dist/client/idle.d.ts +7 -0
- package/dist/client/idle.js +22 -0
- package/dist/server/cookies.d.ts +1 -1
- package/dist/server/cookies.js +28 -7
- package/dist/server/index.js +346 -91
- package/dist/server/jwt.js +13 -3
- package/dist/server/store.d.ts +3 -1
- package/dist/server/store.js +27 -7
- package/dist/server/types.d.ts +41 -2
- package/dist/server/utils.d.ts +32 -1
- package/dist/server/utils.js +97 -4
- package/package.json +2 -2
package/dist/server/index.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { error, redirect } from '@sveltejs/kit';
|
|
2
|
-
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import {
|
|
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
|
-
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
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
|
|
137
|
-
|
|
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()
|
|
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
|
|
240
|
+
let header;
|
|
171
241
|
try {
|
|
172
|
-
|
|
242
|
+
header = decodeJwt(token).header;
|
|
173
243
|
}
|
|
174
244
|
catch {
|
|
175
245
|
throw error(400, { message: 'Invalid JWT format' });
|
|
176
246
|
}
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
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:
|
|
252
|
+
message: `JWT algorithm '${header.alg ?? 'missing'}' is not allowed`
|
|
182
253
|
});
|
|
183
254
|
}
|
|
184
|
-
|
|
185
|
-
|
|
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:
|
|
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
|
|
414
|
+
algorithms: idTokenAlgorithms(metadata),
|
|
415
|
+
types: ['JWT'],
|
|
280
416
|
clockSkew: clockSkewSeconds
|
|
281
417
|
});
|
|
282
|
-
validateIdTokenClaims(claims, nonce);
|
|
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'],
|
|
428
|
+
clockSkew: clockSkewSeconds
|
|
429
|
+
});
|
|
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.
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
reason: 'refresh',
|
|
387
|
-
event,
|
|
388
|
-
tokenResponse
|
|
389
|
-
})) ?? nextSession;
|
|
390
|
-
await writePersistedSession(cookies, persistedSession, persisted?.id);
|
|
391
|
-
log.debug('OIDC session tokens refreshed', {
|
|
392
|
-
expiresAt: persistedSession.tokens.expiresAt,
|
|
393
|
-
refreshExpiresAt: persistedSession.tokens.refreshExpiresAt,
|
|
394
|
-
hasRefreshToken: Boolean(persistedSession.tokens.refreshToken)
|
|
584
|
+
task = (options.refreshLock ? options.refreshLock.runExclusive(refreshKey, execute) : execute()).finally(() => {
|
|
585
|
+
refreshTasks.delete(refreshKey);
|
|
395
586
|
});
|
|
396
|
-
|
|
587
|
+
refreshTasks.set(refreshKey, task);
|
|
397
588
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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, {
|
|
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,
|
|
@@ -508,15 +750,24 @@ export function createOIDC(options) {
|
|
|
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
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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');
|
package/dist/server/jwt.js
CHANGED
|
@@ -13,11 +13,17 @@ export async function fetchJson(url, init, fetchImpl = fetch) {
|
|
|
13
13
|
return (await response.json());
|
|
14
14
|
}
|
|
15
15
|
export function asAuthorizationHeader(clientId, clientSecret) {
|
|
16
|
-
|
|
16
|
+
const encode = (value) => {
|
|
17
|
+
const encoded = new URLSearchParams({ value }).toString();
|
|
18
|
+
return encoded.slice('value='.length);
|
|
19
|
+
};
|
|
20
|
+
return `Basic ${Buffer.from(`${encode(clientId)}:${encode(clientSecret)}`).toString('base64')}`;
|
|
17
21
|
}
|
|
18
22
|
export async function createClientSecretJwtAssertion(options) {
|
|
19
23
|
if (!options.clientSecret) {
|
|
20
|
-
throw error(500, {
|
|
24
|
+
throw error(500, {
|
|
25
|
+
message: 'clientSecret is required for client_secret_jwt'
|
|
26
|
+
});
|
|
21
27
|
}
|
|
22
28
|
const algorithm = options.algorithm ?? 'HS256';
|
|
23
29
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -40,5 +46,9 @@ export async function createPrivateKeyJwtAssertion(options) {
|
|
|
40
46
|
jti: base64UrlEncode(randomBytes(24)),
|
|
41
47
|
iat: now,
|
|
42
48
|
exp: now + (options.expiresInSeconds ?? 60)
|
|
43
|
-
}, options.privateKey, {
|
|
49
|
+
}, options.privateKey, {
|
|
50
|
+
alg: algorithm,
|
|
51
|
+
typ: 'JWT',
|
|
52
|
+
...(options.keyId ? { kid: options.keyId } : {})
|
|
53
|
+
});
|
|
44
54
|
}
|