@zero-server/auth 0.9.1 → 0.9.2
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/LICENSE +21 -21
- package/index.js +26 -26
- package/lib/auth/authorize.js +399 -0
- package/lib/auth/enrollment.js +367 -0
- package/lib/auth/index.js +57 -0
- package/lib/auth/jwt.js +731 -0
- package/lib/auth/oauth.js +362 -0
- package/lib/auth/session.js +588 -0
- package/lib/auth/trustedDevice.js +409 -0
- package/lib/auth/twoFactor.js +1150 -0
- package/lib/auth/webauthn.js +946 -0
- package/lib/debug.js +372 -0
- package/package.json +11 -2
package/lib/auth/jwt.js
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module auth/jwt
|
|
3
|
+
* @description Zero-dependency JWT (JSON Web Token) middleware.
|
|
4
|
+
* Supports HMAC (HS256/384/512) and RSA (RS256/384/512)
|
|
5
|
+
* algorithms, JWKS endpoint auto-fetching, token extraction
|
|
6
|
+
* from header/cookie/query, and configurable validation rules.
|
|
7
|
+
*
|
|
8
|
+
* Populates `req.user` with the decoded payload and `req.token`
|
|
9
|
+
* with the raw token string.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const { createApp, json, jwt, jwtSign, Router } = require('@zero-server/sdk');
|
|
13
|
+
* const app = createApp();
|
|
14
|
+
* const SECRET = process.env.JWT_SECRET;
|
|
15
|
+
*
|
|
16
|
+
* // Public — issue tokens
|
|
17
|
+
* app.post('/login', json(), async (req, res) => {
|
|
18
|
+
* const { email, password } = req.body;
|
|
19
|
+
* const user = await db.users.findOne({ email });
|
|
20
|
+
* if (!user || !await verify(password, user.hash))
|
|
21
|
+
* return res.status(401).json({ error: 'Invalid credentials' });
|
|
22
|
+
*
|
|
23
|
+
* const token = jwtSign({ sub: user.id, role: user.role }, SECRET, { expiresIn: 3600 });
|
|
24
|
+
* res.json({ token });
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* // Protected — everything under /api requires a valid token
|
|
28
|
+
* const api = Router();
|
|
29
|
+
* api.use(jwt({ secret: SECRET }));
|
|
30
|
+
* api.get('/me', (req, res) => res.json({ id: req.user.sub, role: req.user.role }));
|
|
31
|
+
* app.use('/api', api);
|
|
32
|
+
*
|
|
33
|
+
* @example | RSA with JWKS Auto-fetch
|
|
34
|
+
* app.use(jwt({
|
|
35
|
+
* jwksUri: 'https://auth.example.com/.well-known/jwks.json',
|
|
36
|
+
* audience: 'my-api',
|
|
37
|
+
* issuer: 'https://auth.example.com',
|
|
38
|
+
* }));
|
|
39
|
+
*
|
|
40
|
+
* @example | Extract from Cookie
|
|
41
|
+
* app.use(jwt({
|
|
42
|
+
* secret: 'my-secret',
|
|
43
|
+
* getToken: (req) => req.cookies?.access_token,
|
|
44
|
+
* }));
|
|
45
|
+
*/
|
|
46
|
+
const crypto = require('crypto');
|
|
47
|
+
const log = require('../debug')('zero:jwt');
|
|
48
|
+
|
|
49
|
+
// -- Constants -------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** @private */
|
|
52
|
+
const ALG_MAP = {
|
|
53
|
+
HS256: { type: 'hmac', hash: 'sha256' },
|
|
54
|
+
HS384: { type: 'hmac', hash: 'sha384' },
|
|
55
|
+
HS512: { type: 'hmac', hash: 'sha512' },
|
|
56
|
+
RS256: { type: 'rsa', hash: 'sha256' },
|
|
57
|
+
RS384: { type: 'rsa', hash: 'sha384' },
|
|
58
|
+
RS512: { type: 'rsa', hash: 'sha512' },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const SUPPORTED_ALGORITHMS = Object.keys(ALG_MAP);
|
|
62
|
+
|
|
63
|
+
// -- Base64url helpers -----------------------------------------
|
|
64
|
+
|
|
65
|
+
/** @private */
|
|
66
|
+
function _base64urlEncode(data)
|
|
67
|
+
{
|
|
68
|
+
const buf = typeof data === 'string' ? Buffer.from(data) : data;
|
|
69
|
+
return buf.toString('base64url');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @private */
|
|
73
|
+
function _base64urlDecode(str)
|
|
74
|
+
{
|
|
75
|
+
return Buffer.from(str, 'base64url');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// -- JWT Core Functions ----------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decode a JWT without verifying the signature.
|
|
82
|
+
* Returns `null` for malformed tokens — never throws.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} token - Raw JWT string.
|
|
85
|
+
* @returns {{ header: object, payload: object, signature: string }|null}
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* const parts = decode(token);
|
|
89
|
+
* console.log(parts.payload.sub); // '1234'
|
|
90
|
+
*/
|
|
91
|
+
function decode(token)
|
|
92
|
+
{
|
|
93
|
+
if (!token || typeof token !== 'string') return null;
|
|
94
|
+
const parts = token.split('.');
|
|
95
|
+
if (parts.length !== 3) return null;
|
|
96
|
+
try
|
|
97
|
+
{
|
|
98
|
+
const header = JSON.parse(_base64urlDecode(parts[0]).toString());
|
|
99
|
+
const payload = JSON.parse(_base64urlDecode(parts[1]).toString());
|
|
100
|
+
return { header, payload, signature: parts[2] };
|
|
101
|
+
}
|
|
102
|
+
catch (_) { return null; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sign a payload and produce a JWT string.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} payload - Claims to encode.
|
|
109
|
+
* @param {string|Buffer} secret - HMAC secret or RSA private key (PEM).
|
|
110
|
+
* @param {object} [opts] - Signing options.
|
|
111
|
+
* @param {string} [opts.algorithm='HS256'] - Signing algorithm.
|
|
112
|
+
* @param {number} [opts.expiresIn] - Expiry in seconds from now.
|
|
113
|
+
* @param {string} [opts.issuer] - `iss` claim.
|
|
114
|
+
* @param {string} [opts.audience] - `aud` claim.
|
|
115
|
+
* @param {string} [opts.subject] - `sub` claim.
|
|
116
|
+
* @param {string} [opts.jwtId] - `jti` claim.
|
|
117
|
+
* @param {object} [opts.header] - Extra header fields.
|
|
118
|
+
* @returns {string} Signed JWT.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* const token = sign({ userId: 42 }, 'my-secret', { expiresIn: 3600 });
|
|
122
|
+
*
|
|
123
|
+
* @example | RSA Signing
|
|
124
|
+
* const rsaPrivateKey = fs.readFileSync('private.pem');
|
|
125
|
+
* const token = sign({ userId: 42 }, rsaPrivateKey, { algorithm: 'RS256' });
|
|
126
|
+
*/
|
|
127
|
+
function sign(payload, secret, opts = {})
|
|
128
|
+
{
|
|
129
|
+
const alg = opts.algorithm || 'HS256';
|
|
130
|
+
const algInfo = ALG_MAP[alg];
|
|
131
|
+
if (!algInfo) throw new Error(`Unsupported algorithm: ${alg}`);
|
|
132
|
+
|
|
133
|
+
const now = Math.floor(Date.now() / 1000);
|
|
134
|
+
const claims = { ...payload, iat: payload.iat ?? now };
|
|
135
|
+
if (opts.expiresIn) claims.exp = now + opts.expiresIn;
|
|
136
|
+
if (opts.issuer) claims.iss = opts.issuer;
|
|
137
|
+
if (opts.audience) claims.aud = opts.audience;
|
|
138
|
+
if (opts.subject) claims.sub = opts.subject;
|
|
139
|
+
if (opts.jwtId) claims.jti = opts.jwtId;
|
|
140
|
+
if (opts.notBefore) claims.nbf = now + opts.notBefore;
|
|
141
|
+
|
|
142
|
+
const header = { alg, typ: 'JWT', ...(opts.header || {}) };
|
|
143
|
+
const segments = [
|
|
144
|
+
_base64urlEncode(JSON.stringify(header)),
|
|
145
|
+
_base64urlEncode(JSON.stringify(claims)),
|
|
146
|
+
];
|
|
147
|
+
const signingInput = segments.join('.');
|
|
148
|
+
|
|
149
|
+
let sig;
|
|
150
|
+
if (algInfo.type === 'hmac')
|
|
151
|
+
{
|
|
152
|
+
sig = crypto.createHmac(algInfo.hash, secret).update(signingInput).digest();
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
{
|
|
156
|
+
sig = crypto.sign(algInfo.hash, Buffer.from(signingInput), secret);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
segments.push(_base64urlEncode(sig));
|
|
160
|
+
return segments.join('.');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Verify a JWT signature and validate claims.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} token - Raw JWT string.
|
|
167
|
+
* @param {string|Buffer} secretOrKey - HMAC secret or RSA public key (PEM).
|
|
168
|
+
* @param {object} [opts] - Verification options.
|
|
169
|
+
* @param {string|string[]} [opts.algorithms] - Allowed algorithms. Default: inferred from key type.
|
|
170
|
+
* @param {string|string[]} [opts.audience] - Required `aud` claim.
|
|
171
|
+
* @param {string|string[]} [opts.issuer] - Required `iss` claim.
|
|
172
|
+
* @param {string} [opts.subject] - Required `sub` claim.
|
|
173
|
+
* @param {number} [opts.clockTolerance=0] - Seconds of clock skew tolerance for `exp`/`nbf`.
|
|
174
|
+
* @param {number} [opts.maxAge] - Maximum token age in seconds (from `iat`).
|
|
175
|
+
* @param {boolean} [opts.ignoreExpiration=false] - Skip expiry validation.
|
|
176
|
+
* @returns {{ header: object, payload: object }} Decoded and verified token.
|
|
177
|
+
* @throws {Error} If the token is invalid, expired, or fails any claim check.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* try {
|
|
181
|
+
* const { payload } = verify(token, secret);
|
|
182
|
+
* console.log(payload.userId);
|
|
183
|
+
* } catch (err) {
|
|
184
|
+
* console.error(err.code); // 'TOKEN_EXPIRED', 'INVALID_SIGNATURE', etc.
|
|
185
|
+
* }
|
|
186
|
+
*/
|
|
187
|
+
function verify(token, secretOrKey, opts = {})
|
|
188
|
+
{
|
|
189
|
+
const decoded = decode(token);
|
|
190
|
+
if (!decoded) throw _jwtError('Malformed token', 'MALFORMED_TOKEN');
|
|
191
|
+
|
|
192
|
+
const { header, payload } = decoded;
|
|
193
|
+
const alg = header.alg;
|
|
194
|
+
const algInfo = ALG_MAP[alg];
|
|
195
|
+
if (!algInfo) throw _jwtError(`Unsupported algorithm: ${alg}`, 'UNSUPPORTED_ALGORITHM');
|
|
196
|
+
|
|
197
|
+
// Check allowed algorithms
|
|
198
|
+
const allowed = opts.algorithms
|
|
199
|
+
? (Array.isArray(opts.algorithms) ? opts.algorithms : [opts.algorithms])
|
|
200
|
+
: (algInfo.type === 'hmac' ? ['HS256', 'HS384', 'HS512'] : ['RS256', 'RS384', 'RS512']);
|
|
201
|
+
if (!allowed.includes(alg)) throw _jwtError(`Algorithm ${alg} not allowed`, 'ALGORITHM_NOT_ALLOWED');
|
|
202
|
+
|
|
203
|
+
// Verify signature
|
|
204
|
+
const parts = token.split('.');
|
|
205
|
+
const signingInput = parts[0] + '.' + parts[1];
|
|
206
|
+
const sigBuf = _base64urlDecode(parts[2]);
|
|
207
|
+
|
|
208
|
+
if (algInfo.type === 'hmac')
|
|
209
|
+
{
|
|
210
|
+
const expected = crypto.createHmac(algInfo.hash, secretOrKey).update(signingInput).digest();
|
|
211
|
+
if (expected.length !== sigBuf.length || !crypto.timingSafeEqual(expected, sigBuf))
|
|
212
|
+
{
|
|
213
|
+
throw _jwtError('Invalid signature', 'INVALID_SIGNATURE');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else
|
|
217
|
+
{
|
|
218
|
+
const valid = crypto.verify(algInfo.hash, Buffer.from(signingInput), secretOrKey, sigBuf);
|
|
219
|
+
if (!valid) throw _jwtError('Invalid signature', 'INVALID_SIGNATURE');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Validate claims
|
|
223
|
+
const now = Math.floor(Date.now() / 1000);
|
|
224
|
+
const clockTolerance = opts.clockTolerance || 0;
|
|
225
|
+
|
|
226
|
+
if (payload.exp !== undefined && !opts.ignoreExpiration)
|
|
227
|
+
{
|
|
228
|
+
if (now > payload.exp + clockTolerance)
|
|
229
|
+
{
|
|
230
|
+
throw _jwtError('Token expired', 'TOKEN_EXPIRED');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (payload.nbf !== undefined)
|
|
235
|
+
{
|
|
236
|
+
if (now < payload.nbf - clockTolerance)
|
|
237
|
+
{
|
|
238
|
+
throw _jwtError('Token not yet valid', 'TOKEN_NOT_ACTIVE');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (opts.maxAge !== undefined && payload.iat !== undefined)
|
|
243
|
+
{
|
|
244
|
+
if (now - payload.iat > opts.maxAge + clockTolerance)
|
|
245
|
+
{
|
|
246
|
+
throw _jwtError('Token exceeds maximum age', 'TOKEN_MAX_AGE');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (opts.audience) _validateClaim('aud', payload.aud, opts.audience);
|
|
251
|
+
if (opts.issuer) _validateClaim('iss', payload.iss, opts.issuer);
|
|
252
|
+
if (opts.subject && payload.sub !== opts.subject)
|
|
253
|
+
{
|
|
254
|
+
throw _jwtError(`Subject mismatch: expected ${opts.subject}`, 'INVALID_SUBJECT');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { header, payload };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// -- JWKS Support -----------------------------------------------
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Create a JWKS key provider that fetches and caches public keys.
|
|
264
|
+
* Auto-refreshes keys when a `kid` is not found.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} jwksUri - URL of the JWKS endpoint.
|
|
267
|
+
* @param {object} [opts] - Options.
|
|
268
|
+
* @param {Function} [opts.fetcher] - Custom fetch function (default: built-in fetch).
|
|
269
|
+
* @param {number} [opts.cacheTtl=600000] - Cache TTL in ms (default 10 minutes).
|
|
270
|
+
* @param {number} [opts.requestTimeout=5000] - Request timeout in ms.
|
|
271
|
+
* @returns {Function} `async (header) => publicKey` — resolves the signing key for a JWT header.
|
|
272
|
+
*
|
|
273
|
+
* @example
|
|
274
|
+
* const getKey = jwks('https://auth.example.com/.well-known/jwks.json');
|
|
275
|
+
* app.use(jwt({ getKey }));
|
|
276
|
+
*/
|
|
277
|
+
function jwks(jwksUri, opts = {})
|
|
278
|
+
{
|
|
279
|
+
const fetchFn = opts.fetcher || require('@zero-server/fetch');
|
|
280
|
+
const cacheTtl = opts.cacheTtl || 600000;
|
|
281
|
+
const requestTimeout = opts.requestTimeout || 5000;
|
|
282
|
+
let _cache = null;
|
|
283
|
+
let _lastFetch = 0;
|
|
284
|
+
|
|
285
|
+
async function _fetchKeys()
|
|
286
|
+
{
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
if (_cache && (now - _lastFetch) < cacheTtl) return _cache;
|
|
289
|
+
|
|
290
|
+
log.debug('fetching JWKS from %s', jwksUri);
|
|
291
|
+
const res = await fetchFn(jwksUri, { timeout: requestTimeout });
|
|
292
|
+
if (!res.ok) throw _jwtError(`JWKS fetch failed: ${res.status}`, 'JWKS_FETCH_FAILED');
|
|
293
|
+
|
|
294
|
+
const body = await res.json();
|
|
295
|
+
if (!body.keys || !Array.isArray(body.keys)) throw _jwtError('Invalid JWKS response', 'JWKS_INVALID');
|
|
296
|
+
|
|
297
|
+
_cache = new Map();
|
|
298
|
+
for (const key of body.keys)
|
|
299
|
+
{
|
|
300
|
+
if (key.kty === 'RSA' && key.use !== 'enc')
|
|
301
|
+
{
|
|
302
|
+
_cache.set(key.kid, _rsaJwkToPem(key));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
_lastFetch = now;
|
|
306
|
+
return _cache;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Resolve a signing key from the JWKS based on the JWT header.
|
|
311
|
+
*
|
|
312
|
+
* @param {{ kid?: string, alg?: string }} header - JWT header.
|
|
313
|
+
* @returns {Promise<string>} PEM-encoded public key.
|
|
314
|
+
*/
|
|
315
|
+
async function getKey(header)
|
|
316
|
+
{
|
|
317
|
+
let keys = await _fetchKeys();
|
|
318
|
+
|
|
319
|
+
if (header.kid)
|
|
320
|
+
{
|
|
321
|
+
let pem = keys.get(header.kid);
|
|
322
|
+
if (!pem)
|
|
323
|
+
{
|
|
324
|
+
// Force refresh and retry once
|
|
325
|
+
_cache = null;
|
|
326
|
+
keys = await _fetchKeys();
|
|
327
|
+
pem = keys.get(header.kid);
|
|
328
|
+
}
|
|
329
|
+
if (!pem) throw _jwtError(`Key ${header.kid} not found in JWKS`, 'JWKS_KID_NOT_FOUND');
|
|
330
|
+
return pem;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// No kid — return the first RSA key
|
|
334
|
+
const first = keys.values().next().value;
|
|
335
|
+
if (!first) throw _jwtError('No suitable key in JWKS', 'JWKS_NO_KEY');
|
|
336
|
+
return first;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Expose for testing
|
|
340
|
+
getKey._clearCache = () => { _cache = null; _lastFetch = 0; };
|
|
341
|
+
return getKey;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// -- JWT Middleware -----------------------------------------------
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Create JWT authentication middleware.
|
|
348
|
+
*
|
|
349
|
+
* On success, populates:
|
|
350
|
+
* - `req.user` — decoded payload
|
|
351
|
+
* - `req.auth` — `{ header, payload, token }` full decode info
|
|
352
|
+
* - `req.token` — raw JWT string
|
|
353
|
+
*
|
|
354
|
+
* @param {object} opts - Configuration.
|
|
355
|
+
* @param {string|Buffer} [opts.secret] - HMAC secret for HS* algorithms.
|
|
356
|
+
* @param {string|Buffer} [opts.publicKey] - RSA public key (PEM) for RS* algorithms.
|
|
357
|
+
* @param {Function} [opts.getKey] - Dynamic key resolver `async (header, payload) => key`. Overrides `secret`/`publicKey`.
|
|
358
|
+
* @param {string} [opts.jwksUri] - JWKS endpoint URL (creates a `getKey` automatically).
|
|
359
|
+
* @param {string|string[]} [opts.algorithms] - Allowed algorithms. Default: auto-detect.
|
|
360
|
+
* @param {Function} [opts.getToken] - Custom token extractor `(req) => string|null`.
|
|
361
|
+
* @param {string} [opts.tokenLocation='header'] - Where to look: `'header'`, `'cookie'`, `'query'`.
|
|
362
|
+
* @param {string} [opts.cookieName='token'] - Cookie name when `tokenLocation='cookie'`.
|
|
363
|
+
* @param {string} [opts.queryParam='token'] - Query param name when `tokenLocation='query'`.
|
|
364
|
+
* @param {string|string[]} [opts.audience] - Required `aud` claim.
|
|
365
|
+
* @param {string|string[]} [opts.issuer] - Required `iss` claim.
|
|
366
|
+
* @param {string} [opts.subject] - Required `sub` claim.
|
|
367
|
+
* @param {number} [opts.clockTolerance=0] - Clock skew tolerance in seconds.
|
|
368
|
+
* @param {number} [opts.maxAge] - Maximum token age in seconds.
|
|
369
|
+
* @param {boolean} [opts.credentialsRequired=true] - Return 401 if no token found (false = optional auth).
|
|
370
|
+
* @param {Function} [opts.isRevoked] - `async (payload) => boolean` — check token revocation.
|
|
371
|
+
* @param {Function} [opts.onError] - Custom error handler `(err, req, res) => void`.
|
|
372
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
373
|
+
*
|
|
374
|
+
* @example | Simple HMAC
|
|
375
|
+
* app.use(jwt({ secret: 'my-secret' }));
|
|
376
|
+
*
|
|
377
|
+
* @example | Optional Auth
|
|
378
|
+
* app.use(jwt({ secret: 'my-secret', credentialsRequired: false }));
|
|
379
|
+
*
|
|
380
|
+
* @example | Custom Token Location
|
|
381
|
+
* app.use(jwt({
|
|
382
|
+
* secret: 'my-secret',
|
|
383
|
+
* getToken: (req) => req.cookies?.access_token || req.get('x-auth-token'),
|
|
384
|
+
* }));
|
|
385
|
+
*
|
|
386
|
+
* @example | With Revocation Check
|
|
387
|
+
* const revokedTokens = new Set();
|
|
388
|
+
* app.use(jwt({
|
|
389
|
+
* secret: 'my-secret',
|
|
390
|
+
* isRevoked: async (payload) => {
|
|
391
|
+
* return revokedTokens.has(payload.jti);
|
|
392
|
+
* },
|
|
393
|
+
* }));
|
|
394
|
+
*/
|
|
395
|
+
function jwt(opts = {})
|
|
396
|
+
{
|
|
397
|
+
if (!opts.secret && !opts.publicKey && !opts.getKey && !opts.jwksUri)
|
|
398
|
+
{
|
|
399
|
+
throw new Error('jwt() requires secret, publicKey, getKey, or jwksUri');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const algorithms = opts.algorithms
|
|
403
|
+
? (Array.isArray(opts.algorithms) ? opts.algorithms : [opts.algorithms])
|
|
404
|
+
: null;
|
|
405
|
+
const credentialsRequired = opts.credentialsRequired !== false;
|
|
406
|
+
const getToken = _buildTokenExtractor(opts);
|
|
407
|
+
const isRevoked = typeof opts.isRevoked === 'function' ? opts.isRevoked : null;
|
|
408
|
+
const onError = typeof opts.onError === 'function' ? opts.onError : null;
|
|
409
|
+
const clockTolerance = opts.clockTolerance || 0;
|
|
410
|
+
const verifyOpts = {
|
|
411
|
+
audience: opts.audience,
|
|
412
|
+
issuer: opts.issuer,
|
|
413
|
+
subject: opts.subject,
|
|
414
|
+
clockTolerance,
|
|
415
|
+
maxAge: opts.maxAge,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Build key resolver
|
|
419
|
+
let getKey = opts.getKey;
|
|
420
|
+
if (!getKey && opts.jwksUri)
|
|
421
|
+
{
|
|
422
|
+
getKey = jwks(opts.jwksUri, { fetcher: opts.fetcher, cacheTtl: opts.cacheTtl });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return async function jwtMiddleware(req, res, next)
|
|
426
|
+
{
|
|
427
|
+
try
|
|
428
|
+
{
|
|
429
|
+
const token = getToken(req);
|
|
430
|
+
if (!token)
|
|
431
|
+
{
|
|
432
|
+
if (!credentialsRequired) return next();
|
|
433
|
+
return _sendError(res, 401, 'No token provided', 'CREDENTIALS_REQUIRED', onError, req);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Decode to get header (for kid/alg-based key lookup)
|
|
437
|
+
const decoded = decode(token);
|
|
438
|
+
if (!decoded)
|
|
439
|
+
{
|
|
440
|
+
return _sendError(res, 401, 'Malformed token', 'MALFORMED_TOKEN', onError, req);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Resolve key
|
|
444
|
+
let key;
|
|
445
|
+
if (getKey)
|
|
446
|
+
{
|
|
447
|
+
key = await getKey(decoded.header, decoded.payload);
|
|
448
|
+
}
|
|
449
|
+
else if (opts.publicKey)
|
|
450
|
+
{
|
|
451
|
+
key = opts.publicKey;
|
|
452
|
+
}
|
|
453
|
+
else
|
|
454
|
+
{
|
|
455
|
+
key = opts.secret;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Verify
|
|
459
|
+
const algsOpt = algorithms || verifyOpts.algorithms;
|
|
460
|
+
const result = verify(token, key, { ...verifyOpts, algorithms: algsOpt });
|
|
461
|
+
|
|
462
|
+
// Revocation check
|
|
463
|
+
if (isRevoked)
|
|
464
|
+
{
|
|
465
|
+
const revoked = await isRevoked(result.payload);
|
|
466
|
+
if (revoked)
|
|
467
|
+
{
|
|
468
|
+
return _sendError(res, 401, 'Token has been revoked', 'TOKEN_REVOKED', onError, req);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Populate request
|
|
473
|
+
req.user = result.payload;
|
|
474
|
+
req.auth = { header: result.header, payload: result.payload, token };
|
|
475
|
+
req.token = token;
|
|
476
|
+
|
|
477
|
+
log.debug('JWT authenticated: sub=%s', result.payload.sub || 'n/a');
|
|
478
|
+
next();
|
|
479
|
+
}
|
|
480
|
+
catch (err)
|
|
481
|
+
{
|
|
482
|
+
const code = err.code || 'INVALID_TOKEN';
|
|
483
|
+
const status = code === 'TOKEN_EXPIRED' ? 401 : 401;
|
|
484
|
+
return _sendError(res, status, err.message, code, onError, req);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// -- Token Refresh Helpers ----------------------------------------
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Generate a signed refresh token.
|
|
493
|
+
* Refresh tokens are long-lived and should be stored securely.
|
|
494
|
+
*
|
|
495
|
+
* @param {object} payload - Claims (typically `{ sub, jti }`).
|
|
496
|
+
* @param {string|Buffer} secret - Signing secret.
|
|
497
|
+
* @param {object} [opts] - Options.
|
|
498
|
+
* @param {number} [opts.expiresIn=604800] - Expiry in seconds (default: 7 days).
|
|
499
|
+
* @param {string} [opts.algorithm='HS256'] - Signing algorithm.
|
|
500
|
+
* @returns {string} Signed refresh token (JWT).
|
|
501
|
+
*
|
|
502
|
+
* @example
|
|
503
|
+
* const refreshToken = createRefreshToken(
|
|
504
|
+
* { sub: user.id, jti: crypto.randomUUID() },
|
|
505
|
+
* process.env.REFRESH_SECRET,
|
|
506
|
+
* { expiresIn: 30 * 86400 }, // 30 days
|
|
507
|
+
* );
|
|
508
|
+
*/
|
|
509
|
+
function createRefreshToken(payload, secret, opts = {})
|
|
510
|
+
{
|
|
511
|
+
return sign(payload, secret, {
|
|
512
|
+
algorithm: opts.algorithm || 'HS256',
|
|
513
|
+
expiresIn: opts.expiresIn || 604800,
|
|
514
|
+
...opts,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Create a token-pair factory for convenient access + refresh token generation.
|
|
520
|
+
*
|
|
521
|
+
* @param {object} config - Configuration.
|
|
522
|
+
* @param {string|Buffer} config.accessSecret - Secret for access tokens.
|
|
523
|
+
* @param {string|Buffer} [config.refreshSecret] - Secret for refresh tokens (defaults to accessSecret).
|
|
524
|
+
* @param {number} [config.accessExpiresIn=900] - Access token expiry in seconds (default: 15 min).
|
|
525
|
+
* @param {number} [config.refreshExpiresIn=604800] - Refresh token expiry (default: 7 days).
|
|
526
|
+
* @param {string} [config.algorithm='HS256'] - Signing algorithm.
|
|
527
|
+
* @returns {{ generateTokens: Function, verifyRefreshToken: Function, verifyAccessToken: Function }}
|
|
528
|
+
*
|
|
529
|
+
* @example
|
|
530
|
+
* const tokens = tokenPair({
|
|
531
|
+
* accessSecret: process.env.JWT_SECRET,
|
|
532
|
+
* refreshSecret: process.env.REFRESH_SECRET,
|
|
533
|
+
* accessExpiresIn: 900,
|
|
534
|
+
* refreshExpiresIn: 86400 * 30,
|
|
535
|
+
* });
|
|
536
|
+
*
|
|
537
|
+
* // Login route
|
|
538
|
+
* app.post('/login', async (req, res) => {
|
|
539
|
+
* const user = await authenticate(req.body);
|
|
540
|
+
* const { accessToken, refreshToken } = tokens.generateTokens({ sub: user.id });
|
|
541
|
+
* res.json({ accessToken, refreshToken });
|
|
542
|
+
* });
|
|
543
|
+
*
|
|
544
|
+
* // Refresh route
|
|
545
|
+
* app.post('/refresh', async (req, res) => {
|
|
546
|
+
* const { payload } = tokens.verifyRefreshToken(req.body.refreshToken);
|
|
547
|
+
* const { accessToken, refreshToken } = tokens.generateTokens({ sub: payload.sub });
|
|
548
|
+
* res.json({ accessToken, refreshToken });
|
|
549
|
+
* });
|
|
550
|
+
*/
|
|
551
|
+
function tokenPair(config)
|
|
552
|
+
{
|
|
553
|
+
const accessSecret = config.accessSecret;
|
|
554
|
+
const refreshSecret = config.refreshSecret || accessSecret;
|
|
555
|
+
const accessExp = config.accessExpiresIn || 900;
|
|
556
|
+
const refreshExp = config.refreshExpiresIn || 604800;
|
|
557
|
+
const alg = config.algorithm || 'HS256';
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
/**
|
|
561
|
+
* Generate an access + refresh token pair.
|
|
562
|
+
* @param {object} payload - Claims to include.
|
|
563
|
+
* @returns {{ accessToken: string, refreshToken: string }}
|
|
564
|
+
*/
|
|
565
|
+
generateTokens(payload)
|
|
566
|
+
{
|
|
567
|
+
const jti = crypto.randomUUID();
|
|
568
|
+
return {
|
|
569
|
+
accessToken: sign(payload, accessSecret, { algorithm: alg, expiresIn: accessExp }),
|
|
570
|
+
refreshToken: sign({ ...payload, jti }, refreshSecret, { algorithm: alg, expiresIn: refreshExp }),
|
|
571
|
+
};
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Verify a refresh token.
|
|
576
|
+
* @param {string} token - Raw refresh token.
|
|
577
|
+
* @returns {{ header: object, payload: object }}
|
|
578
|
+
*/
|
|
579
|
+
verifyRefreshToken(token)
|
|
580
|
+
{
|
|
581
|
+
return verify(token, refreshSecret, { algorithms: [alg] });
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Verify an access token.
|
|
586
|
+
* @param {string} token - Raw access token.
|
|
587
|
+
* @returns {{ header: object, payload: object }}
|
|
588
|
+
*/
|
|
589
|
+
verifyAccessToken(token)
|
|
590
|
+
{
|
|
591
|
+
return verify(token, accessSecret, { algorithms: [alg] });
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// -- Internal Helpers ---------------------------------------------
|
|
597
|
+
|
|
598
|
+
/** @private */
|
|
599
|
+
function _buildTokenExtractor(opts)
|
|
600
|
+
{
|
|
601
|
+
if (typeof opts.getToken === 'function') return opts.getToken;
|
|
602
|
+
|
|
603
|
+
const location = opts.tokenLocation || 'header';
|
|
604
|
+
const cookieName = opts.cookieName || 'token';
|
|
605
|
+
const queryParam = opts.queryParam || 'token';
|
|
606
|
+
|
|
607
|
+
return (req) =>
|
|
608
|
+
{
|
|
609
|
+
// Always try Authorization header first
|
|
610
|
+
const authHeader = req.headers?.authorization || req.get?.('authorization');
|
|
611
|
+
if (authHeader)
|
|
612
|
+
{
|
|
613
|
+
const parts = authHeader.split(' ');
|
|
614
|
+
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer')
|
|
615
|
+
{
|
|
616
|
+
return parts[1];
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (location === 'cookie' || location !== 'header')
|
|
621
|
+
{
|
|
622
|
+
const cookieVal = req.cookies?.[cookieName] || req.signedCookies?.[cookieName];
|
|
623
|
+
if (cookieVal) return cookieVal;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (location === 'query' || location !== 'header')
|
|
627
|
+
{
|
|
628
|
+
const queryVal = req.query?.[queryParam];
|
|
629
|
+
if (queryVal) return queryVal;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return null;
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** @private */
|
|
637
|
+
function _validateClaim(name, actual, expected)
|
|
638
|
+
{
|
|
639
|
+
const expectedArr = Array.isArray(expected) ? expected : [expected];
|
|
640
|
+
const actualArr = Array.isArray(actual) ? actual : [actual];
|
|
641
|
+
const match = actualArr.some(a => expectedArr.includes(a));
|
|
642
|
+
if (!match)
|
|
643
|
+
{
|
|
644
|
+
throw _jwtError(
|
|
645
|
+
`${name} mismatch: expected ${expectedArr.join(' or ')}`,
|
|
646
|
+
`INVALID_${name.toUpperCase()}`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** @private */
|
|
652
|
+
function _jwtError(message, code)
|
|
653
|
+
{
|
|
654
|
+
const err = new Error(message);
|
|
655
|
+
err.code = code;
|
|
656
|
+
return err;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** @private */
|
|
660
|
+
function _sendError(res, status, message, code, onError, req)
|
|
661
|
+
{
|
|
662
|
+
if (onError) return onError({ message, code, statusCode: status }, req, res);
|
|
663
|
+
res.status(status).json({ error: message, code, statusCode: status });
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Convert an RSA JWK (JSON Web Key) to PEM format.
|
|
668
|
+
* Handles the DER encoding of RSA public keys per RFC 3447.
|
|
669
|
+
*
|
|
670
|
+
* @param {object} jwk - JWK with `n` and `e` fields.
|
|
671
|
+
* @returns {string} PEM-encoded RSA public key.
|
|
672
|
+
* @private
|
|
673
|
+
*/
|
|
674
|
+
function _rsaJwkToPem(jwk)
|
|
675
|
+
{
|
|
676
|
+
const n = _base64urlDecode(jwk.n);
|
|
677
|
+
const e = _base64urlDecode(jwk.e);
|
|
678
|
+
|
|
679
|
+
// DER-encode RSA public key
|
|
680
|
+
const nBytes = _derUint(n);
|
|
681
|
+
const eBytes = _derUint(e);
|
|
682
|
+
const seq = _derSequence(Buffer.concat([nBytes, eBytes]));
|
|
683
|
+
|
|
684
|
+
// Wrap in SubjectPublicKeyInfo
|
|
685
|
+
const algId = Buffer.from([
|
|
686
|
+
0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86,
|
|
687
|
+
0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00,
|
|
688
|
+
]);
|
|
689
|
+
const bitString = Buffer.concat([Buffer.from([0x03, ...(_derLength(seq.length + 1)), 0x00]), seq]);
|
|
690
|
+
const spki = _derSequence(Buffer.concat([algId, bitString]));
|
|
691
|
+
|
|
692
|
+
const b64 = spki.toString('base64');
|
|
693
|
+
const lines = b64.match(/.{1,64}/g) || [];
|
|
694
|
+
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** @private */
|
|
698
|
+
function _derUint(buf)
|
|
699
|
+
{
|
|
700
|
+
// Ensure positive integer (prepend 0x00 if high bit set)
|
|
701
|
+
let b = buf;
|
|
702
|
+
if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0x00]), b]);
|
|
703
|
+
return Buffer.concat([Buffer.from([0x02, ..._derLength(b.length)]), b]);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** @private */
|
|
707
|
+
function _derLength(len)
|
|
708
|
+
{
|
|
709
|
+
if (len < 0x80) return [len];
|
|
710
|
+
const bytes = [];
|
|
711
|
+
let tmp = len;
|
|
712
|
+
while (tmp > 0) { bytes.unshift(tmp & 0xff); tmp >>= 8; }
|
|
713
|
+
return [0x80 | bytes.length, ...bytes];
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** @private */
|
|
717
|
+
function _derSequence(buf)
|
|
718
|
+
{
|
|
719
|
+
return Buffer.concat([Buffer.from([0x30, ..._derLength(buf.length)]), buf]);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
module.exports = {
|
|
723
|
+
jwt,
|
|
724
|
+
sign,
|
|
725
|
+
verify,
|
|
726
|
+
decode,
|
|
727
|
+
jwks,
|
|
728
|
+
tokenPair,
|
|
729
|
+
createRefreshToken,
|
|
730
|
+
SUPPORTED_ALGORITHMS,
|
|
731
|
+
};
|