@better-auth/core 1.7.0-rc.1 → 1.7.0-rc.3

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 (213) hide show
  1. package/dist/api/index.d.mts +28 -28
  2. package/dist/async_hooks/index.d.mts +0 -1
  3. package/dist/async_hooks/pure.index.d.mts +0 -1
  4. package/dist/context/endpoint-context.d.mts +1 -1
  5. package/dist/context/global.d.mts +0 -1
  6. package/dist/context/global.mjs +1 -1
  7. package/dist/context/request-state.d.mts +0 -1
  8. package/dist/context/request-state.mjs +8 -5
  9. package/dist/context/transaction.d.mts +8 -3
  10. package/dist/context/transaction.mjs +27 -10
  11. package/dist/db/adapter/factory.d.mts +3 -5
  12. package/dist/db/adapter/factory.mjs +7 -5
  13. package/dist/db/adapter/get-default-field-name.d.mts +2 -9
  14. package/dist/db/adapter/get-default-model-name.d.mts +1 -5
  15. package/dist/db/adapter/get-default-model-name.mjs +6 -5
  16. package/dist/db/adapter/get-field-attributes.d.mts +4 -11
  17. package/dist/db/adapter/get-field-name.d.mts +2 -9
  18. package/dist/db/adapter/get-id-field.d.mts +4 -12
  19. package/dist/db/adapter/get-model-name.d.mts +1 -5
  20. package/dist/db/adapter/index.d.mts +7 -32
  21. package/dist/db/adapter/types.d.mts +5 -17
  22. package/dist/db/adapter/utils.d.mts +0 -1
  23. package/dist/db/database-index.d.mts +44 -0
  24. package/dist/db/database-index.mjs +168 -0
  25. package/dist/db/get-tables.d.mts +7 -1
  26. package/dist/db/get-tables.mjs +47 -6
  27. package/dist/db/index.d.mts +3 -3
  28. package/dist/db/index.mjs +2 -2
  29. package/dist/db/internal.d.mts +3 -0
  30. package/dist/db/internal.mjs +3 -0
  31. package/dist/db/plugin.d.mts +4 -3
  32. package/dist/db/schema/account.d.mts +16 -3
  33. package/dist/db/schema/account.mjs +35 -2
  34. package/dist/db/schema/rate-limit.d.mts +1 -1
  35. package/dist/db/schema/rate-limit.mjs +9 -0
  36. package/dist/db/schema/session.d.mts +1 -1
  37. package/dist/db/schema/shared.d.mts +0 -1
  38. package/dist/db/schema/user.d.mts +1 -1
  39. package/dist/db/schema/verification.d.mts +1 -1
  40. package/dist/db/type.d.mts +17 -9
  41. package/dist/env/logger.d.mts +25 -25
  42. package/dist/error/codes.d.mts +0 -1
  43. package/dist/error/index.d.mts +0 -1
  44. package/dist/index.d.mts +1 -1
  45. package/dist/instrumentation/attributes.d.mts +0 -1
  46. package/dist/instrumentation/pure.index.d.mts +0 -1
  47. package/dist/instrumentation/tracer.mjs +1 -1
  48. package/dist/oauth2/authorization-params.d.mts +0 -1
  49. package/dist/oauth2/client-assertion.d.mts +3 -13
  50. package/dist/oauth2/client-credentials-token.d.mts +3 -17
  51. package/dist/oauth2/client-credentials-token.mjs +1 -4
  52. package/dist/oauth2/create-authorization-url.d.mts +3 -21
  53. package/dist/oauth2/dpop.d.mts +2 -23
  54. package/dist/oauth2/dpop.mjs +1 -1
  55. package/dist/oauth2/index.d.mts +3 -3
  56. package/dist/oauth2/index.mjs +2 -2
  57. package/dist/oauth2/oauth-provider.d.mts +94 -36
  58. package/dist/oauth2/refresh-access-token.d.mts +3 -19
  59. package/dist/oauth2/refresh-access-token.mjs +3 -9
  60. package/dist/oauth2/reject-redirects.mjs +1 -1
  61. package/dist/oauth2/token-endpoint-auth.d.mts +0 -1
  62. package/dist/oauth2/utils.d.mts +0 -1
  63. package/dist/oauth2/utils.mjs +2 -3
  64. package/dist/oauth2/validate-authorization-code.d.mts +5 -29
  65. package/dist/oauth2/verify-id-token.d.mts +3 -2
  66. package/dist/oauth2/verify-id-token.mjs +3 -3
  67. package/dist/oauth2/verify.d.mts +18 -9
  68. package/dist/oauth2/verify.mjs +68 -5
  69. package/dist/social-providers/apple.d.mts +9 -24
  70. package/dist/social-providers/apple.mjs +2 -1
  71. package/dist/social-providers/atlassian.d.mts +18 -19
  72. package/dist/social-providers/atlassian.mjs +1 -1
  73. package/dist/social-providers/cognito.d.mts +33 -22
  74. package/dist/social-providers/cognito.mjs +2 -2
  75. package/dist/social-providers/discord.d.mts +7 -20
  76. package/dist/social-providers/discord.mjs +1 -1
  77. package/dist/social-providers/dropbox.d.mts +7 -22
  78. package/dist/social-providers/dropbox.mjs +1 -1
  79. package/dist/social-providers/facebook.d.mts +19 -27
  80. package/dist/social-providers/facebook.mjs +6 -17
  81. package/dist/social-providers/figma.d.mts +7 -22
  82. package/dist/social-providers/figma.mjs +1 -1
  83. package/dist/social-providers/github.d.mts +7 -23
  84. package/dist/social-providers/github.mjs +1 -1
  85. package/dist/social-providers/gitlab.d.mts +6 -43
  86. package/dist/social-providers/gitlab.mjs +1 -1
  87. package/dist/social-providers/google.d.mts +10 -32
  88. package/dist/social-providers/google.mjs +2 -1
  89. package/dist/social-providers/huggingface.d.mts +7 -22
  90. package/dist/social-providers/huggingface.mjs +1 -1
  91. package/dist/social-providers/index.d.mts +229 -787
  92. package/dist/social-providers/kakao.d.mts +8 -33
  93. package/dist/social-providers/kakao.mjs +1 -1
  94. package/dist/social-providers/kick.d.mts +7 -22
  95. package/dist/social-providers/kick.mjs +1 -1
  96. package/dist/social-providers/line.d.mts +8 -44
  97. package/dist/social-providers/line.mjs +4 -6
  98. package/dist/social-providers/linear.d.mts +7 -21
  99. package/dist/social-providers/linear.mjs +1 -1
  100. package/dist/social-providers/linkedin.d.mts +7 -21
  101. package/dist/social-providers/linkedin.mjs +1 -1
  102. package/dist/social-providers/microsoft-entra-id.d.mts +8 -18
  103. package/dist/social-providers/microsoft-entra-id.mjs +24 -4
  104. package/dist/social-providers/naver.d.mts +27 -42
  105. package/dist/social-providers/naver.mjs +1 -1
  106. package/dist/social-providers/notion.d.mts +7 -21
  107. package/dist/social-providers/notion.mjs +1 -1
  108. package/dist/social-providers/paybin.d.mts +8 -23
  109. package/dist/social-providers/paybin.mjs +2 -1
  110. package/dist/social-providers/paypal.d.mts +8 -33
  111. package/dist/social-providers/paypal.mjs +1 -1
  112. package/dist/social-providers/polar.d.mts +7 -22
  113. package/dist/social-providers/polar.mjs +1 -1
  114. package/dist/social-providers/railway.d.mts +7 -22
  115. package/dist/social-providers/railway.mjs +1 -1
  116. package/dist/social-providers/reddit.d.mts +7 -20
  117. package/dist/social-providers/reddit.mjs +1 -1
  118. package/dist/social-providers/roblox.d.mts +7 -20
  119. package/dist/social-providers/roblox.mjs +1 -1
  120. package/dist/social-providers/salesforce.d.mts +7 -22
  121. package/dist/social-providers/salesforce.mjs +1 -1
  122. package/dist/social-providers/slack.d.mts +7 -20
  123. package/dist/social-providers/slack.mjs +1 -1
  124. package/dist/social-providers/spotify.d.mts +7 -22
  125. package/dist/social-providers/spotify.mjs +1 -1
  126. package/dist/social-providers/tiktok.d.mts +10 -22
  127. package/dist/social-providers/tiktok.mjs +1 -1
  128. package/dist/social-providers/twitch.d.mts +7 -20
  129. package/dist/social-providers/twitch.mjs +1 -1
  130. package/dist/social-providers/twitter.d.mts +29 -26
  131. package/dist/social-providers/twitter.mjs +1 -1
  132. package/dist/social-providers/vercel.d.mts +7 -22
  133. package/dist/social-providers/vercel.mjs +1 -1
  134. package/dist/social-providers/vk.d.mts +8 -24
  135. package/dist/social-providers/vk.mjs +5 -6
  136. package/dist/social-providers/wechat.d.mts +7 -19
  137. package/dist/social-providers/wechat.mjs +1 -1
  138. package/dist/social-providers/zoom.d.mts +9 -25
  139. package/dist/social-providers/zoom.mjs +1 -1
  140. package/dist/types/context.d.mts +16 -9
  141. package/dist/types/cookie.d.mts +0 -1
  142. package/dist/types/helper.d.mts +1 -1
  143. package/dist/types/index.d.mts +1 -1
  144. package/dist/types/init-options.d.mts +37 -26
  145. package/dist/types/plugin-client.d.mts +1 -3
  146. package/dist/types/plugin.d.mts +2 -2
  147. package/dist/utils/async.d.mts +0 -1
  148. package/dist/utils/db.d.mts +1 -0
  149. package/dist/utils/deprecate.d.mts +0 -1
  150. package/dist/utils/error-codes.d.mts +2 -2
  151. package/dist/utils/host.d.mts +27 -1
  152. package/dist/utils/host.mjs +1 -1
  153. package/dist/utils/ip.d.mts +1 -0
  154. package/dist/utils/ip.mjs +1 -1
  155. package/dist/utils/is-api-error.d.mts +0 -1
  156. package/dist/utils/redirect-uri.d.mts +6 -2
  157. package/dist/utils/redirect-uri.mjs +11 -1
  158. package/package.json +10 -2
  159. package/src/context/request-state.ts +26 -6
  160. package/src/context/transaction.ts +37 -18
  161. package/src/db/adapter/factory.ts +34 -28
  162. package/src/db/adapter/get-default-model-name.ts +23 -14
  163. package/src/db/database-index.ts +351 -0
  164. package/src/db/get-tables.ts +89 -9
  165. package/src/db/index.ts +4 -0
  166. package/src/db/internal.ts +12 -0
  167. package/src/db/plugin.ts +3 -1
  168. package/src/db/schema/account.ts +27 -1
  169. package/src/db/schema/user.ts +1 -1
  170. package/src/db/type.ts +12 -0
  171. package/src/error/codes.ts +1 -0
  172. package/src/oauth2/index.ts +5 -0
  173. package/src/oauth2/oauth-provider.ts +105 -36
  174. package/src/oauth2/verify-id-token.ts +4 -2
  175. package/src/oauth2/verify.ts +121 -12
  176. package/src/social-providers/apple.ts +2 -1
  177. package/src/social-providers/atlassian.ts +1 -1
  178. package/src/social-providers/cognito.ts +2 -2
  179. package/src/social-providers/discord.ts +1 -1
  180. package/src/social-providers/dropbox.ts +1 -1
  181. package/src/social-providers/facebook.ts +21 -28
  182. package/src/social-providers/figma.ts +1 -1
  183. package/src/social-providers/github.ts +1 -1
  184. package/src/social-providers/gitlab.ts +1 -1
  185. package/src/social-providers/google.ts +2 -1
  186. package/src/social-providers/huggingface.ts +1 -1
  187. package/src/social-providers/kakao.ts +1 -1
  188. package/src/social-providers/kick.ts +1 -1
  189. package/src/social-providers/line.ts +7 -12
  190. package/src/social-providers/linear.ts +1 -1
  191. package/src/social-providers/linkedin.ts +1 -1
  192. package/src/social-providers/microsoft-entra-id.ts +36 -27
  193. package/src/social-providers/naver.ts +1 -1
  194. package/src/social-providers/notion.ts +1 -1
  195. package/src/social-providers/paybin.ts +2 -1
  196. package/src/social-providers/paypal.ts +1 -1
  197. package/src/social-providers/polar.ts +1 -1
  198. package/src/social-providers/railway.ts +1 -1
  199. package/src/social-providers/reddit.ts +1 -1
  200. package/src/social-providers/roblox.ts +1 -1
  201. package/src/social-providers/salesforce.ts +1 -1
  202. package/src/social-providers/slack.ts +1 -1
  203. package/src/social-providers/spotify.ts +1 -1
  204. package/src/social-providers/tiktok.ts +2 -2
  205. package/src/social-providers/twitch.ts +1 -1
  206. package/src/social-providers/twitter.ts +1 -1
  207. package/src/social-providers/vercel.ts +1 -1
  208. package/src/social-providers/vk.ts +2 -2
  209. package/src/social-providers/wechat.ts +1 -1
  210. package/src/social-providers/zoom.ts +1 -1
  211. package/src/types/context.ts +21 -14
  212. package/src/types/init-options.ts +15 -14
  213. package/src/utils/redirect-uri.ts +20 -0
@@ -0,0 +1,12 @@
1
+ export {
2
+ type BoundedDatabaseIndexDialect,
3
+ type DBTableIndexSource,
4
+ getDatabaseFieldIndexName,
5
+ getDatabaseIndexName,
6
+ getDatabaseIndexStringLength,
7
+ getPortableDatabaseIdentifierKey,
8
+ type ResolvedDBTableIndex,
9
+ resolveDatabaseSchemaIndexes,
10
+ resolveDatabaseTableIndexes,
11
+ } from "./database-index";
12
+ export { getAuthTablesWithResolvedIndexes } from "./get-tables";
package/src/db/plugin.ts CHANGED
@@ -1,10 +1,12 @@
1
- import type { DBFieldAttribute } from "./type";
1
+ import type { DBFieldAttribute, DBTableIndex } from "./type";
2
2
 
3
3
  export type BetterAuthPluginDBSchema = {
4
4
  [table in string]: {
5
5
  fields: {
6
6
  [field: string]: DBFieldAttribute;
7
7
  };
8
+ /** Table-level indexes, including compound indexes. */
9
+ indexes?: readonly DBTableIndex[] | undefined;
8
10
  disableMigration?: boolean | undefined;
9
11
  modelName?: string | undefined;
10
12
  };
@@ -9,7 +9,8 @@ import { coreSchema } from "./shared";
9
9
 
10
10
  export const accountSchema = coreSchema.extend({
11
11
  providerId: z.string(),
12
- accountId: z.string(),
12
+ issuer: z.string(),
13
+ providerAccountId: z.string(),
13
14
  userId: z.coerce.string(),
14
15
  accessToken: z.string().nullish(),
15
16
  refreshToken: z.string().nullish(),
@@ -37,6 +38,31 @@ export const accountSchema = coreSchema.extend({
37
38
 
38
39
  export type BaseAccount = z.infer<typeof accountSchema>;
39
40
 
41
+ /** The stable provider-side key used to recognize an account. */
42
+ export type AccountKey = Readonly<
43
+ Pick<BaseAccount, "issuer" | "providerAccountId">
44
+ >;
45
+
46
+ function encodeAccountIssuerProviderId(providerId: string): string {
47
+ return encodeURIComponent(providerId);
48
+ }
49
+
50
+ /**
51
+ * Creates the synthetic issuer used by providers without an issuer of their own.
52
+ */
53
+ export function createLocalAccountIssuer(providerId: string): string {
54
+ return `local:${encodeAccountIssuerProviderId(providerId)}`;
55
+ }
56
+
57
+ /**
58
+ * Creates the synthetic issuer used by OAuth providers without an issuer of
59
+ * their own. OAuth identities use a distinct namespace so a provider ID
60
+ * cannot collide with an internal local authentication method.
61
+ */
62
+ export function createOAuthAccountIssuer(providerId: string): string {
63
+ return `local:oauth:${encodeAccountIssuerProviderId(providerId)}`;
64
+ }
65
+
40
66
  /**
41
67
  * Account schema type used by better-auth, note that it's possible that account could have additional fields
42
68
  */
@@ -9,7 +9,7 @@ import { coreSchema } from "./shared";
9
9
  export const userSchema = coreSchema.extend({
10
10
  // TODO(#9124): widen to nullish in v2. OAuth providers (Discord phone-only,
11
11
  // Apple subsequent sign-ins, etc.) can legitimately omit email; identity
12
- // must key on (providerId, accountId) per OpenID Connect Core §5.7.
12
+ // must key on (issuer, providerAccountId) per OpenID Connect Core §5.7.
13
13
  email: z.string().transform((val) => val.toLowerCase()),
14
14
  emailVerified: z.boolean().default(false),
15
15
  name: z.string(),
package/src/db/type.ts CHANGED
@@ -281,6 +281,16 @@ export type DBFieldAttribute<T extends DBFieldType = DBFieldType> = {
281
281
  type: T;
282
282
  } & DBFieldAttributeConfig;
283
283
 
284
+ /** A database index spanning one or more logical schema fields. */
285
+ export interface DBTableIndex {
286
+ /** One to sixteen logical field names included in the index, in index order. */
287
+ fields: readonly [string, ...string[]];
288
+ /** Portable database index name of at most 63 UTF-8 bytes. */
289
+ name?: string | undefined;
290
+ /** Whether the indexed field tuple must be unique. */
291
+ unique?: boolean | undefined;
292
+ }
293
+
284
294
  export type BetterAuthDBSchema = Record<
285
295
  string,
286
296
  {
@@ -292,6 +302,8 @@ export type BetterAuthDBSchema = Record<
292
302
  * The fields of the table
293
303
  */
294
304
  fields: Record<string, DBFieldAttribute>;
305
+ /** Table-level indexes, including compound indexes. */
306
+ indexes?: readonly DBTableIndex[] | undefined;
295
307
  /**
296
308
  * Whether to disable migrations for this table
297
309
  * @default false
@@ -1,3 +1,4 @@
1
+ import type {} from "@better-auth/core";
1
2
  import { defineErrorCodes } from "../utils/error-codes";
2
3
 
3
4
  declare module "@better-auth/core" {
@@ -65,7 +65,10 @@ export {
65
65
  export type {
66
66
  OAuth2Tokens,
67
67
  OAuth2UserInfo,
68
+ OAuthAccountKeyContext,
69
+ OAuthAccountSubject,
68
70
  OAuthIdTokenConfig,
71
+ OAuthMappedUser,
69
72
  OAuthProvider,
70
73
  OAuthRefreshContext,
71
74
  ProviderOptions,
@@ -97,7 +100,9 @@ export type {
97
100
  VerifyAccessTokenRequestOptions,
98
101
  } from "./verify";
99
102
  export {
103
+ createInsufficientScopeError,
100
104
  getJwks,
105
+ isInsufficientScopeError,
101
106
  requestToResourceInput,
102
107
  verifyAccessTokenRequest,
103
108
  verifyBearerToken,
@@ -1,5 +1,9 @@
1
1
  import type { JWTVerifyGetKey } from "jose";
2
- import type { Awaitable, LiteralString } from "../types";
2
+ import type {
3
+ Awaitable,
4
+ GenericEndpointContext,
5
+ LiteralString,
6
+ } from "../types";
3
7
 
4
8
  /**
5
9
  * id_token verification config for a social provider.
@@ -51,7 +55,11 @@ export type OAuthIdTokenConfig =
51
55
  * Custom verifier for providers that cannot verify against a local JWKS, such as a
52
56
  * remote verification endpoint (e.g. LINE).
53
57
  */
54
- verify: (token: string, nonce?: string) => Promise<boolean>;
58
+ verify: (
59
+ token: string,
60
+ nonce?: string,
61
+ ctx?: GenericEndpointContext,
62
+ ) => Promise<boolean>;
55
63
  };
56
64
 
57
65
  export interface OAuth2Tokens {
@@ -69,14 +77,52 @@ export interface OAuth2Tokens {
69
77
  raw?: Record<string, unknown> | undefined;
70
78
  }
71
79
 
80
+ /** Mutable local-user attributes normalized from an OAuth provider profile. */
72
81
  export type OAuth2UserInfo = {
73
- id: string | number;
82
+ /** Provider identity belongs in raw profile data and `accountSubject`. */
83
+ id?: never;
74
84
  name?: string | undefined;
75
85
  email?: (string | null) | undefined;
76
86
  image?: string | undefined;
77
87
  emailVerified: boolean;
78
88
  };
79
89
 
90
+ /**
91
+ * Verified provider data available when deriving a stable OAuth account key.
92
+ *
93
+ * Account-key resolvers must use the raw provider profile or verified token
94
+ * response. They never receive the mapped local user, so profile mapping
95
+ * cannot redefine provider identity.
96
+ */
97
+ export interface OAuthAccountKeyContext<Profile extends object = object> {
98
+ tokens: OAuth2Tokens;
99
+ profile: Profile;
100
+ }
101
+
102
+ /**
103
+ * Resolves one part of an account key from a profile returned by the same
104
+ * provider. The method-derived callback keeps that profile pairing intact when
105
+ * providers with different profile shapes share an `OAuthProvider[]`.
106
+ */
107
+ type OAuthAccountKeyResolver<Profile extends object, Value> = {
108
+ resolve(context: OAuthAccountKeyContext<Profile>): Awaitable<Value>;
109
+ }["resolve"];
110
+
111
+ /** Resolves the stable provider subject used to build an OAuth account key. */
112
+ export type OAuthAccountSubject<Profile extends object = object> =
113
+ OAuthAccountKeyResolver<Profile, string | number>;
114
+
115
+ /** Mutable local-user attributes returned by `mapProfileToUser`. */
116
+ export type OAuthMappedUser = {
117
+ /** Provider identity is defined by `accountSubject`, not local user mapping. */
118
+ id?: never;
119
+ name?: string;
120
+ email?: string | null;
121
+ image?: string;
122
+ emailVerified?: boolean;
123
+ [key: string]: unknown;
124
+ };
125
+
80
126
  /**
81
127
  * Request metadata available to provider refresh hooks.
82
128
  *
@@ -90,8 +136,8 @@ export interface OAuthRefreshContext {
90
136
  }
91
137
 
92
138
  export interface OAuthProvider<
93
- T extends Record<string, any> = Record<string, any>,
94
- O extends Record<string, any> = Partial<ProviderOptions>,
139
+ T extends object = object,
140
+ O extends object = Partial<ProviderOptions>,
95
141
  > {
96
142
  id: LiteralString;
97
143
  /**
@@ -127,6 +173,15 @@ export interface OAuthProvider<
127
173
  additionalParams?: Record<string, string> | undefined;
128
174
  }) => Awaitable<URL>;
129
175
  name: string;
176
+ /**
177
+ * Stable subject that identifies the provider account.
178
+ *
179
+ * Read this value from the raw, provider-verified profile. For OpenID
180
+ * Connect providers, use the `sub` claim. For OAuth providers, use the
181
+ * provider's documented immutable user identifier. Never derive this value
182
+ * from `mapProfileToUser`.
183
+ */
184
+ accountSubject: OAuthAccountSubject<T>;
130
185
  validateAuthorizationCode: (data: {
131
186
  code: string;
132
187
  redirectURI: string;
@@ -173,6 +228,17 @@ export interface OAuthProvider<
173
228
  ) => Promise<OAuth2Tokens>)
174
229
  | undefined;
175
230
  revokeToken?: ((token: string) => Promise<void>) | undefined;
231
+ /**
232
+ * Builds an OpenID Connect RP-Initiated Logout URL for this provider.
233
+ * Returns `null` when provider logout is unavailable or disabled.
234
+ */
235
+ createEndSessionURL?:
236
+ | ((data: {
237
+ idToken?: string | null | undefined;
238
+ postLogoutRedirectURI?: string | undefined;
239
+ state?: string | undefined;
240
+ }) => Awaitable<URL | null>)
241
+ | undefined;
176
242
  /**
177
243
  * Declarative id_token verification config consumed by the shared
178
244
  * `verifyProviderIdToken` verifier. Providers set this instead of implementing a boolean
@@ -185,6 +251,16 @@ export interface OAuthProvider<
185
251
  * against this value to prevent authorization server mix-up attacks.
186
252
  */
187
253
  issuer?: string | undefined;
254
+ /**
255
+ * Stable issuer used with the provider subject to recognize an account.
256
+ *
257
+ * Use the validated OpenID Connect issuer for OIDC providers. A resolver is
258
+ * supported for tenant-specific issuers and receives only provider-verified
259
+ * data. OAuth providers without an issuer omit this property and are scoped
260
+ * to the synthetic `local:oauth:<encoded providerId>` issuer, where the
261
+ * provider ID segment is percent-encoded.
262
+ */
263
+ accountIssuer?: string | OAuthAccountKeyResolver<T, string> | undefined;
188
264
  /**
189
265
  * Require shared OAuth redirect routes to bind ID-token verification to an
190
266
  * authorization request nonce. When true, routes generate `idTokenNonce`,
@@ -218,7 +294,15 @@ export interface OAuthProvider<
218
294
  options?: O | undefined;
219
295
  }
220
296
 
221
- export type ProviderOptions<Profile extends Record<string, any> = any> = {
297
+ /**
298
+ * Maps a provider-specific profile while remaining compatible with the erased
299
+ * profile type used by shared OAuth helpers.
300
+ */
301
+ type OAuthProfileMapper<Profile extends object> = {
302
+ map(profile: Profile): Awaitable<OAuthMappedUser>;
303
+ }["map"];
304
+
305
+ export type ProviderOptions<Profile extends object = object> = {
222
306
  /**
223
307
  * The client ID of your application.
224
308
  *
@@ -262,25 +346,28 @@ export type ProviderOptions<Profile extends Record<string, any> = any> = {
262
346
  */
263
347
  disableIdTokenSignIn?: boolean | undefined;
264
348
  /**
265
- * verifyIdToken function to verify the id token
349
+ * verifyIdToken function to verify the id token.
350
+ *
351
+ * The optional endpoint context exposes request metadata to custom
352
+ * verifiers without coupling built-in provider verification to a runtime.
266
353
  */
267
354
  verifyIdToken?:
268
- | ((token: string, nonce?: string) => Promise<boolean>)
355
+ | ((
356
+ token: string,
357
+ nonce?: string,
358
+ ctx?: GenericEndpointContext,
359
+ ) => Promise<boolean>)
269
360
  | undefined;
270
361
  /**
271
362
  * Custom function to get user info from the provider
363
+ *
364
+ * `data` must preserve the declared raw profile shape because account-key
365
+ * resolvers consume it after this hook returns.
272
366
  */
273
367
  getUserInfo?:
274
368
  | ((token: OAuth2Tokens) => Promise<{
275
- user: {
276
- id: string;
277
- name?: string;
278
- email?: string | null;
279
- image?: string;
280
- emailVerified: boolean;
281
- [key: string]: any;
282
- };
283
- data: any;
369
+ user: OAuth2UserInfo & Record<string, unknown>;
370
+ data: Profile;
284
371
  } | null>)
285
372
  | undefined;
286
373
  /**
@@ -293,25 +380,7 @@ export type ProviderOptions<Profile extends Record<string, any> = any> = {
293
380
  * Custom function to map the provider profile to a
294
381
  * user.
295
382
  */
296
- mapProfileToUser?:
297
- | ((profile: Profile) =>
298
- | {
299
- id?: string;
300
- name?: string;
301
- email?: string | null;
302
- image?: string;
303
- emailVerified?: boolean;
304
- [key: string]: any;
305
- }
306
- | Promise<{
307
- id?: string;
308
- name?: string;
309
- email?: string | null;
310
- image?: string;
311
- emailVerified?: boolean;
312
- [key: string]: any;
313
- }>)
314
- | undefined;
383
+ mapProfileToUser?: OAuthProfileMapper<Profile> | undefined;
315
384
  /**
316
385
  * Disable implicit sign up for new users. When set to true for the provider,
317
386
  * sign-in need to be called with with requestSignUp as true to create new users.
@@ -1,4 +1,5 @@
1
1
  import { decodeProtectedHeader, jwtVerify } from "jose";
2
+ import type { GenericEndpointContext } from "../types";
2
3
  import type { OAuthProvider, ProviderOptions } from "./oauth-provider";
3
4
 
4
5
  type ProviderWithIdTokenConfig = Pick<OAuthProvider, "idToken" | "options">;
@@ -59,6 +60,7 @@ export async function verifyProviderIdToken(
59
60
  provider: ProviderWithIdTokenConfig,
60
61
  token: string,
61
62
  nonce?: string,
63
+ ctx?: GenericEndpointContext,
62
64
  ): Promise<boolean> {
63
65
  const options = (provider.options ?? {}) as Partial<ProviderOptions>;
64
66
  if (options.disableIdTokenSignIn) {
@@ -69,14 +71,14 @@ export async function verifyProviderIdToken(
69
71
  // escaping to the caller as a server error.
70
72
  try {
71
73
  if (options.verifyIdToken) {
72
- return await options.verifyIdToken(token, nonce);
74
+ return await options.verifyIdToken(token, nonce, ctx);
73
75
  }
74
76
  const config = provider.idToken;
75
77
  if (!config) {
76
78
  return false;
77
79
  }
78
80
  if ("verify" in config) {
79
- return await config.verify(token, nonce);
81
+ return await config.verify(token, nonce, ctx);
80
82
  }
81
83
  // Opaque (non-JWS) tokens carry no signature to check. They are accepted only when the
82
84
  // provider opts in, in which case getUserInfo resolves identity from the access token via
@@ -164,8 +164,16 @@ export interface VerifyAccessTokenOptions {
164
164
  /** Verify options */
165
165
  verifyOptions: JWTVerifyOptions &
166
166
  Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
167
- /** Scopes to additionally verify. Token must include all but not exact. */
168
- scopes?: string[];
167
+ /** Scopes the token must satisfy. */
168
+ requiredScopes?: readonly string[];
169
+ /**
170
+ * Determines whether a required scope is satisfied by the granted scope
171
+ * set. Defaults to exact set membership.
172
+ */
173
+ isScopeSatisfied?: (
174
+ requiredScope: string,
175
+ grantedScopes: ReadonlySet<string>,
176
+ ) => boolean;
169
177
  /** Required to verify access token locally */
170
178
  jwksUrl?: string;
171
179
  /** If provided, can verify a token remotely */
@@ -442,23 +450,122 @@ async function verifyAccessTokenPayload(
442
450
  message: `no token payload`,
443
451
  });
444
452
 
445
- // Check scopes if provided
446
- if (opts.scopes) {
447
- const validScopes = new Set(
448
- (payload.scope as string | undefined)?.split(" "),
453
+ const grantedScopes = parseGrantedScopes(payload.scope);
454
+
455
+ // Check scopes if provided.
456
+ if (opts.requiredScopes) {
457
+ const isScopeSatisfied =
458
+ opts.isScopeSatisfied ??
459
+ ((requiredScope: string, scopes: ReadonlySet<string>) =>
460
+ scopes.has(requiredScope));
461
+ // RFC 6750 §3.1: report every missing scope at once. Challenging with one
462
+ // scope at a time costs the user a browser round-trip per scope.
463
+ const missingScopes = opts.requiredScopes.filter(
464
+ (scope) => !isScopeSatisfied(scope, grantedScopes),
449
465
  );
450
- for (const sc of opts.scopes) {
451
- if (!validScopes.has(sc)) {
452
- throw new APIError("FORBIDDEN", {
453
- message: `invalid scope ${sc}`,
454
- });
455
- }
466
+ if (missingScopes.length > 0) {
467
+ throw createInsufficientScopeError(missingScopes);
456
468
  }
457
469
  }
458
470
 
459
471
  return payload;
460
472
  }
461
473
 
474
+ /**
475
+ * Build the RFC 6750 §3.1 insufficient-scope failure: the access token is valid
476
+ * but lacks scopes the operation needs.
477
+ *
478
+ * Resource-server challenge builders turn this into a `403` carrying a
479
+ * `WWW-Authenticate: Bearer error="insufficient_scope"` challenge that names
480
+ * `scopes`, so the client knows what to request when it re-authorizes. Throw it
481
+ * from a route handler to challenge for scopes only that operation needs; a
482
+ * plain `FORBIDDEN` stays a plain `403`, since a permission denial the client
483
+ * cannot fix by re-authorizing must not send the user through consent again.
484
+ *
485
+ * @param requiredScopes - Every scope the operation requires but the token lacks.
486
+ * @param description - RFC 6750 `error_description` text. It must use the
487
+ * printable ASCII character set allowed by the specification.
488
+ */
489
+ const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5b\x5d-\x7e]+$/;
490
+ const OAUTH_ERROR_DESCRIPTION_PATTERN = /^[\x20-\x21\x23-\x5b\x5d-\x7e]+$/;
491
+ const insufficientScopeErrors = new WeakSet<APIError>();
492
+
493
+ function isOAuthScopeToken(value: string): boolean {
494
+ return OAUTH_SCOPE_TOKEN_PATTERN.test(value);
495
+ }
496
+
497
+ function validateScopeTokens(scopes: readonly string[], label: string): void {
498
+ for (const scope of scopes) {
499
+ if (!isOAuthScopeToken(scope)) {
500
+ throw new TypeError(`invalid ${label}: ${JSON.stringify(scope)}`);
501
+ }
502
+ }
503
+ }
504
+
505
+ function validateRequiredScopes(opts: VerifyAccessTokenOptions): void {
506
+ if (opts.requiredScopes) {
507
+ validateScopeTokens(opts.requiredScopes, "required scope");
508
+ }
509
+ }
510
+
511
+ function parseGrantedScopes(scope: unknown): ReadonlySet<string> {
512
+ if (scope === undefined) return new Set();
513
+ if (
514
+ typeof scope !== "string" ||
515
+ scope.length === 0 ||
516
+ scope.split(" ").some((token) => !isOAuthScopeToken(token))
517
+ ) {
518
+ throw new APIError("UNAUTHORIZED", {
519
+ message: "access token scope claim is invalid",
520
+ error: "invalid_token",
521
+ error_description: "access token scope claim is invalid",
522
+ });
523
+ }
524
+ return new Set(scope.split(" "));
525
+ }
526
+
527
+ export function createInsufficientScopeError(
528
+ requiredScopes: readonly string[],
529
+ description = `access token is missing required scope: ${requiredScopes.join(" ")}`,
530
+ ): APIError {
531
+ if (requiredScopes.length === 0) {
532
+ throw new TypeError("requiredScopes must contain at least one scope");
533
+ }
534
+ validateScopeTokens(requiredScopes, "required scope");
535
+ if (
536
+ typeof description !== "string" ||
537
+ !OAUTH_ERROR_DESCRIPTION_PATTERN.test(description)
538
+ ) {
539
+ throw new TypeError("invalid error_description");
540
+ }
541
+ const error = new APIError("FORBIDDEN", {
542
+ message: description,
543
+ error: "insufficient_scope",
544
+ error_description: description,
545
+ scope: [...new Set(requiredScopes)].join(" "),
546
+ });
547
+ insufficientScopeErrors.add(error);
548
+ return error;
549
+ }
550
+
551
+ /**
552
+ * Returns whether an error is a typed RFC 6750 insufficient-scope failure.
553
+ */
554
+ export function isInsufficientScopeError(error: unknown): error is APIError {
555
+ if (
556
+ !(error instanceof APIError) ||
557
+ !insufficientScopeErrors.has(error) ||
558
+ error.status !== "FORBIDDEN"
559
+ )
560
+ return false;
561
+ const body = error.body as { error?: unknown; scope?: unknown } | undefined;
562
+ if (body?.error !== "insufficient_scope" || typeof body.scope !== "string") {
563
+ return false;
564
+ }
565
+ const scopes = body.scope.split(" ");
566
+ return scopes.length > 0 && scopes.every(isOAuthScopeToken);
567
+ }
568
+
462
569
  function throwDpopUnauthorized(
463
570
  message: string,
464
571
  error?: "invalid_dpop_proof" | "invalid_token",
@@ -489,6 +596,7 @@ export async function verifyBearerToken(
489
596
  token: string,
490
597
  opts: VerifyAccessTokenOptions,
491
598
  ) {
599
+ validateRequiredScopes(opts);
492
600
  const payload = await verifyAccessTokenPayload(token, opts);
493
601
  if (getDpopJktFromPayload(payload)) {
494
602
  throwDpopUnauthorized(
@@ -512,6 +620,7 @@ export async function verifyAccessTokenRequest(
512
620
  request: ResourceRequestInput,
513
621
  opts: VerifyAccessTokenRequestOptions,
514
622
  ) {
623
+ validateRequiredScopes(opts);
515
624
  const authorization = parseAccessTokenAuthorization(
516
625
  request.authorizationHeader,
517
626
  );
@@ -82,6 +82,8 @@ export const apple = (options: AppleOptions) => {
82
82
  return {
83
83
  id: "apple",
84
84
  name: "Apple",
85
+ accountSubject: ({ profile }) => profile.sub,
86
+ accountIssuer: "https://appleid.apple.com",
85
87
  async createAuthorizationURL({
86
88
  state,
87
89
  scopes,
@@ -174,7 +176,6 @@ export const apple = (options: AppleOptions) => {
174
176
  const userMap = await options.mapProfileToUser?.(enrichedProfile);
175
177
  return {
176
178
  user: {
177
- id: profile.sub,
178
179
  name: enrichedProfile.name,
179
180
  emailVerified: emailVerified,
180
181
  email: profile.email,
@@ -34,6 +34,7 @@ export const atlassian = (options: AtlassianOptions) => {
34
34
  return {
35
35
  id: "atlassian",
36
36
  name: "Atlassian",
37
+ accountSubject: ({ profile }) => profile.account_id,
37
38
 
38
39
  async createAuthorizationURL({
39
40
  state,
@@ -120,7 +121,6 @@ export const atlassian = (options: AtlassianOptions) => {
120
121
 
121
122
  return {
122
123
  user: {
123
- id: profile.account_id,
124
124
  name: profile.name,
125
125
  email: profile.email,
126
126
  image: profile.picture,
@@ -73,6 +73,8 @@ export const cognito = (options: CognitoOptions) => {
73
73
  return {
74
74
  id: "cognito",
75
75
  name: "Cognito",
76
+ accountSubject: ({ profile }) => profile.sub,
77
+ accountIssuer: `https://cognito-idp.${options.region}.amazonaws.com/${options.userPoolId}`,
76
78
  async createAuthorizationURL({
77
79
  state,
78
80
  scopes,
@@ -184,7 +186,6 @@ export const cognito = (options: CognitoOptions) => {
184
186
 
185
187
  return {
186
188
  user: {
187
- id: profile.sub,
188
189
  name: enrichedProfile.name,
189
190
  email: profile.email,
190
191
  image: profile.picture,
@@ -213,7 +214,6 @@ export const cognito = (options: CognitoOptions) => {
213
214
  const userMap = await options.mapProfileToUser?.(userInfo);
214
215
  return {
215
216
  user: {
216
- id: userInfo.sub,
217
217
  name:
218
218
  userInfo.name ||
219
219
  userInfo.given_name ||
@@ -88,6 +88,7 @@ export const discord = (options: DiscordOptions) => {
88
88
  return {
89
89
  id: "discord",
90
90
  name: "Discord",
91
+ accountSubject: ({ profile }) => profile.id,
91
92
  createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
92
93
  const _scopes = options.disableDefaultScope ? [] : ["identify", "email"];
93
94
  if (scopes) _scopes.push(...scopes);
@@ -159,7 +160,6 @@ export const discord = (options: DiscordOptions) => {
159
160
  const userMap = await options.mapProfileToUser?.(profile);
160
161
  return {
161
162
  user: {
162
- id: profile.id,
163
163
  name: profile.global_name || profile.username || "",
164
164
  email: profile.email,
165
165
  emailVerified: profile.verified,
@@ -31,6 +31,7 @@ export const dropbox = (options: DropboxOptions) => {
31
31
  return {
32
32
  id: "dropbox",
33
33
  name: "Dropbox",
34
+ accountSubject: ({ profile }) => profile.account_id,
34
35
  createAuthorizationURL: async ({
35
36
  state,
36
37
  scopes,
@@ -99,7 +100,6 @@ export const dropbox = (options: DropboxOptions) => {
99
100
  const userMap = await options.mapProfileToUser?.(profile);
100
101
  return {
101
102
  user: {
102
- id: profile.account_id,
103
103
  name: profile.name?.display_name,
104
104
  email: profile.email,
105
105
  emailVerified: profile.email_verified || false,