@better-auth/core 1.6.16 → 1.6.17

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/api/index.ts CHANGED
@@ -132,6 +132,51 @@ export function createAuthEndpoint<
132
132
  );
133
133
  }
134
134
 
135
+ /**
136
+ * Set `metadata.SERVER_ONLY` while preserving any existing metadata
137
+ * (`$Infer`, `openapi`, ...).
138
+ */
139
+ function withServerOnly<Options extends EndpointOptions>(
140
+ options: Options,
141
+ ): Options {
142
+ return {
143
+ ...options,
144
+ metadata: { ...options.metadata, SERVER_ONLY: true },
145
+ } as Options;
146
+ }
147
+
148
+ /**
149
+ * Declare a **server-only** endpoint.
150
+ *
151
+ * The endpoint is callable through `auth.api.*` from trusted server code but is
152
+ * never registered on the HTTP router and never emitted into the OpenAPI
153
+ * schema. It takes no path because it has no URL to be reached at.
154
+ *
155
+ * Prefer this over the path-less `createAuthEndpoint({ ... }, handler)` form.
156
+ * Setting `metadata.SERVER_ONLY` makes the intent explicit at the call site and
157
+ * keeps the endpoint off the HTTP surface even if a path is later added by
158
+ * mistake: better-call's router skips an endpoint when its path is missing *or*
159
+ * when `SERVER_ONLY` is set, so the two together are defense in depth. Relying
160
+ * on path omission alone is invisible and one keystroke away from exposure.
161
+ *
162
+ * @example
163
+ * ```ts
164
+ * viewBackupCodes: createAuthEndpoint.serverOnly(
165
+ * { method: "POST", body: schema },
166
+ * async (ctx) => { ... },
167
+ * )
168
+ * ```
169
+ */
170
+ createAuthEndpoint.serverOnly = <
171
+ Path extends string,
172
+ Options extends EndpointOptions,
173
+ R,
174
+ >(
175
+ options: Options,
176
+ handler: EndpointHandler<Path, Options, R>,
177
+ ): StrictEndpoint<Path, Options, R> =>
178
+ createAuthEndpoint(withServerOnly(options), handler);
179
+
135
180
  export type AuthEndpoint<
136
181
  Path extends string,
137
182
  Opts extends EndpointOptions,
@@ -138,6 +138,11 @@ export const createAdapterFactory =
138
138
  !config.debugLogs.consumeOne
139
139
  ) {
140
140
  return;
141
+ } else if (
142
+ method === "incrementOne" &&
143
+ !config.debugLogs.incrementOne
144
+ ) {
145
+ return;
141
146
  } else if (method === "count" && !config.debugLogs.count) {
142
147
  return;
143
148
  }
@@ -491,6 +496,7 @@ export const createAdapterFactory =
491
496
  | "delete"
492
497
  | "deleteMany"
493
498
  | "consumeOne"
499
+ | "incrementOne"
494
500
  | "count";
495
501
  }): W extends undefined ? undefined : CleanedWhere[] => {
496
502
  if (!where) return undefined as any;
@@ -1430,6 +1436,152 @@ export const createAdapterFactory =
1430
1436
  );
1431
1437
  return transformed as T | null;
1432
1438
  },
1439
+ incrementOne: async <T>({
1440
+ model: unsafeModel,
1441
+ where: unsafeWhere,
1442
+ increment: unsafeIncrement,
1443
+ set: unsafeSet,
1444
+ }: {
1445
+ model: string;
1446
+ where: Where[];
1447
+ increment: Record<string, number>;
1448
+ set?: Record<string, unknown> | undefined;
1449
+ }): Promise<T | null> => {
1450
+ transactionId++;
1451
+ const thisTransactionId = transactionId;
1452
+ const model = getModelName(unsafeModel);
1453
+ const where = transformWhereClause({
1454
+ model: unsafeModel,
1455
+ where: unsafeWhere,
1456
+ action: "incrementOne",
1457
+ });
1458
+ unsafeModel = getDefaultModelName(unsafeModel);
1459
+ debugLog(
1460
+ { method: "incrementOne" },
1461
+ `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`,
1462
+ `${formatMethod("incrementOne")} ${formatAction("IncrementOne")}:`,
1463
+ { model, where, increment: unsafeIncrement, set: unsafeSet },
1464
+ );
1465
+
1466
+ let res: T | null;
1467
+ let resultNeedsOutputTransform = true;
1468
+ if (adapterInstance.incrementOne) {
1469
+ // Map each increment key to its DB column name, honoring a custom
1470
+ // `mapKeysTransformInput` override the same way `transformInput`
1471
+ // does, and keep the numeric delta unchanged: deltas are arithmetic
1472
+ // operands, not stored values, so they must never be value-transformed.
1473
+ const mappedKeys = config.mapKeysTransformInput ?? {};
1474
+ const increment: Record<string, number> = {};
1475
+ for (const [field, delta] of Object.entries(unsafeIncrement)) {
1476
+ increment[
1477
+ mappedKeys[field] || getFieldName({ model: unsafeModel, field })
1478
+ ] = delta;
1479
+ }
1480
+ let set: Record<string, unknown> | undefined;
1481
+ if (unsafeSet && !config.disableTransformInput) {
1482
+ set = await transformInput(unsafeSet, unsafeModel, "update");
1483
+ } else {
1484
+ set = unsafeSet;
1485
+ }
1486
+ res = await withSpan(
1487
+ `db incrementOne ${model}`,
1488
+ {
1489
+ [ATTR_DB_OPERATION_NAME]: "incrementOne",
1490
+ [ATTR_DB_COLLECTION_NAME]: model,
1491
+ },
1492
+ () =>
1493
+ adapterInstance.incrementOne!<T>({
1494
+ model,
1495
+ where,
1496
+ increment,
1497
+ set,
1498
+ }),
1499
+ );
1500
+ } else {
1501
+ // FIXME(increment-one-required): remove this fallback when
1502
+ // incrementOne becomes required on `next`. Adapters without a native
1503
+ // incrementOne fall back to `transaction(findMany + updateMany)`.
1504
+ res = await withSpan(
1505
+ `db incrementOne ${model}`,
1506
+ {
1507
+ [ATTR_DB_OPERATION_NAME]: "incrementOne",
1508
+ [ATTR_DB_COLLECTION_NAME]: model,
1509
+ },
1510
+ () =>
1511
+ adapter.transaction(async (trx) => {
1512
+ const rows = await trx.findMany<Record<string, any>>({
1513
+ model: unsafeModel,
1514
+ where: unsafeWhere,
1515
+ limit: 1,
1516
+ });
1517
+ const target = rows[0];
1518
+ if (!target) return null;
1519
+ const nextValues: Record<string, unknown> = {
1520
+ ...(unsafeSet ?? {}),
1521
+ };
1522
+ for (const [field, delta] of Object.entries(unsafeIncrement)) {
1523
+ const current =
1524
+ typeof target[field] === "number" ? target[field] : 0;
1525
+ nextValues[field] = current + delta;
1526
+ }
1527
+ // Re-applying `unsafeWhere` in the update's where is the
1528
+ // compare-and-swap guard: under an adapter whose transaction
1529
+ // lacks real isolation, it still rejects a racer that
1530
+ // invalidated the guard between the read and the write (e.g.
1531
+ // remaining dropped to 0).
1532
+ const updated = await trx.updateMany({
1533
+ model: unsafeModel,
1534
+ where: [
1535
+ ...unsafeWhere,
1536
+ {
1537
+ field: "id",
1538
+ value: target.id,
1539
+ operator: "eq",
1540
+ connector: "AND",
1541
+ mode: "sensitive",
1542
+ },
1543
+ ],
1544
+ update: nextValues,
1545
+ });
1546
+ // A non-numeric count coerces to a false miss, so fail loud.
1547
+ if (typeof updated !== "number") {
1548
+ throw new BetterAuthError(
1549
+ `Adapter "${config.adapterId}" returned a non-numeric value from updateMany during the incrementOne fallback. Return the number of updated rows, or implement a native incrementOne for atomic guarded counter updates.`,
1550
+ );
1551
+ }
1552
+ return updated > 0 ? ({ ...target, ...nextValues } as T) : null;
1553
+ }),
1554
+ );
1555
+ resultNeedsOutputTransform = false;
1556
+ }
1557
+
1558
+ debugLog(
1559
+ { method: "incrementOne" },
1560
+ `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`,
1561
+ `${formatMethod("incrementOne")} ${formatAction("DB Result")}:`,
1562
+ { model, data: res },
1563
+ );
1564
+ let transformed: any = res;
1565
+ if (
1566
+ !config.disableTransformOutput &&
1567
+ resultNeedsOutputTransform &&
1568
+ res
1569
+ ) {
1570
+ transformed = await transformOutput(
1571
+ res as Record<string, any>,
1572
+ unsafeModel,
1573
+ undefined,
1574
+ undefined,
1575
+ );
1576
+ }
1577
+ debugLog(
1578
+ { method: "incrementOne" },
1579
+ `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`,
1580
+ `${formatMethod("incrementOne")} ${formatAction("Parsed Result")}:`,
1581
+ { model, data: transformed },
1582
+ );
1583
+ return transformed as T | null;
1584
+ },
1433
1585
  count: async ({
1434
1586
  model: unsafeModel,
1435
1587
  where: unsafeWhere,
@@ -16,6 +16,7 @@ export type DBAdapterDebugLogOption =
16
16
  delete?: boolean | undefined;
17
17
  deleteMany?: boolean | undefined;
18
18
  consumeOne?: boolean | undefined;
19
+ incrementOne?: boolean | undefined;
19
20
  count?: boolean | undefined;
20
21
  }
21
22
  | {
@@ -213,6 +214,7 @@ export interface DBAdapterFactoryConfig<
213
214
  | "delete"
214
215
  | "deleteMany"
215
216
  | "consumeOne"
217
+ | "incrementOne"
216
218
  | "count";
217
219
  /**
218
220
  * The model name.
@@ -464,6 +466,36 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
464
466
  * and returns the row only when the delete reports an affected row.
465
467
  */
466
468
  consumeOne: <T>(data: { model: string; where: Where[] }) => Promise<T | null>;
469
+ /**
470
+ * Atomically apply signed numeric deltas to a single row matching the where
471
+ * clause. For each entry in `increment`, the operation applies
472
+ * `field = field + delta` in one atomic step; a negative delta decrements.
473
+ *
474
+ * The `where` clause is both the selector AND the guard: comparison
475
+ * operators are honored, so passing `{ field: "remaining", operator: "gt",
476
+ * value: 0 }` only mutates the row while `remaining` is still above zero.
477
+ * When the guard matches no row, the operation makes no change and returns
478
+ * `null`.
479
+ *
480
+ * The optional `set` map assigns absolute values to fields in the same
481
+ * atomic operation, alongside the increments.
482
+ *
483
+ * Returns the updated row, or `null` when the guard matched no row. Under
484
+ * concurrent invocation against the same row, this is the race-safe
485
+ * primitive for guarded counter updates (e.g. decrementing a remaining-uses
486
+ * counter only while it is still positive).
487
+ *
488
+ * Always defined on the factory-wrapped adapter. When the underlying
489
+ * `CustomAdapter` does not implement `incrementOne`, the factory provides a
490
+ * fallback that wraps `findMany + updateMany` in `transaction(...)` and
491
+ * re-applies the where clause as a compare-and-swap guard on the update.
492
+ */
493
+ incrementOne: <T>(data: {
494
+ model: string;
495
+ where: Where[];
496
+ increment: Record<string, number>;
497
+ set?: Record<string, unknown> | undefined;
498
+ }) => Promise<T | null>;
467
499
  /**
468
500
  * Execute multiple operations in a transaction.
469
501
  * If the adapter doesn't support transactions, operations will be executed sequentially.
@@ -563,6 +595,25 @@ export interface CustomAdapter {
563
595
  model: string;
564
596
  where: CleanedWhere[];
565
597
  }) => Promise<T | null>;
598
+ /**
599
+ * Optional native atomic guarded counter mutation. Applies
600
+ * `field = field + delta` for each entry in `increment` (negative deltas
601
+ * decrement), with `where` acting as both selector and guard and `set`
602
+ * assigning absolute values in the same operation. Returns the updated row,
603
+ * or `null` when the guard matched no row.
604
+ *
605
+ * Implementing this natively (e.g. `UPDATE ... SET n = n + $delta WHERE ...
606
+ * RETURNING *`) gives one round trip and the strongest race-safety
607
+ * guarantee. When omitted, the adapter factory provides a transaction-based
608
+ * fallback over `findMany + updateMany`. TODO(increment-one-required):
609
+ * tighten to required in the next minor on `next`.
610
+ */
611
+ incrementOne?: <T>(data: {
612
+ model: string;
613
+ where: CleanedWhere[];
614
+ increment: Record<string, number>;
615
+ set?: Record<string, unknown> | undefined;
616
+ }) => Promise<T | null>;
566
617
  count: ({
567
618
  model,
568
619
  where,
@@ -123,6 +123,7 @@ export type AdapterFactoryCustomizeAdapterCreator = (config: {
123
123
  | "delete"
124
124
  | "deleteMany"
125
125
  | "consumeOne"
126
+ | "incrementOne"
126
127
  | "count";
127
128
  }) => W extends undefined ? undefined : CleanedWhere[];
128
129
  }) => CustomAdapter;
package/src/db/type.ts CHANGED
@@ -323,6 +323,21 @@ export interface SecondaryStorage {
323
323
  * security-sensitive consume paths.
324
324
  */
325
325
  getAndDelete?: (key: string) => Awaitable<unknown>;
326
+ /**
327
+ * Atomically increment the counter at `key` by one, returning the
328
+ * post-increment value.
329
+ *
330
+ * When the key is absent, it is created with a value of `1` and the given
331
+ * `ttl` (in SECONDS). The TTL is applied only on creation; later increments
332
+ * never extend it, so the counter expires a fixed window after it was first
333
+ * created.
334
+ *
335
+ * This is optional for backwards compatibility with existing secondary
336
+ * storage implementations. TODO(secondary-storage-increment-required): make
337
+ * this required for secondary-storage-backed rate limiting in the next minor
338
+ * on `next`.
339
+ */
340
+ increment?: (key: string, ttl: number) => Awaitable<number>;
326
341
  set: (
327
342
  /**
328
343
  * Key to store
@@ -4,6 +4,7 @@ import type {
4
4
  JSONWebKeySet,
5
5
  JWTPayload,
6
6
  JWTVerifyOptions,
7
+ JWTVerifyResult,
7
8
  ProtectedHeaderParameters,
8
9
  } from "jose";
9
10
  import {
@@ -28,12 +29,37 @@ function isJoseInfrastructureError(error: joseErrors.JOSEError) {
28
29
  interface JwksCacheEntry {
29
30
  jwks: JSONWebKeySet;
30
31
  fetchedAt: number;
32
+ noKidRefetchedAt?: number | undefined;
31
33
  }
32
34
 
33
- const jwksCache = new Map<
34
- string | (() => Promise<JSONWebKeySet | undefined>),
35
- JwksCacheEntry
36
- >();
35
+ type JwksFetchOptions = {
36
+ /** Jwks url or promise of a Jwks */
37
+ jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
38
+ /**
39
+ * Stable object to cache the result of a function `jwksFetch` under,
40
+ * with the same TTL and kid-miss refetch rules as string sources.
41
+ * Without it, a function source is fetched on every verification.
42
+ */
43
+ jwksCacheKey?: object;
44
+ };
45
+
46
+ type ResolvedJwks = {
47
+ jwks: JSONWebKeySet;
48
+ fromCache: boolean;
49
+ kid: string | undefined;
50
+ noKidRefetchedAt?: number | undefined;
51
+ };
52
+
53
+ /**
54
+ * @internal
55
+ */
56
+ export const jwksCache = new Map<string, JwksCacheEntry>();
57
+
58
+ /**
59
+ * Cache for function jwks sources, keyed by a caller-provided stable object.
60
+ * Entries are released with their key, so per-request keys cannot accumulate.
61
+ */
62
+ const functionJwksCache = new WeakMap<object, JwksCacheEntry>();
37
63
 
38
64
  /**
39
65
  * How long a cached JWKS is trusted before it is refetched
@@ -41,6 +67,61 @@ const jwksCache = new Map<
41
67
  * @internal
42
68
  */
43
69
  const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
70
+ const JWKS_NO_KID_REFETCH_COOLDOWN_MS = 30 * 1000;
71
+
72
+ /**
73
+ * Returns the cached key set when it is within the TTL. When the token carries
74
+ * `kid`, the cached set must contain that key id; without `kid`, key selection
75
+ * is deferred to JOSE because RFC 7515 makes the header parameter optional.
76
+ */
77
+ function getFreshJwksWithKid(
78
+ cached: JwksCacheEntry | undefined,
79
+ kid: string | undefined,
80
+ ): JSONWebKeySet | undefined {
81
+ if (!cached) return undefined;
82
+ if (Date.now() - cached.fetchedAt >= JWKS_CACHE_TTL_MS) return undefined;
83
+ if (kid && !cached.jwks.keys.some((jwk) => jwk.kid === kid)) {
84
+ return undefined;
85
+ }
86
+ return cached.jwks;
87
+ }
88
+
89
+ function shouldRefetchCachedJwksWithoutKid(
90
+ error: unknown,
91
+ resolved: ResolvedJwks,
92
+ ) {
93
+ const isRetryableNoKidFailure =
94
+ resolved.fromCache &&
95
+ !resolved.kid &&
96
+ (error instanceof joseErrors.JWKSNoMatchingKey ||
97
+ error instanceof joseErrors.JWSSignatureVerificationFailed);
98
+ if (!isRetryableNoKidFailure) return false;
99
+ if (!resolved.noKidRefetchedAt) return true;
100
+ return (
101
+ Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS
102
+ );
103
+ }
104
+
105
+ async function fetchJwks(
106
+ jwksFetch: JwksFetchOptions["jwksFetch"],
107
+ ): Promise<JSONWebKeySet> {
108
+ const jwks =
109
+ typeof jwksFetch === "string"
110
+ ? await betterFetch<JSONWebKeySet>(jwksFetch, {
111
+ headers: {
112
+ Accept: "application/json",
113
+ },
114
+ }).then(async (res) => {
115
+ if (res.error)
116
+ throw new Error(
117
+ `Jwks failed: ${res.error.message ?? res.error.statusText}`,
118
+ );
119
+ return res.data;
120
+ })
121
+ : await jwksFetch();
122
+ if (!jwks) throw new Error("No jwks found");
123
+ return jwks;
124
+ }
44
125
 
45
126
  export interface VerifyAccessTokenRemote {
46
127
  /** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
@@ -78,21 +159,36 @@ export interface VerifyAccessTokenRemote {
78
159
  */
79
160
  export async function verifyJwsAccessToken(
80
161
  token: string,
81
- opts: {
82
- /** Jwks url or promise of a Jwks */
83
- jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
162
+ opts: JwksFetchOptions & {
84
163
  /** Verify options */
85
164
  verifyOptions: JWTVerifyOptions &
86
165
  Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
87
166
  },
88
167
  ) {
89
168
  try {
90
- const jwks = await getJwks(token, opts);
91
- const jwt = await jwtVerify<JWTPayload>(
92
- token,
93
- createLocalJWKSet(jwks),
94
- opts.verifyOptions,
95
- );
169
+ const resolved = await getJwksForVerification(token, opts);
170
+ let jwt: JWTVerifyResult<JWTPayload>;
171
+ try {
172
+ jwt = await jwtVerify<JWTPayload>(
173
+ token,
174
+ createLocalJWKSet(resolved.jwks),
175
+ opts.verifyOptions,
176
+ );
177
+ } catch (error) {
178
+ if (shouldRefetchCachedJwksWithoutKid(error, resolved)) {
179
+ const refreshed = await getJwksForVerification(token, {
180
+ ...opts,
181
+ forceRefresh: true,
182
+ });
183
+ jwt = await jwtVerify<JWTPayload>(
184
+ token,
185
+ createLocalJWKSet(refreshed.jwks),
186
+ opts.verifyOptions,
187
+ );
188
+ } else {
189
+ throw error;
190
+ }
191
+ }
96
192
  // Return the JWT payload in introspection format
97
193
  // https://datatracker.ietf.org/doc/html/rfc7662#section-2.2
98
194
  if (jwt.payload.azp) {
@@ -105,12 +201,13 @@ export async function verifyJwsAccessToken(
105
201
  }
106
202
  }
107
203
 
108
- export async function getJwks(
204
+ export async function getJwks(token: string, opts: JwksFetchOptions) {
205
+ return (await getJwksForVerification(token, opts)).jwks;
206
+ }
207
+
208
+ async function getJwksForVerification(
109
209
  token: string,
110
- opts: {
111
- /** Jwks url or promise of a Jwks */
112
- jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
113
- },
210
+ opts: JwksFetchOptions & { forceRefresh?: boolean },
114
211
  ) {
115
212
  // Attempt to decode the token and find a matching kid in jwks
116
213
  let jwtHeaders: ProtectedHeaderParameters | undefined;
@@ -121,43 +218,65 @@ export async function getJwks(
121
218
  throw new Error(error as unknown as string);
122
219
  }
123
220
 
124
- if (!jwtHeaders.kid) {
125
- throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
126
- }
127
221
  const kid = jwtHeaders.kid;
128
222
 
223
+ // Function sources have no usable identity of their own (callers pass
224
+ // fresh closures per request), so they are cached only under a stable
225
+ // caller-provided key object.
226
+ if (typeof opts.jwksFetch !== "string") {
227
+ const cacheKey = opts.jwksCacheKey;
228
+ if (!cacheKey) {
229
+ const jwks = await opts.jwksFetch();
230
+ if (!jwks) throw new Error("No jwks found");
231
+ return { jwks, fromCache: false, kid };
232
+ }
233
+ const cached = functionJwksCache.get(cacheKey);
234
+ const cachedJwks = opts.forceRefresh
235
+ ? undefined
236
+ : getFreshJwksWithKid(cached, kid);
237
+ if (cachedJwks) {
238
+ return {
239
+ jwks: cachedJwks,
240
+ fromCache: true,
241
+ kid,
242
+ noKidRefetchedAt: cached?.noKidRefetchedAt,
243
+ };
244
+ }
245
+ const jwks = await opts.jwksFetch();
246
+ if (!jwks) throw new Error("No jwks found");
247
+ const fetchedAt = Date.now();
248
+ functionJwksCache.set(cacheKey, {
249
+ jwks,
250
+ fetchedAt,
251
+ ...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
252
+ });
253
+ return { jwks, fromCache: false, kid };
254
+ }
255
+
256
+ // The cache is scoped to `cacheKey`, so a token is only ever matched
257
+ // against the key set published by its own source.
129
258
  const cacheKey = opts.jwksFetch;
130
259
  const cached = jwksCache.get(cacheKey);
131
- const isFresh = cached
132
- ? Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS
133
- : false;
134
- const hasKid = cached?.jwks.keys.some((jwk) => jwk.kid === kid) ?? false;
135
-
136
- // Refetch when this source has no cached set, the cached set has expired, or
137
- // it does not contain the token's kid (e.g. a newly rotated-in key). The
138
- // cache is scoped to `cacheKey`, so a token is only ever matched against the
139
- // key set published by its own source.
140
- if (!cached || !isFresh || !hasKid) {
141
- const jwks =
142
- typeof opts.jwksFetch === "string"
143
- ? await betterFetch<JSONWebKeySet>(opts.jwksFetch, {
144
- headers: {
145
- Accept: "application/json",
146
- },
147
- }).then(async (res) => {
148
- if (res.error)
149
- throw new Error(
150
- `Jwks failed: ${res.error.message ?? res.error.statusText}`,
151
- );
152
- return res.data;
153
- })
154
- : await opts.jwksFetch();
155
- if (!jwks) throw new Error("No jwks found");
156
- jwksCache.set(cacheKey, { jwks, fetchedAt: Date.now() });
157
- return jwks;
260
+ const cachedJwks = opts.forceRefresh
261
+ ? undefined
262
+ : getFreshJwksWithKid(cached, kid);
263
+ if (!cachedJwks) {
264
+ const jwks = await fetchJwks(opts.jwksFetch);
265
+ const fetchedAt = Date.now();
266
+ jwksCache.set(cacheKey, {
267
+ jwks,
268
+ fetchedAt,
269
+ ...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
270
+ });
271
+ return { jwks, fromCache: false, kid };
158
272
  }
159
273
 
160
- return cached.jwks;
274
+ return {
275
+ jwks: cachedJwks,
276
+ fromCache: true,
277
+ kid,
278
+ noKidRefetchedAt: cached?.noKidRefetchedAt,
279
+ };
161
280
  }
162
281
 
163
282
  /**
@@ -11,6 +11,14 @@ import {
11
11
  validateAuthorizationCode,
12
12
  } from "../oauth2";
13
13
 
14
+ /**
15
+ * Microsoft's fixed tenant id for personal (consumer) Microsoft accounts. Every
16
+ * personal-account token carries it as the `tid` claim, so it distinguishes the
17
+ * consumer account class from work/school tenants.
18
+ * @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
19
+ */
20
+ const MICROSOFT_CONSUMER_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad";
21
+
14
22
  /**
15
23
  * @see [Microsoft Identity Platform - Optional claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference)
16
24
  */
@@ -143,7 +151,14 @@ export interface MicrosoftOptions
143
151
 
144
152
  export const microsoft = (options: MicrosoftOptions) => {
145
153
  const tenant = options.tenantId || "common";
146
- const authority = options.authority || "https://login.microsoftonline.com";
154
+ // Trim any trailing slash so endpoint URLs and the issuer comparison below
155
+ // never produce a double slash (e.g. a configured `https://host/` would make
156
+ // the expected issuer `https://host//<tid>/v2.0` and reject every token). A
157
+ // loop avoids a trailing-slash regex, which is a polynomial-ReDoS shape.
158
+ let authority = options.authority || "https://login.microsoftonline.com";
159
+ while (authority.endsWith("/")) {
160
+ authority = authority.slice(0, -1);
161
+ }
147
162
  const authorizationEndpoint = `${authority}/${tenant}/oauth2/v2.0/authorize`;
148
163
  const tokenEndpoint = `${authority}/${tenant}/oauth2/v2.0/token`;
149
164
  return {
@@ -229,6 +244,30 @@ export const microsoft = (options: MicrosoftOptions) => {
229
244
  return false;
230
245
  }
231
246
 
247
+ // The multi-tenant endpoints (common/organizations/consumers) skip
248
+ // jose's issuer check above because the issuer varies per tenant, and
249
+ // the organizations and consumers JWKS sets overlap. Enforce the tenant
250
+ // binding explicitly so a token from a disallowed account class cannot
251
+ // pass: the issuer must name the token's own tenant, and the account
252
+ // class must match the configured restriction.
253
+ // @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
254
+ const tid = jwtClaims.tid;
255
+ if (
256
+ typeof tid !== "string" ||
257
+ jwtClaims.iss !== `${authority}/${tid}/v2.0`
258
+ ) {
259
+ return false;
260
+ }
261
+ if (
262
+ tenant === "organizations" &&
263
+ tid === MICROSOFT_CONSUMER_TENANT_ID
264
+ ) {
265
+ return false;
266
+ }
267
+ if (tenant === "consumers" && tid !== MICROSOFT_CONSUMER_TENANT_ID) {
268
+ return false;
269
+ }
270
+
232
271
  return true;
233
272
  } catch (error) {
234
273
  logger.error("Failed to verify ID token:", error);
@@ -104,7 +104,11 @@ export const reddit = (options: RedditOptions) => {
104
104
  }
105
105
 
106
106
  const userMap = await options.mapProfileToUser?.(profile);
107
- const email = userMap?.email || `${profile.id}@reddit.com`;
107
+ // Reddit's identity scope does not return an email. Synthesize a stable,
108
+ // non-routable placeholder (RFC 2606 `.invalid`) keyed to the user's
109
+ // Reddit id rather than the routable `reddit.com`, which could collide
110
+ // with a real address. Left unverified; `mapProfileToUser` can override.
111
+ const email = userMap?.email || `${profile.id}@reddit.invalid`;
108
112
  return {
109
113
  user: {
110
114
  id: profile.id,