@oneunit/auth 2.0.0 → 2.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/ARCHITECTURE.md +119 -15
- package/CHANGELOG.md +82 -0
- package/README.md +52 -1
- package/dist/auth.d.ts +22 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +85 -4
- package/dist/auth.js.map +1 -1
- package/dist/password.d.ts.map +1 -1
- package/dist/password.js +96 -7
- package/dist/password.js.map +1 -1
- package/dist/providers.d.ts.map +1 -1
- package/dist/providers.js +130 -6
- package/dist/providers.js.map +1 -1
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/auth.ts +98 -4
- package/src/password.ts +117 -7
- package/src/providers.ts +143 -6
- package/src/types.ts +62 -0
package/src/auth.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
RBACOptions,
|
|
20
20
|
RefreshRecord,
|
|
21
21
|
RefreshStore,
|
|
22
|
+
SessionStore,
|
|
22
23
|
RequestLike,
|
|
23
24
|
RoleDefinition,
|
|
24
25
|
Secret,
|
|
@@ -58,6 +59,10 @@ const RESERVED_CLAIMS: ReadonlySet<string> = new Set([
|
|
|
58
59
|
"iat",
|
|
59
60
|
"nbf",
|
|
60
61
|
"jti",
|
|
62
|
+
// Session version. Written by the library from the SessionStore and compared
|
|
63
|
+
// on every verification, so it must not be settable by an extractor or by
|
|
64
|
+
// additionalClaims.
|
|
65
|
+
"sv",
|
|
61
66
|
]);
|
|
62
67
|
|
|
63
68
|
type ClaimExtractor = (user: UserRecord) => unknown;
|
|
@@ -74,6 +79,7 @@ export class Auth {
|
|
|
74
79
|
trustUserPermissions: boolean;
|
|
75
80
|
userStore: UserStore | null;
|
|
76
81
|
refreshStore: RefreshStore;
|
|
82
|
+
sessionStore?: SessionStore;
|
|
77
83
|
onLogin: AuthOptions["onLogin"];
|
|
78
84
|
onLink: AuthOptions["onLink"];
|
|
79
85
|
rbac: RBAC;
|
|
@@ -102,6 +108,7 @@ export class Auth {
|
|
|
102
108
|
|
|
103
109
|
this.userStore = options.userStore ?? null;
|
|
104
110
|
this.refreshStore = options.refreshStore ?? createMemoryRefreshStore();
|
|
111
|
+
this.sessionStore = options.sessionStore;
|
|
105
112
|
this.onLogin = options.onLogin;
|
|
106
113
|
this.onLink = options.onLink;
|
|
107
114
|
|
|
@@ -180,6 +187,13 @@ export class Auth {
|
|
|
180
187
|
...additional,
|
|
181
188
|
};
|
|
182
189
|
|
|
190
|
+
// Only stamped when a SessionStore is configured, so tokens are unchanged
|
|
191
|
+
// for the many deployments that do not opt into session revocation.
|
|
192
|
+
const sessionVersion = await this.currentSessionVersion(user.id);
|
|
193
|
+
if (sessionVersion !== undefined) {
|
|
194
|
+
payload.sv = sessionVersion;
|
|
195
|
+
}
|
|
196
|
+
|
|
183
197
|
const subject = String(user.id);
|
|
184
198
|
|
|
185
199
|
const accessToken = encodeAccessToken(payload, this.secret, {
|
|
@@ -194,7 +208,7 @@ export class Auth {
|
|
|
194
208
|
if (options.refresh !== false) {
|
|
195
209
|
const jwtid = randomToken(16);
|
|
196
210
|
refreshToken = encodeRefreshToken(
|
|
197
|
-
{ sub: subject, userId: user.id, roles },
|
|
211
|
+
{ sub: subject, userId: user.id, roles, ...(sessionVersion !== undefined && { sv: sessionVersion }) },
|
|
198
212
|
this.refreshSecret,
|
|
199
213
|
{
|
|
200
214
|
audience: this.audience,
|
|
@@ -297,6 +311,10 @@ export class Auth {
|
|
|
297
311
|
clockTolerance: options.clockTolerance ?? this.clockTolerance,
|
|
298
312
|
});
|
|
299
313
|
|
|
314
|
+
// Checked after the signature, so `sv` is a claim this library signed
|
|
315
|
+
// rather than a number the caller supplied.
|
|
316
|
+
await this.assertSessionActive(claims);
|
|
317
|
+
|
|
300
318
|
// A refresh token is signed with the same secret and would otherwise verify
|
|
301
319
|
// as a bearer credential, turning a 7-day token into a 7-day access token.
|
|
302
320
|
if (claims.typ === "refresh" && options.acceptTokenType !== "refresh") {
|
|
@@ -334,8 +352,16 @@ export class Auth {
|
|
|
334
352
|
return cookies[cookieName];
|
|
335
353
|
}
|
|
336
354
|
|
|
337
|
-
|
|
338
|
-
|
|
355
|
+
// Opt-in only. Reading the query string by default put the token in every
|
|
356
|
+
// access log, proxy log, browser history entry, and outgoing Referer header
|
|
357
|
+
// on every request, which is a far wider blast radius than a header or a
|
|
358
|
+
// cookie.
|
|
359
|
+
if (options.query) {
|
|
360
|
+
const query = collectQuery(req);
|
|
361
|
+
return query.access_token ?? query.token ?? null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return null;
|
|
339
365
|
}
|
|
340
366
|
|
|
341
367
|
async refresh(refreshToken: string, options: LoginOptions = {}): Promise<LoginResult> {
|
|
@@ -354,7 +380,18 @@ export class Auth {
|
|
|
354
380
|
throw new UnauthorizedError("Not a refresh token");
|
|
355
381
|
}
|
|
356
382
|
|
|
357
|
-
if (claims.jti) {
|
|
383
|
+
if (!claims.jti) {
|
|
384
|
+
// A refresh token without a jti has no store entry, so there is nothing
|
|
385
|
+
// to consume and nothing to revoke: it would stay valid until it expired
|
|
386
|
+
// and would survive logout entirely. encodeRefreshToken is public, so
|
|
387
|
+
// this is reachable by anyone holding the secret; refuse it rather than
|
|
388
|
+
// mint a session that no revocation list can ever reach.
|
|
389
|
+
throw new UnauthorizedError(
|
|
390
|
+
"Refresh token is missing a jti and cannot be revoked; mint refresh tokens through login()",
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
{
|
|
358
395
|
// Consume the token in a single store operation when the store supports
|
|
359
396
|
// it, so two concurrent refreshes cannot both pass the validity check.
|
|
360
397
|
if (typeof this.refreshStore.consume === "function") {
|
|
@@ -376,6 +413,12 @@ export class Auth {
|
|
|
376
413
|
id: (claims.userId ?? claims.sub) as string | number,
|
|
377
414
|
roles: claims.roles,
|
|
378
415
|
};
|
|
416
|
+
|
|
417
|
+
// Before the store is touched. A revoked session must not be able to
|
|
418
|
+
// consume its refresh token and mint a fresh access token, which would
|
|
419
|
+
// make revokeAllSessions pointless for anyone holding a stolen one.
|
|
420
|
+
await this.assertSessionActive(claims);
|
|
421
|
+
|
|
379
422
|
if (this.userStore?.findById) {
|
|
380
423
|
user = await this.userStore.findById(user.id) ?? user;
|
|
381
424
|
}
|
|
@@ -395,6 +438,57 @@ export class Auth {
|
|
|
395
438
|
}
|
|
396
439
|
}
|
|
397
440
|
|
|
441
|
+
/**
|
|
442
|
+
* The user's current session version, or undefined when no SessionStore is
|
|
443
|
+
* configured and the feature is therefore off.
|
|
444
|
+
*/
|
|
445
|
+
private async currentSessionVersion(userId: string | number): Promise<number | undefined> {
|
|
446
|
+
if (!this.sessionStore) {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
const version = await this.sessionStore.getVersion(userId);
|
|
450
|
+
return version ?? 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Rejects a token whose session version has been superseded. A token minted
|
|
455
|
+
* before any SessionStore existed carries no `sv`, and is treated as
|
|
456
|
+
* version 0 so that enabling the feature does not invalidate sessions that
|
|
457
|
+
* were already in flight.
|
|
458
|
+
*/
|
|
459
|
+
private async assertSessionActive(claims: JwtPayload): Promise<void> {
|
|
460
|
+
if (!this.sessionStore) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const current = await this.currentSessionVersion(claims.userId ?? claims.sub ?? "");
|
|
464
|
+
const presented = typeof claims.sv === "number" ? claims.sv : 0;
|
|
465
|
+
if (presented !== current) {
|
|
466
|
+
throw new UnauthorizedError("Session has been revoked");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Invalidates every session currently issued to a user, across all devices.
|
|
472
|
+
* Returns false when no SessionStore is configured, since there is then
|
|
473
|
+
* nothing to bump and the call would silently do nothing.
|
|
474
|
+
*/
|
|
475
|
+
async revokeAllSessions(userId: string | number): Promise<boolean> {
|
|
476
|
+
if (!this.sessionStore?.bumpVersion) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
await this.sessionStore.bumpVersion(userId);
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Invalidates a single session by the jti of its refresh token. */
|
|
484
|
+
async revokeSession(jti: string): Promise<boolean> {
|
|
485
|
+
if (!this.sessionStore?.revokeSession) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
await this.sessionStore.revokeSession(jti);
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
398
492
|
async revoke(refreshToken?: string | null): Promise<boolean> {
|
|
399
493
|
if (!refreshToken) {
|
|
400
494
|
return false;
|
package/src/password.ts
CHANGED
|
@@ -26,6 +26,74 @@ const DEFAULT_BLOCK_SIZE = 8;
|
|
|
26
26
|
const DEFAULT_PARALLELISM = 1;
|
|
27
27
|
const PREFIX = "scrypt";
|
|
28
28
|
|
|
29
|
+
// Every one of these values can be attacker-influenced when they come out of a
|
|
30
|
+
// stored hash string: N and r decide the memory block, p and keyLength decide
|
|
31
|
+
// the CPU time and output size, and none of them are covered by the 32MB
|
|
32
|
+
// maxmem guard Node applies by default. A stored hash of
|
|
33
|
+
// "scrypt$16384$8$1$1073741824$..." costs ~25s of CPU on a default
|
|
34
|
+
// configuration, on every login attempt, and costs the attacker one string to
|
|
35
|
+
// write. So parameters are bounded on the way in as well as on the way out, and
|
|
36
|
+
// a hash asking for more work than this is treated as malformed rather than
|
|
37
|
+
// computed.
|
|
38
|
+
const MIN_COST = 2;
|
|
39
|
+
const MAX_COST = 1 << 20;
|
|
40
|
+
const MIN_BLOCK_SIZE = 1;
|
|
41
|
+
const MAX_BLOCK_SIZE = 32;
|
|
42
|
+
const MIN_PARALLELISM = 1;
|
|
43
|
+
const MAX_PARALLELISM = 16;
|
|
44
|
+
const MIN_KEY_LENGTH = 16;
|
|
45
|
+
const MAX_KEY_LENGTH = 128;
|
|
46
|
+
const MAX_SALT_BYTES = 64;
|
|
47
|
+
|
|
48
|
+
// The real constraint is the product, not N on its own: scrypt needs roughly
|
|
49
|
+
// 128 * N * r bytes, and Node rejects anything at or above 32MB. Capping N * r
|
|
50
|
+
// at 24MB of working memory therefore accepts every configuration Node can
|
|
51
|
+
// actually run while rejecting the ones that would throw at the crypto layer.
|
|
52
|
+
// The default is 16MB (16384 * 8), leaving 50% headroom to raise N, r, or both.
|
|
53
|
+
const MAX_WORK = (24 * 1024 * 1024) / 128;
|
|
54
|
+
|
|
55
|
+
// The floor matters as much as the ceiling. A stored hash asking for a tiny
|
|
56
|
+
// amount of work (N=2, r=1) is a downgrade attack in slow motion: it verifies
|
|
57
|
+
// instantly, so an attacker able to write a hash row can brute-force passwords
|
|
58
|
+
// against it at enormous speed. 2^12 is three orders of magnitude below the
|
|
59
|
+
// default and still weaker than OWASP's recommended N=2^17, r=8.
|
|
60
|
+
const MIN_WORK = 1 << 12;
|
|
61
|
+
|
|
62
|
+
interface ScryptParams {
|
|
63
|
+
N: number;
|
|
64
|
+
r: number;
|
|
65
|
+
p: number;
|
|
66
|
+
keyLength: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns null when any parameter is non-integer or outside its bounds, which
|
|
71
|
+
* is what makes an over-budget stored hash uncomputable rather than slow.
|
|
72
|
+
*/
|
|
73
|
+
function parseBoundedParams(n: number, r: number, p: number, keyLength: number): ScryptParams | null {
|
|
74
|
+
const values = [n, r, p, keyLength];
|
|
75
|
+
if (!values.every((value) => Number.isInteger(value))) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (n < MIN_COST || n > MAX_COST || (n & (n - 1)) !== 0) {
|
|
79
|
+
// scrypt requires N to be a power of two greater than one.
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (r < MIN_BLOCK_SIZE || r > MAX_BLOCK_SIZE) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (n * r > MAX_WORK || n * r < MIN_WORK) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (p < MIN_PARALLELISM || p > MAX_PARALLELISM) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (keyLength < MIN_KEY_LENGTH || keyLength > MAX_KEY_LENGTH) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return { N: n, r, p, keyLength };
|
|
95
|
+
}
|
|
96
|
+
|
|
29
97
|
function toBuffer(value: string | Buffer): Buffer {
|
|
30
98
|
return Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");
|
|
31
99
|
}
|
|
@@ -36,12 +104,39 @@ export async function hashPassword(password: string, options: PasswordOptions =
|
|
|
36
104
|
}
|
|
37
105
|
|
|
38
106
|
const saltBytes = options.saltBytes ?? DEFAULT_SALT_BYTES;
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
107
|
+
const params = parseBoundedParams(
|
|
108
|
+
options.cost ?? DEFAULT_COST,
|
|
109
|
+
options.blockSize ?? DEFAULT_BLOCK_SIZE,
|
|
110
|
+
options.parallelism ?? DEFAULT_PARALLELISM,
|
|
111
|
+
options.keyLength ?? DEFAULT_KEY_LENGTH,
|
|
112
|
+
);
|
|
113
|
+
if (!params) {
|
|
114
|
+
throw new ValidationError(
|
|
115
|
+
"Password hashing parameters are out of range: N must be a power of two in " +
|
|
116
|
+
`[${MIN_COST}, ${MAX_COST}], blockSize in [${MIN_BLOCK_SIZE}, ${MAX_BLOCK_SIZE}], ` +
|
|
117
|
+
`parallelism in [${MIN_PARALLELISM}, ${MAX_PARALLELISM}], keyLength in ` +
|
|
118
|
+
`[${MIN_KEY_LENGTH}, ${MAX_KEY_LENGTH}], and N * blockSize must be in ` +
|
|
119
|
+
`[${MIN_WORK}, ${MAX_WORK}] (24MB of working memory)`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (saltBytes < 8 || saltBytes > MAX_SALT_BYTES) {
|
|
123
|
+
throw new ValidationError(`saltBytes must be in [8, ${MAX_SALT_BYTES}]`);
|
|
124
|
+
}
|
|
125
|
+
const { N, r, p, keyLength } = params;
|
|
43
126
|
const salt = options.salt ? toBuffer(options.salt) : randomBytes(saltBytes);
|
|
44
|
-
|
|
127
|
+
|
|
128
|
+
// The bounds above are a cheap pre-filter, not a complete model of what
|
|
129
|
+
// OpenSSL accepts. Node also throws synchronously for some in-range
|
|
130
|
+
// combinations, so it is translated here rather than escaping as a raw
|
|
131
|
+
// RangeError from a function whose contract is to report bad input.
|
|
132
|
+
let key: Buffer;
|
|
133
|
+
try {
|
|
134
|
+
key = await scrypt(password, salt, keyLength, { N, r, p });
|
|
135
|
+
} catch {
|
|
136
|
+
throw new ValidationError(
|
|
137
|
+
`scrypt rejected the combination N=${N}, r=${r}, p=${p}, keyLength=${keyLength} for this platform`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
45
140
|
|
|
46
141
|
return [
|
|
47
142
|
PREFIX,
|
|
@@ -70,14 +165,22 @@ export async function verifyPassword(password: string, storedHash: string): Prom
|
|
|
70
165
|
const p = Number.parseInt(pRaw, 10);
|
|
71
166
|
const keyLength = Number.parseInt(lengthRaw, 10);
|
|
72
167
|
|
|
73
|
-
|
|
168
|
+
// An over-budget or non-integer parameter is a malformed hash, not a slow
|
|
169
|
+
// one. This check has to happen before scrypt is called: Node's own maxmem
|
|
170
|
+
// guard only covers the 128 * N * r memory block, so keyLength and p are
|
|
171
|
+
// otherwise unbounded and a crafted hash buys unbounded CPU per login.
|
|
172
|
+
const params = parseBoundedParams(N, r, p, keyLength);
|
|
173
|
+
if (!params) {
|
|
74
174
|
return false;
|
|
75
175
|
}
|
|
76
176
|
|
|
77
177
|
try {
|
|
78
178
|
const salt = Buffer.from(saltB64, "base64url");
|
|
79
179
|
const expected = Buffer.from(hashB64, "base64url");
|
|
80
|
-
|
|
180
|
+
if (salt.length < 8 || salt.length > MAX_SALT_BYTES || expected.length !== keyLength) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const actual = await scrypt(password, salt, keyLength, { N: params.N, r: params.r, p: params.p });
|
|
81
184
|
if (actual.length !== expected.length) {
|
|
82
185
|
return false;
|
|
83
186
|
}
|
|
@@ -102,6 +205,13 @@ export function needsRehash(storedHash: string, options: PasswordOptions = {}):
|
|
|
102
205
|
const p = Number.parseInt(parts[3], 10);
|
|
103
206
|
const keyLength = Number.parseInt(parts[4], 10);
|
|
104
207
|
|
|
208
|
+
// A hash carrying out-of-range parameters can never be verified, so it is
|
|
209
|
+
// reported as needing a rehash rather than silently comparing equal to a
|
|
210
|
+
// legitimate-looking configuration.
|
|
211
|
+
if (!parseBoundedParams(N, r, p, keyLength)) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
105
215
|
return (
|
|
106
216
|
N !== (options.cost ?? DEFAULT_COST) ||
|
|
107
217
|
r !== (options.blockSize ?? DEFAULT_BLOCK_SIZE) ||
|
package/src/providers.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, createPublicKey, randomBytes, verify as verifySignature } from "node:crypto";
|
|
2
2
|
import { ProviderError, ValidationError } from "./errors.js";
|
|
3
3
|
import type {
|
|
4
4
|
AuthorizationUrlOptions,
|
|
@@ -357,6 +357,133 @@ export const discord = createProvider({
|
|
|
357
357
|
});
|
|
358
358
|
|
|
359
359
|
const APPLE_ISSUER = "https://appleid.apple.com";
|
|
360
|
+
const APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys";
|
|
361
|
+
const DEFAULT_JWKS_TTL_MS = 60 * 60 * 1000;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Fetched signing keys, shared across providers and kept past a single request
|
|
365
|
+
* so a burst of logins does not turn into a burst of JWKS traffic. An entry
|
|
366
|
+
* whose kid is missing triggers a single refetch before failing, which is what
|
|
367
|
+
* makes a routine Apple key rotation invisible to callers.
|
|
368
|
+
*/
|
|
369
|
+
const jwksCache = new Map<string, { set: Record<string, unknown>; expiresAt: number }>();
|
|
370
|
+
|
|
371
|
+
function clearJwksCache(url: string): void {
|
|
372
|
+
jwksCache.delete(url);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function loadJwks(url: string, ttlMs: number, refresh: boolean): Promise<Record<string, unknown>> {
|
|
376
|
+
if (!refresh) {
|
|
377
|
+
const cached = jwksCache.get(url);
|
|
378
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
379
|
+
return cached.set;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const response = await fetch(url);
|
|
384
|
+
if (!response.ok) {
|
|
385
|
+
throw new ProviderError(`Could not fetch signing keys from ${url}: HTTP ${response.status}`);
|
|
386
|
+
}
|
|
387
|
+
const set = (await response.json()) as Record<string, unknown>;
|
|
388
|
+
jwksCache.set(url, { set, expiresAt: Date.now() + ttlMs });
|
|
389
|
+
return set;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function resolveJwks(config: OAuthProviderConfig): Promise<Record<string, unknown>> {
|
|
393
|
+
if (typeof config.jwks === "function") {
|
|
394
|
+
return (await config.jwks()) as Record<string, unknown>;
|
|
395
|
+
}
|
|
396
|
+
if (config.jwks && typeof config.jwks === "object") {
|
|
397
|
+
return config.jwks;
|
|
398
|
+
}
|
|
399
|
+
const url = typeof config.jwksUrl === "string" ? config.jwksUrl : APPLE_JWKS_URL;
|
|
400
|
+
const ttl = typeof config.jwksCacheTtlMs === "number" ? config.jwksCacheTtlMs : DEFAULT_JWKS_TTL_MS;
|
|
401
|
+
return loadJwks(url, ttl, false);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function findJwk(set: Record<string, unknown>, kid: string): Record<string, unknown> | undefined {
|
|
405
|
+
const keys = set.keys;
|
|
406
|
+
if (!Array.isArray(keys)) {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
return keys.find((entry): entry is Record<string, unknown> =>
|
|
410
|
+
typeof entry === "object" && entry !== null && (entry as { kid?: unknown }).kid === kid);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Verifies the RS256 signature over an id_token's header and payload against
|
|
415
|
+
* the provider's published keys, and returns the decoded payload.
|
|
416
|
+
*
|
|
417
|
+
* The claims inside a token are unauthenticated until this runs. Checking `iss`
|
|
418
|
+
* or `aud` on an unverified payload only proves the payload says what the
|
|
419
|
+
* attacker wants it to say, so the signature has to be checked first and the
|
|
420
|
+
* claim checks read the verified bytes.
|
|
421
|
+
*/
|
|
422
|
+
async function verifyIdToken(idToken: string, config: OAuthProviderConfig, providerName: string): Promise<Record<string, unknown>> {
|
|
423
|
+
const parts = idToken.split(".");
|
|
424
|
+
if (parts.length !== 3) {
|
|
425
|
+
throw new ProviderError(`${providerName} id_token is not a well-formed JWT`);
|
|
426
|
+
}
|
|
427
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts as [string, string, string];
|
|
428
|
+
|
|
429
|
+
let header: { alg?: unknown; kid?: unknown };
|
|
430
|
+
let payload: Record<string, unknown>;
|
|
431
|
+
try {
|
|
432
|
+
header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")) as { alg?: unknown; kid?: unknown };
|
|
433
|
+
payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")) as Record<string, unknown>;
|
|
434
|
+
} catch {
|
|
435
|
+
throw new ProviderError(`${providerName} id_token has undecodable segments`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (config.verifyIdTokenSignature === false) {
|
|
439
|
+
return payload;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Pinning the algorithm is what stops the classic JWT confusion attack: a
|
|
443
|
+
// token declaring "alg": "none", or an HMAC algorithm keyed with the RSA
|
|
444
|
+
// public key, would otherwise be accepted.
|
|
445
|
+
if (header.alg !== "RS256") {
|
|
446
|
+
throw new ProviderError(`${providerName} id_token must be signed with RS256, got ${String(header.alg)}`);
|
|
447
|
+
}
|
|
448
|
+
if (typeof header.kid !== "string" || header.kid.length === 0) {
|
|
449
|
+
throw new ProviderError(`${providerName} id_token is missing a kid`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const ttl = typeof config.jwksCacheTtlMs === "number" ? config.jwksCacheTtlMs : DEFAULT_JWKS_TTL_MS;
|
|
453
|
+
const url = typeof config.jwksUrl === "string" ? config.jwksUrl : APPLE_JWKS_URL;
|
|
454
|
+
const usesCache = typeof config.jwks !== "function" && typeof config.jwks !== "object";
|
|
455
|
+
|
|
456
|
+
let jwk = findJwk(await resolveJwks(config), header.kid);
|
|
457
|
+
if (!jwk && usesCache) {
|
|
458
|
+
// An unknown kid is either a rotation or an attack. Refetch once, since a
|
|
459
|
+
// rotation is the common case and the attacker has to survive the retry.
|
|
460
|
+
clearJwksCache(url);
|
|
461
|
+
jwk = findJwk(await loadJwks(url, ttl, true), header.kid);
|
|
462
|
+
}
|
|
463
|
+
if (!jwk) {
|
|
464
|
+
throw new ProviderError(`${providerName} id_token was signed by an unknown key (kid: ${header.kid})`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let publicKey;
|
|
468
|
+
try {
|
|
469
|
+
publicKey = createPublicKey({ key: jwk, format: "jwk" });
|
|
470
|
+
} catch {
|
|
471
|
+
throw new ProviderError(`${providerName} id_token signing key could not be read`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const signed = Buffer.from(`${encodedHeader}.${encodedPayload}`, "utf8");
|
|
475
|
+
let signature: Buffer;
|
|
476
|
+
try {
|
|
477
|
+
signature = Buffer.from(encodedSignature, "base64url");
|
|
478
|
+
} catch {
|
|
479
|
+
throw new ProviderError(`${providerName} id_token has an undecodable signature`);
|
|
480
|
+
}
|
|
481
|
+
if (signature.length === 0 || !verifySignature("RSA-SHA256", signed, publicKey, signature)) {
|
|
482
|
+
throw new ProviderError(`${providerName} id_token signature is invalid`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return payload;
|
|
486
|
+
}
|
|
360
487
|
|
|
361
488
|
export const apple = createProvider({
|
|
362
489
|
id: "apple",
|
|
@@ -365,16 +492,17 @@ export const apple = createProvider({
|
|
|
365
492
|
tokenUrl: "https://appleid.apple.com/auth/token",
|
|
366
493
|
scopes: ["name", "email"],
|
|
367
494
|
extraAuthParams: { response_mode: "form_post" },
|
|
368
|
-
parseProfile(tokens, config) {
|
|
495
|
+
async parseProfile(tokens, config) {
|
|
369
496
|
const idToken = tokens?.id_token;
|
|
370
497
|
if (!idToken) {
|
|
371
498
|
return { provider: "apple", raw: tokens };
|
|
372
499
|
}
|
|
373
500
|
|
|
374
501
|
// Apple is the only built-in provider whose profile comes from a token in
|
|
375
|
-
// the response rather than a server-side userinfo call, so the
|
|
376
|
-
//
|
|
377
|
-
|
|
502
|
+
// the response rather than a server-side userinfo call, so the token is
|
|
503
|
+
// verified here. See ARCHITECTURE.md for why the signature is checked even
|
|
504
|
+
// though the token usually arrives over a server-side TLS exchange.
|
|
505
|
+
const payload = await verifyIdToken(idToken, config, "Apple");
|
|
378
506
|
|
|
379
507
|
const iss = typeof payload.iss === "string" ? payload.iss : undefined;
|
|
380
508
|
if (iss !== APPLE_ISSUER) {
|
|
@@ -390,10 +518,19 @@ export const apple = createProvider({
|
|
|
390
518
|
}
|
|
391
519
|
}
|
|
392
520
|
|
|
393
|
-
|
|
521
|
+
// Required rather than checked when present. A token with no usable exp
|
|
522
|
+
// would otherwise be treated as never expiring, which turns a leaked token
|
|
523
|
+
// into permanent access.
|
|
524
|
+
const exp = payload.exp;
|
|
525
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) {
|
|
526
|
+
throw new ProviderError("Apple id_token is missing a numeric exp claim");
|
|
527
|
+
}
|
|
528
|
+
if (exp <= Math.floor(Date.now() / 1000)) {
|
|
394
529
|
throw new ProviderError("Apple id_token has expired");
|
|
395
530
|
}
|
|
396
531
|
|
|
532
|
+
// Apple only returns these on the first authorization for an account, so
|
|
533
|
+
// callers must not depend on them being present.
|
|
397
534
|
return {
|
|
398
535
|
provider: "apple",
|
|
399
536
|
id: payload.sub as string | undefined,
|
package/src/types.ts
CHANGED
|
@@ -47,6 +47,8 @@ export type JwtPayload = Record<string, unknown> & {
|
|
|
47
47
|
name?: string;
|
|
48
48
|
roles?: string[];
|
|
49
49
|
permissions?: string[];
|
|
50
|
+
/** Session version, written only when a SessionStore is configured. */
|
|
51
|
+
sv?: number;
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
export type Secret = string | Buffer;
|
|
@@ -128,6 +130,32 @@ export interface OAuthProviderConfig {
|
|
|
128
130
|
userInfoHeaders?: Record<string, string>;
|
|
129
131
|
tokenAuthMethod?: TokenAuthMethod;
|
|
130
132
|
provider?: OAuthProvider | (Pick<OAuthProvider, "id"> & Partial<OAuthProvider>);
|
|
133
|
+
/**
|
|
134
|
+
* Where to fetch id_token signing keys. Defaults to Apple's published JWKS
|
|
135
|
+
* endpoint. Only used by providers that carry an id_token in the token
|
|
136
|
+
* response.
|
|
137
|
+
*/
|
|
138
|
+
jwksUrl?: string;
|
|
139
|
+
/**
|
|
140
|
+
* Supply signing keys directly instead of fetching them. Useful for tests, for
|
|
141
|
+
* offline environments, and for applications that already maintain their own
|
|
142
|
+
* JWKS cache. Accepts a JWK set or a function returning one.
|
|
143
|
+
*/
|
|
144
|
+
jwks?: Record<string, unknown> | (() => Promise<Record<string, unknown>> | Record<string, unknown>);
|
|
145
|
+
/**
|
|
146
|
+
* How long fetched keys are reused before being refreshed, in milliseconds.
|
|
147
|
+
* Defaults to one hour. Apple rotates its keys infrequently, and an unknown
|
|
148
|
+
* `kid` always triggers one immediate refetch regardless of this.
|
|
149
|
+
*/
|
|
150
|
+
jwksCacheTtlMs?: number;
|
|
151
|
+
/**
|
|
152
|
+
* Set to false to skip id_token signature verification. Verification is on by
|
|
153
|
+
* default and should stay on: an id_token is a bearer assertion of identity,
|
|
154
|
+
* and accepting one without checking Apple's signature means anyone who can
|
|
155
|
+
* influence the token response can choose the user. Only turn this off in a
|
|
156
|
+
* test harness, never in production.
|
|
157
|
+
*/
|
|
158
|
+
verifyIdTokenSignature?: boolean;
|
|
131
159
|
[key: string]: unknown;
|
|
132
160
|
}
|
|
133
161
|
|
|
@@ -294,6 +322,25 @@ export interface LoginResult {
|
|
|
294
322
|
};
|
|
295
323
|
}
|
|
296
324
|
|
|
325
|
+
export interface SessionStore {
|
|
326
|
+
/**
|
|
327
|
+
* The user's current session version. Tokens are minted carrying the value
|
|
328
|
+
* that was current at login, and are rejected once it no longer matches, so
|
|
329
|
+
* bumping the number logs out every session issued before that moment.
|
|
330
|
+
* Returning `undefined` or `null` is treated as version 0.
|
|
331
|
+
*/
|
|
332
|
+
getVersion(userId: string | number): Promise<number | undefined | null> | number | undefined | null;
|
|
333
|
+
/**
|
|
334
|
+
* Invalidate every session currently issued to this user and return the new
|
|
335
|
+
* version. Called by `revokeAllSessions`.
|
|
336
|
+
*/
|
|
337
|
+
bumpVersion?(userId: string | number): Promise<number> | number;
|
|
338
|
+
/**
|
|
339
|
+
* Invalidate one session by its jti. Called by `revokeSession`.
|
|
340
|
+
*/
|
|
341
|
+
revokeSession?(jti: string): Promise<void> | void;
|
|
342
|
+
}
|
|
343
|
+
|
|
297
344
|
export interface AuthOptions {
|
|
298
345
|
secret: Secret;
|
|
299
346
|
refreshSecret?: Secret;
|
|
@@ -312,6 +359,13 @@ export interface AuthOptions {
|
|
|
312
359
|
trustUserPermissions?: boolean;
|
|
313
360
|
userStore?: UserStore;
|
|
314
361
|
refreshStore?: RefreshStore;
|
|
362
|
+
/**
|
|
363
|
+
* Enables session revocation. Configuring one adds an `sv` claim to minted
|
|
364
|
+
* tokens and costs one store read per verification, which is why it is
|
|
365
|
+
* opt-in: without it, revoking a refresh token only invalidates that one
|
|
366
|
+
* session, and an already-issued access token stays valid until it expires.
|
|
367
|
+
*/
|
|
368
|
+
sessionStore?: SessionStore;
|
|
315
369
|
rbac?: import("./rbac.js").RBAC | RBACOptions;
|
|
316
370
|
roles?: RBACOptions | Record<string, RoleDefinition> | Array<string | RoleDefinition>;
|
|
317
371
|
oauth?: import("./oauth.js").OAuth | OAuthOptions;
|
|
@@ -336,6 +390,14 @@ export interface RequestLike {
|
|
|
336
390
|
|
|
337
391
|
export interface ExtractTokenOptions {
|
|
338
392
|
cookie?: string;
|
|
393
|
+
/**
|
|
394
|
+
* Allow reading the token from `?access_token=` or `?token=`. Off by default:
|
|
395
|
+
* a token in a URL is written to access logs, proxy logs, browser history, and
|
|
396
|
+
* the Referer header sent to third parties, none of which a header or cookie
|
|
397
|
+
* token leaks into. Only enable it for flows that genuinely cannot set a
|
|
398
|
+
* header, such as an EventSource or a file download.
|
|
399
|
+
*/
|
|
400
|
+
query?: boolean;
|
|
339
401
|
optional?: boolean;
|
|
340
402
|
passthrough?: boolean;
|
|
341
403
|
audience?: string | string[];
|