@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
@@ -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 {
@@ -14,6 +15,14 @@ import {
14
15
  UnsecuredJWT,
15
16
  } from "jose";
16
17
  import { logger } from "../env";
18
+ import type { DpopReplayStore } from "./dpop";
19
+ import {
20
+ createInMemoryDpopReplayStore,
21
+ enforceDpopBinding,
22
+ getDpopJktFromPayload,
23
+ isDpopBindingError,
24
+ parseAccessTokenAuthorization,
25
+ } from "./dpop";
17
26
 
18
27
  const joseInfrastructureErrorCodes = new Set([
19
28
  joseErrors.JWKSTimeout.code,
@@ -25,8 +34,102 @@ function isJoseInfrastructureError(error: joseErrors.JOSEError) {
25
34
  return joseInfrastructureErrorCodes.has(error.code);
26
35
  }
27
36
 
28
- /** Last fetched jwks used locally in getJwks @internal */
29
- let jwks: JSONWebKeySet | undefined;
37
+ interface JwksCacheEntry {
38
+ jwks: JSONWebKeySet;
39
+ fetchedAt: number;
40
+ noKidRefetchedAt?: number | undefined;
41
+ }
42
+
43
+ type JwksFetchOptions = {
44
+ /** Jwks url or promise of a Jwks */
45
+ jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
46
+ /**
47
+ * Stable object to cache the result of a function `jwksFetch` under,
48
+ * with the same TTL and kid-miss refetch rules as string sources.
49
+ * Without it, a function source is fetched on every verification.
50
+ */
51
+ jwksCacheKey?: object;
52
+ };
53
+
54
+ type ResolvedJwks = {
55
+ jwks: JSONWebKeySet;
56
+ fromCache: boolean;
57
+ kid: string | undefined;
58
+ noKidRefetchedAt?: number | undefined;
59
+ };
60
+
61
+ /**
62
+ * @internal
63
+ */
64
+ export const jwksCache = new Map<string, JwksCacheEntry>();
65
+
66
+ /**
67
+ * Cache for function jwks sources, keyed by a caller-provided stable object.
68
+ * Entries are released with their key, so per-request keys cannot accumulate.
69
+ */
70
+ const functionJwksCache = new WeakMap<object, JwksCacheEntry>();
71
+
72
+ /**
73
+ * How long a cached JWKS is trusted before it is refetched
74
+ *
75
+ * @internal
76
+ */
77
+ const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
78
+ const JWKS_NO_KID_REFETCH_COOLDOWN_MS = 30 * 1000;
79
+
80
+ /**
81
+ * Returns the cached key set when it is within the TTL. When the token carries
82
+ * `kid`, the cached set must contain that key id; without `kid`, key selection
83
+ * is deferred to JOSE because RFC 7515 makes the header parameter optional.
84
+ */
85
+ function getFreshJwksWithKid(
86
+ cached: JwksCacheEntry | undefined,
87
+ kid: string | undefined,
88
+ ): JSONWebKeySet | undefined {
89
+ if (!cached) return undefined;
90
+ if (Date.now() - cached.fetchedAt >= JWKS_CACHE_TTL_MS) return undefined;
91
+ if (kid && !cached.jwks.keys.some((jwk) => jwk.kid === kid)) {
92
+ return undefined;
93
+ }
94
+ return cached.jwks;
95
+ }
96
+
97
+ function shouldRefetchCachedJwksWithoutKid(
98
+ error: unknown,
99
+ resolved: ResolvedJwks,
100
+ ) {
101
+ const isRetryableNoKidFailure =
102
+ resolved.fromCache &&
103
+ !resolved.kid &&
104
+ (error instanceof joseErrors.JWKSNoMatchingKey ||
105
+ error instanceof joseErrors.JWSSignatureVerificationFailed);
106
+ if (!isRetryableNoKidFailure) return false;
107
+ if (!resolved.noKidRefetchedAt) return true;
108
+ return (
109
+ Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS
110
+ );
111
+ }
112
+
113
+ async function fetchJwks(
114
+ jwksFetch: JwksFetchOptions["jwksFetch"],
115
+ ): Promise<JSONWebKeySet> {
116
+ const jwks =
117
+ typeof jwksFetch === "string"
118
+ ? await betterFetch<JSONWebKeySet>(jwksFetch, {
119
+ headers: {
120
+ Accept: "application/json",
121
+ },
122
+ }).then(async (res) => {
123
+ if (res.error)
124
+ throw new Error(
125
+ `Jwks failed: ${res.error.message ?? res.error.statusText}`,
126
+ );
127
+ return res.data;
128
+ })
129
+ : await jwksFetch();
130
+ if (!jwks) throw new Error("No jwks found");
131
+ return jwks;
132
+ }
30
133
 
31
134
  export interface VerifyAccessTokenRemote {
32
135
  /** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
@@ -41,8 +144,81 @@ export interface VerifyAccessTokenRemote {
41
144
  * is also still active.
42
145
  */
43
146
  force?: boolean;
147
+ /**
148
+ * Accept introspection responses that omit the `aud` claim even when a
149
+ * required `audience` is configured in `verifyOptions`.
150
+ *
151
+ * By default verification fails closed: if you configure an `audience` and
152
+ * the introspection response has no `aud` (or a mismatching one), the token
153
+ * is rejected. Some authorization servers legitimately omit `aud` from
154
+ * introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
155
+ * this if you trust the issuer to bind the token to this resource through
156
+ * another mechanism, as it skips the audience check in that case.
157
+ *
158
+ * @default false
159
+ */
160
+ allowMissingAudience?: boolean;
161
+ }
162
+
163
+ export interface VerifyAccessTokenOptions {
164
+ /** Verify options */
165
+ verifyOptions: JWTVerifyOptions &
166
+ Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
167
+ /** Scopes to additionally verify. Token must include all but not exact. */
168
+ scopes?: string[];
169
+ /** Required to verify access token locally */
170
+ jwksUrl?: string;
171
+ /** If provided, can verify a token remotely */
172
+ remoteVerify?: VerifyAccessTokenRemote;
173
+ }
174
+
175
+ export interface VerifyAccessTokenRequestOptions
176
+ extends VerifyAccessTokenOptions {
177
+ dpop?: {
178
+ proofMaxAgeSeconds?: number;
179
+ /**
180
+ * Store used to reject replayed DPoP proof `jti` values.
181
+ *
182
+ * Defaults to a process-local in-memory store, which is only safe for a
183
+ * single-instance deployment: it shares no state across instances and
184
+ * resets on cold start, so a captured proof can be replayed against
185
+ * another instance within the proof's lifetime. Supply a shared,
186
+ * persistent store (for example one backed by your database) for any
187
+ * multi-instance or serverless resource server.
188
+ */
189
+ replayStore?: DpopReplayStore;
190
+ signingAlgorithms?: readonly string[];
191
+ };
192
+ }
193
+
194
+ export interface ResourceRequestInput {
195
+ authorizationHeader: string | null | undefined;
196
+ dpopProofJwt?: string | null | undefined;
197
+ method: string;
198
+ url: string;
199
+ }
200
+
201
+ /**
202
+ * Builds a {@link ResourceRequestInput} from a standard `Request`, reading the
203
+ * `Authorization` and `DPoP` headers and the request method and URL. Resource
204
+ * servers share this so every entry point maps the wire request the same way.
205
+ */
206
+ export function requestToResourceInput(request: Request): ResourceRequestInput {
207
+ return {
208
+ authorizationHeader: request.headers.get("authorization"),
209
+ dpopProofJwt: request.headers.get("dpop"),
210
+ method: request.method,
211
+ url: request.url,
212
+ };
44
213
  }
45
214
 
215
+ /**
216
+ * Process-local, single-instance replay store. See the warning on
217
+ * {@link VerifyAccessTokenRequestOptions.dpop.replayStore}; multi-instance
218
+ * resource servers must pass their own shared store.
219
+ */
220
+ const defaultDpopReplayStore = createInMemoryDpopReplayStore();
221
+
46
222
  /**
47
223
  * Performs local verification of an access token for your APIs.
48
224
  *
@@ -50,21 +226,36 @@ export interface VerifyAccessTokenRemote {
50
226
  */
51
227
  export async function verifyJwsAccessToken(
52
228
  token: string,
53
- opts: {
54
- /** Jwks url or promise of a Jwks */
55
- jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
229
+ opts: JwksFetchOptions & {
56
230
  /** Verify options */
57
231
  verifyOptions: JWTVerifyOptions &
58
232
  Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
59
233
  },
60
234
  ) {
61
235
  try {
62
- const jwks = await getJwks(token, opts);
63
- const jwt = await jwtVerify<JWTPayload>(
64
- token,
65
- createLocalJWKSet(jwks),
66
- opts.verifyOptions,
67
- );
236
+ const resolved = await getJwksForVerification(token, opts);
237
+ let jwt: JWTVerifyResult<JWTPayload>;
238
+ try {
239
+ jwt = await jwtVerify<JWTPayload>(
240
+ token,
241
+ createLocalJWKSet(resolved.jwks),
242
+ opts.verifyOptions,
243
+ );
244
+ } catch (error) {
245
+ if (shouldRefetchCachedJwksWithoutKid(error, resolved)) {
246
+ const refreshed = await getJwksForVerification(token, {
247
+ ...opts,
248
+ forceRefresh: true,
249
+ });
250
+ jwt = await jwtVerify<JWTPayload>(
251
+ token,
252
+ createLocalJWKSet(refreshed.jwks),
253
+ opts.verifyOptions,
254
+ );
255
+ } else {
256
+ throw error;
257
+ }
258
+ }
68
259
  // Return the JWT payload in introspection format
69
260
  // https://datatracker.ietf.org/doc/html/rfc7662#section-2.2
70
261
  if (jwt.payload.azp) {
@@ -77,12 +268,13 @@ export async function verifyJwsAccessToken(
77
268
  }
78
269
  }
79
270
 
80
- export async function getJwks(
271
+ export async function getJwks(token: string, opts: JwksFetchOptions) {
272
+ return (await getJwksForVerification(token, opts)).jwks;
273
+ }
274
+
275
+ async function getJwksForVerification(
81
276
  token: string,
82
- opts: {
83
- /** Jwks url or promise of a Jwks */
84
- jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
85
- },
277
+ opts: JwksFetchOptions & { forceRefresh?: boolean },
86
278
  ) {
87
279
  // Attempt to decode the token and find a matching kid in jwks
88
280
  let jwtHeaders: ProtectedHeaderParameters | undefined;
@@ -93,50 +285,70 @@ export async function getJwks(
93
285
  throw new Error(error as unknown as string);
94
286
  }
95
287
 
96
- if (!jwtHeaders.kid) {
97
- throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
98
- }
288
+ const kid = jwtHeaders.kid;
99
289
 
100
- // Fetch jwks if not set or has a different kid than the one stored
101
- if (!jwks || !jwks.keys.find((jwk) => jwk.kid === jwtHeaders.kid)) {
102
- jwks =
103
- typeof opts.jwksFetch === "string"
104
- ? await betterFetch<JSONWebKeySet>(opts.jwksFetch, {
105
- headers: {
106
- Accept: "application/json",
107
- },
108
- }).then(async (res) => {
109
- if (res.error)
110
- throw new Error(
111
- `Jwks failed: ${res.error.message ?? res.error.statusText}`,
112
- );
113
- return res.data;
114
- })
115
- : await opts.jwksFetch();
290
+ // Function sources have no usable identity of their own (callers pass
291
+ // fresh closures per request), so they are cached only under a stable
292
+ // caller-provided key object.
293
+ if (typeof opts.jwksFetch !== "string") {
294
+ const cacheKey = opts.jwksCacheKey;
295
+ if (!cacheKey) {
296
+ const jwks = await opts.jwksFetch();
297
+ if (!jwks) throw new Error("No jwks found");
298
+ return { jwks, fromCache: false, kid };
299
+ }
300
+ const cached = functionJwksCache.get(cacheKey);
301
+ const cachedJwks = opts.forceRefresh
302
+ ? undefined
303
+ : getFreshJwksWithKid(cached, kid);
304
+ if (cachedJwks) {
305
+ return {
306
+ jwks: cachedJwks,
307
+ fromCache: true,
308
+ kid,
309
+ noKidRefetchedAt: cached?.noKidRefetchedAt,
310
+ };
311
+ }
312
+ const jwks = await opts.jwksFetch();
116
313
  if (!jwks) throw new Error("No jwks found");
314
+ const fetchedAt = Date.now();
315
+ functionJwksCache.set(cacheKey, {
316
+ jwks,
317
+ fetchedAt,
318
+ ...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
319
+ });
320
+ return { jwks, fromCache: false, kid };
117
321
  }
118
322
 
119
- return jwks;
323
+ // The cache is scoped to `cacheKey`, so a token is only ever matched
324
+ // against the key set published by its own source.
325
+ const cacheKey = opts.jwksFetch;
326
+ const cached = jwksCache.get(cacheKey);
327
+ const cachedJwks = opts.forceRefresh
328
+ ? undefined
329
+ : getFreshJwksWithKid(cached, kid);
330
+ if (!cachedJwks) {
331
+ const jwks = await fetchJwks(opts.jwksFetch);
332
+ const fetchedAt = Date.now();
333
+ jwksCache.set(cacheKey, {
334
+ jwks,
335
+ fetchedAt,
336
+ ...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
337
+ });
338
+ return { jwks, fromCache: false, kid };
339
+ }
340
+
341
+ return {
342
+ jwks: cachedJwks,
343
+ fromCache: true,
344
+ kid,
345
+ noKidRefetchedAt: cached?.noKidRefetchedAt,
346
+ };
120
347
  }
121
348
 
122
- /**
123
- * Performs local verification of an access token for your API.
124
- *
125
- * Can also be configured for remote verification.
126
- */
127
- export async function verifyAccessToken(
349
+ async function verifyAccessTokenPayload(
128
350
  token: string,
129
- opts: {
130
- /** Verify options */
131
- verifyOptions: JWTVerifyOptions &
132
- Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
133
- /** Scopes to additionally verify. Token must include all but not exact. */
134
- scopes?: string[];
135
- /** Required to verify access token locally */
136
- jwksUrl?: string;
137
- /** If provided, can verify a token remotely */
138
- remoteVerify?: VerifyAccessTokenRemote;
139
- },
351
+ opts: VerifyAccessTokenOptions,
140
352
  ) {
141
353
  let payload: JWTPayload | undefined;
142
354
  // Locally verify
@@ -201,13 +413,23 @@ export async function verifyAccessToken(
201
413
  throw new APIError("UNAUTHORIZED", {
202
414
  message: "token inactive",
203
415
  });
204
- // Verifies payload using verify options (token valid through introspect)
416
+ // Verifies payload using verify options (token valid through introspect).
417
+ // Audience is enforced by default: when `verifyOptions.audience` is set
418
+ // but the introspection response omits `aud` (or it mismatches),
419
+ // `UnsecuredJWT.decode` throws and the token is rejected. Otherwise a
420
+ // token issued for a different resource/client on the same issuer would
421
+ // also pass. Only drop the audience check when the caller has explicitly
422
+ // opted in via `remoteVerify.allowMissingAudience`.
205
423
  try {
206
424
  const unsecuredJwt = new UnsecuredJWT(introspect).encode();
207
- const { audience: _audience, ...verifyOptions } = opts.verifyOptions;
208
- const verify = introspect.aud
209
- ? UnsecuredJWT.decode(unsecuredJwt, opts.verifyOptions)
210
- : UnsecuredJWT.decode(unsecuredJwt, verifyOptions);
425
+ const { audience: _audience, ...verifyOptionsNoAudience } =
426
+ opts.verifyOptions;
427
+ const skipAudience =
428
+ !introspect.aud && opts.remoteVerify.allowMissingAudience === true;
429
+ const verify = UnsecuredJWT.decode(
430
+ unsecuredJwt,
431
+ skipAudience ? verifyOptionsNoAudience : opts.verifyOptions,
432
+ );
211
433
  payload = verify.payload;
212
434
  } catch (error) {
213
435
  throw new Error(error as unknown as string);
@@ -235,3 +457,95 @@ export async function verifyAccessToken(
235
457
 
236
458
  return payload;
237
459
  }
460
+
461
+ function throwDpopUnauthorized(
462
+ message: string,
463
+ error?: "invalid_dpop_proof" | "invalid_token",
464
+ ): never {
465
+ throw new APIError(
466
+ "UNAUTHORIZED",
467
+ error
468
+ ? {
469
+ message,
470
+ error,
471
+ error_description: message,
472
+ }
473
+ : { message },
474
+ );
475
+ }
476
+
477
+ /**
478
+ * Performs local verification of a bearer access token for your API.
479
+ *
480
+ * Can also be configured for remote verification. DPoP-bound access tokens
481
+ * require {@link verifyAccessTokenRequest}, because sender-constraining cannot
482
+ * be verified without the HTTP method, URL, Authorization scheme, DPoP proof,
483
+ * and access-token hash. This function rejects DPoP-bound tokens; reach for it
484
+ * only when you hold a raw token string and intentionally accept bearer tokens
485
+ * alone.
486
+ */
487
+ export async function verifyBearerToken(
488
+ token: string,
489
+ opts: VerifyAccessTokenOptions,
490
+ ) {
491
+ const payload = await verifyAccessTokenPayload(token, opts);
492
+ if (getDpopJktFromPayload(payload)) {
493
+ throwDpopUnauthorized(
494
+ "DPoP-bound access token requires verifyAccessTokenRequest",
495
+ "invalid_token",
496
+ );
497
+ }
498
+ return payload;
499
+ }
500
+
501
+ /**
502
+ * Verifies an HTTP resource request carrying an OAuth access token. This is the
503
+ * recommended resource-server entry point: it handles both bearer and
504
+ * DPoP-bound tokens, the bearer case being the request with no DPoP proof.
505
+ *
506
+ * It performs the same token validation as {@link verifyBearerToken}, then adds
507
+ * the RFC 9449 sender-constraint checks that need request context: authorization
508
+ * scheme, method, URL, DPoP proof, `ath`, and `cnf.jkt` binding.
509
+ */
510
+ export async function verifyAccessTokenRequest(
511
+ request: ResourceRequestInput,
512
+ opts: VerifyAccessTokenRequestOptions,
513
+ ) {
514
+ const authorization = parseAccessTokenAuthorization(
515
+ request.authorizationHeader,
516
+ );
517
+ if (!authorization?.token) {
518
+ throwDpopUnauthorized("missing authorization header");
519
+ }
520
+ // RFC 6750 §2.1 / RFC 9449 §7.1: an access token is presented with the
521
+ // `Bearer` or `DPoP` scheme. Reject a scheme-less or unknown-scheme value
522
+ // rather than accept a bare token.
523
+ if (authorization.scheme === "Unknown") {
524
+ throwDpopUnauthorized(
525
+ "authorization scheme must be Bearer or DPoP",
526
+ "invalid_token",
527
+ );
528
+ }
529
+
530
+ const payload = await verifyAccessTokenPayload(authorization.token, opts);
531
+
532
+ try {
533
+ await enforceDpopBinding({
534
+ payload,
535
+ authorization,
536
+ proofJwt: request.dpopProofJwt,
537
+ method: request.method,
538
+ url: request.url,
539
+ replayStore: opts.dpop?.replayStore ?? defaultDpopReplayStore,
540
+ proofMaxAgeSeconds: opts.dpop?.proofMaxAgeSeconds,
541
+ signingAlgorithms: opts.dpop?.signingAlgorithms,
542
+ });
543
+ } catch (error) {
544
+ if (isDpopBindingError(error)) {
545
+ throwDpopUnauthorized(error.message, error.code);
546
+ }
547
+ throw error;
548
+ }
549
+
550
+ return payload;
551
+ }
@@ -1,13 +1,14 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
2
 
3
- import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
3
+ import { decodeJwt, importJWK } from "jose";
4
4
  import { logger } from "../env";
5
5
  import { APIError, BetterAuthError } from "../error";
6
- import type { OAuthProvider, ProviderOptions } from "../oauth2";
6
+ import type { ProviderOptions, UpstreamProvider } from "../oauth2";
7
7
  import {
8
8
  createAuthorizationURL,
9
9
  getPrimaryClientId,
10
10
  refreshAccessToken,
11
+ resolveRequestedScopes,
11
12
  validateAuthorizationCode,
12
13
  } from "../oauth2";
13
14
  export interface AppleProfile {
@@ -77,29 +78,14 @@ export interface AppleOptions extends ProviderOptions<AppleProfile> {
77
78
  audience?: (string | string[]) | undefined;
78
79
  }
79
80
 
80
- async function sha256Hex(value: string) {
81
- const data = new TextEncoder().encode(value);
82
- const digest = await crypto.subtle.digest("SHA-256", data);
83
- return Array.from(new Uint8Array(digest))
84
- .map((byte) => byte.toString(16).padStart(2, "0"))
85
- .join("");
86
- }
87
-
88
- async function nonceMatches(jwtNonce: unknown, nonce: string) {
89
- if (typeof jwtNonce !== "string") {
90
- return false;
91
- }
92
- if (jwtNonce === nonce) {
93
- return true;
94
- }
95
- return jwtNonce === (await sha256Hex(nonce));
96
- }
81
+ const APPLE_DEFAULT_SCOPES = ["email", "name"];
97
82
 
98
83
  export const apple = (options: AppleOptions) => {
99
84
  const tokenEndpoint = "https://appleid.apple.com/auth/token";
100
85
  return {
101
86
  id: "apple",
102
87
  name: "Apple",
88
+ callbackPath: "/callback/apple",
103
89
  async createAuthorizationURL({
104
90
  state,
105
91
  scopes,
@@ -112,21 +98,22 @@ export const apple = (options: AppleOptions) => {
112
98
  );
113
99
  throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED");
114
100
  }
115
- const _scope = options.disableDefaultScope ? [] : ["email", "name"];
116
- if (options.scope) _scope.push(...options.scope);
117
- if (scopes) _scope.push(...scopes);
118
- const url = await createAuthorizationURL({
101
+ const requestedScopes = resolveRequestedScopes(
102
+ options,
103
+ APPLE_DEFAULT_SCOPES,
104
+ scopes,
105
+ );
106
+ return createAuthorizationURL({
119
107
  id: "apple",
120
108
  options,
121
109
  authorizationEndpoint: "https://appleid.apple.com/auth/authorize",
122
- scopes: _scope,
110
+ scopes: requestedScopes,
123
111
  state,
124
112
  redirectURI,
125
113
  responseMode: "form_post",
126
114
  responseType: "code id_token",
127
115
  additionalParams,
128
116
  });
129
- return url;
130
117
  },
131
118
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
132
119
  return validateAuthorizationCode({
@@ -137,41 +124,17 @@ export const apple = (options: AppleOptions) => {
137
124
  tokenEndpoint,
138
125
  });
139
126
  },
140
- async verifyIdToken(token, nonce) {
141
- if (options.disableIdTokenSignIn) {
142
- return false;
143
- }
144
- if (options.verifyIdToken) {
145
- return options.verifyIdToken(token, nonce);
146
- }
147
- try {
148
- const decodedHeader = decodeProtectedHeader(token);
149
- const { kid, alg: jwtAlg } = decodedHeader;
150
- if (!kid || !jwtAlg) return false;
151
- const publicKey = await getApplePublicKey(kid);
152
- const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
153
- algorithms: [jwtAlg],
154
- issuer: "https://appleid.apple.com",
155
- audience:
156
- options.audience && options.audience.length
157
- ? options.audience
158
- : options.appBundleIdentifier
159
- ? options.appBundleIdentifier
160
- : options.clientId,
161
- maxTokenAge: "1h",
162
- });
163
- ["email_verified", "is_private_email"].forEach((field) => {
164
- if (jwtClaims[field] !== undefined) {
165
- jwtClaims[field] = Boolean(jwtClaims[field]);
166
- }
167
- });
168
- if (nonce && !(await nonceMatches(jwtClaims.nonce, nonce))) {
169
- return false;
170
- }
171
- return !!jwtClaims;
172
- } catch {
173
- return false;
174
- }
127
+ idToken: {
128
+ jwks: (header) => getApplePublicKey(header.kid!),
129
+ issuer: "https://appleid.apple.com",
130
+ audience:
131
+ options.audience && options.audience.length
132
+ ? options.audience
133
+ : options.appBundleIdentifier
134
+ ? options.appBundleIdentifier
135
+ : options.clientId,
136
+ maxTokenAge: "1h",
137
+ nonceComparison: "exact-or-sha256",
175
138
  },
176
139
  refreshAccessToken: options.refreshAccessToken
177
140
  ? options.refreshAccessToken
@@ -226,7 +189,7 @@ export const apple = (options: AppleOptions) => {
226
189
  };
227
190
  },
228
191
  options,
229
- } satisfies OAuthProvider<AppleProfile>;
192
+ } satisfies UpstreamProvider<AppleProfile>;
230
193
  };
231
194
 
232
195
  export const getApplePublicKey = async (kid: string) => {
@@ -1,10 +1,11 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
2
  import { logger } from "../env";
3
3
  import { BetterAuthError } from "../error";
4
- import type { OAuthProvider, ProviderOptions } from "../oauth2";
4
+ import type { ProviderOptions, UpstreamProvider } from "../oauth2";
5
5
  import {
6
6
  createAuthorizationURL,
7
7
  refreshAccessToken,
8
+ resolveRequestedScopes,
8
9
  validateAuthorizationCode,
9
10
  } from "../oauth2";
10
11
 
@@ -29,11 +30,14 @@ export interface AtlassianOptions extends ProviderOptions<AtlassianProfile> {
29
30
  clientId: string;
30
31
  }
31
32
 
33
+ const ATLASSIAN_DEFAULT_SCOPES = ["read:jira-user", "offline_access"];
34
+
32
35
  export const atlassian = (options: AtlassianOptions) => {
33
36
  const tokenEndpoint = "https://auth.atlassian.com/oauth/token";
34
37
  return {
35
38
  id: "atlassian",
36
39
  name: "Atlassian",
40
+ callbackPath: "/callback/atlassian",
37
41
 
38
42
  async createAuthorizationURL({
39
43
  state,
@@ -50,17 +54,17 @@ export const atlassian = (options: AtlassianOptions) => {
50
54
  throw new BetterAuthError("codeVerifier is required for Atlassian");
51
55
  }
52
56
 
53
- const _scopes = options.disableDefaultScope
54
- ? []
55
- : ["read:jira-user", "offline_access"];
56
- if (options.scope) _scopes.push(...options.scope);
57
- if (scopes) _scopes.push(...scopes);
57
+ const requestedScopes = resolveRequestedScopes(
58
+ options,
59
+ ATLASSIAN_DEFAULT_SCOPES,
60
+ scopes,
61
+ );
58
62
 
59
63
  return createAuthorizationURL({
60
64
  id: "atlassian",
61
65
  options,
62
66
  authorizationEndpoint: "https://auth.atlassian.com/authorize",
63
- scopes: _scopes,
67
+ scopes: requestedScopes,
64
68
  state,
65
69
  codeVerifier,
66
70
  redirectURI,
@@ -136,5 +140,5 @@ export const atlassian = (options: AtlassianOptions) => {
136
140
  },
137
141
 
138
142
  options,
139
- } satisfies OAuthProvider<AtlassianProfile>;
143
+ } satisfies UpstreamProvider<AtlassianProfile>;
140
144
  };