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/README.md +60 -22
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/jwt-auth.d.ts +98 -0
- package/lib/jwt-auth.js +491 -0
- package/lib/oauth.d.ts +21 -43
- package/lib/oauth.js +62 -297
- package/lib/server.js +1 -0
- package/lib/unified-auth.d.ts +12 -6
- package/lib/unified-auth.js +24 -158
- package/package.json +2 -2
package/lib/oauth.js
CHANGED
|
@@ -1,305 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
-
*
|
|
268
|
-
*
|
|
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
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
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.
|
|
33
|
+
const clientId = process.env.OAUTH_COOKIE_CLIENT_ID;
|
|
292
34
|
if (!clientId)
|
|
293
|
-
throw new Error('
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
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 &&
|
|
329
|
-
const issuers =
|
|
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 &&
|
|
87
|
+
const issuerUrl = req.query.issuer && issuerUrls.includes(req.query.issuer)
|
|
341
88
|
? req.query.issuer
|
|
342
|
-
: [
|
|
343
|
-
const discovery = await
|
|
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]) : [
|
|
393
|
-
const discovery = await
|
|
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);
|
package/lib/unified-auth.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
-
import { type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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;
|
package/lib/unified-auth.js
CHANGED
|
@@ -1,176 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
17
|
+
* @deprecated Use `jwtAuthenticate({ authenticateAll: true })` instead.
|
|
161
18
|
*/
|
|
162
19
|
export async function unifiedAuthenticateAll(req) {
|
|
163
|
-
return (await
|
|
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
|
|
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',
|
|
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)
|