fastify-txstate 3.6.8 → 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 +76 -51
- package/package.json +27 -25
- package/lib-esm/index.js +0 -20
- package/lib-esm/package.json +0 -3
package/lib/unified-auth.js
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
exports.requireCookieAuth = requireCookieAuth;
|
|
6
|
-
exports.registerUaCookieRoutes = registerUaCookieRoutes;
|
|
7
|
-
const crypto_1 = require("crypto");
|
|
8
|
-
const jose_1 = require("jose");
|
|
9
|
-
const txstate_utils_1 = require("txstate-utils");
|
|
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';
|
|
4
|
+
import { apiBaseUrl, uiBaseUrl } from "./server.js";
|
|
10
5
|
let hasInit = false;
|
|
11
6
|
const issuerKeys = new Map();
|
|
12
7
|
const issuerConfig = new Map();
|
|
13
8
|
const trustedClients = new Set();
|
|
14
|
-
const uaCookieName = process.env.UA_COOKIE_NAME ??
|
|
15
|
-
const uaCookieNameRegex = new RegExp(`${uaCookieName}=([^;]+)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
const uaCookieName = process.env.UA_COOKIE_NAME ?? randomBytes(16).toString('hex');
|
|
10
|
+
const uaCookieNameRegex = new RegExp(`${uaCookieName}=([^;]+)`, 'v');
|
|
11
|
+
function uaServiceUrl(req) {
|
|
12
|
+
return apiBaseUrl(req) + '/.uaService';
|
|
13
|
+
}
|
|
14
|
+
const tokenCache = new Cache(async (token, req) => {
|
|
15
|
+
const claims = decodeJwt(token);
|
|
19
16
|
let verifyKey;
|
|
20
17
|
if (claims.iss && issuerKeys.has(claims.iss))
|
|
21
18
|
verifyKey = issuerKeys.get(claims.iss);
|
|
@@ -24,7 +21,7 @@ const tokenCache = new txstate_utils_1.Cache(async (token, req) => {
|
|
|
24
21
|
return undefined;
|
|
25
22
|
}
|
|
26
23
|
try {
|
|
27
|
-
const { payload } = await
|
|
24
|
+
const { payload } = await jwtVerify(token, verifyKey);
|
|
28
25
|
if (trustedClients.size && !trustedClients.has(payload.client_id)) {
|
|
29
26
|
req.log.warn(`Received token with untrusted client_id: ${payload.client_id}.`);
|
|
30
27
|
return undefined;
|
|
@@ -32,13 +29,14 @@ const tokenCache = new txstate_utils_1.Cache(async (token, req) => {
|
|
|
32
29
|
return payload;
|
|
33
30
|
}
|
|
34
31
|
catch (e) {
|
|
35
|
-
// squelch errors
|
|
36
|
-
|
|
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')
|
|
37
35
|
req.log.error(e);
|
|
38
36
|
return undefined;
|
|
39
37
|
}
|
|
40
38
|
}, { freshseconds: 3600 });
|
|
41
|
-
const validateCache = new
|
|
39
|
+
const validateCache = new Cache(async (token, payload) => {
|
|
42
40
|
const config = issuerConfig.get(payload.iss);
|
|
43
41
|
if (!config?.validateUrl)
|
|
44
42
|
return;
|
|
@@ -52,12 +50,12 @@ const validateCache = new txstate_utils_1.Cache(async (token, payload) => {
|
|
|
52
50
|
if (!validate.valid)
|
|
53
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.');
|
|
54
52
|
});
|
|
55
|
-
const jwkCache = new
|
|
53
|
+
const jwkCache = new Cache(async (url) => {
|
|
56
54
|
const { keys } = await (await fetch(url)).json();
|
|
57
55
|
const publicKeyByKid = {};
|
|
58
56
|
for (const jwk of keys) {
|
|
59
57
|
if (jwk.kid)
|
|
60
|
-
publicKeyByKid[jwk.kid] = await
|
|
58
|
+
publicKeyByKid[jwk.kid] = await importJWK(jwk);
|
|
61
59
|
}
|
|
62
60
|
return publicKeyByKid;
|
|
63
61
|
});
|
|
@@ -69,12 +67,12 @@ function remoteJWKSet(jwkUrl) {
|
|
|
69
67
|
}
|
|
70
68
|
function processIssuerConfig(config) {
|
|
71
69
|
if (config.iss === 'unified-auth') {
|
|
72
|
-
const validateUrl =
|
|
70
|
+
const validateUrl = isNotBlank(config.validateUrl)
|
|
73
71
|
? new URL(config.validateUrl, config.url)
|
|
74
72
|
: new URL('validateToken', config.url);
|
|
75
|
-
const logoutUrl =
|
|
73
|
+
const logoutUrl = isNotBlank(config.logoutUrl)
|
|
76
74
|
? new URL(config.logoutUrl, config.url)
|
|
77
|
-
:
|
|
75
|
+
: isNotBlank(process.env.UA_URL)
|
|
78
76
|
? new URL(process.env.UA_URL + '/logout')
|
|
79
77
|
: new URL('logout', config.url);
|
|
80
78
|
return {
|
|
@@ -92,25 +90,25 @@ function processIssuerConfig(config) {
|
|
|
92
90
|
function init() {
|
|
93
91
|
hasInit = true;
|
|
94
92
|
if (process.env.JWT_TRUSTED_ISSUERS) {
|
|
95
|
-
const issuers =
|
|
93
|
+
const issuers = toArray(JSON.parse(process.env.JWT_TRUSTED_ISSUERS));
|
|
96
94
|
for (const issuer of issuers) {
|
|
97
95
|
issuerConfig.set(issuer.iss, processIssuerConfig(issuer));
|
|
98
96
|
if (issuer.iss === 'unified-auth')
|
|
99
97
|
issuerKeys.set(issuer.iss, remoteJWKSet(issuer.url));
|
|
100
98
|
else if (issuer.url)
|
|
101
|
-
issuerKeys.set(issuer.iss,
|
|
99
|
+
issuerKeys.set(issuer.iss, createRemoteJWKSet(new URL(issuer.url)));
|
|
102
100
|
else if (issuer.publicKey)
|
|
103
|
-
issuerKeys.set(issuer.iss,
|
|
101
|
+
issuerKeys.set(issuer.iss, createPublicKey(issuer.publicKey));
|
|
104
102
|
else if (issuer.secret)
|
|
105
|
-
issuerKeys.set(issuer.iss,
|
|
103
|
+
issuerKeys.set(issuer.iss, createSecretKey(Buffer.from(issuer.secret, 'ascii')));
|
|
106
104
|
}
|
|
107
105
|
}
|
|
108
|
-
for (const clientId of (process.env.JWT_TRUSTED_CLIENTIDS?.split(',').filter(
|
|
106
|
+
for (const clientId of (process.env.JWT_TRUSTED_CLIENTIDS?.split(',').filter(isNotBlank).map(clientId => clientId.trim()) ?? [])) {
|
|
109
107
|
trustedClients.add(clientId);
|
|
110
108
|
}
|
|
111
109
|
}
|
|
112
110
|
function tokenFromReq(req) {
|
|
113
|
-
const m = req?.headers.authorization?.match(/^bearer (.*)$/
|
|
111
|
+
const m = req?.headers.authorization?.match(/^bearer (.*)$/iv);
|
|
114
112
|
if (m != null)
|
|
115
113
|
return m[1];
|
|
116
114
|
const m2 = req?.headers.cookie?.match(uaCookieNameRegex);
|
|
@@ -126,8 +124,9 @@ async function unifiedAuthenticateInternal(req) {
|
|
|
126
124
|
const payload = await tokenCache.get(token, req);
|
|
127
125
|
if (!payload)
|
|
128
126
|
return undefined;
|
|
127
|
+
if (payload.exp && payload.exp * 1000 <= Date.now())
|
|
128
|
+
return undefined;
|
|
129
129
|
await validateCache.get(token, payload);
|
|
130
|
-
req.token = token;
|
|
131
130
|
return {
|
|
132
131
|
token,
|
|
133
132
|
issuerConfig: payload.iss ? issuerConfig.get(payload.iss) : undefined,
|
|
@@ -139,7 +138,7 @@ async function unifiedAuthenticateInternal(req) {
|
|
|
139
138
|
scope: payload.scope
|
|
140
139
|
};
|
|
141
140
|
}
|
|
142
|
-
async function unifiedAuthenticate(req, options) {
|
|
141
|
+
export async function unifiedAuthenticate(req, options) {
|
|
143
142
|
const auth = await unifiedAuthenticateInternal(req);
|
|
144
143
|
if (options?.usingUaCookieRoutes) {
|
|
145
144
|
options.exceptRoutes ??= new Set();
|
|
@@ -149,10 +148,10 @@ async function unifiedAuthenticate(req, options) {
|
|
|
149
148
|
options.optionalRoutes.add('/.uaLogout');
|
|
150
149
|
}
|
|
151
150
|
const isNoAuthenticationRoute = options?.exceptRoutes?.has(req.routeOptions.url);
|
|
152
|
-
const requiresAuthenticationRoute = options?.authenticateAll
|
|
153
|
-
!options
|
|
154
|
-
!options
|
|
155
|
-
if (requiresAuthenticationRoute &&
|
|
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)) {
|
|
156
155
|
throw new Error('Request requires authentication.');
|
|
157
156
|
}
|
|
158
157
|
return isNoAuthenticationRoute ? undefined : auth;
|
|
@@ -160,7 +159,7 @@ async function unifiedAuthenticate(req, options) {
|
|
|
160
159
|
/**
|
|
161
160
|
* @deprecated Use unifiedAuthenticateWithOptions with { authenticateAll: true } instead.
|
|
162
161
|
*/
|
|
163
|
-
async function unifiedAuthenticateAll(req) {
|
|
162
|
+
export async function unifiedAuthenticateAll(req) {
|
|
164
163
|
return (await unifiedAuthenticate(req, { authenticateAll: true }));
|
|
165
164
|
}
|
|
166
165
|
/**
|
|
@@ -168,11 +167,11 @@ async function unifiedAuthenticateAll(req) {
|
|
|
168
167
|
* using a framework. It will automatically redirect the user to the Unified Auth login page
|
|
169
168
|
* and return true if they are not authenticated. Otherwise it simply returns false.
|
|
170
169
|
*/
|
|
171
|
-
async function requireCookieAuth(req, res) {
|
|
172
|
-
if (
|
|
170
|
+
export async function requireCookieAuth(req, res) {
|
|
171
|
+
if (isBlank(req.auth?.username)) {
|
|
173
172
|
const loginUrl = new URL(process.env.UA_URL + '/login');
|
|
174
173
|
loginUrl.searchParams.set('clientId', process.env.UA_CLIENTID);
|
|
175
|
-
loginUrl.searchParams.set('returnUrl', uaServiceUrl
|
|
174
|
+
loginUrl.searchParams.set('returnUrl', uaServiceUrl(req));
|
|
176
175
|
loginUrl.searchParams.set('requestedUrl', req.originalUrl);
|
|
177
176
|
void res.redirect(loginUrl.toString());
|
|
178
177
|
return true;
|
|
@@ -181,7 +180,7 @@ async function requireCookieAuth(req, res) {
|
|
|
181
180
|
return false;
|
|
182
181
|
}
|
|
183
182
|
}
|
|
184
|
-
function registerUaCookieRoutes(app) {
|
|
183
|
+
export function registerUaCookieRoutes(app) {
|
|
185
184
|
app.get('/.uaLogout', {
|
|
186
185
|
schema: {
|
|
187
186
|
headers: {
|
|
@@ -193,12 +192,20 @@ function registerUaCookieRoutes(app) {
|
|
|
193
192
|
}
|
|
194
193
|
}
|
|
195
194
|
}, async (req, res) => {
|
|
196
|
-
const redirectUrl = req.auth?.issuerConfig?.logoutUrl &&
|
|
195
|
+
const redirectUrl = req.auth?.issuerConfig?.logoutUrl && isNotBlank(req.auth.token)
|
|
197
196
|
? `${req.auth.issuerConfig.logoutUrl.toString()}?unifiedJwt=${encodeURIComponent(req.auth.token)}`
|
|
198
|
-
: (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
197
|
+
: uiBaseUrl(req);
|
|
198
|
+
void res.header('Set-Cookie', `${uaCookieName}=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT`);
|
|
199
|
+
return `<!DOCTYPE html>
|
|
200
|
+
<html lang="en">
|
|
201
|
+
<head>
|
|
202
|
+
<meta charset="UTF-8">
|
|
203
|
+
<meta http-equiv="refresh" content="0; url=${htmlEncode(redirectUrl)}">
|
|
204
|
+
<title>Logging out...</title>
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
</body>
|
|
208
|
+
</html>`;
|
|
202
209
|
});
|
|
203
210
|
app.get('/.uaService', {
|
|
204
211
|
schema: {
|
|
@@ -213,9 +220,23 @@ function registerUaCookieRoutes(app) {
|
|
|
213
220
|
}
|
|
214
221
|
}
|
|
215
222
|
}, async (req, res) => {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
223
|
+
const destination = req.query.requestedUrl ?? uiBaseUrl(req);
|
|
224
|
+
if (req.query.requestedUrl && req.originChecker && !req.originChecker.check(req.query.requestedUrl, req.hostname)) {
|
|
225
|
+
void res.status(403);
|
|
226
|
+
return 'Requested URL failed origin check.';
|
|
227
|
+
}
|
|
228
|
+
void res.header('Set-Cookie', `${uaCookieName}=${req.query.unifiedJwt}; Path=/; Secure; HttpOnly; SameSite=Lax`);
|
|
229
|
+
void res.type('text/html');
|
|
230
|
+
return `<!DOCTYPE html>
|
|
231
|
+
<html lang="en">
|
|
232
|
+
<head>
|
|
233
|
+
<meta charset="UTF-8">
|
|
234
|
+
<meta http-equiv="refresh" content="0; url=${htmlEncode(destination)}">
|
|
235
|
+
<title>Logging in...</title>
|
|
236
|
+
</head>
|
|
237
|
+
<body>
|
|
238
|
+
</body>
|
|
239
|
+
</html>`;
|
|
219
240
|
});
|
|
220
241
|
/**
|
|
221
242
|
* In the case of a client-side application that uses the UA cookie to authenticate,
|
|
@@ -235,14 +256,18 @@ function registerUaCookieRoutes(app) {
|
|
|
235
256
|
}
|
|
236
257
|
}
|
|
237
258
|
}, async (req, res) => {
|
|
238
|
-
|
|
259
|
+
if (req.query.requestedUrl && req.originChecker && !req.originChecker.check(req.query.requestedUrl, req.hostname)) {
|
|
260
|
+
void res.status(403);
|
|
261
|
+
return 'Requested URL failed origin check.';
|
|
262
|
+
}
|
|
263
|
+
const loginUrl = isNotBlank(process.env.UA_URL)
|
|
239
264
|
? new URL(process.env.UA_URL + '/login')
|
|
240
265
|
: new URL('login', issuerConfig.get('unified-auth')?.url);
|
|
241
266
|
loginUrl.searchParams.set('clientId', process.env.UA_CLIENTID ?? process.env.JWT_TRUSTED_CLIENTIDS.split(',')[0]);
|
|
242
|
-
const returnUrl = uaServiceUrl
|
|
267
|
+
const returnUrl = uaServiceUrl(req);
|
|
243
268
|
loginUrl.searchParams.set('returnUrl', returnUrl);
|
|
244
269
|
if (req.query.requestedUrl)
|
|
245
270
|
loginUrl.searchParams.set('requestedUrl', req.query.requestedUrl);
|
|
246
|
-
return res.redirect(loginUrl.toString());
|
|
271
|
+
return await res.redirect(loginUrl.toString());
|
|
247
272
|
});
|
|
248
273
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-txstate",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "A small wrapper for fastify providing a set of common conventions & utility functions we use.",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"exports": {
|
|
6
7
|
".": {
|
|
7
8
|
"types": "./lib/index.d.ts",
|
|
8
|
-
"
|
|
9
|
-
"import": "./lib-esm/index.js"
|
|
9
|
+
"import": "./lib/index.js"
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"types": "./lib/index.d.ts",
|
|
@@ -14,37 +14,37 @@
|
|
|
14
14
|
"prepublishOnly": "npm run build",
|
|
15
15
|
"build": "rm -rf lib && tsc",
|
|
16
16
|
"test": "./test.sh",
|
|
17
|
-
"
|
|
18
|
-
"testserver": "node -
|
|
17
|
+
"nodetest": "node --experimental-transform-types --no-warnings --test test/**/*.ts",
|
|
18
|
+
"testserver": "node --experimental-transform-types --no-warnings testserver/index.ts",
|
|
19
|
+
"lint": "eslint src test testserver"
|
|
19
20
|
},
|
|
20
21
|
"dependencies": {
|
|
21
|
-
"@elastic/elasticsearch": "^
|
|
22
|
-
"@fastify/swagger": "^
|
|
23
|
-
"@fastify/swagger-ui": "^
|
|
24
|
-
"@fastify/type-provider-json-schema-to-ts": "^
|
|
22
|
+
"@elastic/elasticsearch": "^9.0.0",
|
|
23
|
+
"@fastify/swagger": "^9.0.0",
|
|
24
|
+
"@fastify/swagger-ui": "^5.0.0",
|
|
25
|
+
"@fastify/type-provider-json-schema-to-ts": "^5.0.0",
|
|
25
26
|
"@txstate-mws/fastify-shared": "^1.0.9",
|
|
26
|
-
"
|
|
27
|
+
"ajv": "^8.20.0",
|
|
27
28
|
"ajv-errors": "^3.0.0",
|
|
28
29
|
"ajv-formats": "^3.0.0",
|
|
29
|
-
"fastify": "^
|
|
30
|
-
"fastify-plugin": "^4.5.1",
|
|
30
|
+
"fastify": "^5.0.0",
|
|
31
31
|
"http-status-codes": "^2.1.4",
|
|
32
|
-
"jose": "^
|
|
32
|
+
"jose": "^6.0.0",
|
|
33
|
+
"openapi-types": "^12.1.3",
|
|
34
|
+
"pino": "^10.3.1",
|
|
33
35
|
"txstate-utils": "^1.9.5",
|
|
34
|
-
"ua-parser-js": "^
|
|
36
|
+
"ua-parser-js": "^2.0.0"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
|
-
"@fastify/multipart": "^
|
|
38
|
-
"@
|
|
39
|
-
"@types/
|
|
39
|
+
"@fastify/multipart": "^10.0.0",
|
|
40
|
+
"@stylistic/eslint-plugin": "^5.0.0",
|
|
41
|
+
"@types/chai": "^5.2.3",
|
|
40
42
|
"@types/node": "^24.0.0",
|
|
41
|
-
"axios": "^1.
|
|
42
|
-
"chai": "^
|
|
43
|
-
"eslint-config-
|
|
43
|
+
"axios": "^1.15.0",
|
|
44
|
+
"chai": "^6.0.0",
|
|
45
|
+
"eslint-config-love": "^153.0.0",
|
|
44
46
|
"json-schema-to-ts": "^3.0.1",
|
|
45
|
-
"
|
|
46
|
-
"ts-node": "^10.2.1",
|
|
47
|
-
"typescript": "^5.0.4"
|
|
47
|
+
"typescript": "^6.0.0"
|
|
48
48
|
},
|
|
49
49
|
"repository": {
|
|
50
50
|
"type": "git",
|
|
@@ -56,8 +56,10 @@
|
|
|
56
56
|
"url": "https://github.com/txstate-etc/fastify-txstate/issues"
|
|
57
57
|
},
|
|
58
58
|
"homepage": "https://github.com/txstate-etc/fastify-txstate#readme",
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=20"
|
|
61
|
+
},
|
|
59
62
|
"files": [
|
|
60
|
-
"lib/**/*"
|
|
61
|
-
"lib-esm/**/*"
|
|
63
|
+
"lib/**/*"
|
|
62
64
|
]
|
|
63
65
|
}
|
package/lib-esm/index.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import ftxst from '../lib/index.js'
|
|
2
|
-
|
|
3
|
-
export const devLogger = ftxst.devLogger
|
|
4
|
-
export const prodLogger = ftxst.prodLogger
|
|
5
|
-
export const HttpError = ftxst.HttpError
|
|
6
|
-
export const FailedValidationError = ftxst.FailedValidationError
|
|
7
|
-
export const ValidationError = ftxst.ValidationError
|
|
8
|
-
export const ValidationErrors = ftxst.ValidationErrors
|
|
9
|
-
export const unifiedAuthenticate = ftxst.unifiedAuthenticate
|
|
10
|
-
export const unifiedAuthenticateAll = ftxst.unifiedAuthenticateAll
|
|
11
|
-
export const registerUaCookieRoutes = ftxst.registerUaCookieRoutes
|
|
12
|
-
export const analyticsPlugin = ftxst.analyticsPlugin
|
|
13
|
-
export const AnalyticsClient = ftxst.AnalyticsClient
|
|
14
|
-
export const LoggingAnalyticsClient = ftxst.LoggingAnalyticsClient
|
|
15
|
-
export const ElasticAnalyticsClient = ftxst.ElasticAnalyticsClient
|
|
16
|
-
export const postFormData = ftxst.postFormData
|
|
17
|
-
export const readableToWebReadable = ftxst.readableToWebReadable
|
|
18
|
-
export const FileSystemHandler = ftxst.FileSystemHandler
|
|
19
|
-
export const fileHandler = ftxst.fileHandler
|
|
20
|
-
export default ftxst.default
|
package/lib-esm/package.json
DELETED