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

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 (170) hide show
  1. package/dist/api/index.d.mts +47 -4
  2. package/dist/api/index.mjs +40 -1
  3. package/dist/context/global.mjs +1 -1
  4. package/dist/context/transaction.d.mts +7 -4
  5. package/dist/context/transaction.mjs +6 -3
  6. package/dist/db/adapter/factory.mjs +57 -31
  7. package/dist/db/adapter/index.d.mts +54 -10
  8. package/dist/db/adapter/types.d.mts +1 -1
  9. package/dist/db/get-tables.mjs +3 -3
  10. package/dist/db/schema/account.d.mts +1 -1
  11. package/dist/db/schema/account.mjs +1 -1
  12. package/dist/db/type.d.mts +12 -7
  13. package/dist/env/env-impl.mjs +1 -1
  14. package/dist/error/codes.d.mts +5 -0
  15. package/dist/error/codes.mjs +5 -0
  16. package/dist/index.d.mts +2 -2
  17. package/dist/instrumentation/tracer.mjs +1 -1
  18. package/dist/oauth2/create-authorization-url.d.mts +4 -1
  19. package/dist/oauth2/create-authorization-url.mjs +5 -2
  20. package/dist/oauth2/dpop.d.mts +142 -0
  21. package/dist/oauth2/dpop.mjs +246 -0
  22. package/dist/oauth2/index.d.mts +6 -3
  23. package/dist/oauth2/index.mjs +5 -2
  24. package/dist/oauth2/oauth-provider.d.mts +128 -9
  25. package/dist/oauth2/refresh-access-token.mjs +1 -1
  26. package/dist/oauth2/scopes.d.mts +76 -0
  27. package/dist/oauth2/scopes.mjs +96 -0
  28. package/dist/oauth2/utils.mjs +2 -1
  29. package/dist/oauth2/verify-id-token.d.mts +26 -0
  30. package/dist/oauth2/verify-id-token.mjs +62 -0
  31. package/dist/oauth2/verify.d.mts +88 -15
  32. package/dist/oauth2/verify.mjs +187 -19
  33. package/dist/social-providers/apple.d.mts +14 -2
  34. package/dist/social-providers/apple.mjs +12 -36
  35. package/dist/social-providers/atlassian.d.mts +5 -1
  36. package/dist/social-providers/atlassian.mjs +4 -4
  37. package/dist/social-providers/cognito.d.mts +13 -2
  38. package/dist/social-providers/cognito.mjs +24 -32
  39. package/dist/social-providers/discord.d.mts +5 -1
  40. package/dist/social-providers/discord.mjs +7 -6
  41. package/dist/social-providers/dropbox.d.mts +5 -1
  42. package/dist/social-providers/dropbox.mjs +5 -5
  43. package/dist/social-providers/facebook.d.mts +21 -2
  44. package/dist/social-providers/facebook.mjs +46 -22
  45. package/dist/social-providers/figma.d.mts +5 -1
  46. package/dist/social-providers/figma.mjs +5 -5
  47. package/dist/social-providers/github.d.mts +5 -1
  48. package/dist/social-providers/github.mjs +4 -4
  49. package/dist/social-providers/gitlab.d.mts +5 -1
  50. package/dist/social-providers/gitlab.mjs +6 -6
  51. package/dist/social-providers/google.d.mts +29 -3
  52. package/dist/social-providers/google.mjs +24 -30
  53. package/dist/social-providers/huggingface.d.mts +5 -1
  54. package/dist/social-providers/huggingface.mjs +8 -8
  55. package/dist/social-providers/index.d.mts +222 -42
  56. package/dist/social-providers/kakao.d.mts +5 -1
  57. package/dist/social-providers/kakao.mjs +8 -8
  58. package/dist/social-providers/kick.d.mts +5 -1
  59. package/dist/social-providers/kick.mjs +4 -4
  60. package/dist/social-providers/line.d.mts +8 -2
  61. package/dist/social-providers/line.mjs +12 -14
  62. package/dist/social-providers/linear.d.mts +5 -1
  63. package/dist/social-providers/linear.mjs +4 -4
  64. package/dist/social-providers/linkedin.d.mts +5 -1
  65. package/dist/social-providers/linkedin.mjs +10 -10
  66. package/dist/social-providers/microsoft-entra-id.d.mts +41 -6
  67. package/dist/social-providers/microsoft-entra-id.mjs +40 -36
  68. package/dist/social-providers/naver.d.mts +5 -1
  69. package/dist/social-providers/naver.mjs +4 -4
  70. package/dist/social-providers/notion.d.mts +5 -1
  71. package/dist/social-providers/notion.mjs +4 -4
  72. package/dist/social-providers/paybin.d.mts +5 -1
  73. package/dist/social-providers/paybin.mjs +10 -10
  74. package/dist/social-providers/paypal.d.mts +5 -2
  75. package/dist/social-providers/paypal.mjs +8 -13
  76. package/dist/social-providers/polar.d.mts +5 -1
  77. package/dist/social-providers/polar.mjs +8 -8
  78. package/dist/social-providers/railway.d.mts +5 -1
  79. package/dist/social-providers/railway.mjs +9 -9
  80. package/dist/social-providers/reddit.d.mts +5 -1
  81. package/dist/social-providers/reddit.mjs +9 -8
  82. package/dist/social-providers/roblox.d.mts +5 -1
  83. package/dist/social-providers/roblox.mjs +5 -5
  84. package/dist/social-providers/salesforce.d.mts +5 -1
  85. package/dist/social-providers/salesforce.mjs +8 -8
  86. package/dist/social-providers/slack.d.mts +5 -1
  87. package/dist/social-providers/slack.mjs +9 -9
  88. package/dist/social-providers/spotify.d.mts +5 -1
  89. package/dist/social-providers/spotify.mjs +5 -5
  90. package/dist/social-providers/tiktok.d.mts +5 -1
  91. package/dist/social-providers/tiktok.mjs +9 -5
  92. package/dist/social-providers/twitch.d.mts +5 -1
  93. package/dist/social-providers/twitch.mjs +4 -4
  94. package/dist/social-providers/twitter.d.mts +6 -4
  95. package/dist/social-providers/twitter.mjs +9 -9
  96. package/dist/social-providers/vercel.d.mts +5 -1
  97. package/dist/social-providers/vercel.mjs +4 -7
  98. package/dist/social-providers/vk.d.mts +5 -1
  99. package/dist/social-providers/vk.mjs +5 -5
  100. package/dist/social-providers/wechat.d.mts +5 -1
  101. package/dist/social-providers/wechat.mjs +10 -6
  102. package/dist/social-providers/zoom.d.mts +6 -1
  103. package/dist/social-providers/zoom.mjs +15 -9
  104. package/dist/types/context.d.mts +27 -8
  105. package/dist/types/index.d.mts +1 -1
  106. package/dist/types/init-options.d.mts +137 -6
  107. package/dist/types/plugin-client.d.mts +12 -2
  108. package/dist/utils/host.mjs +4 -0
  109. package/dist/utils/url.mjs +4 -3
  110. package/package.json +7 -7
  111. package/src/api/index.ts +82 -0
  112. package/src/context/transaction.ts +45 -12
  113. package/src/db/adapter/factory.ts +127 -64
  114. package/src/db/adapter/index.ts +54 -9
  115. package/src/db/adapter/types.ts +1 -0
  116. package/src/db/get-tables.ts +8 -3
  117. package/src/db/schema/account.ts +14 -2
  118. package/src/db/type.ts +12 -7
  119. package/src/env/env-impl.ts +1 -2
  120. package/src/error/codes.ts +5 -0
  121. package/src/oauth2/create-authorization-url.ts +2 -2
  122. package/src/oauth2/dpop.ts +568 -0
  123. package/src/oauth2/index.ts +61 -2
  124. package/src/oauth2/oauth-provider.ts +140 -10
  125. package/src/oauth2/refresh-access-token.ts +2 -2
  126. package/src/oauth2/scopes.ts +118 -0
  127. package/src/oauth2/utils.ts +2 -5
  128. package/src/oauth2/verify-id-token.ts +111 -0
  129. package/src/oauth2/verify.ts +372 -58
  130. package/src/social-providers/apple.ts +24 -61
  131. package/src/social-providers/atlassian.ts +12 -8
  132. package/src/social-providers/cognito.ts +25 -47
  133. package/src/social-providers/discord.ts +19 -8
  134. package/src/social-providers/dropbox.ts +13 -7
  135. package/src/social-providers/facebook.ts +97 -51
  136. package/src/social-providers/figma.ts +13 -9
  137. package/src/social-providers/github.ts +12 -8
  138. package/src/social-providers/gitlab.ts +14 -8
  139. package/src/social-providers/google.ts +66 -47
  140. package/src/social-providers/huggingface.ts +12 -8
  141. package/src/social-providers/kakao.ts +16 -8
  142. package/src/social-providers/kick.ts +12 -7
  143. package/src/social-providers/line.ts +37 -37
  144. package/src/social-providers/linear.ts +12 -6
  145. package/src/social-providers/linkedin.ts +14 -10
  146. package/src/social-providers/microsoft-entra-id.ts +103 -59
  147. package/src/social-providers/naver.ts +12 -6
  148. package/src/social-providers/notion.ts +12 -6
  149. package/src/social-providers/paybin.ts +14 -11
  150. package/src/social-providers/paypal.ts +6 -25
  151. package/src/social-providers/polar.ts +12 -8
  152. package/src/social-providers/railway.ts +13 -9
  153. package/src/social-providers/reddit.ts +25 -10
  154. package/src/social-providers/roblox.ts +18 -7
  155. package/src/social-providers/salesforce.ts +12 -8
  156. package/src/social-providers/slack.ts +18 -9
  157. package/src/social-providers/spotify.ts +13 -7
  158. package/src/social-providers/tiktok.ts +13 -7
  159. package/src/social-providers/twitch.ts +12 -8
  160. package/src/social-providers/twitter.ts +17 -8
  161. package/src/social-providers/vercel.ts +16 -10
  162. package/src/social-providers/vk.ts +13 -7
  163. package/src/social-providers/wechat.ts +28 -9
  164. package/src/social-providers/zoom.ts +19 -6
  165. package/src/types/context.ts +26 -8
  166. package/src/types/index.ts +7 -0
  167. package/src/types/init-options.ts +159 -8
  168. package/src/types/plugin-client.ts +16 -2
  169. package/src/utils/host.ts +15 -0
  170. package/src/utils/url.ts +10 -4
@@ -0,0 +1,76 @@
1
+ import { ProviderOptions } from "./oauth-provider.mjs";
2
+
3
+ //#region src/oauth2/scopes.d.ts
4
+ /**
5
+ * Parse a provider's `scope` token-response field into a string array.
6
+ *
7
+ * RFC 6749 §3.3 defines `scope` as a space-delimited string, but providers
8
+ * vary: some (e.g. Twitch) return an already-split array. Accept both, plus the
9
+ * omitted/empty case, without ever calling `.split` on a non-string. Returns
10
+ * `[]` when no scope is present.
11
+ *
12
+ * @see https://github.com/better-auth/better-auth/issues/9076
13
+ */
14
+ declare function parseScopeField(scope: unknown): string[];
15
+ /**
16
+ * Normalize a scope set into a single deduped, sorted array.
17
+ *
18
+ * Scope order is insignificant per RFC 6749 §3.3, so normalize for idempotent
19
+ * writes and trivial comparisons: trim each token, drop empties, dedupe, and
20
+ * sort ascending. Returns `[]` when the union is empty.
21
+ *
22
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-3.3
23
+ */
24
+ declare function normalizeScopes(stored: string[] | null | undefined, incoming?: string[] | undefined): string[];
25
+ /**
26
+ * Union the stored granted-scope set with the scopes observed on an
27
+ * authorization or token exchange.
28
+ *
29
+ * The provider's echoed `scope` is authoritative when present. RFC 6749 §3.3
30
+ * and §5.1 say an omitted or empty echo means the grant equals what was
31
+ * requested, so fall back to `requested` in that case. The result unions onto
32
+ * the stored grant (never narrows on a normal write) and is normalized per
33
+ * {@link normalizeScopes}.
34
+ *
35
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-3.3
36
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-5.1
37
+ */
38
+ declare function unionGrantedScopes(stored: string[] | null | undefined, echoed: string[] | undefined, requested: string[] | undefined): string[];
39
+ /**
40
+ * Coerce a stored granted-scope value into a usable array.
41
+ *
42
+ * `account.grantedScopes` is nullable (legacy rows and non-OAuth accounts read
43
+ * as unset), and on dialects that store the array as a JSON string a malformed
44
+ * operator backfill could deserialize to a non-array. Both collapse to `[]`
45
+ * here so every reader works against a real `string[]` without re-deriving the
46
+ * guard.
47
+ */
48
+ declare function readGrantedScopes(stored: string[] | null | undefined): string[];
49
+ /**
50
+ * Test whether a normalized granted-scope set contains a specific scope.
51
+ *
52
+ * Matching is exact and case-sensitive per RFC 6749 §3.3. The argument is the
53
+ * normalized `account.grantedScopes` array; a raw provider `scope` string must
54
+ * be run through {@link parseScopeField} first.
55
+ *
56
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-3.3
57
+ */
58
+ declare function includesGrantedScope(granted: string[] | null | undefined, scope: string): boolean;
59
+ /**
60
+ * Compose the effective scope set to encode in a single authorization URL.
61
+ *
62
+ * Precedence: the provider's built-in defaults (unless `disableDefaultScope`),
63
+ * then the integrator's configured `options.scope`, then the per-request
64
+ * `scopes`. The result is the value persisted into OAuth state as the RFC 6749
65
+ * §5.1 fallback, so it is preserved verbatim (not normalized) to match what is
66
+ * sent to the provider.
67
+ *
68
+ * `defaultScopes` is a parameter rather than a provider-contract field so the
69
+ * runtime-synthesized generic OAuth provider, which has no static default set,
70
+ * can pass its configured scopes here.
71
+ *
72
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-5.1
73
+ */
74
+ declare function resolveRequestedScopes(options: Pick<ProviderOptions, "scope" | "disableDefaultScope"> | undefined, defaultScopes: string[], perRequestScopes: string[] | undefined): string[];
75
+ //#endregion
76
+ export { includesGrantedScope, normalizeScopes, parseScopeField, readGrantedScopes, resolveRequestedScopes, unionGrantedScopes };
@@ -0,0 +1,96 @@
1
+ //#region src/oauth2/scopes.ts
2
+ /**
3
+ * Parse a provider's `scope` token-response field into a string array.
4
+ *
5
+ * RFC 6749 §3.3 defines `scope` as a space-delimited string, but providers
6
+ * vary: some (e.g. Twitch) return an already-split array. Accept both, plus the
7
+ * omitted/empty case, without ever calling `.split` on a non-string. Returns
8
+ * `[]` when no scope is present.
9
+ *
10
+ * @see https://github.com/better-auth/better-auth/issues/9076
11
+ */
12
+ function parseScopeField(scope) {
13
+ if (Array.isArray(scope)) return scope.filter((s) => typeof s === "string" && s !== "");
14
+ if (typeof scope === "string") return scope.split(" ").filter(Boolean);
15
+ return [];
16
+ }
17
+ /**
18
+ * Normalize a scope set into a single deduped, sorted array.
19
+ *
20
+ * Scope order is insignificant per RFC 6749 §3.3, so normalize for idempotent
21
+ * writes and trivial comparisons: trim each token, drop empties, dedupe, and
22
+ * sort ascending. Returns `[]` when the union is empty.
23
+ *
24
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-3.3
25
+ */
26
+ function normalizeScopes(stored, incoming) {
27
+ const normalized = /* @__PURE__ */ new Set();
28
+ for (const scope of [...stored ?? [], ...incoming ?? []]) {
29
+ const trimmed = scope.trim();
30
+ if (trimmed) normalized.add(trimmed);
31
+ }
32
+ return [...normalized].sort();
33
+ }
34
+ /**
35
+ * Union the stored granted-scope set with the scopes observed on an
36
+ * authorization or token exchange.
37
+ *
38
+ * The provider's echoed `scope` is authoritative when present. RFC 6749 §3.3
39
+ * and §5.1 say an omitted or empty echo means the grant equals what was
40
+ * requested, so fall back to `requested` in that case. The result unions onto
41
+ * the stored grant (never narrows on a normal write) and is normalized per
42
+ * {@link normalizeScopes}.
43
+ *
44
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-3.3
45
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-5.1
46
+ */
47
+ function unionGrantedScopes(stored, echoed, requested) {
48
+ return normalizeScopes(stored, echoed?.length ? echoed : requested);
49
+ }
50
+ /**
51
+ * Coerce a stored granted-scope value into a usable array.
52
+ *
53
+ * `account.grantedScopes` is nullable (legacy rows and non-OAuth accounts read
54
+ * as unset), and on dialects that store the array as a JSON string a malformed
55
+ * operator backfill could deserialize to a non-array. Both collapse to `[]`
56
+ * here so every reader works against a real `string[]` without re-deriving the
57
+ * guard.
58
+ */
59
+ function readGrantedScopes(stored) {
60
+ return Array.isArray(stored) ? stored : [];
61
+ }
62
+ /**
63
+ * Test whether a normalized granted-scope set contains a specific scope.
64
+ *
65
+ * Matching is exact and case-sensitive per RFC 6749 §3.3. The argument is the
66
+ * normalized `account.grantedScopes` array; a raw provider `scope` string must
67
+ * be run through {@link parseScopeField} first.
68
+ *
69
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-3.3
70
+ */
71
+ function includesGrantedScope(granted, scope) {
72
+ return granted?.includes(scope) ?? false;
73
+ }
74
+ /**
75
+ * Compose the effective scope set to encode in a single authorization URL.
76
+ *
77
+ * Precedence: the provider's built-in defaults (unless `disableDefaultScope`),
78
+ * then the integrator's configured `options.scope`, then the per-request
79
+ * `scopes`. The result is the value persisted into OAuth state as the RFC 6749
80
+ * §5.1 fallback, so it is preserved verbatim (not normalized) to match what is
81
+ * sent to the provider.
82
+ *
83
+ * `defaultScopes` is a parameter rather than a provider-contract field so the
84
+ * runtime-synthesized generic OAuth provider, which has no static default set,
85
+ * can pass its configured scopes here.
86
+ *
87
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-5.1
88
+ */
89
+ function resolveRequestedScopes(options, defaultScopes, perRequestScopes) {
90
+ const scopes = options?.disableDefaultScope ? [] : [...defaultScopes];
91
+ if (options?.scope) scopes.push(...options.scope);
92
+ if (perRequestScopes) scopes.push(...perRequestScopes);
93
+ return scopes;
94
+ }
95
+ //#endregion
96
+ export { includesGrantedScope, normalizeScopes, parseScopeField, readGrantedScopes, resolveRequestedScopes, unionGrantedScopes };
@@ -1,3 +1,4 @@
1
+ import { parseScopeField } from "./scopes.mjs";
1
2
  import { base64Url } from "@better-auth/utils/base64";
2
3
  //#region src/oauth2/utils.ts
3
4
  function getOAuth2Tokens(data) {
@@ -11,7 +12,7 @@ function getOAuth2Tokens(data) {
11
12
  refreshToken: data.refresh_token,
12
13
  accessTokenExpiresAt: data.expires_in ? getDate(data.expires_in) : void 0,
13
14
  refreshTokenExpiresAt: data.refresh_token_expires_in ? getDate(data.refresh_token_expires_in) : void 0,
14
- scopes: data?.scope ? typeof data.scope === "string" ? data.scope.split(" ") : data.scope : [],
15
+ scopes: parseScopeField(data.scope),
15
16
  idToken: data.id_token,
16
17
  raw: data
17
18
  };
@@ -0,0 +1,26 @@
1
+ import { UpstreamProvider } from "./oauth-provider.mjs";
2
+
3
+ //#region src/oauth2/verify-id-token.d.ts
4
+ /**
5
+ * Whether a provider can verify a client-submitted id_token.
6
+ *
7
+ * A provider supports id_token sign-in when it declares an {@link UpstreamProvider.idToken}
8
+ * verification config, or when the integrator supplies a `verifyIdToken` override on the
9
+ * provider options. A provider whose options set `disableIdTokenSignIn`, or that declares
10
+ * neither, rejects the client id_token sign-in path with `ID_TOKEN_NOT_SUPPORTED`.
11
+ */
12
+ declare function supportsIdTokenSignIn(provider: UpstreamProvider<any, any>): boolean;
13
+ /**
14
+ * Verify a client-submitted id_token against a provider's verification config.
15
+ *
16
+ * This is the single id_token verifier for every social provider. Providers no longer
17
+ * implement their own boolean `verifyIdToken`; they declare an {@link UpstreamProvider.idToken}
18
+ * config and this function performs the cryptographic check. The contract is fail-closed: a
19
+ * provider without a config (and without an integrator `verifyIdToken` override) returns
20
+ * `false`, so a forged token can never be accepted by omission.
21
+ *
22
+ * @returns `true` only when the token is authentic for the provider.
23
+ */
24
+ declare function verifyProviderIdToken(provider: UpstreamProvider<any, any>, token: string, nonce?: string): Promise<boolean>;
25
+ //#endregion
26
+ export { supportsIdTokenSignIn, verifyProviderIdToken };
@@ -0,0 +1,62 @@
1
+ import { decodeProtectedHeader, jwtVerify } from "jose";
2
+ //#region src/oauth2/verify-id-token.ts
3
+ async function sha256Hex(value) {
4
+ const data = new TextEncoder().encode(value);
5
+ const digest = await crypto.subtle.digest("SHA-256", data);
6
+ return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
7
+ }
8
+ async function nonceMatches(claimNonce, nonce, comparison = "exact") {
9
+ if (typeof claimNonce !== "string") return false;
10
+ if (claimNonce === nonce) return true;
11
+ if (comparison === "exact-or-sha256") return claimNonce === await sha256Hex(nonce);
12
+ return false;
13
+ }
14
+ /**
15
+ * Whether a provider can verify a client-submitted id_token.
16
+ *
17
+ * A provider supports id_token sign-in when it declares an {@link UpstreamProvider.idToken}
18
+ * verification config, or when the integrator supplies a `verifyIdToken` override on the
19
+ * provider options. A provider whose options set `disableIdTokenSignIn`, or that declares
20
+ * neither, rejects the client id_token sign-in path with `ID_TOKEN_NOT_SUPPORTED`.
21
+ */
22
+ function supportsIdTokenSignIn(provider) {
23
+ const options = provider.options ?? {};
24
+ if (options.disableIdTokenSignIn) return false;
25
+ return Boolean(provider.idToken || options.verifyIdToken);
26
+ }
27
+ /**
28
+ * Verify a client-submitted id_token against a provider's verification config.
29
+ *
30
+ * This is the single id_token verifier for every social provider. Providers no longer
31
+ * implement their own boolean `verifyIdToken`; they declare an {@link UpstreamProvider.idToken}
32
+ * config and this function performs the cryptographic check. The contract is fail-closed: a
33
+ * provider without a config (and without an integrator `verifyIdToken` override) returns
34
+ * `false`, so a forged token can never be accepted by omission.
35
+ *
36
+ * @returns `true` only when the token is authentic for the provider.
37
+ */
38
+ async function verifyProviderIdToken(provider, token, nonce) {
39
+ const options = provider.options ?? {};
40
+ if (options.disableIdTokenSignIn) return false;
41
+ try {
42
+ if (options.verifyIdToken) return await options.verifyIdToken(token, nonce);
43
+ const config = provider.idToken;
44
+ if (!config) return false;
45
+ if ("verify" in config) return await config.verify(token, nonce);
46
+ if (token.split(".").length !== 3) return config.allowOpaqueToken === true;
47
+ const { alg } = decodeProtectedHeader(token);
48
+ const { payload } = await jwtVerify(token, config.jwks, {
49
+ issuer: config.issuer,
50
+ audience: config.audience,
51
+ algorithms: config.algorithms ?? (alg ? [alg] : void 0),
52
+ maxTokenAge: config.maxTokenAge
53
+ });
54
+ if (nonce && !await nonceMatches(payload.nonce, nonce, config.nonceComparison)) return false;
55
+ if (config.verifyClaims && !config.verifyClaims(payload)) return false;
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+ //#endregion
62
+ export { supportsIdTokenSignIn, verifyProviderIdToken };
@@ -1,6 +1,19 @@
1
+ import { DpopReplayStore } from "./dpop.mjs";
1
2
  import { JSONWebKeySet, JWTPayload, JWTVerifyOptions } from "jose";
2
3
 
3
4
  //#region src/oauth2/verify.d.ts
5
+ type JwksFetchOptions = {
6
+ /** Jwks url or promise of a Jwks */jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
7
+ /**
8
+ * Stable object to cache the result of a function `jwksFetch` under,
9
+ * with the same TTL and kid-miss refetch rules as string sources.
10
+ * Without it, a function source is fetched on every verification.
11
+ */
12
+ jwksCacheKey?: object;
13
+ };
14
+ /**
15
+ * @internal
16
+ */
4
17
  interface VerifyAccessTokenRemote {
5
18
  /** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
6
19
  introspectUrl: string;
@@ -14,29 +27,89 @@ interface VerifyAccessTokenRemote {
14
27
  * is also still active.
15
28
  */
16
29
  force?: boolean;
30
+ /**
31
+ * Accept introspection responses that omit the `aud` claim even when a
32
+ * required `audience` is configured in `verifyOptions`.
33
+ *
34
+ * By default verification fails closed: if you configure an `audience` and
35
+ * the introspection response has no `aud` (or a mismatching one), the token
36
+ * is rejected. Some authorization servers legitimately omit `aud` from
37
+ * introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
38
+ * this if you trust the issuer to bind the token to this resource through
39
+ * another mechanism, as it skips the audience check in that case.
40
+ *
41
+ * @default false
42
+ */
43
+ allowMissingAudience?: boolean;
44
+ }
45
+ interface VerifyAccessTokenOptions {
46
+ /** Verify options */
47
+ verifyOptions: JWTVerifyOptions & Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
48
+ /** Scopes to additionally verify. Token must include all but not exact. */
49
+ scopes?: string[];
50
+ /** Required to verify access token locally */
51
+ jwksUrl?: string;
52
+ /** If provided, can verify a token remotely */
53
+ remoteVerify?: VerifyAccessTokenRemote;
54
+ }
55
+ interface VerifyAccessTokenRequestOptions extends VerifyAccessTokenOptions {
56
+ dpop?: {
57
+ proofMaxAgeSeconds?: number;
58
+ /**
59
+ * Store used to reject replayed DPoP proof `jti` values.
60
+ *
61
+ * Defaults to a process-local in-memory store, which is only safe for a
62
+ * single-instance deployment: it shares no state across instances and
63
+ * resets on cold start, so a captured proof can be replayed against
64
+ * another instance within the proof's lifetime. Supply a shared,
65
+ * persistent store (for example one backed by your database) for any
66
+ * multi-instance or serverless resource server.
67
+ */
68
+ replayStore?: DpopReplayStore;
69
+ signingAlgorithms?: readonly string[];
70
+ };
17
71
  }
72
+ interface ResourceRequestInput {
73
+ authorizationHeader: string | null | undefined;
74
+ dpopProofJwt?: string | null | undefined;
75
+ method: string;
76
+ url: string;
77
+ }
78
+ /**
79
+ * Builds a {@link ResourceRequestInput} from a standard `Request`, reading the
80
+ * `Authorization` and `DPoP` headers and the request method and URL. Resource
81
+ * servers share this so every entry point maps the wire request the same way.
82
+ */
83
+ declare function requestToResourceInput(request: Request): ResourceRequestInput;
18
84
  /**
19
85
  * Performs local verification of an access token for your APIs.
20
86
  *
21
87
  * Can also be configured for remote verification.
22
88
  */
23
- declare function verifyJwsAccessToken(token: string, opts: {
24
- /** Jwks url or promise of a Jwks */jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>); /** Verify options */
25
- verifyOptions: JWTVerifyOptions & Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
89
+ declare function verifyJwsAccessToken(token: string, opts: JwksFetchOptions & {
90
+ /** Verify options */verifyOptions: JWTVerifyOptions & Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
26
91
  }): Promise<JWTPayload>;
27
- declare function getJwks(token: string, opts: {
28
- /** Jwks url or promise of a Jwks */jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
29
- }): Promise<JSONWebKeySet>;
92
+ declare function getJwks(token: string, opts: JwksFetchOptions): Promise<JSONWebKeySet>;
30
93
  /**
31
- * Performs local verification of an access token for your API.
94
+ * Performs local verification of a bearer access token for your API.
32
95
  *
33
- * Can also be configured for remote verification.
96
+ * Can also be configured for remote verification. DPoP-bound access tokens
97
+ * require {@link verifyAccessTokenRequest}, because sender-constraining cannot
98
+ * be verified without the HTTP method, URL, Authorization scheme, DPoP proof,
99
+ * and access-token hash. This function rejects DPoP-bound tokens; reach for it
100
+ * only when you hold a raw token string and intentionally accept bearer tokens
101
+ * alone.
34
102
  */
35
- declare function verifyAccessToken(token: string, opts: {
36
- /** Verify options */verifyOptions: JWTVerifyOptions & Required<Pick<JWTVerifyOptions, "audience" | "issuer">>; /** Scopes to additionally verify. Token must include all but not exact. */
37
- scopes?: string[]; /** Required to verify access token locally */
38
- jwksUrl?: string; /** If provided, can verify a token remotely */
39
- remoteVerify?: VerifyAccessTokenRemote;
40
- }): Promise<JWTPayload>;
103
+ declare function verifyBearerToken(token: string, opts: VerifyAccessTokenOptions): Promise<JWTPayload>;
104
+ /**
105
+ * Verifies an HTTP resource request carrying an OAuth access token. This is the
106
+ * recommended resource-server entry point: it handles both bearer and
107
+ * DPoP-bound tokens, the bearer case being the request with no DPoP proof.
108
+ *
109
+ * It performs the same token validation as {@link verifyBearerToken}, then adds
110
+ * the RFC 9449 sender-constraint checks that need request context: authorization
111
+ * scheme, method, URL, DPoP proof, `ath`, and `cnf.jkt` binding.
112
+ */
113
+ declare function verifyAccessTokenRequest(request: ResourceRequestInput, opts: VerifyAccessTokenRequestOptions): Promise<JWTPayload>;
41
114
  //#endregion
42
- export { getJwks, verifyAccessToken, verifyJwsAccessToken };
115
+ export { ResourceRequestInput, VerifyAccessTokenOptions, VerifyAccessTokenRequestOptions, getJwks, requestToResourceInput, verifyAccessTokenRequest, verifyBearerToken, verifyJwsAccessToken };
@@ -1,4 +1,5 @@
1
1
  import { logger } from "../env/logger.mjs";
2
+ import { createInMemoryDpopReplayStore, enforceDpopBinding, getDpopJktFromPayload, isDpopBindingError, parseAccessTokenAuthorization } from "./dpop.mjs";
2
3
  import { APIError } from "better-call";
3
4
  import { UnsecuredJWT, createLocalJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
4
5
  import { betterFetch } from "@better-fetch/fetch";
@@ -11,8 +12,65 @@ const joseInfrastructureErrorCodes = new Set([
11
12
  function isJoseInfrastructureError(error) {
12
13
  return joseInfrastructureErrorCodes.has(error.code);
13
14
  }
14
- /** Last fetched jwks used locally in getJwks @internal */
15
- let jwks;
15
+ /**
16
+ * @internal
17
+ */
18
+ const jwksCache = /* @__PURE__ */ new Map();
19
+ /**
20
+ * Cache for function jwks sources, keyed by a caller-provided stable object.
21
+ * Entries are released with their key, so per-request keys cannot accumulate.
22
+ */
23
+ const functionJwksCache = /* @__PURE__ */ new WeakMap();
24
+ /**
25
+ * How long a cached JWKS is trusted before it is refetched
26
+ *
27
+ * @internal
28
+ */
29
+ const JWKS_CACHE_TTL_MS = 300 * 1e3;
30
+ const JWKS_NO_KID_REFETCH_COOLDOWN_MS = 30 * 1e3;
31
+ /**
32
+ * Returns the cached key set when it is within the TTL. When the token carries
33
+ * `kid`, the cached set must contain that key id; without `kid`, key selection
34
+ * is deferred to JOSE because RFC 7515 makes the header parameter optional.
35
+ */
36
+ function getFreshJwksWithKid(cached, kid) {
37
+ if (!cached) return void 0;
38
+ if (Date.now() - cached.fetchedAt >= JWKS_CACHE_TTL_MS) return void 0;
39
+ if (kid && !cached.jwks.keys.some((jwk) => jwk.kid === kid)) return;
40
+ return cached.jwks;
41
+ }
42
+ function shouldRefetchCachedJwksWithoutKid(error, resolved) {
43
+ if (!(resolved.fromCache && !resolved.kid && (error instanceof errors.JWKSNoMatchingKey || error instanceof errors.JWSSignatureVerificationFailed))) return false;
44
+ if (!resolved.noKidRefetchedAt) return true;
45
+ return Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS;
46
+ }
47
+ async function fetchJwks(jwksFetch) {
48
+ const jwks = typeof jwksFetch === "string" ? await betterFetch(jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
49
+ if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
50
+ return res.data;
51
+ }) : await jwksFetch();
52
+ if (!jwks) throw new Error("No jwks found");
53
+ return jwks;
54
+ }
55
+ /**
56
+ * Builds a {@link ResourceRequestInput} from a standard `Request`, reading the
57
+ * `Authorization` and `DPoP` headers and the request method and URL. Resource
58
+ * servers share this so every entry point maps the wire request the same way.
59
+ */
60
+ function requestToResourceInput(request) {
61
+ return {
62
+ authorizationHeader: request.headers.get("authorization"),
63
+ dpopProofJwt: request.headers.get("dpop"),
64
+ method: request.method,
65
+ url: request.url
66
+ };
67
+ }
68
+ /**
69
+ * Process-local, single-instance replay store. See the warning on
70
+ * {@link VerifyAccessTokenRequestOptions.dpop.replayStore}; multi-instance
71
+ * resource servers must pass their own shared store.
72
+ */
73
+ const defaultDpopReplayStore = createInMemoryDpopReplayStore();
16
74
  /**
17
75
  * Performs local verification of an access token for your APIs.
18
76
  *
@@ -20,7 +78,17 @@ let jwks;
20
78
  */
21
79
  async function verifyJwsAccessToken(token, opts) {
22
80
  try {
23
- const jwt = await jwtVerify(token, createLocalJWKSet(await getJwks(token, opts)), opts.verifyOptions);
81
+ const resolved = await getJwksForVerification(token, opts);
82
+ let jwt;
83
+ try {
84
+ jwt = await jwtVerify(token, createLocalJWKSet(resolved.jwks), opts.verifyOptions);
85
+ } catch (error) {
86
+ if (shouldRefetchCachedJwksWithoutKid(error, resolved)) jwt = await jwtVerify(token, createLocalJWKSet((await getJwksForVerification(token, {
87
+ ...opts,
88
+ forceRefresh: true
89
+ })).jwks), opts.verifyOptions);
90
+ else throw error;
91
+ }
24
92
  if (jwt.payload.azp) jwt.payload.client_id = jwt.payload.azp;
25
93
  return jwt.payload;
26
94
  } catch (error) {
@@ -29,6 +97,9 @@ async function verifyJwsAccessToken(token, opts) {
29
97
  }
30
98
  }
31
99
  async function getJwks(token, opts) {
100
+ return (await getJwksForVerification(token, opts)).jwks;
101
+ }
102
+ async function getJwksForVerification(token, opts) {
32
103
  let jwtHeaders;
33
104
  try {
34
105
  jwtHeaders = decodeProtectedHeader(token);
@@ -36,22 +107,65 @@ async function getJwks(token, opts) {
36
107
  if (error instanceof Error) throw error;
37
108
  throw new Error(error);
38
109
  }
39
- if (!jwtHeaders.kid) throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
40
- if (!jwks || !jwks.keys.find((jwk) => jwk.kid === jwtHeaders.kid)) {
41
- jwks = typeof opts.jwksFetch === "string" ? await betterFetch(opts.jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
42
- if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
43
- return res.data;
44
- }) : await opts.jwksFetch();
110
+ const kid = jwtHeaders.kid;
111
+ if (typeof opts.jwksFetch !== "string") {
112
+ const cacheKey = opts.jwksCacheKey;
113
+ if (!cacheKey) {
114
+ const jwks = await opts.jwksFetch();
115
+ if (!jwks) throw new Error("No jwks found");
116
+ return {
117
+ jwks,
118
+ fromCache: false,
119
+ kid
120
+ };
121
+ }
122
+ const cached = functionJwksCache.get(cacheKey);
123
+ const cachedJwks = opts.forceRefresh ? void 0 : getFreshJwksWithKid(cached, kid);
124
+ if (cachedJwks) return {
125
+ jwks: cachedJwks,
126
+ fromCache: true,
127
+ kid,
128
+ noKidRefetchedAt: cached?.noKidRefetchedAt
129
+ };
130
+ const jwks = await opts.jwksFetch();
45
131
  if (!jwks) throw new Error("No jwks found");
132
+ const fetchedAt = Date.now();
133
+ functionJwksCache.set(cacheKey, {
134
+ jwks,
135
+ fetchedAt,
136
+ ...opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}
137
+ });
138
+ return {
139
+ jwks,
140
+ fromCache: false,
141
+ kid
142
+ };
46
143
  }
47
- return jwks;
144
+ const cacheKey = opts.jwksFetch;
145
+ const cached = jwksCache.get(cacheKey);
146
+ const cachedJwks = opts.forceRefresh ? void 0 : getFreshJwksWithKid(cached, kid);
147
+ if (!cachedJwks) {
148
+ const jwks = await fetchJwks(opts.jwksFetch);
149
+ const fetchedAt = Date.now();
150
+ jwksCache.set(cacheKey, {
151
+ jwks,
152
+ fetchedAt,
153
+ ...opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}
154
+ });
155
+ return {
156
+ jwks,
157
+ fromCache: false,
158
+ kid
159
+ };
160
+ }
161
+ return {
162
+ jwks: cachedJwks,
163
+ fromCache: true,
164
+ kid,
165
+ noKidRefetchedAt: cached?.noKidRefetchedAt
166
+ };
48
167
  }
49
- /**
50
- * Performs local verification of an access token for your API.
51
- *
52
- * Can also be configured for remote verification.
53
- */
54
- async function verifyAccessToken(token, opts) {
168
+ async function verifyAccessTokenPayload(token, opts) {
55
169
  let payload;
56
170
  if (opts.jwksUrl && !opts?.remoteVerify?.force) try {
57
171
  payload = await verifyJwsAccessToken(token, {
@@ -85,8 +199,9 @@ async function verifyAccessToken(token, opts) {
85
199
  if (!introspect.active) throw new APIError("UNAUTHORIZED", { message: "token inactive" });
86
200
  try {
87
201
  const unsecuredJwt = new UnsecuredJWT(introspect).encode();
88
- const { audience: _audience, ...verifyOptions } = opts.verifyOptions;
89
- payload = (introspect.aud ? UnsecuredJWT.decode(unsecuredJwt, opts.verifyOptions) : UnsecuredJWT.decode(unsecuredJwt, verifyOptions)).payload;
202
+ const { audience: _audience, ...verifyOptionsNoAudience } = opts.verifyOptions;
203
+ const skipAudience = !introspect.aud && opts.remoteVerify.allowMissingAudience === true;
204
+ payload = UnsecuredJWT.decode(unsecuredJwt, skipAudience ? verifyOptionsNoAudience : opts.verifyOptions).payload;
90
205
  } catch (error) {
91
206
  throw new Error(error);
92
207
  }
@@ -98,5 +213,58 @@ async function verifyAccessToken(token, opts) {
98
213
  }
99
214
  return payload;
100
215
  }
216
+ function throwDpopUnauthorized(message, error) {
217
+ throw new APIError("UNAUTHORIZED", error ? {
218
+ message,
219
+ error,
220
+ error_description: message
221
+ } : { message });
222
+ }
223
+ /**
224
+ * Performs local verification of a bearer access token for your API.
225
+ *
226
+ * Can also be configured for remote verification. DPoP-bound access tokens
227
+ * require {@link verifyAccessTokenRequest}, because sender-constraining cannot
228
+ * be verified without the HTTP method, URL, Authorization scheme, DPoP proof,
229
+ * and access-token hash. This function rejects DPoP-bound tokens; reach for it
230
+ * only when you hold a raw token string and intentionally accept bearer tokens
231
+ * alone.
232
+ */
233
+ async function verifyBearerToken(token, opts) {
234
+ const payload = await verifyAccessTokenPayload(token, opts);
235
+ if (getDpopJktFromPayload(payload)) throwDpopUnauthorized("DPoP-bound access token requires verifyAccessTokenRequest", "invalid_token");
236
+ return payload;
237
+ }
238
+ /**
239
+ * Verifies an HTTP resource request carrying an OAuth access token. This is the
240
+ * recommended resource-server entry point: it handles both bearer and
241
+ * DPoP-bound tokens, the bearer case being the request with no DPoP proof.
242
+ *
243
+ * It performs the same token validation as {@link verifyBearerToken}, then adds
244
+ * the RFC 9449 sender-constraint checks that need request context: authorization
245
+ * scheme, method, URL, DPoP proof, `ath`, and `cnf.jkt` binding.
246
+ */
247
+ async function verifyAccessTokenRequest(request, opts) {
248
+ const authorization = parseAccessTokenAuthorization(request.authorizationHeader);
249
+ if (!authorization?.token) throwDpopUnauthorized("missing authorization header");
250
+ if (authorization.scheme === "Unknown") throwDpopUnauthorized("authorization scheme must be Bearer or DPoP", "invalid_token");
251
+ const payload = await verifyAccessTokenPayload(authorization.token, opts);
252
+ try {
253
+ await enforceDpopBinding({
254
+ payload,
255
+ authorization,
256
+ proofJwt: request.dpopProofJwt,
257
+ method: request.method,
258
+ url: request.url,
259
+ replayStore: opts.dpop?.replayStore ?? defaultDpopReplayStore,
260
+ proofMaxAgeSeconds: opts.dpop?.proofMaxAgeSeconds,
261
+ signingAlgorithms: opts.dpop?.signingAlgorithms
262
+ });
263
+ } catch (error) {
264
+ if (isDpopBindingError(error)) throwDpopUnauthorized(error.message, error.code);
265
+ throw error;
266
+ }
267
+ return payload;
268
+ }
101
269
  //#endregion
102
- export { getJwks, verifyAccessToken, verifyJwsAccessToken };
270
+ export { getJwks, requestToResourceInput, verifyAccessTokenRequest, verifyBearerToken, verifyJwsAccessToken };