fastify-txstate 3.6.9 → 4.0.1
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 +392 -7
- package/lib/analytics.d.ts +5 -2
- package/lib/analytics.js +28 -33
- package/lib/error.d.ts +1 -10
- package/lib/error.js +7 -28
- package/lib/filestorage.d.ts +1 -1
- package/lib/filestorage.js +27 -31
- package/lib/index.d.ts +9 -194
- package/lib/index.js +9 -422
- package/lib/jwt-auth.d.ts +98 -0
- package/lib/jwt-auth.js +491 -0
- package/lib/oauth.d.ts +49 -0
- package/lib/oauth.js +272 -0
- package/lib/postformdata.js +13 -17
- package/{lib-esm/index.d.ts → lib/server.d.ts} +69 -37
- package/lib/server.js +441 -0
- package/lib/unified-auth.d.ts +13 -7
- package/lib/unified-auth.js +48 -174
- package/package.json +27 -25
- package/lib-esm/index.js +0 -20
- package/lib-esm/package.json +0 -3
package/lib/jwt-auth.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, createPublicKey, createSecretKey, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createRemoteJWKSet, decodeJwt, importJWK, jwtVerify } from 'jose';
|
|
3
|
+
import { Cache, isBlank, isNotBlank, toArray } from 'txstate-utils';
|
|
4
|
+
// Cookie names are owned here; oauth.ts and unified-auth.ts import them so the random
|
|
5
|
+
// fallbacks don't drift between modules in deployments that don't set the env vars.
|
|
6
|
+
export const oauthCookieName = process.env.OAUTH_COOKIE_NAME ?? randomBytes(16).toString('hex');
|
|
7
|
+
export const refreshCookieName = oauthCookieName + '_rt';
|
|
8
|
+
export const accessTokenCookieName = oauthCookieName + '_at';
|
|
9
|
+
export const uaCookieName = process.env.UA_COOKIE_NAME ?? randomBytes(16).toString('hex');
|
|
10
|
+
const oauthCookieRegex = new RegExp(`${oauthCookieName}=([^;]+)`, 'v');
|
|
11
|
+
const refreshCookieRegex = new RegExp(`${refreshCookieName}=([^;]+)`, 'v');
|
|
12
|
+
const accessTokenCookieRegex = new RegExp(`${accessTokenCookieName}=([^;]+)`, 'v');
|
|
13
|
+
const uaCookieRegex = new RegExp(`${uaCookieName}=([^;]+)`, 'v');
|
|
14
|
+
let cookieEncryptionKey;
|
|
15
|
+
if (isNotBlank(process.env.OAUTH_COOKIE_SECRET)) {
|
|
16
|
+
cookieEncryptionKey = createHash('sha256').update(process.env.OAUTH_COOKIE_SECRET).digest();
|
|
17
|
+
}
|
|
18
|
+
export function wrapRefreshToken(token) {
|
|
19
|
+
if (!cookieEncryptionKey)
|
|
20
|
+
return token;
|
|
21
|
+
const iv = randomBytes(12);
|
|
22
|
+
const cipher = createCipheriv('aes-256-gcm', cookieEncryptionKey, iv);
|
|
23
|
+
const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
|
|
24
|
+
const tag = cipher.getAuthTag();
|
|
25
|
+
return Buffer.concat([iv, tag, encrypted]).toString('base64url');
|
|
26
|
+
}
|
|
27
|
+
export function unwrapRefreshToken(value) {
|
|
28
|
+
if (!cookieEncryptionKey)
|
|
29
|
+
return value;
|
|
30
|
+
try {
|
|
31
|
+
const data = Buffer.from(value, 'base64url');
|
|
32
|
+
const iv = data.subarray(0, 12);
|
|
33
|
+
const tag = data.subarray(12, 28);
|
|
34
|
+
const encrypted = data.subarray(28);
|
|
35
|
+
const decipher = createDecipheriv('aes-256-gcm', cookieEncryptionKey, iv);
|
|
36
|
+
decipher.setAuthTag(tag);
|
|
37
|
+
return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let hasInit = false;
|
|
44
|
+
const issuerConfigByIss = new Map();
|
|
45
|
+
const verifyKeyByIss = new Map();
|
|
46
|
+
const issuerInternalUrls = new Map();
|
|
47
|
+
function inferType(config) {
|
|
48
|
+
if (config.type)
|
|
49
|
+
return config.type;
|
|
50
|
+
if (config.iss === 'unified-auth')
|
|
51
|
+
return 'unified-auth';
|
|
52
|
+
if (isNotBlank(config.secret))
|
|
53
|
+
return 'secret';
|
|
54
|
+
if (isNotBlank(config.publicKey))
|
|
55
|
+
return 'publicKey';
|
|
56
|
+
if (isNotBlank(config.url))
|
|
57
|
+
return 'jwks';
|
|
58
|
+
throw new Error(`Could not infer type for JWT issuer ${config.iss}. Set "type" explicitly or provide one of url/publicKey/secret.`);
|
|
59
|
+
}
|
|
60
|
+
export function toInternalUrl(url) {
|
|
61
|
+
for (const [external, internal] of issuerInternalUrls) {
|
|
62
|
+
if (url.startsWith(external))
|
|
63
|
+
return internal + url.slice(external.length);
|
|
64
|
+
}
|
|
65
|
+
return url;
|
|
66
|
+
}
|
|
67
|
+
export function getOAuthIssuerUrls() {
|
|
68
|
+
init();
|
|
69
|
+
const urls = [];
|
|
70
|
+
for (const config of issuerConfigByIss.values()) {
|
|
71
|
+
if (config.type === 'oauth' && config.url)
|
|
72
|
+
urls.push(config.url);
|
|
73
|
+
}
|
|
74
|
+
return urls;
|
|
75
|
+
}
|
|
76
|
+
export function getIssuerConfig(iss) {
|
|
77
|
+
init();
|
|
78
|
+
const config = issuerConfigByIss.get(iss);
|
|
79
|
+
if (!config)
|
|
80
|
+
return undefined;
|
|
81
|
+
return {
|
|
82
|
+
iss: config.iss,
|
|
83
|
+
url: config.url,
|
|
84
|
+
validateUrl: config.validateUrl,
|
|
85
|
+
logoutUrl: config.logoutUrl
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export async function getOAuthDiscovery(issuerUrl) {
|
|
89
|
+
init();
|
|
90
|
+
return await discoveryCache.get(issuerUrl);
|
|
91
|
+
}
|
|
92
|
+
const jwksRemoteCache = new Cache(async (jwksUri) => createRemoteJWKSet(new URL(toInternalUrl(jwksUri))), { freshseconds: 3600 });
|
|
93
|
+
const uaJwkCache = new Cache(async (url) => {
|
|
94
|
+
const { keys } = await (await fetch(toInternalUrl(url))).json();
|
|
95
|
+
const map = {};
|
|
96
|
+
for (const jwk of keys) {
|
|
97
|
+
if (jwk.kid)
|
|
98
|
+
map[jwk.kid] = await importJWK(jwk);
|
|
99
|
+
}
|
|
100
|
+
return map;
|
|
101
|
+
});
|
|
102
|
+
function uaRemoteJWKSet(url) {
|
|
103
|
+
return async (header) => {
|
|
104
|
+
const map = await uaJwkCache.get(url);
|
|
105
|
+
return map[header.kid];
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const discoveryCache = new Cache(async (issuerUrl) => {
|
|
109
|
+
const base = issuerUrl.endsWith('/') ? issuerUrl : issuerUrl + '/';
|
|
110
|
+
for (const path of ['.well-known/openid-configuration', '.well-known/oauth-authorization-server']) {
|
|
111
|
+
try {
|
|
112
|
+
const resp = await fetch(new URL(path, toInternalUrl(base)));
|
|
113
|
+
if (resp.ok) {
|
|
114
|
+
const doc = await resp.json();
|
|
115
|
+
if (isNotBlank(doc.jwks_uri))
|
|
116
|
+
return doc;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch { /* try next */ }
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}, { freshseconds: 3600 });
|
|
123
|
+
function buildLogoutUrl(config, type) {
|
|
124
|
+
if (isNotBlank(config.logoutUrl)) {
|
|
125
|
+
return config.url ? new URL(config.logoutUrl, config.url) : new URL(config.logoutUrl);
|
|
126
|
+
}
|
|
127
|
+
if (type === 'unified-auth') {
|
|
128
|
+
if (isNotBlank(process.env.UA_URL))
|
|
129
|
+
return new URL(process.env.UA_URL + '/logout');
|
|
130
|
+
if (config.url)
|
|
131
|
+
return new URL('logout', config.url);
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
function csvEnv(value) {
|
|
136
|
+
return value?.split(',').map(s => s.trim()).filter(isNotBlank) ?? [];
|
|
137
|
+
}
|
|
138
|
+
function issuersFromEnv() {
|
|
139
|
+
const result = [];
|
|
140
|
+
if (isNotBlank(process.env.UA_URL)) {
|
|
141
|
+
result.push({
|
|
142
|
+
iss: 'unified-auth',
|
|
143
|
+
type: 'unified-auth',
|
|
144
|
+
url: process.env.UA_URL,
|
|
145
|
+
internalUrl: isNotBlank(process.env.UA_URL_INTERNAL) ? process.env.UA_URL_INTERNAL : undefined
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (isNotBlank(process.env.OAUTH_URLS)) {
|
|
149
|
+
const internalByExternal = new Map();
|
|
150
|
+
for (const pair of csvEnv(process.env.OAUTH_INTERNAL_URLS)) {
|
|
151
|
+
const eq = pair.indexOf('=');
|
|
152
|
+
if (eq > 0)
|
|
153
|
+
internalByExternal.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
154
|
+
}
|
|
155
|
+
for (const url of csvEnv(process.env.OAUTH_URLS)) {
|
|
156
|
+
result.push({
|
|
157
|
+
iss: url,
|
|
158
|
+
type: 'oauth',
|
|
159
|
+
url,
|
|
160
|
+
internalUrl: internalByExternal.get(url)
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (isNotBlank(process.env.JWT_SECRET)) {
|
|
165
|
+
result.push({
|
|
166
|
+
iss: 'jwt-secret',
|
|
167
|
+
type: 'secret',
|
|
168
|
+
secret: process.env.JWT_SECRET
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (isNotBlank(process.env.JWT_PUBLIC_KEY)) {
|
|
172
|
+
// accept PEM with literal \n escapes for env-var friendliness
|
|
173
|
+
result.push({
|
|
174
|
+
iss: 'jwt-public-key',
|
|
175
|
+
type: 'publicKey',
|
|
176
|
+
publicKey: process.env.JWT_PUBLIC_KEY.replace(/\\n/gv, '\n')
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
export function init() {
|
|
182
|
+
if (hasInit)
|
|
183
|
+
return;
|
|
184
|
+
hasInit = true;
|
|
185
|
+
const globalAudiences = csvEnv(process.env.JWT_TRUSTED_AUDIENCES);
|
|
186
|
+
const globalClientIds = csvEnv(process.env.JWT_TRUSTED_CLIENTIDS);
|
|
187
|
+
const jsonIssuers = process.env.JWT_TRUSTED_ISSUERS
|
|
188
|
+
? toArray(JSON.parse(process.env.JWT_TRUSTED_ISSUERS))
|
|
189
|
+
: [];
|
|
190
|
+
// env-derived issuers first, then JSON-derived — JSON entries with the same iss override.
|
|
191
|
+
for (const issuer of [...issuersFromEnv(), ...jsonIssuers]) {
|
|
192
|
+
const type = inferType(issuer);
|
|
193
|
+
if (isNotBlank(issuer.url) && isNotBlank(issuer.internalUrl)) {
|
|
194
|
+
issuerInternalUrls.set(issuer.url, issuer.internalUrl);
|
|
195
|
+
}
|
|
196
|
+
switch (type) {
|
|
197
|
+
case 'unified-auth':
|
|
198
|
+
if (!issuer.url)
|
|
199
|
+
throw new Error(`unified-auth issuer ${issuer.iss} requires url`);
|
|
200
|
+
verifyKeyByIss.set(issuer.iss, uaRemoteJWKSet(issuer.url));
|
|
201
|
+
break;
|
|
202
|
+
case 'oauth':
|
|
203
|
+
if (!issuer.url)
|
|
204
|
+
throw new Error(`oauth issuer ${issuer.iss} requires url`);
|
|
205
|
+
// verify key resolved per request via discovery
|
|
206
|
+
break;
|
|
207
|
+
case 'jwks':
|
|
208
|
+
if (!issuer.url)
|
|
209
|
+
throw new Error(`jwks issuer ${issuer.iss} requires url`);
|
|
210
|
+
verifyKeyByIss.set(issuer.iss, createRemoteJWKSet(new URL(toInternalUrl(issuer.url))));
|
|
211
|
+
break;
|
|
212
|
+
case 'publicKey':
|
|
213
|
+
if (!issuer.publicKey)
|
|
214
|
+
throw new Error(`publicKey issuer ${issuer.iss} requires publicKey`);
|
|
215
|
+
verifyKeyByIss.set(issuer.iss, createPublicKey(issuer.publicKey));
|
|
216
|
+
break;
|
|
217
|
+
case 'secret':
|
|
218
|
+
if (!issuer.secret)
|
|
219
|
+
throw new Error(`secret issuer ${issuer.iss} requires secret`);
|
|
220
|
+
verifyKeyByIss.set(issuer.iss, createSecretKey(Buffer.from(issuer.secret, 'ascii')));
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
const validateUrl = type === 'unified-auth'
|
|
224
|
+
? (isNotBlank(issuer.validateUrl) ? new URL(issuer.validateUrl, issuer.url) : new URL('validateToken', issuer.url))
|
|
225
|
+
: undefined;
|
|
226
|
+
const audiences = new Set([...(issuer.audiences ?? []), ...globalAudiences]);
|
|
227
|
+
const clientIds = new Set([...(issuer.clientIds ?? []), ...globalClientIds]);
|
|
228
|
+
issuerConfigByIss.set(issuer.iss, {
|
|
229
|
+
type,
|
|
230
|
+
iss: issuer.iss,
|
|
231
|
+
url: issuer.url,
|
|
232
|
+
validateUrl,
|
|
233
|
+
logoutUrl: buildLogoutUrl(issuer, type),
|
|
234
|
+
audiences: audiences.size ? audiences : undefined,
|
|
235
|
+
clientIds: clientIds.size ? clientIds : undefined
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function checkAudience(aud, audiences, req) {
|
|
240
|
+
if (!audiences?.size)
|
|
241
|
+
return true;
|
|
242
|
+
// RFC 7519: a consumer accepts a token if it identifies itself in the aud claim, so
|
|
243
|
+
// any single trusted audience appearing in the token is sufficient — additional
|
|
244
|
+
// untrusted audiences alongside it do not cause rejection.
|
|
245
|
+
if (!toArray(aud).some(a => audiences.has(a))) {
|
|
246
|
+
req.log.warn(`Received token with untrusted audience: ${String(aud)}`);
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
function checkClientId(clientId, clientIds, req) {
|
|
252
|
+
if (!clientIds?.size)
|
|
253
|
+
return true;
|
|
254
|
+
if (!clientIds.has(clientId)) {
|
|
255
|
+
req.log.warn(`Received token with untrusted client_id: ${String(clientId)}`);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
async function resolveVerifyKey(config) {
|
|
261
|
+
if (config.type === 'oauth') {
|
|
262
|
+
if (!config.url)
|
|
263
|
+
return undefined;
|
|
264
|
+
const discovery = await discoveryCache.get(config.url);
|
|
265
|
+
if (!discovery?.jwks_uri)
|
|
266
|
+
return undefined;
|
|
267
|
+
return { key: await jwksRemoteCache.get(discovery.jwks_uri), discovery };
|
|
268
|
+
}
|
|
269
|
+
const key = verifyKeyByIss.get(config.iss);
|
|
270
|
+
return key ? { key } : undefined;
|
|
271
|
+
}
|
|
272
|
+
const tokenCache = new Cache(async (token, req) => {
|
|
273
|
+
const claims = decodeJwt(token);
|
|
274
|
+
if (!claims.iss) {
|
|
275
|
+
req.log.warn('Received token without an issuer claim.');
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const config = issuerConfigByIss.get(claims.iss);
|
|
279
|
+
if (!config) {
|
|
280
|
+
req.log.warn(`Received token with untrusted issuer: ${claims.iss}`);
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const resolved = await resolveVerifyKey(config);
|
|
285
|
+
if (!resolved) {
|
|
286
|
+
req.log.warn(`Could not resolve verification key for issuer ${claims.iss}`);
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
const { payload } = await jwtVerify(token, resolved.key);
|
|
290
|
+
if (!checkAudience(payload.aud, config.audiences, req))
|
|
291
|
+
return undefined;
|
|
292
|
+
if (!checkClientId(payload.client_id, config.clientIds, req))
|
|
293
|
+
return undefined;
|
|
294
|
+
return { payload, config, discovery: resolved.discovery };
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
const code = e.code;
|
|
298
|
+
if (code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED' && code !== 'ERR_JWT_EXPIRED')
|
|
299
|
+
req.log.error(e);
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
}, { freshseconds: 3600 });
|
|
303
|
+
const validateCache = new Cache(async (token, payload) => {
|
|
304
|
+
const config = payload.iss ? issuerConfigByIss.get(payload.iss) : undefined;
|
|
305
|
+
if (!config?.validateUrl)
|
|
306
|
+
return;
|
|
307
|
+
// avoid checking for deauth until the token is more than 5 minutes old
|
|
308
|
+
if (new Date(payload.iat * 1000) > new Date(Date.now() - 1000 * 60 * 5))
|
|
309
|
+
return;
|
|
310
|
+
const validateUrl = new URL(config.validateUrl);
|
|
311
|
+
validateUrl.searchParams.set('unifiedJwt', token);
|
|
312
|
+
const resp = await fetch(validateUrl);
|
|
313
|
+
const validate = await resp.json();
|
|
314
|
+
if (!validate.valid)
|
|
315
|
+
throw new Error(validate.reason ?? 'Your session has been ended on another device or in another browser tab/window. It\'s also possible your NetID is no longer active.');
|
|
316
|
+
});
|
|
317
|
+
async function tryRefresh(req, expiredIss) {
|
|
318
|
+
const m = req.headers.cookie?.match(refreshCookieRegex);
|
|
319
|
+
if (!m)
|
|
320
|
+
return undefined;
|
|
321
|
+
const refreshToken = unwrapRefreshToken(m[1]);
|
|
322
|
+
if (!refreshToken)
|
|
323
|
+
return undefined;
|
|
324
|
+
const clientId = process.env.OAUTH_COOKIE_CLIENT_ID;
|
|
325
|
+
if (!clientId)
|
|
326
|
+
return undefined;
|
|
327
|
+
const candidate = (expiredIss && issuerConfigByIss.get(expiredIss)?.type === 'oauth')
|
|
328
|
+
? issuerConfigByIss.get(expiredIss)
|
|
329
|
+
: [...issuerConfigByIss.values()].find(c => c.type === 'oauth');
|
|
330
|
+
if (!candidate?.url)
|
|
331
|
+
return undefined;
|
|
332
|
+
const discovery = await discoveryCache.get(candidate.url);
|
|
333
|
+
if (!discovery?.token_endpoint)
|
|
334
|
+
return undefined;
|
|
335
|
+
const body = {
|
|
336
|
+
grant_type: 'refresh_token',
|
|
337
|
+
refresh_token: refreshToken,
|
|
338
|
+
client_id: clientId
|
|
339
|
+
};
|
|
340
|
+
const clientSecret = process.env.OAUTH_COOKIE_CLIENT_SECRET;
|
|
341
|
+
if (clientSecret)
|
|
342
|
+
body.client_secret = clientSecret;
|
|
343
|
+
try {
|
|
344
|
+
const tokenResp = await fetch(toInternalUrl(discovery.token_endpoint), {
|
|
345
|
+
method: 'POST',
|
|
346
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
347
|
+
body: new URLSearchParams(body)
|
|
348
|
+
});
|
|
349
|
+
if (!tokenResp.ok)
|
|
350
|
+
return undefined;
|
|
351
|
+
const tokens = await tokenResp.json();
|
|
352
|
+
if (!tokens.id_token)
|
|
353
|
+
return undefined;
|
|
354
|
+
req.pendingOAuthCookies = [
|
|
355
|
+
`${oauthCookieName}=${tokens.id_token}; Path=/; Secure; HttpOnly; SameSite=Lax`
|
|
356
|
+
];
|
|
357
|
+
if (tokens.access_token) {
|
|
358
|
+
req.pendingOAuthCookies.push(`${accessTokenCookieName}=${wrapRefreshToken(tokens.access_token)}; Path=/; Secure; HttpOnly; SameSite=Lax`);
|
|
359
|
+
}
|
|
360
|
+
if (tokens.refresh_token) {
|
|
361
|
+
req.pendingOAuthCookies.push(`${refreshCookieName}=${wrapRefreshToken(tokens.refresh_token)}; Path=/; Secure; HttpOnly; SameSite=Lax`);
|
|
362
|
+
}
|
|
363
|
+
const cached = await tokenCache.get(tokens.id_token, req);
|
|
364
|
+
if (!cached)
|
|
365
|
+
return undefined;
|
|
366
|
+
if (cached.payload.exp && cached.payload.exp * 1000 <= Date.now())
|
|
367
|
+
return undefined;
|
|
368
|
+
return { token: tokens.id_token, payload: cached.payload, config: cached.config, discovery: cached.discovery };
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
req.log.error(e);
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function tokenFromReq(req) {
|
|
376
|
+
const m = req.headers.authorization?.match(/^bearer (.*)$/iv);
|
|
377
|
+
if (m)
|
|
378
|
+
return m[1];
|
|
379
|
+
const oauthM = req.headers.cookie?.match(oauthCookieRegex);
|
|
380
|
+
if (oauthM)
|
|
381
|
+
return oauthM[1];
|
|
382
|
+
const uaM = req.headers.cookie?.match(uaCookieRegex);
|
|
383
|
+
if (uaM)
|
|
384
|
+
return uaM[1];
|
|
385
|
+
}
|
|
386
|
+
function accessTokenFromReq(req) {
|
|
387
|
+
const m = req.headers.cookie?.match(accessTokenCookieRegex);
|
|
388
|
+
if (!m)
|
|
389
|
+
return undefined;
|
|
390
|
+
return unwrapRefreshToken(m[1]);
|
|
391
|
+
}
|
|
392
|
+
function authIssuerConfig(config, discovery) {
|
|
393
|
+
const logoutUrl = config.type === 'oauth' && isNotBlank(discovery?.end_session_endpoint)
|
|
394
|
+
? new URL(discovery.end_session_endpoint)
|
|
395
|
+
: config.logoutUrl;
|
|
396
|
+
return {
|
|
397
|
+
iss: config.iss,
|
|
398
|
+
url: config.url,
|
|
399
|
+
validateUrl: config.validateUrl,
|
|
400
|
+
logoutUrl
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function buildAuthInfo(result, extraClaims) {
|
|
404
|
+
const { token, payload, config, discovery } = result;
|
|
405
|
+
return {
|
|
406
|
+
...extraClaims?.(payload),
|
|
407
|
+
token,
|
|
408
|
+
issuerConfig: authIssuerConfig(config, discovery),
|
|
409
|
+
username: payload.sub,
|
|
410
|
+
sessionId: payload.sub + '-' + String(payload.iat),
|
|
411
|
+
sessionCreatedAt: payload.iat ? new Date(payload.iat * 1000) : undefined,
|
|
412
|
+
clientId: payload.client_id,
|
|
413
|
+
impersonatedBy: payload.act?.sub,
|
|
414
|
+
scope: payload.scope
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async function jwtAuthenticateInternal(req, extraClaims) {
|
|
418
|
+
init();
|
|
419
|
+
const token = tokenFromReq(req);
|
|
420
|
+
if (!token)
|
|
421
|
+
return undefined;
|
|
422
|
+
let result;
|
|
423
|
+
const cached = await tokenCache.get(token, req);
|
|
424
|
+
if (!cached) {
|
|
425
|
+
// jwtVerify rejects expired tokens — try a refresh if we have a refresh-token cookie
|
|
426
|
+
try {
|
|
427
|
+
const { iss } = decodeJwt(token);
|
|
428
|
+
result = await tryRefresh(req, iss ?? undefined);
|
|
429
|
+
}
|
|
430
|
+
catch { /* not a JWT */ }
|
|
431
|
+
}
|
|
432
|
+
else if (cached.payload.exp && cached.payload.exp * 1000 <= Date.now()) {
|
|
433
|
+
// belt-and-suspenders: catch tokens that expired between cache and now
|
|
434
|
+
result = await tryRefresh(req, cached.payload.iss);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
result = { token, payload: cached.payload, config: cached.config, discovery: cached.discovery };
|
|
438
|
+
}
|
|
439
|
+
if (!result)
|
|
440
|
+
return undefined;
|
|
441
|
+
await validateCache.get(result.token, result.payload);
|
|
442
|
+
const authInfo = buildAuthInfo(result, extraClaims);
|
|
443
|
+
authInfo.accessToken = accessTokenFromReq(req);
|
|
444
|
+
return authInfo;
|
|
445
|
+
}
|
|
446
|
+
// Routes contributed by registerOAuthCookieRoutes / registerUaCookieRoutes. The
|
|
447
|
+
// authenticator returned by jwtAuthenticate consults these at request time so that
|
|
448
|
+
// registration order (factory vs. route registration) doesn't matter.
|
|
449
|
+
export const registeredExceptRoutes = new Set();
|
|
450
|
+
export const registeredOptionalRoutes = new Set();
|
|
451
|
+
/**
|
|
452
|
+
* Build an `authenticate` function that validates JWTs from the Authorization Bearer
|
|
453
|
+
* header or a session cookie. Supports any mix of issuer types via the
|
|
454
|
+
* JWT_TRUSTED_ISSUERS env var:
|
|
455
|
+
*
|
|
456
|
+
* - 'oauth' — OAuth/OIDC provider with .well-known auto-discovery
|
|
457
|
+
* - 'jwks' — JWKS endpoint URL (no discovery)
|
|
458
|
+
* - 'unified-auth' — TxState Unified Auth (JWKS + /validateToken poll for deauth)
|
|
459
|
+
* - 'publicKey' — PEM-encoded asymmetric public key
|
|
460
|
+
* - 'secret' — symmetric HMAC secret
|
|
461
|
+
*
|
|
462
|
+
* Usage:
|
|
463
|
+
* new Server({ authenticate: jwtAuthenticate({ authenticateAll: true }) })
|
|
464
|
+
*
|
|
465
|
+
* Or with no options:
|
|
466
|
+
* new Server({ authenticate: jwtAuthenticate() })
|
|
467
|
+
*
|
|
468
|
+
* Calling `registerOAuthCookieRoutes` or `registerUaCookieRoutes` automatically excludes
|
|
469
|
+
* their callback/redirect routes from authentication requirements and marks their logout
|
|
470
|
+
* routes as optional, so you do not need to configure that here.
|
|
471
|
+
*
|
|
472
|
+
* If a refresh-token cookie is present (set by registerOAuthCookieRoutes) and the access
|
|
473
|
+
* token has expired, the returned authenticator transparently exchanges the refresh
|
|
474
|
+
* token for a new access token and queues the replacement cookies on
|
|
475
|
+
* `req.pendingOAuthCookies`. The onSend hook installed by registerOAuthCookieRoutes
|
|
476
|
+
* flushes those cookies onto the response.
|
|
477
|
+
*/
|
|
478
|
+
export function jwtAuthenticate(options) {
|
|
479
|
+
const exceptRoutes = new Set(options?.exceptRoutes);
|
|
480
|
+
const optionalRoutes = new Set(options?.optionalRoutes);
|
|
481
|
+
return async (req) => {
|
|
482
|
+
const url = req.routeOptions.url;
|
|
483
|
+
if (exceptRoutes.has(url) || registeredExceptRoutes.has(url))
|
|
484
|
+
return undefined;
|
|
485
|
+
const auth = await jwtAuthenticateInternal(req, options?.extraClaims);
|
|
486
|
+
if (options?.authenticateAll && !optionalRoutes.has(url) && !registeredOptionalRoutes.has(url) && isBlank(auth?.username)) {
|
|
487
|
+
throw new Error('Request requires authentication.');
|
|
488
|
+
}
|
|
489
|
+
return auth;
|
|
490
|
+
};
|
|
491
|
+
}
|
package/lib/oauth.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
+
import { type FastifyInstanceTyped } from './server.ts';
|
|
3
|
+
export interface IssuerChoice {
|
|
4
|
+
issuerUrl: string;
|
|
5
|
+
redirectHref: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Register cookie-based OAuth login/logout endpoints. Uses the authorization code flow
|
|
9
|
+
* with PKCE (S256) to exchange a code for tokens, then stores the ID token in an HttpOnly
|
|
10
|
+
* cookie. The access token and refresh token are stored in separate cookies (optionally
|
|
11
|
+
* encrypted via OAUTH_COOKIE_SECRET) so that the ID token can be transparently refreshed
|
|
12
|
+
* by jwtAuthenticate when it expires, and the access token is available at
|
|
13
|
+
* `req.auth.accessToken` for calling provider APIs.
|
|
14
|
+
*
|
|
15
|
+
* Requires OAUTH_COOKIE_CLIENT_ID environment variable. OAUTH_COOKIE_CLIENT_SECRET is
|
|
16
|
+
* optional — PKCE provides the security for the code exchange, but some providers require
|
|
17
|
+
* a client secret even with PKCE. OAUTH_COOKIE_SECRET is optional — if set, the access
|
|
18
|
+
* token and refresh token cookies are encrypted with AES-256-GCM; if not, they are stored
|
|
19
|
+
* as plaintext (still HttpOnly and Secure).
|
|
20
|
+
*
|
|
21
|
+
* Trusted issuers are configured via OAUTH_URLS or JWT_TRUSTED_ISSUERS (see jwt-auth.ts).
|
|
22
|
+
*
|
|
23
|
+
* Registers three routes:
|
|
24
|
+
* - `/.oauthRedirect` - Redirects to the OAuth provider's login page. The client passes
|
|
25
|
+
* `requestedUrl` (required) which is sent to the provider as the `state` parameter,
|
|
26
|
+
* round-tripped back, and used as the redirect destination after login.
|
|
27
|
+
* - `/.oauthCallback` - Handles the provider's redirect, exchanges the code for tokens
|
|
28
|
+
* using the PKCE code verifier. Sets the ID token (or JWT access token as fallback),
|
|
29
|
+
* access token, and refresh token as cookies.
|
|
30
|
+
* - `/.oauthLogout` - Clears all OAuth cookies and redirects to the provider's logout
|
|
31
|
+
* endpoint if available.
|
|
32
|
+
*/
|
|
33
|
+
export declare function registerOAuthCookieRoutes(app: FastifyInstanceTyped, options?: {
|
|
34
|
+
/** Scopes to always include in the authorization request, merged with any scopes
|
|
35
|
+
* the client passes via the `scope` query parameter. */
|
|
36
|
+
scopes?: string[];
|
|
37
|
+
/** When multiple issuers are configured and the client doesn't specify one,
|
|
38
|
+
* this function is called to render a login selection page. It receives an array
|
|
39
|
+
* of issuer URLs with their corresponding redirect hrefs and should return an HTML
|
|
40
|
+
* string. If not provided, the first trusted issuer is used. */
|
|
41
|
+
loginPage?: (issuers: IssuerChoice[]) => string;
|
|
42
|
+
}): void;
|
|
43
|
+
/**
|
|
44
|
+
* This function is available for server-side view code instead of a client-side application
|
|
45
|
+
* using a framework. It will automatically redirect the user through the OAuth login flow
|
|
46
|
+
* (via /.oauthRedirect, which must be registered by registerOAuthCookieRoutes) and return
|
|
47
|
+
* true if they are not authenticated. Otherwise it simply returns false.
|
|
48
|
+
*/
|
|
49
|
+
export declare function requireCookieAuthOAuth(req: FastifyRequest, res: FastifyReply): Promise<boolean>;
|