@better-auth/core 1.7.0-beta.2 → 1.7.0-beta.4

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.
Files changed (172) hide show
  1. package/dist/context/global.mjs +1 -1
  2. package/dist/db/adapter/factory.mjs +64 -3
  3. package/dist/db/adapter/index.d.mts +35 -1
  4. package/dist/db/adapter/types.d.mts +1 -1
  5. package/dist/db/type.d.mts +12 -0
  6. package/dist/error/codes.d.mts +1 -0
  7. package/dist/error/codes.mjs +1 -0
  8. package/dist/instrumentation/tracer.mjs +1 -1
  9. package/dist/oauth2/authorization-params.d.mts +12 -0
  10. package/dist/oauth2/authorization-params.mjs +12 -0
  11. package/dist/oauth2/basic-credentials.d.mts +30 -0
  12. package/dist/oauth2/basic-credentials.mjs +64 -0
  13. package/dist/oauth2/client-assertion.d.mts +38 -22
  14. package/dist/oauth2/client-assertion.mjs +63 -28
  15. package/dist/oauth2/client-credentials-token.d.mts +19 -40
  16. package/dist/oauth2/client-credentials-token.mjs +18 -29
  17. package/dist/oauth2/create-authorization-url.d.mts +9 -1
  18. package/dist/oauth2/create-authorization-url.mjs +23 -5
  19. package/dist/oauth2/index.d.mts +10 -7
  20. package/dist/oauth2/index.mjs +9 -7
  21. package/dist/oauth2/oauth-provider.d.mts +21 -2
  22. package/dist/oauth2/refresh-access-token.d.mts +20 -40
  23. package/dist/oauth2/refresh-access-token.mjs +19 -32
  24. package/dist/oauth2/token-endpoint-auth.d.mts +17 -0
  25. package/dist/oauth2/token-endpoint-auth.mjs +89 -0
  26. package/dist/oauth2/utils.d.mts +9 -1
  27. package/dist/oauth2/utils.mjs +12 -1
  28. package/dist/oauth2/validate-authorization-code.d.mts +17 -52
  29. package/dist/oauth2/validate-authorization-code.mjs +17 -30
  30. package/dist/oauth2/verify.mjs +15 -5
  31. package/dist/social-providers/apple.d.mts +5 -12
  32. package/dist/social-providers/apple.mjs +14 -3
  33. package/dist/social-providers/atlassian.d.mts +3 -1
  34. package/dist/social-providers/atlassian.mjs +5 -2
  35. package/dist/social-providers/cognito.d.mts +16 -1
  36. package/dist/social-providers/cognito.mjs +6 -2
  37. package/dist/social-providers/discord.d.mts +5 -3
  38. package/dist/social-providers/discord.mjs +16 -3
  39. package/dist/social-providers/dropbox.d.mts +3 -1
  40. package/dist/social-providers/dropbox.mjs +5 -4
  41. package/dist/social-providers/facebook.d.mts +5 -3
  42. package/dist/social-providers/facebook.mjs +6 -3
  43. package/dist/social-providers/figma.d.mts +3 -1
  44. package/dist/social-providers/figma.mjs +3 -2
  45. package/dist/social-providers/github.d.mts +4 -2
  46. package/dist/social-providers/github.mjs +5 -4
  47. package/dist/social-providers/gitlab.d.mts +3 -1
  48. package/dist/social-providers/gitlab.mjs +3 -2
  49. package/dist/social-providers/google.d.mts +3 -1
  50. package/dist/social-providers/google.mjs +5 -2
  51. package/dist/social-providers/huggingface.d.mts +3 -1
  52. package/dist/social-providers/huggingface.mjs +3 -2
  53. package/dist/social-providers/index.d.mts +104 -36
  54. package/dist/social-providers/kakao.d.mts +3 -1
  55. package/dist/social-providers/kakao.mjs +3 -2
  56. package/dist/social-providers/kick.d.mts +3 -1
  57. package/dist/social-providers/kick.mjs +3 -2
  58. package/dist/social-providers/line.d.mts +3 -1
  59. package/dist/social-providers/line.mjs +3 -2
  60. package/dist/social-providers/linear.d.mts +3 -1
  61. package/dist/social-providers/linear.mjs +3 -2
  62. package/dist/social-providers/linkedin.d.mts +5 -3
  63. package/dist/social-providers/linkedin.mjs +4 -3
  64. package/dist/social-providers/microsoft-entra-id.d.mts +3 -2
  65. package/dist/social-providers/microsoft-entra-id.mjs +3 -2
  66. package/dist/social-providers/naver.d.mts +3 -1
  67. package/dist/social-providers/naver.mjs +3 -2
  68. package/dist/social-providers/notion.d.mts +3 -1
  69. package/dist/social-providers/notion.mjs +5 -2
  70. package/dist/social-providers/paybin.d.mts +3 -1
  71. package/dist/social-providers/paybin.mjs +3 -2
  72. package/dist/social-providers/paypal.d.mts +3 -1
  73. package/dist/social-providers/paypal.mjs +4 -3
  74. package/dist/social-providers/polar.d.mts +3 -1
  75. package/dist/social-providers/polar.mjs +3 -2
  76. package/dist/social-providers/railway.d.mts +3 -1
  77. package/dist/social-providers/railway.mjs +3 -2
  78. package/dist/social-providers/reddit.d.mts +3 -1
  79. package/dist/social-providers/reddit.mjs +3 -2
  80. package/dist/social-providers/roblox.d.mts +4 -2
  81. package/dist/social-providers/roblox.mjs +12 -2
  82. package/dist/social-providers/salesforce.d.mts +3 -1
  83. package/dist/social-providers/salesforce.mjs +3 -2
  84. package/dist/social-providers/slack.d.mts +4 -2
  85. package/dist/social-providers/slack.mjs +11 -8
  86. package/dist/social-providers/spotify.d.mts +3 -1
  87. package/dist/social-providers/spotify.mjs +3 -2
  88. package/dist/social-providers/tiktok.d.mts +3 -1
  89. package/dist/social-providers/tiktok.mjs +14 -2
  90. package/dist/social-providers/twitch.d.mts +3 -1
  91. package/dist/social-providers/twitch.mjs +3 -2
  92. package/dist/social-providers/twitter.d.mts +5 -2
  93. package/dist/social-providers/twitter.mjs +2 -1
  94. package/dist/social-providers/vercel.d.mts +3 -1
  95. package/dist/social-providers/vercel.mjs +3 -2
  96. package/dist/social-providers/vk.d.mts +3 -1
  97. package/dist/social-providers/vk.mjs +3 -2
  98. package/dist/social-providers/wechat.d.mts +3 -1
  99. package/dist/social-providers/wechat.mjs +7 -1
  100. package/dist/social-providers/zoom.d.mts +4 -2
  101. package/dist/social-providers/zoom.mjs +10 -17
  102. package/dist/types/context.d.mts +30 -4
  103. package/dist/types/init-options.d.mts +29 -5
  104. package/dist/utils/ip.d.mts +5 -4
  105. package/dist/utils/ip.mjs +3 -3
  106. package/dist/utils/redirect-uri.d.mts +20 -0
  107. package/dist/utils/redirect-uri.mjs +48 -0
  108. package/dist/utils/string.d.mts +5 -1
  109. package/dist/utils/string.mjs +20 -1
  110. package/dist/utils/url.d.mts +18 -1
  111. package/dist/utils/url.mjs +30 -1
  112. package/package.json +9 -8
  113. package/src/db/adapter/factory.ts +121 -3
  114. package/src/db/adapter/index.ts +32 -0
  115. package/src/db/adapter/types.ts +1 -0
  116. package/src/db/get-tables.ts +2 -0
  117. package/src/db/schema/user.ts +3 -0
  118. package/src/db/type.ts +12 -0
  119. package/src/error/codes.ts +1 -0
  120. package/src/oauth2/authorization-params.ts +28 -0
  121. package/src/oauth2/basic-credentials.ts +87 -0
  122. package/src/oauth2/client-assertion.ts +131 -58
  123. package/src/oauth2/client-credentials-token.ts +48 -72
  124. package/src/oauth2/create-authorization-url.ts +28 -6
  125. package/src/oauth2/index.ts +25 -9
  126. package/src/oauth2/oauth-provider.ts +21 -2
  127. package/src/oauth2/refresh-access-token.ts +50 -76
  128. package/src/oauth2/token-endpoint-auth.ts +221 -0
  129. package/src/oauth2/utils.ts +19 -0
  130. package/src/oauth2/validate-authorization-code.ts +55 -85
  131. package/src/oauth2/verify.ts +20 -4
  132. package/src/social-providers/apple.ts +27 -3
  133. package/src/social-providers/atlassian.ts +8 -1
  134. package/src/social-providers/cognito.ts +26 -1
  135. package/src/social-providers/discord.ts +22 -18
  136. package/src/social-providers/dropbox.ts +7 -5
  137. package/src/social-providers/facebook.ts +14 -9
  138. package/src/social-providers/figma.ts +8 -1
  139. package/src/social-providers/github.ts +5 -3
  140. package/src/social-providers/gitlab.ts +2 -0
  141. package/src/social-providers/google.ts +2 -0
  142. package/src/social-providers/huggingface.ts +8 -1
  143. package/src/social-providers/kakao.ts +2 -1
  144. package/src/social-providers/kick.ts +8 -1
  145. package/src/social-providers/line.ts +2 -0
  146. package/src/social-providers/linear.ts +8 -1
  147. package/src/social-providers/linkedin.ts +5 -3
  148. package/src/social-providers/microsoft-entra-id.ts +2 -1
  149. package/src/social-providers/naver.ts +2 -1
  150. package/src/social-providers/notion.ts +8 -1
  151. package/src/social-providers/paybin.ts +2 -0
  152. package/src/social-providers/paypal.ts +7 -1
  153. package/src/social-providers/polar.ts +8 -1
  154. package/src/social-providers/railway.ts +8 -1
  155. package/src/social-providers/reddit.ts +2 -1
  156. package/src/social-providers/roblox.ts +16 -11
  157. package/src/social-providers/salesforce.ts +8 -1
  158. package/src/social-providers/slack.ts +15 -9
  159. package/src/social-providers/spotify.ts +8 -1
  160. package/src/social-providers/tiktok.ts +22 -9
  161. package/src/social-providers/twitch.ts +2 -1
  162. package/src/social-providers/twitter.ts +1 -0
  163. package/src/social-providers/vercel.ts +8 -1
  164. package/src/social-providers/vk.ts +8 -1
  165. package/src/social-providers/wechat.ts +9 -1
  166. package/src/social-providers/zoom.ts +15 -19
  167. package/src/types/context.ts +33 -5
  168. package/src/types/init-options.ts +29 -5
  169. package/src/utils/ip.ts +12 -13
  170. package/src/utils/redirect-uri.ts +54 -0
  171. package/src/utils/string.ts +37 -0
  172. package/src/utils/url.ts +28 -0
@@ -1,10 +1,10 @@
1
- import { createLogger, getColorDepth, TTY_COLORS } from "../../env";
2
- import { BetterAuthError } from "../../error";
3
1
  import {
4
2
  ATTR_DB_COLLECTION_NAME,
5
3
  ATTR_DB_OPERATION_NAME,
6
4
  withSpan,
7
- } from "../../instrumentation";
5
+ } from "@better-auth/core/instrumentation";
6
+ import { createLogger, getColorDepth, TTY_COLORS } from "../../env";
7
+ import { BetterAuthError } from "../../error";
8
8
  import type { BetterAuthOptions } from "../../types";
9
9
  import { safeJSONParse } from "../../utils/json";
10
10
  import { getAuthTables } from "../get-tables";
@@ -133,6 +133,11 @@ export const createAdapterFactory =
133
133
  !config.debugLogs.deleteMany
134
134
  ) {
135
135
  return;
136
+ } else if (
137
+ method === "consumeOne" &&
138
+ !config.debugLogs.consumeOne
139
+ ) {
140
+ return;
136
141
  } else if (method === "count" && !config.debugLogs.count) {
137
142
  return;
138
143
  }
@@ -485,6 +490,7 @@ export const createAdapterFactory =
485
490
  | "updateMany"
486
491
  | "delete"
487
492
  | "deleteMany"
493
+ | "consumeOne"
488
494
  | "count";
489
495
  }): W extends undefined ? undefined : CleanedWhere[] => {
490
496
  if (!where) return undefined as any;
@@ -1312,6 +1318,118 @@ export const createAdapterFactory =
1312
1318
  );
1313
1319
  return res;
1314
1320
  },
1321
+ consumeOne: async <T>({
1322
+ model: unsafeModel,
1323
+ where: unsafeWhere,
1324
+ }: {
1325
+ model: string;
1326
+ where: Where[];
1327
+ }): Promise<T | null> => {
1328
+ transactionId++;
1329
+ const thisTransactionId = transactionId;
1330
+ const model = getModelName(unsafeModel);
1331
+ const where = transformWhereClause({
1332
+ model: unsafeModel,
1333
+ where: unsafeWhere,
1334
+ action: "consumeOne",
1335
+ });
1336
+ unsafeModel = getDefaultModelName(unsafeModel);
1337
+ debugLog(
1338
+ { method: "consumeOne" },
1339
+ `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`,
1340
+ `${formatMethod("consumeOne")} ${formatAction("ConsumeOne")}:`,
1341
+ { model, where },
1342
+ );
1343
+
1344
+ let res: T | null;
1345
+ let resultNeedsOutputTransform = true;
1346
+ if (adapterInstance.consumeOne) {
1347
+ res = await withSpan(
1348
+ `db consumeOne ${model}`,
1349
+ {
1350
+ [ATTR_DB_OPERATION_NAME]: "consumeOne",
1351
+ [ATTR_DB_COLLECTION_NAME]: model,
1352
+ },
1353
+ () => adapterInstance.consumeOne!<T>({ model, where }),
1354
+ );
1355
+ } else {
1356
+ // TODO(consume-one-required): adapters without native `consumeOne`
1357
+ // fall back to `transaction(findMany + deleteMany)`. Race-safe on
1358
+ // engines with real transaction isolation; race window narrows
1359
+ // (does not close) on adapters that fall through to sequential
1360
+ // execution. Remove this branch when consumeOne becomes required.
1361
+ // FIXME(consume-one-nested-transaction): custom adapters without a
1362
+ // native consumeOne have no portable signal for "already inside a
1363
+ // transaction". First-party adapters mark transaction-scoped
1364
+ // adapters as as-is; make that capability explicit in the next
1365
+ // breaking adapter contract.
1366
+ res = await withSpan(
1367
+ `db consumeOne ${model}`,
1368
+ {
1369
+ [ATTR_DB_OPERATION_NAME]: "consumeOne",
1370
+ [ATTR_DB_COLLECTION_NAME]: model,
1371
+ },
1372
+ () =>
1373
+ adapter.transaction(async (trx) => {
1374
+ const rows = await trx.findMany<Record<string, any>>({
1375
+ model: unsafeModel,
1376
+ where: unsafeWhere,
1377
+ limit: 1,
1378
+ });
1379
+ const target = rows[0];
1380
+ if (!target) return null;
1381
+ const deleted = await trx.deleteMany({
1382
+ model: unsafeModel,
1383
+ where: [
1384
+ ...unsafeWhere,
1385
+ {
1386
+ field: "id",
1387
+ value: target.id,
1388
+ operator: "eq",
1389
+ connector: "AND",
1390
+ mode: "sensitive",
1391
+ },
1392
+ ],
1393
+ });
1394
+ // A non-numeric count coerces to a false miss, so fail loud.
1395
+ if (typeof deleted !== "number") {
1396
+ throw new BetterAuthError(
1397
+ `Adapter "${config.adapterId}" returned a non-numeric value from deleteMany during the consumeOne fallback. Return the number of deleted rows, or implement a native consumeOne for atomic single-use consumption.`,
1398
+ );
1399
+ }
1400
+ return deleted > 0 ? (target as T) : null;
1401
+ }),
1402
+ );
1403
+ resultNeedsOutputTransform = false;
1404
+ }
1405
+
1406
+ debugLog(
1407
+ { method: "consumeOne" },
1408
+ `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`,
1409
+ `${formatMethod("consumeOne")} ${formatAction("DB Result")}:`,
1410
+ { model, data: res },
1411
+ );
1412
+ let transformed: any = res;
1413
+ if (
1414
+ !config.disableTransformOutput &&
1415
+ resultNeedsOutputTransform &&
1416
+ res
1417
+ ) {
1418
+ transformed = await transformOutput(
1419
+ res as Record<string, any>,
1420
+ unsafeModel,
1421
+ undefined,
1422
+ undefined,
1423
+ );
1424
+ }
1425
+ debugLog(
1426
+ { method: "consumeOne" },
1427
+ `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`,
1428
+ `${formatMethod("consumeOne")} ${formatAction("Parsed Result")}:`,
1429
+ { model, data: transformed },
1430
+ );
1431
+ return transformed as T | null;
1432
+ },
1315
1433
  count: async ({
1316
1434
  model: unsafeModel,
1317
1435
  where: unsafeWhere,
@@ -15,6 +15,7 @@ export type DBAdapterDebugLogOption =
15
15
  findMany?: boolean | undefined;
16
16
  delete?: boolean | undefined;
17
17
  deleteMany?: boolean | undefined;
18
+ consumeOne?: boolean | undefined;
18
19
  count?: boolean | undefined;
19
20
  }
20
21
  | {
@@ -211,6 +212,7 @@ export interface DBAdapterFactoryConfig<
211
212
  | "updateMany"
212
213
  | "delete"
213
214
  | "deleteMany"
215
+ | "consumeOne"
214
216
  | "count";
215
217
  /**
216
218
  * The model name.
@@ -445,6 +447,23 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
445
447
  }) => Promise<number>;
446
448
  delete: <_T>(data: { model: string; where: Where[] }) => Promise<void>;
447
449
  deleteMany: (data: { model: string; where: Where[] }) => Promise<number>;
450
+ /**
451
+ * Atomically consume a single row matching the where clause: delete it and
452
+ * return the deleted row, or return `null` if no row matched.
453
+ * Implementations MUST NOT delete any additional rows that also match a
454
+ * non-unique predicate.
455
+ *
456
+ * Under concurrent invocation against the same row, exactly one caller
457
+ * receives the row; subsequent racers receive `null`. This is the
458
+ * race-safe primitive for consuming single-use credentials
459
+ * (verification tokens, authorization codes, one-time tokens).
460
+ *
461
+ * Always defined on the factory-wrapped adapter. When the underlying
462
+ * `CustomAdapter` does not implement `consumeOne`, the factory provides
463
+ * a fallback that wraps `findMany + deleteMany` in `transaction(...)`
464
+ * and returns the row only when the delete reports an affected row.
465
+ */
466
+ consumeOne: <T>(data: { model: string; where: Where[] }) => Promise<T | null>;
448
467
  /**
449
468
  * Execute multiple operations in a transaction.
450
469
  * If the adapter doesn't support transactions, operations will be executed sequentially.
@@ -531,6 +550,19 @@ export interface CustomAdapter {
531
550
  model: string;
532
551
  where: CleanedWhere[];
533
552
  }) => Promise<number>;
553
+ /**
554
+ * Optional native atomic single-row consume. When omitted, the adapter
555
+ * factory falls back to `transaction(findMany + deleteMany)`.
556
+ * Implementing this method natively (e.g. `DELETE ... RETURNING *`,
557
+ * `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the
558
+ * strongest race-safety guarantee. Implementations must delete at most
559
+ * one matching row. TODO(consume-one-required): tighten to required in the
560
+ * next minor on `next`.
561
+ */
562
+ consumeOne?: <T>(data: {
563
+ model: string;
564
+ where: CleanedWhere[];
565
+ }) => Promise<T | null>;
534
566
  count: ({
535
567
  model,
536
568
  where,
@@ -122,6 +122,7 @@ export type AdapterFactoryCustomizeAdapterCreator = (config: {
122
122
  | "updateMany"
123
123
  | "delete"
124
124
  | "deleteMany"
125
+ | "consumeOne"
125
126
  | "count";
126
127
  }) => W extends undefined ? undefined : CleanedWhere[];
127
128
  }) => CustomAdapter;
@@ -162,6 +162,8 @@ export const getAuthTables = (
162
162
  },
163
163
  email: {
164
164
  type: "string",
165
+ // TODO(#9124): drop required+unique in v2; use a partial unique
166
+ // index where email is not null (see schema/user.ts).
165
167
  unique: true,
166
168
  required: true,
167
169
  fieldName: options.user?.fields?.email || "email",
@@ -7,6 +7,9 @@ import type {
7
7
  import { coreSchema } from "./shared";
8
8
 
9
9
  export const userSchema = coreSchema.extend({
10
+ // TODO(#9124): widen to nullish in v2. OAuth providers (Discord phone-only,
11
+ // Apple subsequent sign-ins, etc.) can legitimately omit email; identity
12
+ // must key on (providerId, accountId) per OpenID Connect Core §5.7.
10
13
  email: z.string().transform((val) => val.toLowerCase()),
11
14
  emailVerified: z.boolean().default(false),
12
15
  name: z.string(),
package/src/db/type.ts CHANGED
@@ -311,6 +311,18 @@ export interface SecondaryStorage {
311
311
  * @returns - Value of the key
312
312
  */
313
313
  get: (key: string) => Awaitable<unknown>;
314
+ /**
315
+ * Atomically get a value and delete it from storage.
316
+ *
317
+ * This is optional for backwards compatibility with existing secondary
318
+ * storage implementations. Single-use credential consumers use it when
319
+ * present to avoid a read-then-delete race.
320
+ *
321
+ * TODO(secondary-storage-atomic-consume): make this required in the next
322
+ * breaking release, or require database-backed verification storage for
323
+ * security-sensitive consume paths.
324
+ */
325
+ getAndDelete?: (key: string) => Awaitable<unknown>;
314
326
  set: (
315
327
  /**
316
328
  * Key to store
@@ -37,6 +37,7 @@ export const BASE_ERROR_CODES = defineErrorCodes({
37
37
  USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL:
38
38
  "User already exists. Use another email.",
39
39
  EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
40
+ CHANGE_EMAIL_DISABLED: "Change email is disabled",
40
41
  CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
41
42
  SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.",
42
43
  FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
@@ -0,0 +1,28 @@
1
+ import * as z from "zod";
2
+ import {
3
+ RESERVED_AUTHORIZATION_PARAMS,
4
+ RESERVED_AUTHORIZATION_PARAMS_SET,
5
+ } from "./create-authorization-url";
6
+
7
+ /**
8
+ * Zod schema for the `additionalParams` field on social sign-in and
9
+ * account-linking request bodies. Rejects any key reserved by the
10
+ * authorization-URL builder (see `RESERVED_AUTHORIZATION_PARAMS`), so
11
+ * a caller cannot overwrite `state`, PKCE, `redirect_uri`, etc.
12
+ */
13
+ export const additionalAuthorizationParamsSchema = z
14
+ .record(z.string(), z.string())
15
+ .refine(
16
+ (value) =>
17
+ !Object.keys(value).some((key) =>
18
+ RESERVED_AUTHORIZATION_PARAMS_SET.has(key),
19
+ ),
20
+ {
21
+ message: `additionalParams cannot include reserved OAuth parameters: ${RESERVED_AUTHORIZATION_PARAMS.join(", ")}`,
22
+ },
23
+ )
24
+ .meta({
25
+ description:
26
+ "Extra query parameters to append to the provider authorization URL (e.g. Cognito identity_provider, Google hd).",
27
+ })
28
+ .optional();
@@ -0,0 +1,87 @@
1
+ import { base64 } from "@better-auth/utils/base64";
2
+
3
+ // RFC 7235 §2.1: auth scheme is case-insensitive and is followed by one or
4
+ // more SP before the credentials. The trailing capture is everything after
5
+ // the whitespace (may be empty, which downstream checks reject).
6
+ const BASIC_AUTHORIZATION_PATTERN = /^Basic +(.*)$/i;
7
+
8
+ /**
9
+ * Encodes a value using `application/x-www-form-urlencoded` per the URL
10
+ * Living Standard. Differs from `encodeURIComponent` in two ways: it escapes
11
+ * `!`, `'`, `(`, and `)`, and it represents space as `+` rather than `%20`.
12
+ * `*` is left unescaped, matching the URL Standard's percent-encode set.
13
+ */
14
+ function formUrlEncode(value: string): string {
15
+ return new URLSearchParams({ v: value }).toString().slice("v=".length);
16
+ }
17
+
18
+ /**
19
+ * Inverse of `formUrlEncode`: decodes a single `application/x-www-form-urlencoded`
20
+ * value, handling both `+` and `%20` as space.
21
+ */
22
+ function formUrlDecode(value: string): string {
23
+ const decoded = new URLSearchParams(`v=${value}`).get("v");
24
+ if (decoded === null) {
25
+ throw new Error("form-url-encoded value could not be decoded");
26
+ }
27
+ return decoded;
28
+ }
29
+
30
+ /**
31
+ * Encodes an OAuth client id and secret as an HTTP Basic credential string.
32
+ *
33
+ * Follows RFC 6749 §2.3.1: both values are `application/x-www-form-urlencoded`
34
+ * prior to base64 encoding. The returned string is the full value of the
35
+ * `Authorization` header, including the `Basic ` prefix.
36
+ */
37
+ export function encodeBasicCredentials(
38
+ clientId: string,
39
+ clientSecret: string,
40
+ ): string {
41
+ const payload = `${formUrlEncode(clientId)}:${formUrlEncode(clientSecret)}`;
42
+ return `Basic ${base64.encode(payload)}`;
43
+ }
44
+
45
+ /**
46
+ * Decodes an `Authorization: Basic …` header value into its OAuth client id
47
+ * and secret.
48
+ *
49
+ * Scheme matching is case-insensitive and tolerates one or more spaces
50
+ * between the scheme and credentials per RFC 7235 §2.1. The base64 payload
51
+ * is split on the first `:` only, so secrets containing colons round-trip
52
+ * correctly. Each half is form-url-decoded per RFC 6749 §2.3.1, accepting
53
+ * both `+` and `%20` as space. Per the URL Living Standard, invalid
54
+ * percent-escapes pass through as-is; downstream client lookup will fail
55
+ * with `invalid_client` for malformed credentials.
56
+ *
57
+ * Throws when the header is not a Basic credential, when the base64 payload
58
+ * contains no `:`, or when either half is empty.
59
+ */
60
+ export function decodeBasicCredentials(authorization: string): {
61
+ clientId: string;
62
+ clientSecret: string;
63
+ } {
64
+ const match = authorization.match(BASIC_AUTHORIZATION_PATTERN);
65
+ if (!match) {
66
+ throw new Error("Authorization header is not a Basic credential");
67
+ }
68
+ const encoded = match[1] ?? "";
69
+ const decoded = new TextDecoder().decode(base64.decode(encoded));
70
+ const separatorIndex = decoded.indexOf(":");
71
+ if (separatorIndex === -1) {
72
+ throw new Error(
73
+ "Basic credential is missing the client id/secret separator",
74
+ );
75
+ }
76
+ const rawClientId = decoded.slice(0, separatorIndex);
77
+ const rawClientSecret = decoded.slice(separatorIndex + 1);
78
+ if (!rawClientId || !rawClientSecret) {
79
+ throw new Error(
80
+ "Basic credential client id and secret must both be non-empty",
81
+ );
82
+ }
83
+ return {
84
+ clientId: formUrlDecode(rawClientId),
85
+ clientSecret: formUrlDecode(rawClientSecret),
86
+ };
87
+ }
@@ -1,8 +1,9 @@
1
1
  import type { JWTHeaderParameters } from "jose";
2
2
  import { importJWK, importPKCS8, SignJWT } from "jose";
3
+ import type { Awaitable } from "../types";
3
4
 
4
5
  /** Asymmetric signing algorithms compatible with private_key_jwt (RFC 7523). */
5
- export const ASSERTION_SIGNING_ALGORITHMS = [
6
+ export const PRIVATE_KEY_JWT_SIGNING_ALGORITHMS = [
6
7
  "RS256",
7
8
  "RS384",
8
9
  "RS512",
@@ -15,15 +16,79 @@ export const ASSERTION_SIGNING_ALGORITHMS = [
15
16
  "EdDSA",
16
17
  ] as const;
17
18
 
18
- export type AssertionSigningAlgorithm =
19
- (typeof ASSERTION_SIGNING_ALGORITHMS)[number];
19
+ export type PrivateKeyJwtSigningAlgorithm =
20
+ (typeof PRIVATE_KEY_JWT_SIGNING_ALGORITHMS)[number];
21
+
22
+ function assertSupportedPrivateKeyJwtAlgorithm(
23
+ candidate: string,
24
+ ): asserts candidate is PrivateKeyJwtSigningAlgorithm {
25
+ if (
26
+ !(PRIVATE_KEY_JWT_SIGNING_ALGORITHMS as readonly string[]).includes(
27
+ candidate,
28
+ )
29
+ ) {
30
+ throw new Error(
31
+ `Unsupported private_key_jwt signing algorithm: ${candidate}. Use one of ${PRIVATE_KEY_JWT_SIGNING_ALGORITHMS.join(", ")}.`,
32
+ );
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Validates `private_key_jwt` options eagerly and returns the algorithm to
38
+ * use for signing.
39
+ *
40
+ * Asserts that key material is configured, that any explicit `algorithm` is
41
+ * supported, that any JWK-embedded `alg` is supported, and that the two
42
+ * agree when both are set.
43
+ */
44
+ function resolveValidPrivateKeyJwtOptions(options: {
45
+ privateKeyJwk?: JsonWebKey;
46
+ privateKeyPem?: string;
47
+ algorithm?: PrivateKeyJwtSigningAlgorithm;
48
+ }): PrivateKeyJwtSigningAlgorithm {
49
+ if (!options.privateKeyJwk && !options.privateKeyPem) {
50
+ throw new Error(
51
+ "private_key_jwt requires either privateKeyJwk or privateKeyPem",
52
+ );
53
+ }
54
+ if (options.algorithm) {
55
+ assertSupportedPrivateKeyJwtAlgorithm(options.algorithm);
56
+ }
57
+ const jwkAlg = options.privateKeyJwk?.alg;
58
+ if (typeof jwkAlg === "string") {
59
+ assertSupportedPrivateKeyJwtAlgorithm(jwkAlg);
60
+ }
61
+ if (
62
+ options.algorithm &&
63
+ typeof jwkAlg === "string" &&
64
+ options.algorithm !== jwkAlg
65
+ ) {
66
+ throw new Error(
67
+ `JWK alg "${jwkAlg}" does not match configured algorithm "${options.algorithm}". Remove the JWK alg field, or pass an algorithm that matches the JWK.`,
68
+ );
69
+ }
70
+ return options.algorithm ?? (typeof jwkAlg === "string" ? jwkAlg : "RS256");
71
+ }
20
72
 
21
73
  export const CLIENT_ASSERTION_TYPE =
22
74
  "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
23
75
 
24
- export interface ClientAssertionConfig {
25
- /** Pre-signed JWT assertion string. If provided, signing is skipped. */
26
- assertion?: string;
76
+ export type ClientAssertionGrantType =
77
+ | "authorization_code"
78
+ | "refresh_token"
79
+ | "client_credentials";
80
+
81
+ export interface ClientAssertionContext {
82
+ clientId: string;
83
+ tokenEndpoint: string;
84
+ grantType: ClientAssertionGrantType;
85
+ }
86
+
87
+ export type ClientAssertionGetter = (
88
+ context: ClientAssertionContext,
89
+ ) => Awaitable<string>;
90
+
91
+ export interface PrivateKeyJwtClientAssertionGetterOptions {
27
92
  /** Private key in JWK format for signing. */
28
93
  privateKeyJwk?: JsonWebKey;
29
94
  /** Private key in PKCS#8 PEM format for signing. */
@@ -31,9 +96,7 @@ export interface ClientAssertionConfig {
31
96
  /** Key ID to include in the JWT header. */
32
97
  kid?: string;
33
98
  /** Asymmetric signing algorithm. Symmetric algorithms (HS256) and "none" are not allowed. @default "RS256" */
34
- algorithm?: AssertionSigningAlgorithm;
35
- /** Token endpoint URL (used as the JWT `aud` claim). */
36
- tokenEndpoint?: string;
99
+ algorithm?: PrivateKeyJwtSigningAlgorithm;
37
100
  /** Assertion lifetime in seconds. @default 120 */
38
101
  expiresIn?: number;
39
102
  }
@@ -41,10 +104,16 @@ export interface ClientAssertionConfig {
41
104
  /**
42
105
  * Signs an RFC 7523 client assertion JWT for `private_key_jwt` authentication.
43
106
  *
44
- * The JWT contains: iss=clientId, sub=clientId, aud=tokenEndpoint,
45
- * exp=now+120s, jti=unique, iat=now.
107
+ * The JWT contains these claims:
108
+ *
109
+ * - iss=clientId
110
+ * - sub=clientId
111
+ * - aud=tokenEndpoint
112
+ * - exp=now + 120s
113
+ * - jti=unique
114
+ * - iat=now
46
115
  */
47
- export async function signClientAssertion({
116
+ export async function signPrivateKeyJwtClientAssertion({
48
117
  clientId,
49
118
  tokenEndpoint,
50
119
  privateKeyJwk,
@@ -58,34 +127,30 @@ export async function signClientAssertion({
58
127
  privateKeyJwk?: JsonWebKey;
59
128
  privateKeyPem?: string;
60
129
  kid?: string;
61
- algorithm?: AssertionSigningAlgorithm;
130
+ algorithm?: PrivateKeyJwtSigningAlgorithm;
62
131
  expiresIn?: number;
63
132
  }): Promise<string> {
64
- // Fall back to JWK-embedded kid/alg when not explicitly provided (RFC 7517).
65
- // JsonWebKey includes alg but not kid; access kid via index.
133
+ const resolvedAlg = resolveValidPrivateKeyJwtOptions({
134
+ privateKeyJwk,
135
+ privateKeyPem,
136
+ algorithm,
137
+ });
138
+ // Fall back to the JWK-embedded kid when not explicitly provided (RFC 7517).
139
+ // JsonWebKey types include alg but not kid; access kid via index.
66
140
  const jwk = privateKeyJwk as Record<string, unknown> | undefined;
67
141
  const resolvedKid = kid ?? (jwk?.kid as string | undefined);
68
- const resolvedAlg =
69
- algorithm ??
70
- (privateKeyJwk?.alg as AssertionSigningAlgorithm | undefined) ??
71
- "RS256";
72
-
73
- let key: Awaited<ReturnType<typeof importJWK>>;
74
- if (privateKeyJwk) {
75
- key = await importJWK(privateKeyJwk, resolvedAlg);
76
- } else if (privateKeyPem) {
77
- key = await importPKCS8(privateKeyPem, resolvedAlg);
78
- } else {
79
- throw new Error(
80
- "private_key_jwt requires either privateKeyJwk or privateKeyPem",
81
- );
82
- }
142
+
143
+ const key: Awaited<ReturnType<typeof importJWK>> = privateKeyJwk
144
+ ? await importJWK(privateKeyJwk, resolvedAlg)
145
+ : await importPKCS8(privateKeyPem as string, resolvedAlg);
83
146
 
84
147
  const now = Math.floor(Date.now() / 1000);
85
148
  const jti = crypto.randomUUID();
86
149
 
87
150
  const header: JWTHeaderParameters = { alg: resolvedAlg, typ: "JWT" };
88
- if (resolvedKid) header.kid = resolvedKid;
151
+ if (resolvedKid) {
152
+ header.kid = resolvedKid;
153
+ }
89
154
 
90
155
  return new SignJWT({})
91
156
  .setProtectedHeader(header)
@@ -99,36 +164,44 @@ export async function signClientAssertion({
99
164
  }
100
165
 
101
166
  /**
102
- * Resolves a ClientAssertionConfig into `client_assertion` + `client_assertion_type`
103
- * params for injection into a token request body.
167
+ * Creates a client assertion getter for `private_key_jwt` authentication.
168
+ *
169
+ * Validates options eagerly (key material, supported algorithm, JWK alg
170
+ * agreement) so misconfiguration surfaces at construction rather than on the
171
+ * first token request. The returned function signs a fresh RFC 7523 JWT
172
+ * assertion for every token endpoint request.
104
173
  */
105
- export async function resolveAssertionParams({
106
- clientAssertion,
107
- clientId,
108
- tokenEndpoint,
109
- }: {
110
- clientAssertion: ClientAssertionConfig;
111
- clientId: string;
112
- tokenEndpoint?: string;
113
- }): Promise<Record<string, string>> {
114
- let assertion = clientAssertion.assertion;
115
- if (!assertion) {
116
- const audEndpoint = tokenEndpoint ?? clientAssertion.tokenEndpoint;
117
- if (!audEndpoint) {
118
- throw new Error(
119
- "private_key_jwt requires a tokenEndpoint for the JWT audience claim",
120
- );
121
- }
122
- assertion = await signClientAssertion({
174
+ export function createPrivateKeyJwtClientAssertionGetter(
175
+ options: PrivateKeyJwtClientAssertionGetterOptions,
176
+ ): ClientAssertionGetter {
177
+ resolveValidPrivateKeyJwtOptions({
178
+ privateKeyJwk: options.privateKeyJwk,
179
+ privateKeyPem: options.privateKeyPem,
180
+ algorithm: options.algorithm,
181
+ });
182
+ return ({ clientId, tokenEndpoint }) =>
183
+ signPrivateKeyJwtClientAssertion({
123
184
  clientId,
124
- tokenEndpoint: audEndpoint,
125
- privateKeyJwk: clientAssertion.privateKeyJwk,
126
- privateKeyPem: clientAssertion.privateKeyPem,
127
- kid: clientAssertion.kid,
128
- algorithm: clientAssertion.algorithm,
129
- expiresIn: clientAssertion.expiresIn,
185
+ tokenEndpoint,
186
+ privateKeyJwk: options.privateKeyJwk,
187
+ privateKeyPem: options.privateKeyPem,
188
+ kid: options.kid,
189
+ algorithm: options.algorithm,
190
+ expiresIn: options.expiresIn,
130
191
  });
131
- }
192
+ }
193
+
194
+ /**
195
+ * Resolves a client assertion getter into `client_assertion` + `client_assertion_type` params for injection into a token request body.
196
+ */
197
+ export async function resolveClientAssertionParams({
198
+ getClientAssertion,
199
+ context,
200
+ }: {
201
+ getClientAssertion: ClientAssertionGetter;
202
+ context: ClientAssertionContext;
203
+ }): Promise<Record<string, string>> {
204
+ const assertion = await getClientAssertion(context);
132
205
  return {
133
206
  client_assertion: assertion,
134
207
  client_assertion_type: CLIENT_ASSERTION_TYPE,