@better-auth/core 1.6.16 → 1.6.18
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/dist/api/index.d.mts +3 -0
- package/dist/api/index.mjs +36 -0
- package/dist/context/global.mjs +1 -1
- package/dist/db/adapter/factory.mjs +82 -0
- package/dist/db/adapter/index.d.mts +51 -1
- package/dist/db/adapter/types.d.mts +1 -1
- package/dist/db/type.d.mts +15 -0
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/verify.d.mts +15 -6
- package/dist/oauth2/verify.mjs +97 -13
- package/dist/social-providers/microsoft-entra-id.d.mts +1 -1
- package/dist/social-providers/microsoft-entra-id.mjs +13 -1
- package/dist/social-providers/reddit.mjs +1 -1
- package/dist/social-providers/wechat.mjs +1 -1
- package/dist/types/context.d.mts +16 -0
- package/dist/types/init-options.d.mts +29 -0
- package/dist/types/plugin-client.d.mts +12 -2
- package/dist/utils/host.mjs +4 -0
- package/dist/utils/url.mjs +4 -3
- package/package.json +3 -3
- package/src/api/index.ts +45 -0
- package/src/db/adapter/factory.ts +152 -0
- package/src/db/adapter/index.ts +51 -0
- package/src/db/adapter/types.ts +1 -0
- package/src/db/type.ts +15 -0
- package/src/oauth2/verify.ts +168 -49
- package/src/social-providers/microsoft-entra-id.ts +40 -1
- package/src/social-providers/reddit.ts +5 -1
- package/src/social-providers/wechat.ts +8 -1
- package/src/types/context.ts +17 -0
- package/src/types/init-options.ts +26 -0
- package/src/types/plugin-client.ts +16 -2
- package/src/utils/host.ts +15 -0
- package/src/utils/url.ts +10 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/core",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.18",
|
|
4
4
|
"description": "The most comprehensive authentication framework for TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
},
|
|
154
154
|
"devDependencies": {
|
|
155
155
|
"@better-auth/utils": "0.4.1",
|
|
156
|
-
"@better-fetch/fetch": "1.
|
|
156
|
+
"@better-fetch/fetch": "1.3.0",
|
|
157
157
|
"@opentelemetry/api": "^1.9.0",
|
|
158
158
|
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
|
159
159
|
"@opentelemetry/sdk-trace-node": "^1.30.0",
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
},
|
|
167
167
|
"peerDependencies": {
|
|
168
168
|
"@better-auth/utils": "0.4.1",
|
|
169
|
-
"@better-fetch/fetch": "1.
|
|
169
|
+
"@better-fetch/fetch": "1.3.0",
|
|
170
170
|
"@opentelemetry/api": "^1.9.0",
|
|
171
171
|
"better-call": "1.3.6",
|
|
172
172
|
"@cloudflare/workers-types": ">=4",
|
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,
|
package/src/db/adapter/index.ts
CHANGED
|
@@ -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,
|
package/src/db/adapter/types.ts
CHANGED
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
|
package/src/oauth2/verify.ts
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
132
|
-
?
|
|
133
|
-
:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
-
|
|
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);
|