@better-auth/core 1.7.0-beta.0 → 1.7.0-beta.10

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 (218) hide show
  1. package/dist/api/index.d.mts +44 -1
  2. package/dist/api/index.mjs +69 -4
  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 +91 -3
  7. package/dist/db/adapter/get-id-field.mjs +1 -1
  8. package/dist/db/adapter/index.d.mts +87 -3
  9. package/dist/db/adapter/types.d.mts +1 -1
  10. package/dist/db/get-tables.mjs +2 -1
  11. package/dist/db/type.d.mts +17 -0
  12. package/dist/env/env-impl.mjs +1 -1
  13. package/dist/error/codes.d.mts +1 -0
  14. package/dist/error/codes.mjs +1 -0
  15. package/dist/error/index.d.mts +7 -0
  16. package/dist/index.d.mts +2 -2
  17. package/dist/instrumentation/api.mjs +12 -0
  18. package/dist/instrumentation/noop.mjs +42 -0
  19. package/dist/instrumentation/pure.index.d.mts +7 -0
  20. package/dist/instrumentation/pure.index.mjs +7 -0
  21. package/dist/instrumentation/tracer.mjs +6 -3
  22. package/dist/oauth2/authorization-params.d.mts +12 -0
  23. package/dist/oauth2/authorization-params.mjs +12 -0
  24. package/dist/oauth2/basic-credentials.d.mts +30 -0
  25. package/dist/oauth2/basic-credentials.mjs +64 -0
  26. package/dist/oauth2/client-assertion.d.mts +38 -22
  27. package/dist/oauth2/client-assertion.mjs +63 -28
  28. package/dist/oauth2/client-credentials-token.d.mts +19 -40
  29. package/dist/oauth2/client-credentials-token.mjs +20 -31
  30. package/dist/oauth2/create-authorization-url.d.mts +11 -1
  31. package/dist/oauth2/create-authorization-url.mjs +27 -7
  32. package/dist/oauth2/dpop.d.mts +142 -0
  33. package/dist/oauth2/dpop.mjs +246 -0
  34. package/dist/oauth2/index.d.mts +14 -9
  35. package/dist/oauth2/index.mjs +12 -8
  36. package/dist/oauth2/oauth-provider.d.mts +150 -10
  37. package/dist/oauth2/refresh-access-token.d.mts +20 -40
  38. package/dist/oauth2/refresh-access-token.mjs +38 -36
  39. package/dist/oauth2/reject-redirects.mjs +65 -0
  40. package/dist/oauth2/token-endpoint-auth.d.mts +17 -0
  41. package/dist/oauth2/token-endpoint-auth.mjs +89 -0
  42. package/dist/oauth2/utils.d.mts +23 -1
  43. package/dist/oauth2/utils.mjs +48 -2
  44. package/dist/oauth2/validate-authorization-code.d.mts +17 -52
  45. package/dist/oauth2/validate-authorization-code.mjs +28 -34
  46. package/dist/oauth2/verify-id-token.d.mts +27 -0
  47. package/dist/oauth2/verify-id-token.mjs +62 -0
  48. package/dist/oauth2/verify.d.mts +88 -15
  49. package/dist/oauth2/verify.mjs +203 -25
  50. package/dist/social-providers/apple.d.mts +16 -4
  51. package/dist/social-providers/apple.mjs +17 -24
  52. package/dist/social-providers/atlassian.d.mts +5 -1
  53. package/dist/social-providers/atlassian.mjs +6 -3
  54. package/dist/social-providers/cognito.d.mts +27 -3
  55. package/dist/social-providers/cognito.mjs +15 -25
  56. package/dist/social-providers/discord.d.mts +7 -3
  57. package/dist/social-providers/discord.mjs +16 -3
  58. package/dist/social-providers/dropbox.d.mts +5 -1
  59. package/dist/social-providers/dropbox.mjs +5 -4
  60. package/dist/social-providers/facebook.d.mts +24 -5
  61. package/dist/social-providers/facebook.mjs +54 -20
  62. package/dist/social-providers/figma.d.mts +5 -1
  63. package/dist/social-providers/figma.mjs +4 -3
  64. package/dist/social-providers/github.d.mts +6 -2
  65. package/dist/social-providers/github.mjs +5 -4
  66. package/dist/social-providers/gitlab.d.mts +5 -1
  67. package/dist/social-providers/gitlab.mjs +3 -2
  68. package/dist/social-providers/google.d.mts +53 -5
  69. package/dist/social-providers/google.mjs +69 -25
  70. package/dist/social-providers/huggingface.d.mts +5 -1
  71. package/dist/social-providers/huggingface.mjs +3 -2
  72. package/dist/social-providers/index.d.mts +221 -45
  73. package/dist/social-providers/index.mjs +2 -2
  74. package/dist/social-providers/kakao.d.mts +5 -1
  75. package/dist/social-providers/kakao.mjs +3 -2
  76. package/dist/social-providers/kick.d.mts +5 -1
  77. package/dist/social-providers/kick.mjs +3 -2
  78. package/dist/social-providers/line.d.mts +8 -2
  79. package/dist/social-providers/line.mjs +5 -6
  80. package/dist/social-providers/linear.d.mts +5 -1
  81. package/dist/social-providers/linear.mjs +3 -2
  82. package/dist/social-providers/linkedin.d.mts +7 -3
  83. package/dist/social-providers/linkedin.mjs +4 -3
  84. package/dist/social-providers/microsoft-entra-id.d.mts +41 -7
  85. package/dist/social-providers/microsoft-entra-id.mjs +37 -28
  86. package/dist/social-providers/naver.d.mts +5 -1
  87. package/dist/social-providers/naver.mjs +3 -2
  88. package/dist/social-providers/notion.d.mts +5 -1
  89. package/dist/social-providers/notion.mjs +5 -2
  90. package/dist/social-providers/paybin.d.mts +5 -1
  91. package/dist/social-providers/paybin.mjs +4 -3
  92. package/dist/social-providers/paypal.d.mts +6 -2
  93. package/dist/social-providers/paypal.mjs +19 -14
  94. package/dist/social-providers/polar.d.mts +5 -1
  95. package/dist/social-providers/polar.mjs +3 -2
  96. package/dist/social-providers/railway.d.mts +5 -1
  97. package/dist/social-providers/railway.mjs +3 -2
  98. package/dist/social-providers/reddit.d.mts +5 -1
  99. package/dist/social-providers/reddit.mjs +7 -5
  100. package/dist/social-providers/roblox.d.mts +6 -2
  101. package/dist/social-providers/roblox.mjs +12 -2
  102. package/dist/social-providers/salesforce.d.mts +5 -1
  103. package/dist/social-providers/salesforce.mjs +4 -3
  104. package/dist/social-providers/slack.d.mts +6 -2
  105. package/dist/social-providers/slack.mjs +11 -8
  106. package/dist/social-providers/spotify.d.mts +5 -1
  107. package/dist/social-providers/spotify.mjs +3 -2
  108. package/dist/social-providers/tiktok.d.mts +5 -1
  109. package/dist/social-providers/tiktok.mjs +14 -2
  110. package/dist/social-providers/twitch.d.mts +5 -1
  111. package/dist/social-providers/twitch.mjs +3 -2
  112. package/dist/social-providers/twitter.d.mts +4 -1
  113. package/dist/social-providers/twitter.mjs +2 -1
  114. package/dist/social-providers/vercel.d.mts +5 -1
  115. package/dist/social-providers/vercel.mjs +3 -2
  116. package/dist/social-providers/vk.d.mts +5 -1
  117. package/dist/social-providers/vk.mjs +3 -2
  118. package/dist/social-providers/wechat.d.mts +5 -1
  119. package/dist/social-providers/wechat.mjs +8 -2
  120. package/dist/social-providers/zoom.d.mts +5 -1
  121. package/dist/social-providers/zoom.mjs +10 -17
  122. package/dist/types/context.d.mts +55 -6
  123. package/dist/types/index.d.mts +1 -1
  124. package/dist/types/init-options.d.mts +182 -12
  125. package/dist/types/plugin-client.d.mts +12 -2
  126. package/dist/utils/async.d.mts +22 -0
  127. package/dist/utils/async.mjs +32 -0
  128. package/dist/utils/host.d.mts +147 -0
  129. package/dist/utils/host.mjs +298 -0
  130. package/dist/utils/ip.d.mts +28 -5
  131. package/dist/utils/ip.mjs +118 -4
  132. package/dist/utils/is-api-error.d.mts +6 -0
  133. package/dist/utils/is-api-error.mjs +8 -0
  134. package/dist/utils/redirect-uri.d.mts +20 -0
  135. package/dist/utils/redirect-uri.mjs +48 -0
  136. package/dist/utils/string.d.mts +5 -1
  137. package/dist/utils/string.mjs +20 -1
  138. package/dist/utils/url.d.mts +18 -1
  139. package/dist/utils/url.mjs +34 -4
  140. package/package.json +26 -16
  141. package/src/api/index.ts +121 -5
  142. package/src/context/transaction.ts +45 -12
  143. package/src/db/adapter/factory.ts +193 -7
  144. package/src/db/adapter/get-id-field.ts +2 -2
  145. package/src/db/adapter/index.ts +85 -2
  146. package/src/db/adapter/types.ts +2 -0
  147. package/src/db/get-tables.ts +9 -1
  148. package/src/db/schema/account.ts +4 -1
  149. package/src/db/schema/user.ts +3 -0
  150. package/src/db/type.ts +17 -0
  151. package/src/env/env-impl.ts +1 -2
  152. package/src/error/codes.ts +1 -0
  153. package/src/error/index.ts +9 -0
  154. package/src/instrumentation/api.ts +17 -0
  155. package/src/instrumentation/noop.ts +74 -0
  156. package/src/instrumentation/pure.index.ts +31 -0
  157. package/src/instrumentation/tracer.ts +8 -3
  158. package/src/oauth2/authorization-params.ts +28 -0
  159. package/src/oauth2/basic-credentials.ts +87 -0
  160. package/src/oauth2/client-assertion.ts +131 -58
  161. package/src/oauth2/client-credentials-token.ts +50 -74
  162. package/src/oauth2/create-authorization-url.ts +33 -7
  163. package/src/oauth2/dpop.ts +568 -0
  164. package/src/oauth2/index.ts +81 -11
  165. package/src/oauth2/oauth-provider.ts +159 -11
  166. package/src/oauth2/refresh-access-token.ts +82 -83
  167. package/src/oauth2/reject-redirects.ts +75 -0
  168. package/src/oauth2/token-endpoint-auth.ts +221 -0
  169. package/src/oauth2/utils.ts +72 -5
  170. package/src/oauth2/validate-authorization-code.ts +69 -89
  171. package/src/oauth2/verify-id-token.ts +115 -0
  172. package/src/oauth2/verify.ts +409 -78
  173. package/src/social-providers/apple.ts +30 -40
  174. package/src/social-providers/atlassian.ts +8 -1
  175. package/src/social-providers/cognito.ts +36 -39
  176. package/src/social-providers/discord.ts +22 -18
  177. package/src/social-providers/dropbox.ts +7 -5
  178. package/src/social-providers/facebook.ts +108 -52
  179. package/src/social-providers/figma.ts +8 -1
  180. package/src/social-providers/github.ts +5 -3
  181. package/src/social-providers/gitlab.ts +2 -0
  182. package/src/social-providers/google.ts +141 -39
  183. package/src/social-providers/huggingface.ts +8 -1
  184. package/src/social-providers/kakao.ts +2 -1
  185. package/src/social-providers/kick.ts +8 -1
  186. package/src/social-providers/line.ts +25 -27
  187. package/src/social-providers/linear.ts +8 -1
  188. package/src/social-providers/linkedin.ts +5 -3
  189. package/src/social-providers/microsoft-entra-id.ts +100 -55
  190. package/src/social-providers/naver.ts +2 -1
  191. package/src/social-providers/notion.ts +8 -1
  192. package/src/social-providers/paybin.ts +2 -0
  193. package/src/social-providers/paypal.ts +28 -17
  194. package/src/social-providers/polar.ts +8 -1
  195. package/src/social-providers/railway.ts +8 -1
  196. package/src/social-providers/reddit.ts +9 -4
  197. package/src/social-providers/roblox.ts +16 -11
  198. package/src/social-providers/salesforce.ts +8 -1
  199. package/src/social-providers/slack.ts +15 -9
  200. package/src/social-providers/spotify.ts +8 -1
  201. package/src/social-providers/tiktok.ts +22 -9
  202. package/src/social-providers/twitch.ts +2 -1
  203. package/src/social-providers/twitter.ts +1 -0
  204. package/src/social-providers/vercel.ts +8 -1
  205. package/src/social-providers/vk.ts +8 -1
  206. package/src/social-providers/wechat.ts +17 -2
  207. package/src/social-providers/zoom.ts +15 -19
  208. package/src/types/context.ts +57 -5
  209. package/src/types/index.ts +7 -0
  210. package/src/types/init-options.ts +204 -14
  211. package/src/types/plugin-client.ts +16 -2
  212. package/src/utils/async.ts +53 -0
  213. package/src/utils/host.ts +425 -0
  214. package/src/utils/ip.ts +197 -13
  215. package/src/utils/is-api-error.ts +10 -0
  216. package/src/utils/redirect-uri.ts +54 -0
  217. package/src/utils/string.ts +37 -0
  218. package/src/utils/url.ts +38 -4
@@ -1,10 +1,12 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
+ import type { JWTPayload } from "jose";
2
3
  import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
3
4
  import { logger } from "../env";
4
5
  import { APIError, BetterAuthError } from "../error";
5
6
  import type { OAuthProvider, ProviderOptions } from "../oauth2";
6
7
  import {
7
8
  createAuthorizationURL,
9
+ getPrimaryClientId,
8
10
  refreshAccessToken,
9
11
  validateAuthorizationCode,
10
12
  } from "../oauth2";
@@ -37,7 +39,7 @@ export interface GoogleProfile {
37
39
  }
38
40
 
39
41
  export interface GoogleOptions extends ProviderOptions<GoogleProfile> {
40
- clientId: string;
42
+ clientId: string | string[];
41
43
  /**
42
44
  * The access type to use for the authorization code request
43
45
  */
@@ -47,11 +49,103 @@ export interface GoogleOptions extends ProviderOptions<GoogleProfile> {
47
49
  */
48
50
  display?: ("page" | "popup" | "touch" | "wap") | undefined;
49
51
  /**
50
- * The hosted domain of the user
52
+ * The hosted domain (Google Workspace) the user must belong to.
53
+ *
54
+ * This is sent to Google as the `hd` authorization hint and, when set, is
55
+ * also enforced against the `hd` claim of the returned id token/profile.
56
+ * Set `hd: "*"` to require any Workspace hosted-domain claim. Sign-in is
57
+ * rejected when the claim is missing or does not satisfy this restriction.
51
58
  */
52
59
  hd?: string | undefined;
60
+ /**
61
+ * Whether to send `include_granted_scopes=true` to Google's authorization
62
+ * endpoint, which lets new access tokens cover scopes from prior grants
63
+ * in addition to the ones requested for this flow. Set to `false` when
64
+ * each OAuth flow should request only its own scopes.
65
+ *
66
+ * Defaults to `true`.
67
+ *
68
+ * @see https://developers.google.com/identity/protocols/oauth2/web-server#incrementalAuth
69
+ */
70
+ includeGrantedScopes?: boolean | undefined;
71
+ }
72
+
73
+ const GOOGLE_ID_TOKEN_MAX_AGE = "1h";
74
+ const GOOGLE_ID_TOKEN_ALGORITHM = "RS256";
75
+ type GoogleIdTokenAlgorithm = typeof GOOGLE_ID_TOKEN_ALGORITHM;
76
+ const GOOGLE_ID_TOKEN_ALGORITHMS: GoogleIdTokenAlgorithm[] = [
77
+ GOOGLE_ID_TOKEN_ALGORITHM,
78
+ ];
79
+
80
+ function isGoogleIdTokenAlgorithm(
81
+ algorithm: unknown,
82
+ ): algorithm is GoogleIdTokenAlgorithm {
83
+ return GOOGLE_ID_TOKEN_ALGORITHMS.includes(
84
+ algorithm as GoogleIdTokenAlgorithm,
85
+ );
86
+ }
87
+
88
+ export interface VerifyGoogleIdTokenOptions {
89
+ token: string;
90
+ audience: string | string[];
91
+ nonce?: string | undefined;
53
92
  }
54
93
 
94
+ /**
95
+ * Verifies a Google ID token against Google's issuer, audience, signature,
96
+ * expiry, and maximum token age.
97
+ */
98
+ export const verifyGoogleIdToken = async ({
99
+ token,
100
+ audience,
101
+ nonce,
102
+ }: VerifyGoogleIdTokenOptions): Promise<JWTPayload | null> => {
103
+ try {
104
+ const { kid, alg } = decodeProtectedHeader(token);
105
+ if (!isGoogleIdTokenAlgorithm(alg)) return null;
106
+
107
+ const publicKeys = await getGooglePublicKeys(kid);
108
+ for (const publicKey of publicKeys) {
109
+ try {
110
+ const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
111
+ algorithms: GOOGLE_ID_TOKEN_ALGORITHMS,
112
+ issuer: ["https://accounts.google.com", "accounts.google.com"],
113
+ audience,
114
+ maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE,
115
+ });
116
+
117
+ if (nonce && jwtClaims.nonce !== nonce) {
118
+ return null;
119
+ }
120
+
121
+ return jwtClaims;
122
+ } catch {
123
+ continue;
124
+ }
125
+ }
126
+
127
+ return null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ };
132
+
133
+ /**
134
+ * Checks whether Google's verified `hd` claim satisfies the configured hosted
135
+ * domain restriction. `hd: "*"` accepts any Google Workspace hosted domain.
136
+ */
137
+ export const isGoogleHostedDomainAllowed = (
138
+ configuredHostedDomain: string | undefined,
139
+ tokenHostedDomain: unknown,
140
+ ) => {
141
+ if (!configuredHostedDomain) return true;
142
+ if (typeof tokenHostedDomain !== "string" || !tokenHostedDomain) {
143
+ return false;
144
+ }
145
+ if (configuredHostedDomain === "*") return true;
146
+ return tokenHostedDomain === configuredHostedDomain;
147
+ };
148
+
55
149
  export const google = (options: GoogleOptions) => {
56
150
  return {
57
151
  id: "google",
@@ -63,8 +157,9 @@ export const google = (options: GoogleOptions) => {
63
157
  redirectURI,
64
158
  loginHint,
65
159
  display,
160
+ additionalParams,
66
161
  }) {
67
- if (!options.clientId || !options.clientSecret) {
162
+ if (!getPrimaryClientId(options.clientId) || !options.clientSecret) {
68
163
  logger.error(
69
164
  "Client Id and Client Secret is required for Google. Make sure to provide them in the options.",
70
165
  );
@@ -92,7 +187,10 @@ export const google = (options: GoogleOptions) => {
92
187
  loginHint,
93
188
  hd: options.hd,
94
189
  additionalParams: {
95
- include_granted_scopes: "true",
190
+ ...(options.includeGrantedScopes === false
191
+ ? {}
192
+ : { include_granted_scopes: "true" }),
193
+ ...(additionalParams ?? {}),
96
194
  },
97
195
  });
98
196
  return url;
@@ -119,37 +217,20 @@ export const google = (options: GoogleOptions) => {
119
217
  tokenEndpoint: "https://oauth2.googleapis.com/token",
120
218
  });
121
219
  },
122
- async verifyIdToken(token, nonce) {
123
- if (options.disableIdTokenSignIn) {
124
- return false;
125
- }
126
- if (options.verifyIdToken) {
127
- return options.verifyIdToken(token, nonce);
128
- }
129
-
130
- // Verify JWT integrity
131
- // See https://developers.google.com/identity/sign-in/web/backend-auth#verify-the-integrity-of-the-id-token
132
-
133
- try {
134
- const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
135
- if (!kid || !jwtAlg) return false;
136
-
137
- const publicKey = await getGooglePublicKey(kid);
138
- const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
139
- algorithms: [jwtAlg],
140
- issuer: ["https://accounts.google.com", "accounts.google.com"],
141
- audience: options.clientId,
142
- maxTokenAge: "1h",
143
- });
144
-
145
- if (nonce && jwtClaims.nonce !== nonce) {
146
- return false;
147
- }
148
-
149
- return true;
150
- } catch {
151
- return false;
152
- }
220
+ idToken: {
221
+ // https://developers.google.com/identity/sign-in/web/backend-auth#verify-the-integrity-of-the-id-token
222
+ jwks: (header) => getGooglePublicKey(header.kid!),
223
+ issuer: ["https://accounts.google.com", "accounts.google.com"],
224
+ audience: options.clientId,
225
+ maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE,
226
+ // Google's `hd` authorization parameter is only a UI hint and can be
227
+ // removed or changed by the user. When a hosted domain is configured,
228
+ // the `hd` claim in the verified id token is the authoritative value
229
+ // and must satisfy the configured restriction, otherwise accounts
230
+ // outside the workspace domain would be accepted on the id_token path.
231
+ verifyClaims: options.hd
232
+ ? (claims) => isGoogleHostedDomainAllowed(options.hd, claims.hd)
233
+ : undefined,
153
234
  },
154
235
  async getUserInfo(token) {
155
236
  if (options.getUserInfo) {
@@ -159,6 +240,17 @@ export const google = (options: GoogleOptions) => {
159
240
  return null;
160
241
  }
161
242
  const user = decodeJwt(token.idToken) as GoogleProfile;
243
+ // Enforce the configured hosted domain on the callback profile path.
244
+ // The authorization-time `hd` value is only a UI hint; the verified
245
+ // token/profile claim is the authoritative Workspace signal.
246
+ if (!isGoogleHostedDomainAllowed(options.hd, user.hd)) {
247
+ logger.error(
248
+ `Google sign-in rejected: id token hosted domain (hd) "${
249
+ user.hd ?? "<missing>"
250
+ }" does not satisfy the configured "hd" option "${options.hd}".`,
251
+ );
252
+ return null;
253
+ }
162
254
  const userMap = await options.mapProfileToUser?.(user);
163
255
  return {
164
256
  user: {
@@ -177,10 +269,18 @@ export const google = (options: GoogleOptions) => {
177
269
  };
178
270
 
179
271
  export const getGooglePublicKey = async (kid: string) => {
272
+ const [publicKey] = await getGooglePublicKeys(kid);
273
+ if (!publicKey) {
274
+ throw new Error(`JWK with kid ${kid} not found`);
275
+ }
276
+ return publicKey;
277
+ };
278
+
279
+ const getGooglePublicKeys = async (kid?: string) => {
180
280
  const { data } = await betterFetch<{
181
281
  keys: Array<{
182
282
  kid: string;
183
- alg: string;
283
+ alg?: string;
184
284
  kty: string;
185
285
  use: string;
186
286
  n: string;
@@ -194,10 +294,12 @@ export const getGooglePublicKey = async (kid: string) => {
194
294
  });
195
295
  }
196
296
 
197
- const jwk = data.keys.find((key) => key.kid === kid);
198
- if (!jwk) {
297
+ const jwks = kid ? data.keys.filter((key) => key.kid === kid) : data.keys;
298
+ if (!jwks.length) {
199
299
  throw new Error(`JWK with kid ${kid} not found`);
200
300
  }
201
301
 
202
- return await importJWK(jwk, jwk.alg);
302
+ return Promise.all(
303
+ jwks.map((jwk) => importJWK(jwk, GOOGLE_ID_TOKEN_ALGORITHM)),
304
+ );
203
305
  };
@@ -47,7 +47,13 @@ export const huggingface = (options: HuggingFaceOptions) => {
47
47
  return {
48
48
  id: "huggingface",
49
49
  name: "Hugging Face",
50
- createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) {
50
+ createAuthorizationURL({
51
+ state,
52
+ scopes,
53
+ codeVerifier,
54
+ redirectURI,
55
+ additionalParams,
56
+ }) {
51
57
  const _scopes = options.disableDefaultScope
52
58
  ? []
53
59
  : ["openid", "profile", "email"];
@@ -61,6 +67,7 @@ export const huggingface = (options: HuggingFaceOptions) => {
61
67
  state,
62
68
  codeVerifier,
63
69
  redirectURI,
70
+ additionalParams,
64
71
  });
65
72
  },
66
73
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
@@ -106,7 +106,7 @@ export const kakao = (options: KakaoOptions) => {
106
106
  return {
107
107
  id: "kakao",
108
108
  name: "Kakao",
109
- createAuthorizationURL({ state, scopes, redirectURI }) {
109
+ createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
110
110
  const _scopes = options.disableDefaultScope
111
111
  ? []
112
112
  : ["account_email", "profile_image", "profile_nickname"];
@@ -119,6 +119,7 @@ export const kakao = (options: KakaoOptions) => {
119
119
  scopes: _scopes,
120
120
  state,
121
121
  redirectURI,
122
+ additionalParams,
122
123
  });
123
124
  },
124
125
  validateAuthorizationCode: async ({ code, redirectURI }) => {
@@ -33,7 +33,13 @@ export const kick = (options: KickOptions) => {
33
33
  return {
34
34
  id: "kick",
35
35
  name: "Kick",
36
- createAuthorizationURL({ state, scopes, redirectURI, codeVerifier }) {
36
+ createAuthorizationURL({
37
+ state,
38
+ scopes,
39
+ redirectURI,
40
+ codeVerifier,
41
+ additionalParams,
42
+ }) {
37
43
  const _scopes = options.disableDefaultScope ? [] : ["user:read"];
38
44
  if (options.scope) _scopes.push(...options.scope);
39
45
  if (scopes) _scopes.push(...scopes);
@@ -46,6 +52,7 @@ export const kick = (options: KickOptions) => {
46
52
  scopes: _scopes,
47
53
  codeVerifier,
48
54
  state,
55
+ additionalParams,
49
56
  });
50
57
  },
51
58
  async validateAuthorizationCode({ code, redirectURI, codeVerifier }) {
@@ -56,6 +56,7 @@ export const line = (options: LineOptions) => {
56
56
  codeVerifier,
57
57
  redirectURI,
58
58
  loginHint,
59
+ additionalParams,
59
60
  }) {
60
61
  const _scopes = options.disableDefaultScope
61
62
  ? []
@@ -71,6 +72,7 @@ export const line = (options: LineOptions) => {
71
72
  codeVerifier,
72
73
  redirectURI,
73
74
  loginHint,
75
+ additionalParams,
74
76
  });
75
77
  },
76
78
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
@@ -94,34 +96,30 @@ export const line = (options: LineOptions) => {
94
96
  tokenEndpoint,
95
97
  });
96
98
  },
97
- async verifyIdToken(token, nonce) {
98
- if (options.disableIdTokenSignIn) {
99
- return false;
100
- }
101
- if (options.verifyIdToken) {
102
- return options.verifyIdToken(token, nonce);
103
- }
104
- const body = new URLSearchParams();
105
- body.set("id_token", token);
106
- body.set("client_id", options.clientId);
107
- if (nonce) body.set("nonce", nonce);
108
- const { data, error } = await betterFetch<LineIdTokenPayload>(
109
- verifyIdTokenEndpoint,
110
- {
111
- method: "POST",
112
- headers: {
113
- "content-type": "application/x-www-form-urlencoded",
99
+ idToken: {
100
+ verify: async (token, nonce) => {
101
+ const body = new URLSearchParams();
102
+ body.set("id_token", token);
103
+ body.set("client_id", options.clientId);
104
+ if (nonce) body.set("nonce", nonce);
105
+ const { data, error } = await betterFetch<LineIdTokenPayload>(
106
+ verifyIdTokenEndpoint,
107
+ {
108
+ method: "POST",
109
+ headers: {
110
+ "content-type": "application/x-www-form-urlencoded",
111
+ },
112
+ body,
114
113
  },
115
- body,
116
- },
117
- );
118
- if (error || !data) {
119
- return false;
120
- }
121
- // aud must match clientId; nonce (if provided) must also match nonce
122
- if (data.aud !== options.clientId) return false;
123
- if (data.nonce && data.nonce !== nonce) return false;
124
- return true;
114
+ );
115
+ if (error || !data) {
116
+ return false;
117
+ }
118
+ // aud must match clientId; nonce (if provided) must also match nonce
119
+ if (data.aud !== options.clientId) return false;
120
+ if (data.nonce && data.nonce !== nonce) return false;
121
+ return true;
122
+ },
125
123
  },
126
124
  async getUserInfo(token) {
127
125
  if (options.getUserInfo) {
@@ -31,7 +31,13 @@ export const linear = (options: LinearOptions) => {
31
31
  return {
32
32
  id: "linear",
33
33
  name: "Linear",
34
- createAuthorizationURL({ state, scopes, loginHint, redirectURI }) {
34
+ createAuthorizationURL({
35
+ state,
36
+ scopes,
37
+ loginHint,
38
+ redirectURI,
39
+ additionalParams,
40
+ }) {
35
41
  const _scopes = options.disableDefaultScope ? [] : ["read"];
36
42
  if (options.scope) _scopes.push(...options.scope);
37
43
  if (scopes) _scopes.push(...scopes);
@@ -43,6 +49,7 @@ export const linear = (options: LinearOptions) => {
43
49
  state,
44
50
  redirectURI,
45
51
  loginHint,
52
+ additionalParams,
46
53
  });
47
54
  },
48
55
  validateAuthorizationCode: async ({ code, redirectURI }) => {
@@ -16,8 +16,8 @@ export interface LinkedInProfile {
16
16
  country: string;
17
17
  language: string;
18
18
  };
19
- email: string;
20
- email_verified: boolean;
19
+ email?: string;
20
+ email_verified?: boolean;
21
21
  }
22
22
 
23
23
  export interface LinkedInOptions extends ProviderOptions<LinkedInProfile> {
@@ -37,6 +37,7 @@ export const linkedin = (options: LinkedInOptions) => {
37
37
  scopes,
38
38
  redirectURI,
39
39
  loginHint,
40
+ additionalParams,
40
41
  }) => {
41
42
  const _scopes = options.disableDefaultScope
42
43
  ? []
@@ -51,6 +52,7 @@ export const linkedin = (options: LinkedInOptions) => {
51
52
  state,
52
53
  loginHint,
53
54
  redirectURI,
55
+ additionalParams,
54
56
  });
55
57
  },
56
58
  validateAuthorizationCode: async ({ code, redirectURI }) => {
@@ -98,7 +100,7 @@ export const linkedin = (options: LinkedInOptions) => {
98
100
  id: profile.sub,
99
101
  name: profile.name,
100
102
  email: profile.email,
101
- emailVerified: profile.email_verified || false,
103
+ emailVerified: profile.email_verified ?? false,
102
104
  image: profile.picture,
103
105
  ...userMap,
104
106
  },
@@ -1,15 +1,29 @@
1
1
  import { base64 } from "@better-auth/utils/base64";
2
2
  import { betterFetch } from "@better-fetch/fetch";
3
- import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
3
+ import { decodeJwt, importJWK } from "jose";
4
4
  import { logger } from "../env";
5
- import { APIError } from "../error";
6
- import type { OAuthProvider, ProviderOptions } from "../oauth2";
5
+ import { APIError, BetterAuthError } from "../error";
6
+ import type {
7
+ ClientAssertionGetter,
8
+ OAuthProvider,
9
+ ProviderOptions,
10
+ TokenEndpointAuth,
11
+ } from "../oauth2";
7
12
  import {
8
13
  createAuthorizationURL,
14
+ getPrimaryClientId,
9
15
  refreshAccessToken,
10
16
  validateAuthorizationCode,
11
17
  } from "../oauth2";
12
18
 
19
+ /**
20
+ * Microsoft's fixed tenant id for personal (consumer) Microsoft accounts. Every
21
+ * personal-account token carries it as the `tid` claim, so it distinguishes the
22
+ * consumer account class from work/school tenants.
23
+ * @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
24
+ */
25
+ const MICROSOFT_CONSUMER_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad";
26
+
13
27
  /**
14
28
  * @see [Microsoft Identity Platform - Optional claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference)
15
29
  */
@@ -35,7 +49,7 @@ export interface MicrosoftEntraIDProfile extends Record<string, any> {
35
49
  /** The primary username that represents the user */
36
50
  preferred_username: string;
37
51
  /** User's email address */
38
- email: string;
52
+ email?: string;
39
53
  /** Human-readable value that identifies the subject of the token */
40
54
  name: string;
41
55
  /** Matches the parameter included in the original authorize request */
@@ -116,39 +130,73 @@ export interface MicrosoftEntraIDProfile extends Record<string, any> {
116
130
 
117
131
  export interface MicrosoftOptions
118
132
  extends ProviderOptions<MicrosoftEntraIDProfile> {
119
- clientId: string;
133
+ clientId: string | string[];
120
134
  /**
121
135
  * The tenant ID of the Microsoft account
122
136
  * @default "common"
123
137
  */
124
- tenantId?: string | undefined;
138
+ tenantId?: string;
125
139
  /**
126
140
  * The authentication authority URL. Use the default "https://login.microsoftonline.com" for standard Entra ID or "https://<tenant-id>.ciamlogin.com" for CIAM scenarios.
127
141
  * @default "https://login.microsoftonline.com"
128
142
  */
129
- authority?: string | undefined;
143
+ authority?: string;
144
+ /**
145
+ * Function that returns a JWT client assertion for token endpoint authentication.
146
+ *
147
+ * Use this instead of `clientSecret` when your Microsoft Entra ID app is
148
+ * configured for client authentication with assertions (private_key_jwt or
149
+ * workload identity federation).
150
+ */
151
+ clientAssertion?: ClientAssertionGetter;
130
152
  /**
131
153
  * The size of the profile photo
132
154
  * @default 48
133
155
  */
134
- profilePhotoSize?:
135
- | (48 | 64 | 96 | 120 | 240 | 360 | 432 | 504 | 648)
136
- | undefined;
156
+ profilePhotoSize?: 48 | 64 | 96 | 120 | 240 | 360 | 432 | 504 | 648;
137
157
  /**
138
158
  * Disable profile photo
139
159
  */
140
- disableProfilePhoto?: boolean | undefined;
160
+ disableProfilePhoto?: boolean;
141
161
  }
142
162
 
143
163
  export const microsoft = (options: MicrosoftOptions) => {
144
164
  const tenant = options.tenantId || "common";
145
- const authority = options.authority || "https://login.microsoftonline.com";
165
+ // Trim any trailing slash so endpoint URLs and the issuer comparison below
166
+ // never produce a double slash (e.g. a configured `https://host/` would make
167
+ // the expected issuer `https://host//<tid>/v2.0` and reject every token). A
168
+ // loop avoids a trailing-slash regex, which is a polynomial-ReDoS shape.
169
+ let authority = options.authority || "https://login.microsoftonline.com";
170
+ while (authority.endsWith("/")) {
171
+ authority = authority.slice(0, -1);
172
+ }
146
173
  const authorizationEndpoint = `${authority}/${tenant}/oauth2/v2.0/authorize`;
147
174
  const tokenEndpoint = `${authority}/${tenant}/oauth2/v2.0/token`;
175
+ if (options.clientSecret && options.clientAssertion) {
176
+ throw new BetterAuthError(
177
+ "Microsoft Entra ID clientAssertion cannot be combined with clientSecret",
178
+ );
179
+ }
180
+ const tokenEndpointAuth: TokenEndpointAuth | undefined =
181
+ options.clientAssertion
182
+ ? {
183
+ method: "private_key_jwt",
184
+ getClientAssertion: options.clientAssertion,
185
+ }
186
+ : undefined;
148
187
  return {
149
188
  id: "microsoft",
150
189
  name: "Microsoft EntraID",
151
190
  createAuthorizationURL(data) {
191
+ // Microsoft Entra supports public clients (SPA / native apps with
192
+ // PKCE only), so clientSecret is intentionally not required here.
193
+ // See https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow
194
+ if (!getPrimaryClientId(options.clientId)) {
195
+ logger.error(
196
+ "Client Id is required for Microsoft Entra ID. Make sure to provide it in the options.",
197
+ );
198
+ throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED");
199
+ }
152
200
  const scopes = options.disableDefaultScope
153
201
  ? []
154
202
  : ["openid", "profile", "email", "User.Read", "offline_access"];
@@ -164,6 +212,7 @@ export const microsoft = (options: MicrosoftOptions) => {
164
212
  redirectURI: data.redirectURI,
165
213
  prompt: options.prompt,
166
214
  loginHint: data.loginHint,
215
+ additionalParams: data.additionalParams,
167
216
  });
168
217
  },
169
218
  validateAuthorizationCode({ code, codeVerifier, redirectURI }) {
@@ -173,57 +222,52 @@ export const microsoft = (options: MicrosoftOptions) => {
173
222
  redirectURI,
174
223
  options,
175
224
  tokenEndpoint,
225
+ tokenEndpointAuth,
176
226
  });
177
227
  },
178
- async verifyIdToken(token, nonce) {
179
- if (options.disableIdTokenSignIn) {
180
- return false;
181
- }
182
- if (options.verifyIdToken) {
183
- return options.verifyIdToken(token, nonce);
184
- }
185
-
186
- try {
187
- const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
188
- if (!kid || !jwtAlg) return false;
189
-
190
- const publicKey = await getMicrosoftPublicKey(kid, tenant, authority);
191
- const verifyOptions: {
192
- algorithms: [string];
193
- audience: string;
194
- maxTokenAge: string;
195
- issuer?: string;
196
- } = {
197
- algorithms: [jwtAlg],
198
- audience: options.clientId,
199
- maxTokenAge: "1h",
200
- };
201
- /**
202
- * Issuer varies per user's tenant for multi-tenant endpoints, so only validate for specific tenants.
203
- * @see https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints
204
- */
228
+ idToken: {
229
+ jwks: (header) => getMicrosoftPublicKey(header.kid!, tenant, authority),
230
+ audience: options.clientId,
231
+ maxTokenAge: "1h",
232
+ /**
233
+ * Issuer varies per tenant for multi-tenant endpoints, so only validate it for
234
+ * specific tenants.
235
+ * @see https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints
236
+ */
237
+ issuer:
238
+ tenant !== "common" &&
239
+ tenant !== "organizations" &&
240
+ tenant !== "consumers"
241
+ ? `${authority}/${tenant}/v2.0`
242
+ : undefined,
243
+ /**
244
+ * The multi-tenant endpoints (common/organizations/consumers) skip the
245
+ * issuer check above because the issuer varies per tenant, and the
246
+ * organizations and consumers JWKS sets overlap. Enforce the tenant
247
+ * binding explicitly so a token from a disallowed account class cannot
248
+ * pass: the issuer must name the token's own tenant, and the account
249
+ * class must match the configured restriction.
250
+ * @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
251
+ */
252
+ verifyClaims: (claims) => {
253
+ const tid = claims.tid;
205
254
  if (
206
- tenant !== "common" &&
207
- tenant !== "organizations" &&
208
- tenant !== "consumers"
255
+ typeof tid !== "string" ||
256
+ claims.iss !== `${authority}/${tid}/v2.0`
209
257
  ) {
210
- verifyOptions.issuer = `${authority}/${tenant}/v2.0`;
258
+ return false;
211
259
  }
212
- const { payload: jwtClaims } = await jwtVerify(
213
- token,
214
- publicKey,
215
- verifyOptions,
216
- );
217
-
218
- if (nonce && jwtClaims.nonce !== nonce) {
260
+ if (
261
+ tenant === "organizations" &&
262
+ tid === MICROSOFT_CONSUMER_TENANT_ID
263
+ ) {
264
+ return false;
265
+ }
266
+ if (tenant === "consumers" && tid !== MICROSOFT_CONSUMER_TENANT_ID) {
219
267
  return false;
220
268
  }
221
-
222
269
  return true;
223
- } catch (error) {
224
- logger.error("Failed to verify ID token:", error);
225
- return false;
226
- }
270
+ },
227
271
  },
228
272
  async getUserInfo(token) {
229
273
  if (options.getUserInfo) {
@@ -303,6 +347,7 @@ export const microsoft = (options: MicrosoftOptions) => {
303
347
  scope: scopes.join(" "), // Include the scopes in request to microsoft
304
348
  },
305
349
  tokenEndpoint,
350
+ tokenEndpointAuth,
306
351
  });
307
352
  },
308
353
  options,