@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/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
- const query = collectQuery(req);
338
- return query.access_token ?? query.token ?? null;
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 keyLength = options.keyLength ?? DEFAULT_KEY_LENGTH;
40
- const N = options.cost ?? DEFAULT_COST;
41
- const r = options.blockSize ?? DEFAULT_BLOCK_SIZE;
42
- const p = options.parallelism ?? DEFAULT_PARALLELISM;
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
- const key = await scrypt(password, salt, keyLength, { N, r, p });
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
- if (![N, r, p, keyLength].every(Number.isFinite)) {
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
- const actual = await scrypt(password, salt, keyLength, { N, r, p });
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 claims are
376
- // checked here instead. See ARCHITECTURE.md for the signature caveat.
377
- const payload = JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString("utf8")) as Record<string, unknown>;
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
- if (typeof payload.exp === "number" && payload.exp <= Math.floor(Date.now() / 1000)) {
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[];