fastify-txstate 3.6.9 → 4.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 +354 -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 +8 -194
- package/lib/index.js +8 -422
- package/lib/oauth.d.ts +71 -0
- package/lib/oauth.js +507 -0
- package/lib/postformdata.js +13 -17
- package/{lib-esm/index.d.ts → lib/server.d.ts} +69 -37
- package/lib/server.js +440 -0
- package/lib/unified-auth.d.ts +2 -2
- package/lib/unified-auth.js +55 -47
- package/package.json +27 -25
- package/lib-esm/index.js +0 -20
- package/lib-esm/package.json +0 -3
package/lib/oauth.js
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
|
|
3
|
+
import { Cache, isBlank, isNotBlank, htmlEncode } from 'txstate-utils';
|
|
4
|
+
import { apiBaseUrl, uiBaseUrl } from "./server.js";
|
|
5
|
+
let hasInit = false;
|
|
6
|
+
const trustedIssuers = new Set();
|
|
7
|
+
const trustedAudiences = new Set();
|
|
8
|
+
const trustedClients = new Set();
|
|
9
|
+
const issuerInternalUrls = new Map();
|
|
10
|
+
/** Rewrite a URL for server-to-server requests. Not for browser-facing URLs. */
|
|
11
|
+
function toInternalUrl(url) {
|
|
12
|
+
for (const [external, internal] of issuerInternalUrls) {
|
|
13
|
+
if (url.startsWith(external))
|
|
14
|
+
return internal + url.slice(external.length);
|
|
15
|
+
}
|
|
16
|
+
return url;
|
|
17
|
+
}
|
|
18
|
+
const oauthCookieName = process.env.OAUTH_COOKIE_NAME ?? randomBytes(16).toString('hex');
|
|
19
|
+
const oauthCookieNameRegex = new RegExp(`${oauthCookieName}=([^;]+)`, 'v');
|
|
20
|
+
const refreshCookieName = oauthCookieName + '_rt';
|
|
21
|
+
const refreshCookieRegex = new RegExp(`${refreshCookieName}=([^;]+)`, 'v');
|
|
22
|
+
const accessTokenCookieName = oauthCookieName + '_at';
|
|
23
|
+
const accessTokenCookieRegex = new RegExp(`${accessTokenCookieName}=([^;]+)`, 'v');
|
|
24
|
+
let cookieEncryptionKey;
|
|
25
|
+
function wrapRefreshToken(token) {
|
|
26
|
+
if (!cookieEncryptionKey)
|
|
27
|
+
return token;
|
|
28
|
+
const iv = randomBytes(12);
|
|
29
|
+
const cipher = createCipheriv('aes-256-gcm', cookieEncryptionKey, iv);
|
|
30
|
+
const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
|
|
31
|
+
const tag = cipher.getAuthTag();
|
|
32
|
+
return Buffer.concat([iv, tag, encrypted]).toString('base64url');
|
|
33
|
+
}
|
|
34
|
+
function unwrapRefreshToken(value) {
|
|
35
|
+
if (!cookieEncryptionKey)
|
|
36
|
+
return value;
|
|
37
|
+
try {
|
|
38
|
+
const data = Buffer.from(value, 'base64url');
|
|
39
|
+
const iv = data.subarray(0, 12);
|
|
40
|
+
const tag = data.subarray(12, 28);
|
|
41
|
+
const encrypted = data.subarray(28);
|
|
42
|
+
const decipher = createDecipheriv('aes-256-gcm', cookieEncryptionKey, iv);
|
|
43
|
+
decipher.setAuthTag(tag);
|
|
44
|
+
return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const discoveryCache = new Cache(async (issuerUrl) => {
|
|
51
|
+
const base = issuerUrl.endsWith('/') ? issuerUrl : issuerUrl + '/';
|
|
52
|
+
// try OpenID Connect discovery first, then OAuth 2.0 Authorization Server Metadata
|
|
53
|
+
for (const path of ['.well-known/openid-configuration', '.well-known/oauth-authorization-server']) {
|
|
54
|
+
try {
|
|
55
|
+
const resp = await fetch(new URL(path, toInternalUrl(base)));
|
|
56
|
+
if (resp.ok) {
|
|
57
|
+
const doc = await resp.json();
|
|
58
|
+
if (isNotBlank(doc.jwks_uri))
|
|
59
|
+
return doc;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch { /* try next */ }
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}, { freshseconds: 3600 });
|
|
66
|
+
const jwkSetCache = new Cache(async (jwksUri) => createRemoteJWKSet(new URL(toInternalUrl(jwksUri))), { freshseconds: 3600 });
|
|
67
|
+
function checkAudience(aud, req) {
|
|
68
|
+
if (!trustedAudiences.size)
|
|
69
|
+
return true;
|
|
70
|
+
const audiences = Array.isArray(aud) ? aud : [aud];
|
|
71
|
+
if (!audiences.some(a => trustedAudiences.has(a))) {
|
|
72
|
+
req.log.warn(`Received token with untrusted audience: ${String(aud)}`);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
function checkClientId(clientId, req) {
|
|
78
|
+
if (!trustedClients.size)
|
|
79
|
+
return true;
|
|
80
|
+
if (!trustedClients.has(clientId)) {
|
|
81
|
+
req.log.warn(`Received token with untrusted client_id: ${String(clientId)}.`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
const tokenCache = new Cache(async (token, req) => {
|
|
87
|
+
const claims = decodeJwt(token);
|
|
88
|
+
if (!claims.iss) {
|
|
89
|
+
req.log.warn('Received OAuth token without an issuer claim.');
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (!trustedIssuers.has(claims.iss)) {
|
|
93
|
+
req.log.warn(`Received token with untrusted issuer: ${claims.iss}`);
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const discovery = await discoveryCache.get(claims.iss);
|
|
98
|
+
if (!discovery?.jwks_uri)
|
|
99
|
+
return undefined;
|
|
100
|
+
const jwkSet = await jwkSetCache.get(discovery.jwks_uri);
|
|
101
|
+
const { payload } = await jwtVerify(token, jwkSet);
|
|
102
|
+
if (!checkAudience(payload.aud, req))
|
|
103
|
+
return undefined;
|
|
104
|
+
if (!checkClientId(payload.client_id, req))
|
|
105
|
+
return undefined;
|
|
106
|
+
return { payload, discovery };
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
const code = e.code;
|
|
110
|
+
if (code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED' && code !== 'ERR_JWT_EXPIRED')
|
|
111
|
+
req.log.error(e);
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}, { freshseconds: 3600 });
|
|
115
|
+
function init() {
|
|
116
|
+
hasInit = true;
|
|
117
|
+
const issuers = process.env.OAUTH_TRUSTED_ISSUERS?.split(',').filter(isNotBlank).map(s => s.trim()) ?? [];
|
|
118
|
+
if (!issuers.length)
|
|
119
|
+
throw new Error('OAUTH_TRUSTED_ISSUERS environment variable must be set when using oauthAuthenticate. Provide a comma-separated list of trusted issuer URLs.');
|
|
120
|
+
for (const issuer of issuers) {
|
|
121
|
+
trustedIssuers.add(issuer);
|
|
122
|
+
}
|
|
123
|
+
// Note: some providers (e.g. Google) set `aud` on ID tokens to the OAuth client ID
|
|
124
|
+
// rather than a resource server URL. If accepting ID tokens from such providers, set
|
|
125
|
+
// OAUTH_TRUSTED_AUDIENCES to your OAuth client ID.
|
|
126
|
+
for (const audience of (process.env.OAUTH_TRUSTED_AUDIENCES?.split(',').filter(isNotBlank).map(s => s.trim()) ?? [])) {
|
|
127
|
+
trustedAudiences.add(audience);
|
|
128
|
+
}
|
|
129
|
+
for (const clientId of (process.env.OAUTH_TRUSTED_CLIENTIDS?.split(',').filter(isNotBlank).map(s => s.trim()) ?? [])) {
|
|
130
|
+
trustedClients.add(clientId);
|
|
131
|
+
}
|
|
132
|
+
// Map external issuer URLs to internal URLs for split-horizon DNS (e.g. communication inside a docker network)
|
|
133
|
+
for (const pair of (process.env.OAUTH_ISSUER_INTERNAL_URLS?.split(',').filter(isNotBlank).map(s => s.trim()) ?? [])) {
|
|
134
|
+
const eq = pair.indexOf('=');
|
|
135
|
+
if (eq > 0)
|
|
136
|
+
issuerInternalUrls.set(pair.slice(0, eq), pair.slice(eq + 1));
|
|
137
|
+
}
|
|
138
|
+
if (isNotBlank(process.env.OAUTH_COOKIE_SECRET)) {
|
|
139
|
+
cookieEncryptionKey = createHash('sha256').update(process.env.OAUTH_COOKIE_SECRET).digest();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function tokenFromReq(req) {
|
|
143
|
+
const m = req?.headers.authorization?.match(/^bearer (.*)$/iv);
|
|
144
|
+
if (m != null)
|
|
145
|
+
return m[1];
|
|
146
|
+
const m2 = req?.headers.cookie?.match(oauthCookieNameRegex);
|
|
147
|
+
if (m2 != null)
|
|
148
|
+
return m2[1];
|
|
149
|
+
}
|
|
150
|
+
function refreshTokenFromReq(req) {
|
|
151
|
+
const m = req.headers.cookie?.match(refreshCookieRegex);
|
|
152
|
+
if (!m)
|
|
153
|
+
return undefined;
|
|
154
|
+
return unwrapRefreshToken(m[1]);
|
|
155
|
+
}
|
|
156
|
+
function accessTokenFromReq(req) {
|
|
157
|
+
const m = req.headers.cookie?.match(accessTokenCookieRegex);
|
|
158
|
+
if (!m)
|
|
159
|
+
return undefined;
|
|
160
|
+
return unwrapRefreshToken(m[1]);
|
|
161
|
+
}
|
|
162
|
+
async function tryRefresh(req, expiredIssuer) {
|
|
163
|
+
const refreshToken = refreshTokenFromReq(req);
|
|
164
|
+
if (!refreshToken)
|
|
165
|
+
return undefined;
|
|
166
|
+
const clientId = process.env.OAUTH_CLIENT_ID;
|
|
167
|
+
if (!clientId)
|
|
168
|
+
return undefined;
|
|
169
|
+
const issuerUrl = (expiredIssuer && trustedIssuers.has(expiredIssuer)) ? expiredIssuer : [...trustedIssuers][0];
|
|
170
|
+
const discovery = await discoveryCache.get(issuerUrl);
|
|
171
|
+
if (!discovery?.token_endpoint)
|
|
172
|
+
return undefined;
|
|
173
|
+
const body = {
|
|
174
|
+
grant_type: 'refresh_token',
|
|
175
|
+
refresh_token: refreshToken,
|
|
176
|
+
client_id: clientId
|
|
177
|
+
};
|
|
178
|
+
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
|
179
|
+
if (clientSecret)
|
|
180
|
+
body.client_secret = clientSecret;
|
|
181
|
+
try {
|
|
182
|
+
const tokenResp = await fetch(toInternalUrl(discovery.token_endpoint), {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
185
|
+
body: new URLSearchParams(body)
|
|
186
|
+
});
|
|
187
|
+
if (!tokenResp.ok)
|
|
188
|
+
return undefined;
|
|
189
|
+
const tokens = await tokenResp.json();
|
|
190
|
+
if (!tokens.id_token)
|
|
191
|
+
return undefined;
|
|
192
|
+
// queue new cookies to be set on the response
|
|
193
|
+
req.pendingOAuthCookies = [
|
|
194
|
+
`${oauthCookieName}=${tokens.id_token}; Path=/; Secure; HttpOnly; SameSite=Lax`
|
|
195
|
+
];
|
|
196
|
+
if (tokens.access_token) {
|
|
197
|
+
req.pendingOAuthCookies.push(`${accessTokenCookieName}=${wrapRefreshToken(tokens.access_token)}; Path=/; Secure; HttpOnly; SameSite=Lax`);
|
|
198
|
+
}
|
|
199
|
+
// some providers rotate the refresh token on each use
|
|
200
|
+
if (tokens.refresh_token) {
|
|
201
|
+
req.pendingOAuthCookies.push(`${refreshCookieName}=${wrapRefreshToken(tokens.refresh_token)}; Path=/; Secure; HttpOnly; SameSite=Lax`);
|
|
202
|
+
}
|
|
203
|
+
const result = await tokenCache.get(tokens.id_token, req);
|
|
204
|
+
if (!result)
|
|
205
|
+
return undefined;
|
|
206
|
+
if (result.payload.exp && result.payload.exp * 1000 <= Date.now())
|
|
207
|
+
return undefined;
|
|
208
|
+
return { token: tokens.id_token, payload: result.payload, discovery };
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
req.log.error(e);
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function buildAuthInfo(token, payload, discovery, extraClaims) {
|
|
216
|
+
const issuerConf = {
|
|
217
|
+
iss: payload.iss,
|
|
218
|
+
url: payload.iss,
|
|
219
|
+
logoutUrl: isNotBlank(discovery.end_session_endpoint) ? new URL(discovery.end_session_endpoint) : undefined
|
|
220
|
+
};
|
|
221
|
+
return {
|
|
222
|
+
...extraClaims?.(payload),
|
|
223
|
+
token,
|
|
224
|
+
issuerConfig: issuerConf,
|
|
225
|
+
username: payload.sub,
|
|
226
|
+
sessionId: payload.sub + '-' + String(payload.iat),
|
|
227
|
+
sessionCreatedAt: payload.iat ? new Date(payload.iat * 1000) : undefined,
|
|
228
|
+
clientId: payload.client_id,
|
|
229
|
+
impersonatedBy: payload.act?.sub,
|
|
230
|
+
scope: payload.scope
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
async function oauthAuthenticateInternal(req, extraClaims) {
|
|
234
|
+
if (!hasInit)
|
|
235
|
+
init();
|
|
236
|
+
const token = tokenFromReq(req);
|
|
237
|
+
if (!token)
|
|
238
|
+
return undefined;
|
|
239
|
+
let result;
|
|
240
|
+
const cacheResult = await tokenCache.get(token, req);
|
|
241
|
+
if (!cacheResult) {
|
|
242
|
+
// jwtVerify rejects expired tokens, so the cache returns undefined — try to refresh
|
|
243
|
+
try {
|
|
244
|
+
const { iss } = decodeJwt(token);
|
|
245
|
+
result = await tryRefresh(req, iss ?? undefined);
|
|
246
|
+
}
|
|
247
|
+
catch { /* no result */ }
|
|
248
|
+
}
|
|
249
|
+
else if (cacheResult.payload.exp && cacheResult.payload.exp * 1000 <= Date.now()) {
|
|
250
|
+
// belt-and-suspenders: catch tokens that expired after being cached
|
|
251
|
+
result = await tryRefresh(req, cacheResult.payload.iss);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
result = { token, payload: cacheResult.payload, discovery: cacheResult.discovery };
|
|
255
|
+
}
|
|
256
|
+
if (!result)
|
|
257
|
+
return undefined;
|
|
258
|
+
const authInfo = buildAuthInfo(result.token, result.payload, result.discovery, extraClaims);
|
|
259
|
+
authInfo.accessToken = accessTokenFromReq(req);
|
|
260
|
+
return authInfo;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Authenticate requests using JWT tokens from any OAuth/OIDC provider. The token's
|
|
264
|
+
* issuer claim is used to auto-discover the provider's JWKS endpoint for signature
|
|
265
|
+
* verification.
|
|
266
|
+
*
|
|
267
|
+
* Expects JWT tokens (access tokens or ID tokens) in the Authorization Bearer header
|
|
268
|
+
* or in a cookie set by registerOAuthCookieRoutes.
|
|
269
|
+
*
|
|
270
|
+
* For providers like Google that issue opaque access tokens, have the client send the
|
|
271
|
+
* ID token instead — it's a standard JWT that proves the user's identity without
|
|
272
|
+
* requiring a round-trip to the provider on every request.
|
|
273
|
+
*/
|
|
274
|
+
export async function oauthAuthenticate(req, options) {
|
|
275
|
+
if (options?.usingOAuthCookieRoutes) {
|
|
276
|
+
options.exceptRoutes ??= new Set();
|
|
277
|
+
options.exceptRoutes.add('/.oauthCallback');
|
|
278
|
+
options.exceptRoutes.add('/.oauthRedirect');
|
|
279
|
+
options.optionalRoutes ??= new Set();
|
|
280
|
+
options.optionalRoutes.add('/.oauthLogout');
|
|
281
|
+
}
|
|
282
|
+
if (options?.exceptRoutes?.has(req.routeOptions.url))
|
|
283
|
+
return undefined;
|
|
284
|
+
const auth = await oauthAuthenticateInternal(req, options?.extraClaims);
|
|
285
|
+
if (options?.authenticateAll && !options.optionalRoutes?.has(req.routeOptions.url) && isBlank(auth?.username)) {
|
|
286
|
+
throw new Error('Request requires authentication.');
|
|
287
|
+
}
|
|
288
|
+
return auth;
|
|
289
|
+
}
|
|
290
|
+
export function registerOAuthCookieRoutes(app, options) {
|
|
291
|
+
const clientId = process.env.OAUTH_CLIENT_ID;
|
|
292
|
+
if (!clientId)
|
|
293
|
+
throw new Error('OAUTH_CLIENT_ID environment variable must be set when using registerOAuthCookieRoutes.');
|
|
294
|
+
if (!hasInit)
|
|
295
|
+
init();
|
|
296
|
+
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
|
297
|
+
const callbackPath = '/.oauthCallback';
|
|
298
|
+
const pkceVerifierCookieName = oauthCookieName + '_pkce';
|
|
299
|
+
const pkceVerifierCookieRegex = new RegExp(`${pkceVerifierCookieName}=([A-Za-z0-9_-]+)`, 'v');
|
|
300
|
+
const issuerCookieName = oauthCookieName + '_iss';
|
|
301
|
+
const issuerCookieRegex = new RegExp(`${issuerCookieName}=([^;]+)`, 'v');
|
|
302
|
+
// flush any pending cookies set during token refresh
|
|
303
|
+
app.addHook('onSend', async (req, res) => {
|
|
304
|
+
if (req.pendingOAuthCookies?.length) {
|
|
305
|
+
for (const cookie of req.pendingOAuthCookies)
|
|
306
|
+
void res.header('Set-Cookie', cookie);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
app.get('/.oauthRedirect', {
|
|
310
|
+
schema: {
|
|
311
|
+
querystring: {
|
|
312
|
+
type: 'object',
|
|
313
|
+
properties: {
|
|
314
|
+
requestedUrl: { type: 'string', format: 'uri' },
|
|
315
|
+
scope: { type: 'string' },
|
|
316
|
+
issuer: { type: 'string', format: 'uri' }
|
|
317
|
+
},
|
|
318
|
+
required: ['requestedUrl'],
|
|
319
|
+
additionalProperties: false
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}, async (req, res) => {
|
|
323
|
+
if (req.originChecker && !req.originChecker.check(req.query.requestedUrl, req.hostname)) {
|
|
324
|
+
void res.status(403);
|
|
325
|
+
return 'Requested URL failed origin check.';
|
|
326
|
+
}
|
|
327
|
+
// if multiple issuers and no issuer specified, show a login selection page
|
|
328
|
+
if (!req.query.issuer && trustedIssuers.size > 1 && options?.loginPage) {
|
|
329
|
+
const issuers = [...trustedIssuers].map(iss => {
|
|
330
|
+
const redirectUrl = new URL(apiBaseUrl(req) + '/.oauthRedirect');
|
|
331
|
+
redirectUrl.searchParams.set('requestedUrl', req.query.requestedUrl);
|
|
332
|
+
if (req.query.scope)
|
|
333
|
+
redirectUrl.searchParams.set('scope', req.query.scope);
|
|
334
|
+
redirectUrl.searchParams.set('issuer', iss);
|
|
335
|
+
return { issuerUrl: iss, redirectHref: redirectUrl.toString() };
|
|
336
|
+
});
|
|
337
|
+
void res.type('text/html');
|
|
338
|
+
return options.loginPage(issuers);
|
|
339
|
+
}
|
|
340
|
+
const issuerUrl = req.query.issuer && trustedIssuers.has(req.query.issuer)
|
|
341
|
+
? req.query.issuer
|
|
342
|
+
: [...trustedIssuers][0];
|
|
343
|
+
const discovery = await discoveryCache.get(issuerUrl);
|
|
344
|
+
if (!discovery?.authorization_endpoint)
|
|
345
|
+
throw new Error(`OAuth issuer ${issuerUrl} does not have an authorization endpoint.`);
|
|
346
|
+
const codeVerifier = randomBytes(32).toString('base64url');
|
|
347
|
+
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
|
|
348
|
+
const redirectUri = apiBaseUrl(req) + callbackPath;
|
|
349
|
+
const authUrl = new URL(discovery.authorization_endpoint);
|
|
350
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
351
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
352
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
353
|
+
const isGoogle = discovery.authorization_endpoint.includes('accounts.google.com');
|
|
354
|
+
const scopeParts = new Set((req.query.scope ?? 'openid').split(' '));
|
|
355
|
+
for (const s of options?.scopes ?? [])
|
|
356
|
+
scopeParts.add(s);
|
|
357
|
+
if (!isGoogle && !req.query.scope)
|
|
358
|
+
scopeParts.add('offline_access');
|
|
359
|
+
authUrl.searchParams.set('scope', [...scopeParts].join(' '));
|
|
360
|
+
authUrl.searchParams.set('state', req.query.requestedUrl);
|
|
361
|
+
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
362
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
363
|
+
if (isGoogle) {
|
|
364
|
+
authUrl.searchParams.set('access_type', 'offline');
|
|
365
|
+
authUrl.searchParams.set('prompt', 'consent');
|
|
366
|
+
}
|
|
367
|
+
// store the code verifier and chosen issuer in short-lived HttpOnly cookies
|
|
368
|
+
void res.header('Set-Cookie', `${pkceVerifierCookieName}=${codeVerifier}; Path=${callbackPath}; Secure; HttpOnly; SameSite=Lax; Max-Age=600`);
|
|
369
|
+
void res.header('Set-Cookie', `${issuerCookieName}=${encodeURIComponent(issuerUrl)}; Path=${callbackPath}; Secure; HttpOnly; SameSite=Lax; Max-Age=600`);
|
|
370
|
+
return await res.redirect(authUrl.toString());
|
|
371
|
+
});
|
|
372
|
+
app.get(callbackPath, {
|
|
373
|
+
schema: {
|
|
374
|
+
querystring: {
|
|
375
|
+
type: 'object',
|
|
376
|
+
properties: {
|
|
377
|
+
code: { type: 'string' },
|
|
378
|
+
state: { type: 'string' }
|
|
379
|
+
},
|
|
380
|
+
required: ['code', 'state'],
|
|
381
|
+
additionalProperties: false
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}, async (req, res) => {
|
|
385
|
+
const verifierMatch = req.headers.cookie?.match(pkceVerifierCookieRegex);
|
|
386
|
+
if (!verifierMatch) {
|
|
387
|
+
void res.status(403);
|
|
388
|
+
return 'Missing PKCE code verifier. The login flow may have expired.';
|
|
389
|
+
}
|
|
390
|
+
const codeVerifier = verifierMatch[1];
|
|
391
|
+
const issuerMatch = req.headers.cookie?.match(issuerCookieRegex);
|
|
392
|
+
const issuerUrl = issuerMatch ? decodeURIComponent(issuerMatch[1]) : [...trustedIssuers][0];
|
|
393
|
+
const discovery = await discoveryCache.get(issuerUrl);
|
|
394
|
+
if (!discovery?.token_endpoint)
|
|
395
|
+
throw new Error(`OAuth issuer ${issuerUrl} does not have a token endpoint.`);
|
|
396
|
+
const redirectUri = apiBaseUrl(req) + callbackPath;
|
|
397
|
+
const body = {
|
|
398
|
+
grant_type: 'authorization_code',
|
|
399
|
+
code: req.query.code,
|
|
400
|
+
redirect_uri: redirectUri,
|
|
401
|
+
client_id: clientId,
|
|
402
|
+
code_verifier: codeVerifier
|
|
403
|
+
};
|
|
404
|
+
if (clientSecret)
|
|
405
|
+
body.client_secret = clientSecret;
|
|
406
|
+
const tokenResp = await fetch(toInternalUrl(discovery.token_endpoint), {
|
|
407
|
+
method: 'POST',
|
|
408
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
409
|
+
body: new URLSearchParams(body)
|
|
410
|
+
});
|
|
411
|
+
if (!tokenResp.ok) {
|
|
412
|
+
req.log.error(`OAuth token exchange failed: ${tokenResp.status} ${await tokenResp.text()}`);
|
|
413
|
+
void res.status(502);
|
|
414
|
+
return 'OAuth token exchange failed.';
|
|
415
|
+
}
|
|
416
|
+
const tokens = await tokenResp.json();
|
|
417
|
+
// prefer id_token, fall back to access_token if it's a JWT (some providers
|
|
418
|
+
// like Okta and Microsoft issue JWT access tokens)
|
|
419
|
+
let sessionToken = tokens.id_token;
|
|
420
|
+
if (!sessionToken && tokens.access_token) {
|
|
421
|
+
try {
|
|
422
|
+
decodeJwt(tokens.access_token);
|
|
423
|
+
sessionToken = tokens.access_token;
|
|
424
|
+
}
|
|
425
|
+
catch { /* not a JWT, can't use it */ }
|
|
426
|
+
}
|
|
427
|
+
if (!sessionToken) {
|
|
428
|
+
req.log.error('OAuth token response did not include a usable JWT (no id_token and access_token is not a JWT).');
|
|
429
|
+
void res.status(502);
|
|
430
|
+
return 'OAuth provider did not return a usable JWT.';
|
|
431
|
+
}
|
|
432
|
+
const destination = isNotBlank(req.query.state) ? req.query.state : uiBaseUrl(req);
|
|
433
|
+
const cookies = [
|
|
434
|
+
`${oauthCookieName}=${sessionToken}; Path=/; Secure; HttpOnly; SameSite=Lax`,
|
|
435
|
+
`${pkceVerifierCookieName}=; Path=${callbackPath}; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
|
|
436
|
+
`${issuerCookieName}=; Path=${callbackPath}; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT`
|
|
437
|
+
];
|
|
438
|
+
if (tokens.access_token) {
|
|
439
|
+
cookies.push(`${accessTokenCookieName}=${wrapRefreshToken(tokens.access_token)}; Path=/; Secure; HttpOnly; SameSite=Lax`);
|
|
440
|
+
}
|
|
441
|
+
if (tokens.refresh_token) {
|
|
442
|
+
cookies.push(`${refreshCookieName}=${wrapRefreshToken(tokens.refresh_token)}; Path=/; Secure; HttpOnly; SameSite=Lax`);
|
|
443
|
+
}
|
|
444
|
+
for (const cookie of cookies)
|
|
445
|
+
void res.header('Set-Cookie', cookie);
|
|
446
|
+
void res.type('text/html');
|
|
447
|
+
return `<!DOCTYPE html>
|
|
448
|
+
<html lang="en">
|
|
449
|
+
<head>
|
|
450
|
+
<meta charset="UTF-8">
|
|
451
|
+
<meta http-equiv="refresh" content="0; url=${htmlEncode(destination)}">
|
|
452
|
+
<title>Logging in...</title>
|
|
453
|
+
</head>
|
|
454
|
+
<body>
|
|
455
|
+
</body>
|
|
456
|
+
</html>`;
|
|
457
|
+
});
|
|
458
|
+
app.get('/.oauthLogout', {
|
|
459
|
+
schema: {
|
|
460
|
+
querystring: {
|
|
461
|
+
type: 'object',
|
|
462
|
+
properties: {
|
|
463
|
+
returnUrl: { type: 'string', format: 'uri' }
|
|
464
|
+
},
|
|
465
|
+
additionalProperties: false
|
|
466
|
+
},
|
|
467
|
+
headers: {
|
|
468
|
+
type: 'object',
|
|
469
|
+
properties: {
|
|
470
|
+
cookie: { type: 'string', pattern: `${oauthCookieName}=[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+` }
|
|
471
|
+
},
|
|
472
|
+
required: ['cookie']
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}, async (req, res) => {
|
|
476
|
+
if (req.query.returnUrl && req.originChecker && !req.originChecker.check(req.query.returnUrl, req.hostname)) {
|
|
477
|
+
void res.status(403);
|
|
478
|
+
return 'Return URL failed origin check.';
|
|
479
|
+
}
|
|
480
|
+
const postLogoutDestination = req.query.returnUrl ?? uiBaseUrl(req);
|
|
481
|
+
let redirectUrl = postLogoutDestination;
|
|
482
|
+
if (req.auth?.issuerConfig?.logoutUrl) {
|
|
483
|
+
const logoutUrl = new URL(req.auth.issuerConfig.logoutUrl);
|
|
484
|
+
if (isNotBlank(req.auth.token))
|
|
485
|
+
logoutUrl.searchParams.set('id_token_hint', req.auth.token);
|
|
486
|
+
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutDestination);
|
|
487
|
+
redirectUrl = logoutUrl.toString();
|
|
488
|
+
}
|
|
489
|
+
const cookies = [
|
|
490
|
+
`${oauthCookieName}=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
|
|
491
|
+
`${accessTokenCookieName}=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
|
|
492
|
+
`${refreshCookieName}=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT`
|
|
493
|
+
];
|
|
494
|
+
for (const cookie of cookies)
|
|
495
|
+
void res.header('Set-Cookie', cookie);
|
|
496
|
+
return `<!DOCTYPE html>
|
|
497
|
+
<html lang="en">
|
|
498
|
+
<head>
|
|
499
|
+
<meta charset="UTF-8">
|
|
500
|
+
<meta http-equiv="refresh" content="0; url=${htmlEncode(redirectUrl)}">
|
|
501
|
+
<title>Logging out...</title>
|
|
502
|
+
</head>
|
|
503
|
+
<body>
|
|
504
|
+
</body>
|
|
505
|
+
</html>`;
|
|
506
|
+
});
|
|
507
|
+
}
|
package/lib/postformdata.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const node_stream_1 = require("node:stream");
|
|
5
|
-
const web_1 = require("node:stream/web");
|
|
6
|
-
async function postFormData(url, fields, headers = {}) {
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import { ReadableStream } from 'node:stream/web';
|
|
3
|
+
export async function postFormData(url, fields, headers = {}) {
|
|
7
4
|
const encoder = new TextEncoder();
|
|
8
5
|
const boundary = `${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
|
|
9
6
|
const footer = `--${boundary}--\r\n`;
|
|
@@ -18,7 +15,7 @@ async function postFormData(url, fields, headers = {}) {
|
|
|
18
15
|
}
|
|
19
16
|
let i = 0;
|
|
20
17
|
let part = 'header';
|
|
21
|
-
const stream = new
|
|
18
|
+
const stream = new ReadableStream({
|
|
22
19
|
async pull(controller) {
|
|
23
20
|
if (i === chunks.length) {
|
|
24
21
|
controller.enqueue(encoder.encode(footer));
|
|
@@ -31,27 +28,26 @@ async function postFormData(url, fields, headers = {}) {
|
|
|
31
28
|
part = 'content';
|
|
32
29
|
}
|
|
33
30
|
else if (part === 'content') {
|
|
34
|
-
const
|
|
35
|
-
if (done)
|
|
31
|
+
const result = await chunk.contentReader.read();
|
|
32
|
+
if (result.done)
|
|
36
33
|
part = 'footer';
|
|
37
34
|
else
|
|
38
|
-
controller.enqueue(value);
|
|
35
|
+
controller.enqueue(result.value);
|
|
39
36
|
}
|
|
40
|
-
else
|
|
37
|
+
else {
|
|
41
38
|
controller.enqueue(encoder.encode(chunk.footer));
|
|
42
|
-
i
|
|
39
|
+
i += 1;
|
|
43
40
|
part = 'header';
|
|
44
41
|
}
|
|
45
42
|
}
|
|
46
43
|
},
|
|
47
44
|
cancel() {
|
|
48
45
|
for (const chunk of chunks) {
|
|
49
|
-
|
|
50
|
-
chunk.contentReader.cancel();
|
|
51
|
-
}
|
|
46
|
+
void chunk.contentReader.cancel();
|
|
52
47
|
}
|
|
53
48
|
}
|
|
54
49
|
});
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- duplex and node ReadableStream aren't in standard RequestInit types
|
|
55
51
|
return await fetch(url, {
|
|
56
52
|
method: 'POST',
|
|
57
53
|
headers,
|
|
@@ -74,12 +70,12 @@ class FormDataChunk {
|
|
|
74
70
|
if (isFileField(field)) {
|
|
75
71
|
this.header += `; filename="${field.filename ?? field.name}"\r\nContent-Type: ${field.filetype ?? 'application/octet-stream'}`;
|
|
76
72
|
this.contentsize = field.filesize;
|
|
77
|
-
this.contentReader = (field.value instanceof
|
|
73
|
+
this.contentReader = (field.value instanceof Readable ? ReadableStream.from(field.value) : field.value).getReader();
|
|
78
74
|
}
|
|
79
75
|
else {
|
|
80
76
|
const encoded = encoder.encode(field.value);
|
|
81
77
|
this.contentsize = encoded.length;
|
|
82
|
-
this.contentReader = new
|
|
78
|
+
this.contentReader = new ReadableStream({
|
|
83
79
|
start: controller => {
|
|
84
80
|
controller.enqueue(encoded);
|
|
85
81
|
controller.close();
|