fastify-txstate 4.0.0 → 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/lib/oauth.js CHANGED
@@ -1,305 +1,49 @@
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';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { decodeJwt } from 'jose';
3
+ import { htmlEncode, isBlank, isNotBlank } from 'txstate-utils';
4
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
- }
5
+ import { accessTokenCookieName, getOAuthDiscovery, getOAuthIssuerUrls, init, oauthCookieName, refreshCookieName, registeredExceptRoutes, registeredOptionalRoutes, toInternalUrl, wrapRefreshToken } from "./jwt-auth.js";
262
6
  /**
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.
7
+ * Register cookie-based OAuth login/logout endpoints. Uses the authorization code flow
8
+ * with PKCE (S256) to exchange a code for tokens, then stores the ID token in an HttpOnly
9
+ * cookie. The access token and refresh token are stored in separate cookies (optionally
10
+ * encrypted via OAUTH_COOKIE_SECRET) so that the ID token can be transparently refreshed
11
+ * by jwtAuthenticate when it expires, and the access token is available at
12
+ * `req.auth.accessToken` for calling provider APIs.
266
13
  *
267
- * Expects JWT tokens (access tokens or ID tokens) in the Authorization Bearer header
268
- * or in a cookie set by registerOAuthCookieRoutes.
14
+ * Requires OAUTH_COOKIE_CLIENT_ID environment variable. OAUTH_COOKIE_CLIENT_SECRET is
15
+ * optional PKCE provides the security for the code exchange, but some providers require
16
+ * a client secret even with PKCE. OAUTH_COOKIE_SECRET is optional — if set, the access
17
+ * token and refresh token cookies are encrypted with AES-256-GCM; if not, they are stored
18
+ * as plaintext (still HttpOnly and Secure).
269
19
  *
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.
20
+ * Trusted issuers are configured via OAUTH_URLS or JWT_TRUSTED_ISSUERS (see jwt-auth.ts).
21
+ *
22
+ * Registers three routes:
23
+ * - `/.oauthRedirect` - Redirects to the OAuth provider's login page. The client passes
24
+ * `requestedUrl` (required) which is sent to the provider as the `state` parameter,
25
+ * round-tripped back, and used as the redirect destination after login.
26
+ * - `/.oauthCallback` - Handles the provider's redirect, exchanges the code for tokens
27
+ * using the PKCE code verifier. Sets the ID token (or JWT access token as fallback),
28
+ * access token, and refresh token as cookies.
29
+ * - `/.oauthLogout` - Clears all OAuth cookies and redirects to the provider's logout
30
+ * endpoint if available.
273
31
  */
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
32
  export function registerOAuthCookieRoutes(app, options) {
291
- const clientId = process.env.OAUTH_CLIENT_ID;
33
+ const clientId = process.env.OAUTH_COOKIE_CLIENT_ID;
292
34
  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;
35
+ throw new Error('OAUTH_COOKIE_CLIENT_ID environment variable must be set when using registerOAuthCookieRoutes.');
36
+ init();
37
+ const clientSecret = process.env.OAUTH_COOKIE_CLIENT_SECRET;
38
+ registeredExceptRoutes.add('/.oauthCallback');
39
+ registeredExceptRoutes.add('/.oauthRedirect');
40
+ registeredOptionalRoutes.add('/.oauthLogout');
297
41
  const callbackPath = '/.oauthCallback';
298
42
  const pkceVerifierCookieName = oauthCookieName + '_pkce';
299
43
  const pkceVerifierCookieRegex = new RegExp(`${pkceVerifierCookieName}=([A-Za-z0-9_-]+)`, 'v');
300
44
  const issuerCookieName = oauthCookieName + '_iss';
301
45
  const issuerCookieRegex = new RegExp(`${issuerCookieName}=([^;]+)`, 'v');
302
- // flush any pending cookies set during token refresh
46
+ // flush any pending cookies queued during token refresh by jwtAuthenticate
303
47
  app.addHook('onSend', async (req, res) => {
304
48
  if (req.pendingOAuthCookies?.length) {
305
49
  for (const cookie of req.pendingOAuthCookies)
@@ -324,9 +68,12 @@ export function registerOAuthCookieRoutes(app, options) {
324
68
  void res.status(403);
325
69
  return 'Requested URL failed origin check.';
326
70
  }
71
+ const issuerUrls = getOAuthIssuerUrls();
72
+ if (!issuerUrls.length)
73
+ throw new Error('No OAuth issuers are configured. Set OAUTH_URLS or include oauth issuers in JWT_TRUSTED_ISSUERS.');
327
74
  // 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 => {
75
+ if (!req.query.issuer && issuerUrls.length > 1 && options?.loginPage) {
76
+ const issuers = issuerUrls.map(iss => {
330
77
  const redirectUrl = new URL(apiBaseUrl(req) + '/.oauthRedirect');
331
78
  redirectUrl.searchParams.set('requestedUrl', req.query.requestedUrl);
332
79
  if (req.query.scope)
@@ -337,10 +84,10 @@ export function registerOAuthCookieRoutes(app, options) {
337
84
  void res.type('text/html');
338
85
  return options.loginPage(issuers);
339
86
  }
340
- const issuerUrl = req.query.issuer && trustedIssuers.has(req.query.issuer)
87
+ const issuerUrl = req.query.issuer && issuerUrls.includes(req.query.issuer)
341
88
  ? req.query.issuer
342
- : [...trustedIssuers][0];
343
- const discovery = await discoveryCache.get(issuerUrl);
89
+ : issuerUrls[0];
90
+ const discovery = await getOAuthDiscovery(issuerUrl);
344
91
  if (!discovery?.authorization_endpoint)
345
92
  throw new Error(`OAuth issuer ${issuerUrl} does not have an authorization endpoint.`);
346
93
  const codeVerifier = randomBytes(32).toString('base64url');
@@ -388,9 +135,10 @@ export function registerOAuthCookieRoutes(app, options) {
388
135
  return 'Missing PKCE code verifier. The login flow may have expired.';
389
136
  }
390
137
  const codeVerifier = verifierMatch[1];
138
+ const issuerUrls = getOAuthIssuerUrls();
391
139
  const issuerMatch = req.headers.cookie?.match(issuerCookieRegex);
392
- const issuerUrl = issuerMatch ? decodeURIComponent(issuerMatch[1]) : [...trustedIssuers][0];
393
- const discovery = await discoveryCache.get(issuerUrl);
140
+ const issuerUrl = issuerMatch ? decodeURIComponent(issuerMatch[1]) : issuerUrls[0];
141
+ const discovery = await getOAuthDiscovery(issuerUrl);
394
142
  if (!discovery?.token_endpoint)
395
143
  throw new Error(`OAuth issuer ${issuerUrl} does not have a token endpoint.`);
396
144
  const redirectUri = apiBaseUrl(req) + callbackPath;
@@ -505,3 +253,20 @@ export function registerOAuthCookieRoutes(app, options) {
505
253
  </html>`;
506
254
  });
507
255
  }
256
+ /**
257
+ * This function is available for server-side view code instead of a client-side application
258
+ * using a framework. It will automatically redirect the user through the OAuth login flow
259
+ * (via /.oauthRedirect, which must be registered by registerOAuthCookieRoutes) and return
260
+ * true if they are not authenticated. Otherwise it simply returns false.
261
+ */
262
+ export async function requireCookieAuthOAuth(req, res) {
263
+ if (isBlank(req.auth?.username)) {
264
+ const redirectUrl = new URL(apiBaseUrl(req) + '/.oauthRedirect');
265
+ redirectUrl.searchParams.set('requestedUrl', apiBaseUrl(req) + req.originalUrl);
266
+ void res.redirect(redirectUrl.toString());
267
+ return true;
268
+ }
269
+ else {
270
+ return false;
271
+ }
272
+ }
package/lib/server.js CHANGED
@@ -149,6 +149,7 @@ export default class Server {
149
149
  else
150
150
  config.trustProxy = process.env.TRUST_PROXY;
151
151
  }
152
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- ajvFormats cast is required by tsc even though eslint thinks otherwise
152
153
  config.ajv = { ...config.ajv, mode: undefined, plugins: [...(config.ajv?.plugins ?? []), ajvErrors, [ajvFormats, { mode: 'fast' }]], customOptions: { ...config.ajv?.customOptions, allErrors: true, strictSchema: false, coerceTypes: true } };
153
154
  this.healthCallback = config.checkHealth;
154
155
  this.app = fastify(config);
@@ -1,9 +1,11 @@
1
1
  import type { FastifyReply, FastifyRequest } from 'fastify';
2
- import { type IssuerConfig, type FastifyInstanceTyped, type FastifyTxStateAuthInfo } from './server.ts';
3
- export interface IssuerConfigRaw extends Omit<IssuerConfig, 'validateUrl' | 'logoutUrl'> {
4
- validateUrl?: string;
5
- logoutUrl?: string;
6
- }
2
+ import { type FastifyInstanceTyped, type FastifyTxStateAuthInfo } from './server.ts';
3
+ /**
4
+ * @deprecated Use `jwtAuthenticate(options)` instead. Note the new shape: `jwtAuthenticate`
5
+ * is now a factory that takes options up front and returns the authenticator function
6
+ * (`authenticate: jwtAuthenticate({ authenticateAll: true })`), so the options actually
7
+ * take effect when wired into `new Server({ authenticate })`.
8
+ */
7
9
  export declare function unifiedAuthenticate(req: FastifyRequest, options?: {
8
10
  authenticateAll?: boolean;
9
11
  exceptRoutes?: Set<string>;
@@ -11,7 +13,7 @@ export declare function unifiedAuthenticate(req: FastifyRequest, options?: {
11
13
  usingUaCookieRoutes?: boolean;
12
14
  }): Promise<FastifyTxStateAuthInfo | undefined>;
13
15
  /**
14
- * @deprecated Use unifiedAuthenticateWithOptions with { authenticateAll: true } instead.
16
+ * @deprecated Use `jwtAuthenticate({ authenticateAll: true })` instead.
15
17
  */
16
18
  export declare function unifiedAuthenticateAll(req: FastifyRequest): Promise<FastifyTxStateAuthInfo>;
17
19
  /**
@@ -19,5 +21,9 @@ export declare function unifiedAuthenticateAll(req: FastifyRequest): Promise<Fas
19
21
  * using a framework. It will automatically redirect the user to the Unified Auth login page
20
22
  * and return true if they are not authenticated. Otherwise it simply returns false.
21
23
  */
24
+ export declare function requireCookieAuthUa(req: FastifyRequest, res: FastifyReply): Promise<boolean>;
25
+ /**
26
+ * @deprecated Use requireCookieAuthUa instead.
27
+ */
22
28
  export declare function requireCookieAuth(req: FastifyRequest, res: FastifyReply): Promise<boolean>;
23
29
  export declare function registerUaCookieRoutes(app: FastifyInstanceTyped): void;
@@ -1,176 +1,33 @@
1
- import { createPublicKey, createSecretKey, randomBytes } from 'node:crypto';
2
- import { createRemoteJWKSet, decodeJwt, jwtVerify, importJWK } from 'jose';
3
- import { Cache, htmlEncode, isBlank, isNotBlank, toArray } from 'txstate-utils';
1
+ import { htmlEncode, isBlank, isNotBlank } from 'txstate-utils';
2
+ import { getIssuerConfig, jwtAuthenticate, registeredExceptRoutes, registeredOptionalRoutes, uaCookieName } from "./jwt-auth.js";
4
3
  import { apiBaseUrl, uiBaseUrl } from "./server.js";
5
- let hasInit = false;
6
- const issuerKeys = new Map();
7
- const issuerConfig = new Map();
8
- const trustedClients = new Set();
9
- const uaCookieName = process.env.UA_COOKIE_NAME ?? randomBytes(16).toString('hex');
10
- const uaCookieNameRegex = new RegExp(`${uaCookieName}=([^;]+)`, 'v');
11
4
  function uaServiceUrl(req) {
12
5
  return apiBaseUrl(req) + '/.uaService';
13
6
  }
14
- const tokenCache = new Cache(async (token, req) => {
15
- const claims = decodeJwt(token);
16
- let verifyKey;
17
- if (claims.iss && issuerKeys.has(claims.iss))
18
- verifyKey = issuerKeys.get(claims.iss);
19
- if (!verifyKey) {
20
- req.log.warn(`Received token with issuer: ${claims.iss} but JWT secret could not be found. The server may be misconfigured or the user may have presented a JWT from an untrusted issuer.`);
21
- return undefined;
22
- }
23
- try {
24
- const { payload } = await jwtVerify(token, verifyKey);
25
- if (trustedClients.size && !trustedClients.has(payload.client_id)) {
26
- req.log.warn(`Received token with untrusted client_id: ${payload.client_id}.`);
27
- return undefined;
28
- }
29
- return payload;
30
- }
31
- catch (e) {
32
- // squelch expected token errors — bad signatures and expirations show as 401 in the access log
33
- const code = e.code;
34
- if (code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED' && code !== 'ERR_JWT_EXPIRED')
35
- req.log.error(e);
36
- return undefined;
37
- }
38
- }, { freshseconds: 3600 });
39
- const validateCache = new Cache(async (token, payload) => {
40
- const config = issuerConfig.get(payload.iss);
41
- if (!config?.validateUrl)
42
- return;
43
- // avoid checking for deauth until the token is more than 5 minutes old
44
- if (new Date(payload.iat * 1000) > new Date(new Date().getTime() - 1000 * 60 * 5))
45
- return;
46
- const validateUrl = new URL(config.validateUrl);
47
- validateUrl.searchParams.set('unifiedJwt', token);
48
- const resp = await fetch(validateUrl);
49
- const validate = await resp.json();
50
- if (!validate.valid)
51
- 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.');
52
- });
53
- const jwkCache = new Cache(async (url) => {
54
- const { keys } = await (await fetch(url)).json();
55
- const publicKeyByKid = {};
56
- for (const jwk of keys) {
57
- if (jwk.kid)
58
- publicKeyByKid[jwk.kid] = await importJWK(jwk);
59
- }
60
- return publicKeyByKid;
61
- });
62
- function remoteJWKSet(jwkUrl) {
63
- return async (protectedHeader) => {
64
- const publicKeyByKid = await jwkCache.get(jwkUrl);
65
- return publicKeyByKid[protectedHeader.kid];
66
- };
67
- }
68
- function processIssuerConfig(config) {
69
- if (config.iss === 'unified-auth') {
70
- const validateUrl = isNotBlank(config.validateUrl)
71
- ? new URL(config.validateUrl, config.url)
72
- : new URL('validateToken', config.url);
73
- const logoutUrl = isNotBlank(config.logoutUrl)
74
- ? new URL(config.logoutUrl, config.url)
75
- : isNotBlank(process.env.UA_URL)
76
- ? new URL(process.env.UA_URL + '/logout')
77
- : new URL('logout', config.url);
78
- return {
79
- ...config,
80
- validateUrl,
81
- logoutUrl
82
- };
83
- }
84
- return {
85
- ...config,
86
- validateUrl: undefined,
87
- logoutUrl: config.logoutUrl ? new URL(config.logoutUrl, config.url) : undefined
88
- };
89
- }
90
- function init() {
91
- hasInit = true;
92
- if (process.env.JWT_TRUSTED_ISSUERS) {
93
- const issuers = toArray(JSON.parse(process.env.JWT_TRUSTED_ISSUERS));
94
- for (const issuer of issuers) {
95
- issuerConfig.set(issuer.iss, processIssuerConfig(issuer));
96
- if (issuer.iss === 'unified-auth')
97
- issuerKeys.set(issuer.iss, remoteJWKSet(issuer.url));
98
- else if (issuer.url)
99
- issuerKeys.set(issuer.iss, createRemoteJWKSet(new URL(issuer.url)));
100
- else if (issuer.publicKey)
101
- issuerKeys.set(issuer.iss, createPublicKey(issuer.publicKey));
102
- else if (issuer.secret)
103
- issuerKeys.set(issuer.iss, createSecretKey(Buffer.from(issuer.secret, 'ascii')));
104
- }
105
- }
106
- for (const clientId of (process.env.JWT_TRUSTED_CLIENTIDS?.split(',').filter(isNotBlank).map(clientId => clientId.trim()) ?? [])) {
107
- trustedClients.add(clientId);
108
- }
109
- }
110
- function tokenFromReq(req) {
111
- const m = req?.headers.authorization?.match(/^bearer (.*)$/iv);
112
- if (m != null)
113
- return m[1];
114
- const m2 = req?.headers.cookie?.match(uaCookieNameRegex);
115
- if (m2 != null)
116
- return m2[1];
117
- }
118
- async function unifiedAuthenticateInternal(req) {
119
- if (!hasInit)
120
- init();
121
- const token = tokenFromReq(req);
122
- if (!token)
123
- return undefined;
124
- const payload = await tokenCache.get(token, req);
125
- if (!payload)
126
- return undefined;
127
- if (payload.exp && payload.exp * 1000 <= Date.now())
128
- return undefined;
129
- await validateCache.get(token, payload);
130
- return {
131
- token,
132
- issuerConfig: payload.iss ? issuerConfig.get(payload.iss) : undefined,
133
- username: payload.sub,
134
- sessionId: payload.sub + '-' + payload.iat,
135
- sessionCreatedAt: payload.iat ? new Date(payload.iat * 1000) : undefined,
136
- clientId: payload.client_id,
137
- impersonatedBy: payload.act?.sub,
138
- scope: payload.scope
139
- };
140
- }
7
+ /**
8
+ * @deprecated Use `jwtAuthenticate(options)` instead. Note the new shape: `jwtAuthenticate`
9
+ * is now a factory that takes options up front and returns the authenticator function
10
+ * (`authenticate: jwtAuthenticate({ authenticateAll: true })`), so the options actually
11
+ * take effect when wired into `new Server({ authenticate })`.
12
+ */
141
13
  export async function unifiedAuthenticate(req, options) {
142
- const auth = await unifiedAuthenticateInternal(req);
143
- if (options?.usingUaCookieRoutes) {
144
- options.exceptRoutes ??= new Set();
145
- options.exceptRoutes.add('/.uaService');
146
- options.exceptRoutes.add('/.uaRedirect');
147
- options.optionalRoutes ??= new Set();
148
- options.optionalRoutes.add('/.uaLogout');
149
- }
150
- const isNoAuthenticationRoute = options?.exceptRoutes?.has(req.routeOptions.url);
151
- const requiresAuthenticationRoute = options?.authenticateAll
152
- && !options.exceptRoutes?.has(req.routeOptions.url)
153
- && !options.optionalRoutes?.has(req.routeOptions.url);
154
- if (requiresAuthenticationRoute && isBlank(auth?.username)) {
155
- throw new Error('Request requires authentication.');
156
- }
157
- return isNoAuthenticationRoute ? undefined : auth;
14
+ return await jwtAuthenticate(options)(req);
158
15
  }
159
16
  /**
160
- * @deprecated Use unifiedAuthenticateWithOptions with { authenticateAll: true } instead.
17
+ * @deprecated Use `jwtAuthenticate({ authenticateAll: true })` instead.
161
18
  */
162
19
  export async function unifiedAuthenticateAll(req) {
163
- return (await unifiedAuthenticate(req, { authenticateAll: true }));
20
+ return (await jwtAuthenticate({ authenticateAll: true })(req));
164
21
  }
165
22
  /**
166
23
  * This function is available for server-side view code instead of a client-side application
167
24
  * using a framework. It will automatically redirect the user to the Unified Auth login page
168
25
  * and return true if they are not authenticated. Otherwise it simply returns false.
169
26
  */
170
- export async function requireCookieAuth(req, res) {
27
+ export async function requireCookieAuthUa(req, res) {
171
28
  if (isBlank(req.auth?.username)) {
172
29
  const loginUrl = new URL(process.env.UA_URL + '/login');
173
- loginUrl.searchParams.set('clientId', process.env.UA_CLIENTID);
30
+ loginUrl.searchParams.set('clientId', (process.env.UA_COOKIE_CLIENTID ?? process.env.UA_CLIENTID));
174
31
  loginUrl.searchParams.set('returnUrl', uaServiceUrl(req));
175
32
  loginUrl.searchParams.set('requestedUrl', req.originalUrl);
176
33
  void res.redirect(loginUrl.toString());
@@ -180,7 +37,16 @@ export async function requireCookieAuth(req, res) {
180
37
  return false;
181
38
  }
182
39
  }
40
+ /**
41
+ * @deprecated Use requireCookieAuthUa instead.
42
+ */
43
+ export async function requireCookieAuth(req, res) {
44
+ return await requireCookieAuthUa(req, res);
45
+ }
183
46
  export function registerUaCookieRoutes(app) {
47
+ registeredExceptRoutes.add('/.uaService');
48
+ registeredExceptRoutes.add('/.uaRedirect');
49
+ registeredOptionalRoutes.add('/.uaLogout');
184
50
  app.get('/.uaLogout', {
185
51
  schema: {
186
52
  headers: {
@@ -262,8 +128,8 @@ export function registerUaCookieRoutes(app) {
262
128
  }
263
129
  const loginUrl = isNotBlank(process.env.UA_URL)
264
130
  ? new URL(process.env.UA_URL + '/login')
265
- : new URL('login', issuerConfig.get('unified-auth')?.url);
266
- loginUrl.searchParams.set('clientId', process.env.UA_CLIENTID ?? process.env.JWT_TRUSTED_CLIENTIDS.split(',')[0]);
131
+ : new URL('login', getIssuerConfig('unified-auth')?.url);
132
+ loginUrl.searchParams.set('clientId', process.env.UA_COOKIE_CLIENTID ?? process.env.UA_CLIENTID ?? process.env.JWT_TRUSTED_CLIENTIDS.split(',')[0]);
267
133
  const returnUrl = uaServiceUrl(req);
268
134
  loginUrl.searchParams.set('returnUrl', returnUrl);
269
135
  if (req.query.requestedUrl)