@better-auth/core 1.7.0-beta.3 → 1.7.0-beta.5

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 (188) hide show
  1. package/dist/api/index.d.mts +3 -3
  2. package/dist/context/global.mjs +1 -1
  3. package/dist/db/adapter/factory.mjs +62 -0
  4. package/dist/db/adapter/index.d.mts +35 -1
  5. package/dist/db/adapter/types.d.mts +1 -1
  6. package/dist/db/get-tables.mjs +3 -3
  7. package/dist/db/schema/account.d.mts +1 -1
  8. package/dist/db/schema/account.mjs +1 -1
  9. package/dist/db/type.d.mts +12 -0
  10. package/dist/env/env-impl.mjs +1 -1
  11. package/dist/error/codes.d.mts +6 -0
  12. package/dist/error/codes.mjs +6 -0
  13. package/dist/index.d.mts +2 -2
  14. package/dist/instrumentation/tracer.mjs +1 -1
  15. package/dist/oauth2/authorization-params.d.mts +12 -0
  16. package/dist/oauth2/authorization-params.mjs +12 -0
  17. package/dist/oauth2/basic-credentials.d.mts +30 -0
  18. package/dist/oauth2/basic-credentials.mjs +64 -0
  19. package/dist/oauth2/client-assertion.d.mts +38 -22
  20. package/dist/oauth2/client-assertion.mjs +63 -28
  21. package/dist/oauth2/client-credentials-token.d.mts +19 -40
  22. package/dist/oauth2/client-credentials-token.mjs +18 -29
  23. package/dist/oauth2/create-authorization-url.d.mts +13 -2
  24. package/dist/oauth2/create-authorization-url.mjs +28 -7
  25. package/dist/oauth2/index.d.mts +13 -8
  26. package/dist/oauth2/index.mjs +11 -7
  27. package/dist/oauth2/oauth-provider.d.mts +149 -11
  28. package/dist/oauth2/refresh-access-token.d.mts +20 -40
  29. package/dist/oauth2/refresh-access-token.mjs +20 -33
  30. package/dist/oauth2/scopes.d.mts +76 -0
  31. package/dist/oauth2/scopes.mjs +96 -0
  32. package/dist/oauth2/token-endpoint-auth.d.mts +17 -0
  33. package/dist/oauth2/token-endpoint-auth.mjs +89 -0
  34. package/dist/oauth2/utils.d.mts +9 -1
  35. package/dist/oauth2/utils.mjs +14 -2
  36. package/dist/oauth2/validate-authorization-code.d.mts +17 -52
  37. package/dist/oauth2/validate-authorization-code.mjs +17 -30
  38. package/dist/oauth2/verify-id-token.d.mts +26 -0
  39. package/dist/oauth2/verify-id-token.mjs +62 -0
  40. package/dist/oauth2/verify.d.mts +14 -0
  41. package/dist/oauth2/verify.mjs +38 -12
  42. package/dist/social-providers/apple.d.mts +18 -20
  43. package/dist/social-providers/apple.mjs +15 -28
  44. package/dist/social-providers/atlassian.d.mts +8 -2
  45. package/dist/social-providers/atlassian.mjs +9 -6
  46. package/dist/social-providers/cognito.d.mts +29 -3
  47. package/dist/social-providers/cognito.mjs +30 -34
  48. package/dist/social-providers/discord.d.mts +8 -2
  49. package/dist/social-providers/discord.mjs +20 -6
  50. package/dist/social-providers/dropbox.d.mts +8 -2
  51. package/dist/social-providers/dropbox.mjs +10 -9
  52. package/dist/social-providers/facebook.d.mts +24 -3
  53. package/dist/social-providers/facebook.mjs +51 -24
  54. package/dist/social-providers/figma.d.mts +8 -2
  55. package/dist/social-providers/figma.mjs +8 -7
  56. package/dist/social-providers/github.d.mts +8 -2
  57. package/dist/social-providers/github.mjs +9 -8
  58. package/dist/social-providers/gitlab.d.mts +8 -2
  59. package/dist/social-providers/gitlab.mjs +8 -7
  60. package/dist/social-providers/google.d.mts +32 -4
  61. package/dist/social-providers/google.mjs +26 -29
  62. package/dist/social-providers/huggingface.d.mts +8 -2
  63. package/dist/social-providers/huggingface.mjs +11 -10
  64. package/dist/social-providers/index.d.mts +322 -75
  65. package/dist/social-providers/kakao.d.mts +8 -2
  66. package/dist/social-providers/kakao.mjs +11 -10
  67. package/dist/social-providers/kick.d.mts +8 -2
  68. package/dist/social-providers/kick.mjs +7 -6
  69. package/dist/social-providers/line.d.mts +11 -3
  70. package/dist/social-providers/line.mjs +14 -15
  71. package/dist/social-providers/linear.d.mts +8 -2
  72. package/dist/social-providers/linear.mjs +7 -6
  73. package/dist/social-providers/linkedin.d.mts +8 -2
  74. package/dist/social-providers/linkedin.mjs +12 -11
  75. package/dist/social-providers/microsoft-entra-id.d.mts +33 -7
  76. package/dist/social-providers/microsoft-entra-id.mjs +28 -38
  77. package/dist/social-providers/naver.d.mts +8 -2
  78. package/dist/social-providers/naver.mjs +7 -6
  79. package/dist/social-providers/notion.d.mts +8 -2
  80. package/dist/social-providers/notion.mjs +9 -6
  81. package/dist/social-providers/paybin.d.mts +8 -2
  82. package/dist/social-providers/paybin.mjs +12 -11
  83. package/dist/social-providers/paypal.d.mts +8 -3
  84. package/dist/social-providers/paypal.mjs +10 -14
  85. package/dist/social-providers/polar.d.mts +8 -2
  86. package/dist/social-providers/polar.mjs +11 -10
  87. package/dist/social-providers/railway.d.mts +8 -2
  88. package/dist/social-providers/railway.mjs +11 -10
  89. package/dist/social-providers/reddit.d.mts +8 -2
  90. package/dist/social-providers/reddit.mjs +11 -9
  91. package/dist/social-providers/roblox.d.mts +8 -2
  92. package/dist/social-providers/roblox.mjs +15 -5
  93. package/dist/social-providers/salesforce.d.mts +8 -2
  94. package/dist/social-providers/salesforce.mjs +11 -10
  95. package/dist/social-providers/slack.d.mts +8 -2
  96. package/dist/social-providers/slack.mjs +18 -15
  97. package/dist/social-providers/spotify.d.mts +8 -2
  98. package/dist/social-providers/spotify.mjs +7 -6
  99. package/dist/social-providers/tiktok.d.mts +8 -2
  100. package/dist/social-providers/tiktok.mjs +21 -5
  101. package/dist/social-providers/twitch.d.mts +8 -2
  102. package/dist/social-providers/twitch.mjs +7 -6
  103. package/dist/social-providers/twitter.d.mts +7 -2
  104. package/dist/social-providers/twitter.mjs +11 -10
  105. package/dist/social-providers/vercel.d.mts +8 -2
  106. package/dist/social-providers/vercel.mjs +7 -9
  107. package/dist/social-providers/vk.d.mts +8 -2
  108. package/dist/social-providers/vk.mjs +7 -6
  109. package/dist/social-providers/wechat.d.mts +8 -2
  110. package/dist/social-providers/wechat.mjs +16 -6
  111. package/dist/social-providers/zoom.d.mts +10 -3
  112. package/dist/social-providers/zoom.mjs +14 -15
  113. package/dist/types/context.d.mts +33 -11
  114. package/dist/types/index.d.mts +1 -1
  115. package/dist/types/init-options.d.mts +121 -6
  116. package/dist/utils/ip.d.mts +5 -4
  117. package/dist/utils/ip.mjs +3 -3
  118. package/dist/utils/redirect-uri.d.mts +20 -0
  119. package/dist/utils/redirect-uri.mjs +48 -0
  120. package/dist/utils/string.d.mts +5 -1
  121. package/dist/utils/string.mjs +20 -1
  122. package/dist/utils/url.d.mts +18 -1
  123. package/dist/utils/url.mjs +30 -1
  124. package/package.json +13 -12
  125. package/src/db/adapter/factory.ts +126 -0
  126. package/src/db/adapter/index.ts +32 -0
  127. package/src/db/adapter/types.ts +1 -0
  128. package/src/db/get-tables.ts +8 -3
  129. package/src/db/schema/account.ts +14 -2
  130. package/src/db/type.ts +12 -0
  131. package/src/env/env-impl.ts +1 -2
  132. package/src/error/codes.ts +6 -0
  133. package/src/oauth2/authorization-params.ts +28 -0
  134. package/src/oauth2/basic-credentials.ts +87 -0
  135. package/src/oauth2/client-assertion.ts +131 -58
  136. package/src/oauth2/client-credentials-token.ts +48 -72
  137. package/src/oauth2/create-authorization-url.ts +30 -8
  138. package/src/oauth2/index.ts +42 -10
  139. package/src/oauth2/oauth-provider.ts +161 -12
  140. package/src/oauth2/refresh-access-token.ts +52 -78
  141. package/src/oauth2/scopes.ts +118 -0
  142. package/src/oauth2/token-endpoint-auth.ts +221 -0
  143. package/src/oauth2/utils.ts +21 -5
  144. package/src/oauth2/validate-authorization-code.ts +55 -85
  145. package/src/oauth2/verify-id-token.ts +111 -0
  146. package/src/oauth2/verify.ts +82 -15
  147. package/src/social-providers/apple.ts +32 -45
  148. package/src/social-providers/atlassian.ts +20 -9
  149. package/src/social-providers/cognito.ts +51 -48
  150. package/src/social-providers/discord.ts +37 -22
  151. package/src/social-providers/dropbox.ts +20 -12
  152. package/src/social-providers/facebook.ts +108 -57
  153. package/src/social-providers/figma.ts +21 -10
  154. package/src/social-providers/github.ts +16 -10
  155. package/src/social-providers/gitlab.ts +16 -8
  156. package/src/social-providers/google.ts +67 -46
  157. package/src/social-providers/huggingface.ts +20 -9
  158. package/src/social-providers/kakao.ts +18 -9
  159. package/src/social-providers/kick.ts +20 -8
  160. package/src/social-providers/line.ts +39 -37
  161. package/src/social-providers/linear.ts +20 -7
  162. package/src/social-providers/linkedin.ts +16 -10
  163. package/src/social-providers/microsoft-entra-id.ts +66 -64
  164. package/src/social-providers/naver.ts +14 -7
  165. package/src/social-providers/notion.ts +20 -7
  166. package/src/social-providers/paybin.ts +16 -11
  167. package/src/social-providers/paypal.ts +12 -25
  168. package/src/social-providers/polar.ts +20 -9
  169. package/src/social-providers/railway.ts +20 -9
  170. package/src/social-providers/reddit.ts +22 -10
  171. package/src/social-providers/roblox.ts +31 -15
  172. package/src/social-providers/salesforce.ts +21 -10
  173. package/src/social-providers/slack.ts +31 -16
  174. package/src/social-providers/spotify.ts +20 -7
  175. package/src/social-providers/tiktok.ts +32 -13
  176. package/src/social-providers/twitch.ts +14 -9
  177. package/src/social-providers/twitter.ts +18 -8
  178. package/src/social-providers/vercel.ts +24 -11
  179. package/src/social-providers/vk.ts +20 -7
  180. package/src/social-providers/wechat.ts +28 -8
  181. package/src/social-providers/zoom.ts +28 -19
  182. package/src/types/context.ts +33 -12
  183. package/src/types/index.ts +7 -0
  184. package/src/types/init-options.ts +148 -5
  185. package/src/utils/ip.ts +12 -13
  186. package/src/utils/redirect-uri.ts +54 -0
  187. package/src/utils/string.ts +37 -0
  188. package/src/utils/url.ts +28 -0
@@ -1,12 +1,13 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
- import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
2
+ import { createRemoteJWKSet, decodeJwt } from "jose";
3
3
  import { logger } from "../env";
4
4
  import { BetterAuthError } from "../error";
5
- import type { OAuthProvider, ProviderOptions } from "../oauth2";
5
+ import type { ProviderOptions, UpstreamProvider } from "../oauth2";
6
6
  import {
7
7
  createAuthorizationURL,
8
8
  getPrimaryClientId,
9
9
  refreshAccessToken,
10
+ resolveRequestedScopes,
10
11
  validateAuthorizationCode,
11
12
  } from "../oauth2";
12
13
  export interface FacebookProfile {
@@ -24,6 +25,58 @@ export interface FacebookProfile {
24
25
  };
25
26
  }
26
27
 
28
+ interface FacebookDebugTokenData {
29
+ app_id?: string;
30
+ is_valid?: boolean;
31
+ user_id?: string;
32
+ }
33
+
34
+ /**
35
+ * Validate an opaque Facebook access token against the configured app.
36
+ *
37
+ * Facebook access tokens are not audience-bound at the Graph `/me` endpoint: a
38
+ * token minted for any Facebook app returns that app's profile. Without this
39
+ * check, a token issued to an unrelated app could be presented to this
40
+ * app's direct sign-in path and accepted as proof of identity. We call the
41
+ * `debug_token` endpoint and require the token to be valid, bound to one of the
42
+ * configured client ids, and tied to a user.
43
+ *
44
+ * @see https://developers.facebook.com/docs/facebook-login/guides/access-tokens/debugging
45
+ *
46
+ * @returns the inspected token's `user_id` when the token is valid and bound to
47
+ * the configured app, otherwise `null`.
48
+ */
49
+ async function verifyFacebookAccessToken(
50
+ accessToken: string,
51
+ options: FacebookOptions,
52
+ ): Promise<string | null> {
53
+ const primaryClientId = getPrimaryClientId(options.clientId);
54
+ if (!primaryClientId || !options.clientSecret) {
55
+ return null;
56
+ }
57
+ const clientIds = Array.isArray(options.clientId)
58
+ ? options.clientId
59
+ : [options.clientId];
60
+ const appAccessToken = `${primaryClientId}|${options.clientSecret}`;
61
+ const { data, error } = await betterFetch<{ data?: FacebookDebugTokenData }>(
62
+ "https://graph.facebook.com/debug_token",
63
+ {
64
+ query: {
65
+ input_token: accessToken,
66
+ access_token: appAccessToken,
67
+ },
68
+ },
69
+ );
70
+ if (error || !data?.data) {
71
+ return null;
72
+ }
73
+ const { is_valid, app_id, user_id } = data.data;
74
+ if (is_valid !== true || !app_id || !clientIds.includes(app_id) || !user_id) {
75
+ return null;
76
+ }
77
+ return user_id;
78
+ }
79
+
27
80
  export interface FacebookOptions extends ProviderOptions<FacebookProfile> {
28
81
  clientId: string | string[];
29
82
  /**
@@ -39,35 +92,43 @@ export interface FacebookOptions extends ProviderOptions<FacebookProfile> {
39
92
  configId?: string | undefined;
40
93
  }
41
94
 
95
+ const FACEBOOK_DEFAULT_SCOPES = ["email", "public_profile"];
96
+
42
97
  export const facebook = (options: FacebookOptions) => {
43
98
  return {
44
99
  id: "facebook",
45
100
  name: "Facebook",
46
- async createAuthorizationURL({ state, scopes, redirectURI, loginHint }) {
101
+ callbackPath: "/callback/facebook",
102
+ async createAuthorizationURL({
103
+ state,
104
+ scopes,
105
+ redirectURI,
106
+ loginHint,
107
+ additionalParams,
108
+ }) {
47
109
  if (!getPrimaryClientId(options.clientId) || !options.clientSecret) {
48
110
  logger.error(
49
111
  "Client ID and client secret are required for Facebook. Make sure to provide them in the options.",
50
112
  );
51
113
  throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED");
52
114
  }
53
- const _scopes = options.disableDefaultScope
54
- ? []
55
- : ["email", "public_profile"];
56
- if (options.scope) _scopes.push(...options.scope);
57
- if (scopes) _scopes.push(...scopes);
58
- return await createAuthorizationURL({
115
+ const requestedScopes = resolveRequestedScopes(
116
+ options,
117
+ FACEBOOK_DEFAULT_SCOPES,
118
+ scopes,
119
+ );
120
+ return createAuthorizationURL({
59
121
  id: "facebook",
60
122
  options,
61
123
  authorizationEndpoint: "https://www.facebook.com/v24.0/dialog/oauth",
62
- scopes: _scopes,
124
+ scopes: requestedScopes,
63
125
  state,
64
126
  redirectURI,
65
127
  loginHint,
66
- additionalParams: options.configId
67
- ? {
68
- config_id: options.configId,
69
- }
70
- : {},
128
+ additionalParams: {
129
+ ...(options.configId ? { config_id: options.configId } : {}),
130
+ ...(additionalParams ?? {}),
131
+ },
71
132
  });
72
133
  },
73
134
  validateAuthorizationCode: async ({ code, redirectURI }) => {
@@ -78,46 +139,17 @@ export const facebook = (options: FacebookOptions) => {
78
139
  tokenEndpoint: "https://graph.facebook.com/v24.0/oauth/access_token",
79
140
  });
80
141
  },
81
- async verifyIdToken(token, nonce) {
82
- if (options.disableIdTokenSignIn) {
83
- return false;
84
- }
85
-
86
- if (options.verifyIdToken) {
87
- return options.verifyIdToken(token, nonce);
88
- }
89
-
90
- /* limited login */
91
- // check is limited token
92
- if (token.split(".").length === 3) {
93
- try {
94
- const { payload: jwtClaims } = await jwtVerify(
95
- token,
96
- createRemoteJWKSet(
97
- // https://developers.facebook.com/docs/facebook-login/limited-login/token/#jwks
98
- new URL(
99
- "https://limited.facebook.com/.well-known/oauth/openid/jwks/",
100
- ),
101
- ),
102
- {
103
- algorithms: ["RS256"],
104
- audience: options.clientId,
105
- issuer: "https://www.facebook.com",
106
- },
107
- );
108
-
109
- if (nonce && jwtClaims.nonce !== nonce) {
110
- return false;
111
- }
112
-
113
- return !!jwtClaims;
114
- } catch {
115
- return false;
116
- }
117
- }
118
-
119
- /* access_token */
120
- return true;
142
+ idToken: {
143
+ // https://developers.facebook.com/docs/facebook-login/limited-login/token/#jwks
144
+ jwks: createRemoteJWKSet(
145
+ new URL("https://limited.facebook.com/.well-known/oauth/openid/jwks/"),
146
+ ),
147
+ issuer: "https://www.facebook.com",
148
+ audience: options.clientId,
149
+ algorithms: ["RS256"],
150
+ // Facebook also accepts an opaque Graph access token on the client sign-in path;
151
+ // identity is then resolved by getUserInfo via the Graph API, which validates it.
152
+ allowOpaqueToken: true,
121
153
  },
122
154
  refreshAccessToken: options.refreshAccessToken
123
155
  ? options.refreshAccessToken
@@ -178,6 +210,21 @@ export const facebook = (options: FacebookOptions) => {
178
210
  };
179
211
  }
180
212
 
213
+ // The profile is fetched with `accessToken`, which is the credential
214
+ // that actually proves identity here. It is a separate request field
215
+ // from the `idToken` checked by the shared id_token verifier via the
216
+ // declarative `idToken` config. Since an opaque token is not app-bound
217
+ // at `/me`, validate this exact token against the configured app
218
+ // before trusting the profile it returns.
219
+ const accessToken = token.accessToken;
220
+ if (!accessToken) {
221
+ return null;
222
+ }
223
+ const tokenUserId = await verifyFacebookAccessToken(accessToken, options);
224
+ if (!tokenUserId) {
225
+ return null;
226
+ }
227
+
181
228
  const fields = [
182
229
  "id",
183
230
  "name",
@@ -190,13 +237,17 @@ export const facebook = (options: FacebookOptions) => {
190
237
  {
191
238
  auth: {
192
239
  type: "Bearer",
193
- token: token.accessToken,
240
+ token: accessToken,
194
241
  },
195
242
  },
196
243
  );
197
244
  if (error) {
198
245
  return null;
199
246
  }
247
+ // Bind the validated token to the profile it returned.
248
+ if (profile.id !== tokenUserId) {
249
+ return null;
250
+ }
200
251
  const userMap = await options.mapProfileToUser?.(profile);
201
252
  return {
202
253
  user: {
@@ -211,5 +262,5 @@ export const facebook = (options: FacebookOptions) => {
211
262
  };
212
263
  },
213
264
  options,
214
- } satisfies OAuthProvider<FacebookProfile>;
265
+ } satisfies UpstreamProvider<FacebookProfile>;
215
266
  };
@@ -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
 
@@ -19,12 +20,21 @@ export interface FigmaOptions extends ProviderOptions<FigmaProfile> {
19
20
  clientId: string;
20
21
  }
21
22
 
23
+ const FIGMA_DEFAULT_SCOPES = ["current_user:read"];
24
+
22
25
  export const figma = (options: FigmaOptions) => {
23
26
  const tokenEndpoint = "https://api.figma.com/v1/oauth/token";
24
27
  return {
25
28
  id: "figma",
26
29
  name: "Figma",
27
- async createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) {
30
+ callbackPath: "/callback/figma",
31
+ async createAuthorizationURL({
32
+ state,
33
+ scopes,
34
+ codeVerifier,
35
+ redirectURI,
36
+ additionalParams,
37
+ }) {
28
38
  if (!options.clientId || !options.clientSecret) {
29
39
  logger.error(
30
40
  "Client Id and Client Secret are required for Figma. Make sure to provide them in the options.",
@@ -35,21 +45,22 @@ export const figma = (options: FigmaOptions) => {
35
45
  throw new BetterAuthError("codeVerifier is required for Figma");
36
46
  }
37
47
 
38
- const _scopes = options.disableDefaultScope ? [] : ["current_user:read"];
39
- if (options.scope) _scopes.push(...options.scope);
40
- if (scopes) _scopes.push(...scopes);
48
+ const requestedScopes = resolveRequestedScopes(
49
+ options,
50
+ FIGMA_DEFAULT_SCOPES,
51
+ scopes,
52
+ );
41
53
 
42
- const url = await createAuthorizationURL({
54
+ return createAuthorizationURL({
43
55
  id: "figma",
44
56
  options,
45
57
  authorizationEndpoint: "https://www.figma.com/oauth",
46
- scopes: _scopes,
58
+ scopes: requestedScopes,
47
59
  state,
48
60
  codeVerifier,
49
61
  redirectURI,
62
+ additionalParams,
50
63
  });
51
-
52
- return url;
53
64
  },
54
65
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
55
66
  return validateAuthorizationCode({
@@ -114,5 +125,5 @@ export const figma = (options: FigmaOptions) => {
114
125
  }
115
126
  },
116
127
  options,
117
- } satisfies OAuthProvider<FigmaProfile>;
128
+ } satisfies UpstreamProvider<FigmaProfile>;
118
129
  };
@@ -1,12 +1,13 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
2
  import { logger } from "../env";
3
- import type { OAuthProvider, ProviderOptions } from "../oauth2";
3
+ import type { ProviderOptions, UpstreamProvider } from "../oauth2";
4
4
  import {
5
5
  createAuthorizationURL,
6
6
  getOAuth2Tokens,
7
7
  refreshAccessToken,
8
+ resolveRequestedScopes,
8
9
  } from "../oauth2";
9
- import { createAuthorizationCodeRequest } from "../oauth2/validate-authorization-code";
10
+ import { authorizationCodeRequest } from "../oauth2/validate-authorization-code";
10
11
 
11
12
  export interface GithubProfile {
12
13
  login: string;
@@ -58,37 +59,42 @@ export interface GithubProfile {
58
59
  export interface GithubOptions extends ProviderOptions<GithubProfile> {
59
60
  clientId: string;
60
61
  }
62
+ const GITHUB_DEFAULT_SCOPES = ["read:user", "user:email"];
63
+
61
64
  export const github = (options: GithubOptions) => {
62
65
  const tokenEndpoint = "https://github.com/login/oauth/access_token";
63
66
  return {
64
67
  id: "github",
65
68
  name: "GitHub",
69
+ callbackPath: "/callback/github",
66
70
  createAuthorizationURL({
67
71
  state,
68
72
  scopes,
69
73
  loginHint,
70
74
  codeVerifier,
71
75
  redirectURI,
76
+ additionalParams,
72
77
  }) {
73
- const _scopes = options.disableDefaultScope
74
- ? []
75
- : ["read:user", "user:email"];
76
- if (options.scope) _scopes.push(...options.scope);
77
- if (scopes) _scopes.push(...scopes);
78
+ const requestedScopes = resolveRequestedScopes(
79
+ options,
80
+ GITHUB_DEFAULT_SCOPES,
81
+ scopes,
82
+ );
78
83
  return createAuthorizationURL({
79
84
  id: "github",
80
85
  options,
81
86
  authorizationEndpoint: "https://github.com/login/oauth/authorize",
82
- scopes: _scopes,
87
+ scopes: requestedScopes,
83
88
  state,
84
89
  codeVerifier,
85
90
  redirectURI,
86
91
  loginHint,
87
92
  prompt: options.prompt,
93
+ additionalParams,
88
94
  });
89
95
  },
90
96
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
91
- const { body, headers: requestHeaders } = createAuthorizationCodeRequest({
97
+ const { body, headers: requestHeaders } = await authorizationCodeRequest({
92
98
  code,
93
99
  codeVerifier,
94
100
  redirectURI,
@@ -180,5 +186,5 @@ export const github = (options: GithubOptions) => {
180
186
  };
181
187
  },
182
188
  options,
183
- } satisfies OAuthProvider<GithubProfile>;
189
+ } satisfies UpstreamProvider<GithubProfile>;
184
190
  };
@@ -1,8 +1,9 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
- import type { OAuthProvider, ProviderOptions } from "../oauth2";
2
+ import type { ProviderOptions, UpstreamProvider } from "../oauth2";
3
3
  import {
4
4
  createAuthorizationURL,
5
5
  refreshAccessToken,
6
+ resolveRequestedScopes,
6
7
  validateAuthorizationCode,
7
8
  } from "../oauth2";
8
9
 
@@ -73,6 +74,8 @@ const issuerToEndpoints = (issuer?: string | undefined) => {
73
74
  };
74
75
  };
75
76
 
77
+ const GITLAB_DEFAULT_SCOPES = ["read_user"];
78
+
76
79
  export const gitlab = (options: GitlabOptions) => {
77
80
  const { authorizationEndpoint, tokenEndpoint, userinfoEndpoint } =
78
81
  issuerToEndpoints(options.issuer);
@@ -81,25 +84,30 @@ export const gitlab = (options: GitlabOptions) => {
81
84
  return {
82
85
  id: issuerId,
83
86
  name: issuerName,
84
- createAuthorizationURL: async ({
87
+ callbackPath: "/callback/gitlab",
88
+ createAuthorizationURL: ({
85
89
  state,
86
90
  scopes,
87
91
  codeVerifier,
88
92
  loginHint,
89
93
  redirectURI,
94
+ additionalParams,
90
95
  }) => {
91
- const _scopes = options.disableDefaultScope ? [] : ["read_user"];
92
- if (options.scope) _scopes.push(...options.scope);
93
- if (scopes) _scopes.push(...scopes);
94
- return await createAuthorizationURL({
96
+ const requestedScopes = resolveRequestedScopes(
97
+ options,
98
+ GITLAB_DEFAULT_SCOPES,
99
+ scopes,
100
+ );
101
+ return createAuthorizationURL({
95
102
  id: issuerId,
96
103
  options,
97
104
  authorizationEndpoint,
98
- scopes: _scopes,
105
+ scopes: requestedScopes,
99
106
  state,
100
107
  redirectURI,
101
108
  codeVerifier,
102
109
  loginHint,
110
+ additionalParams,
103
111
  });
104
112
  },
105
113
  validateAuthorizationCode: async ({ code, redirectURI, codeVerifier }) => {
@@ -151,5 +159,5 @@ export const gitlab = (options: GitlabOptions) => {
151
159
  };
152
160
  },
153
161
  options,
154
- } satisfies OAuthProvider<GitlabProfile>;
162
+ } satisfies UpstreamProvider<GitlabProfile>;
155
163
  };
@@ -1,12 +1,13 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
- import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
2
+ import { decodeJwt, importJWK } from "jose";
3
3
  import { logger } from "../env";
4
4
  import { APIError, BetterAuthError } from "../error";
5
- import type { OAuthProvider, ProviderOptions } from "../oauth2";
5
+ import type { ProviderOptions, UpstreamProvider } from "../oauth2";
6
6
  import {
7
7
  createAuthorizationURL,
8
8
  getPrimaryClientId,
9
9
  refreshAccessToken,
10
+ resolveRequestedScopes,
10
11
  validateAuthorizationCode,
11
12
  } from "../oauth2";
12
13
 
@@ -48,15 +49,33 @@ export interface GoogleOptions extends ProviderOptions<GoogleProfile> {
48
49
  */
49
50
  display?: ("page" | "popup" | "touch" | "wap") | undefined;
50
51
  /**
51
- * 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
+ * Sign-in is rejected when the claim is missing or does not match, so this
57
+ * can be used to restrict sign-in to a Workspace domain.
52
58
  */
53
59
  hd?: string | undefined;
60
+ /**
61
+ * Enable incremental authorization via Google's `include_granted_scopes`
62
+ * parameter. When enabled, Google reports the user's full granted scope set
63
+ * in the token response.
64
+ *
65
+ * @default true
66
+ */
67
+ includeGrantedScopes?: boolean | undefined;
54
68
  }
55
69
 
70
+ const GOOGLE_DEFAULT_SCOPES = ["email", "profile", "openid"];
71
+
56
72
  export const google = (options: GoogleOptions) => {
57
73
  return {
58
74
  id: "google",
59
75
  name: "Google",
76
+ callbackPath: "/callback/google",
77
+ grantAuthority:
78
+ options.includeGrantedScopes !== false ? "full-grant" : "projection",
60
79
  async createAuthorizationURL({
61
80
  state,
62
81
  scopes,
@@ -64,6 +83,7 @@ export const google = (options: GoogleOptions) => {
64
83
  redirectURI,
65
84
  loginHint,
66
85
  display,
86
+ additionalParams,
67
87
  }) {
68
88
  if (!getPrimaryClientId(options.clientId) || !options.clientSecret) {
69
89
  logger.error(
@@ -74,16 +94,16 @@ export const google = (options: GoogleOptions) => {
74
94
  if (!codeVerifier) {
75
95
  throw new BetterAuthError("codeVerifier is required for Google");
76
96
  }
77
- const _scopes = options.disableDefaultScope
78
- ? []
79
- : ["email", "profile", "openid"];
80
- if (options.scope) _scopes.push(...options.scope);
81
- if (scopes) _scopes.push(...scopes);
82
- const url = await createAuthorizationURL({
97
+ const requestedScopes = resolveRequestedScopes(
98
+ options,
99
+ GOOGLE_DEFAULT_SCOPES,
100
+ scopes,
101
+ );
102
+ return createAuthorizationURL({
83
103
  id: "google",
84
104
  options,
85
105
  authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
86
- scopes: _scopes,
106
+ scopes: requestedScopes,
87
107
  state,
88
108
  codeVerifier,
89
109
  redirectURI,
@@ -92,11 +112,17 @@ export const google = (options: GoogleOptions) => {
92
112
  display: display || options.display,
93
113
  loginHint,
94
114
  hd: options.hd,
95
- additionalParams: {
96
- include_granted_scopes: "true",
97
- },
115
+ additionalParams:
116
+ options.includeGrantedScopes === false
117
+ ? { ...(additionalParams ?? {}) }
118
+ : {
119
+ ...(additionalParams ?? {}),
120
+ // Not caller-overridable: the emitted param must stay in
121
+ // lockstep with `grantAuthority` (driven by the option), or
122
+ // the callback would treat a non-authoritative grant as full.
123
+ include_granted_scopes: "true",
124
+ },
98
125
  });
99
- return url;
100
126
  },
101
127
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
102
128
  return validateAuthorizationCode({
@@ -120,37 +146,20 @@ export const google = (options: GoogleOptions) => {
120
146
  tokenEndpoint: "https://oauth2.googleapis.com/token",
121
147
  });
122
148
  },
123
- async verifyIdToken(token, nonce) {
124
- if (options.disableIdTokenSignIn) {
125
- return false;
126
- }
127
- if (options.verifyIdToken) {
128
- return options.verifyIdToken(token, nonce);
129
- }
130
-
131
- // Verify JWT integrity
132
- // See https://developers.google.com/identity/sign-in/web/backend-auth#verify-the-integrity-of-the-id-token
133
-
134
- try {
135
- const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
136
- if (!kid || !jwtAlg) return false;
137
-
138
- const publicKey = await getGooglePublicKey(kid);
139
- const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
140
- algorithms: [jwtAlg],
141
- issuer: ["https://accounts.google.com", "accounts.google.com"],
142
- audience: options.clientId,
143
- maxTokenAge: "1h",
144
- });
145
-
146
- if (nonce && jwtClaims.nonce !== nonce) {
147
- return false;
148
- }
149
-
150
- return true;
151
- } catch {
152
- return false;
153
- }
149
+ idToken: {
150
+ // https://developers.google.com/identity/sign-in/web/backend-auth#verify-the-integrity-of-the-id-token
151
+ jwks: (header) => getGooglePublicKey(header.kid!),
152
+ issuer: ["https://accounts.google.com", "accounts.google.com"],
153
+ audience: options.clientId,
154
+ maxTokenAge: "1h",
155
+ // Google's `hd` authorization parameter is only a UI hint and can be
156
+ // removed or changed by the user. When a hosted domain is configured,
157
+ // the `hd` claim in the verified id token is the authoritative value
158
+ // and must match, otherwise accounts outside the workspace domain would
159
+ // be accepted on the id_token sign-in path.
160
+ verifyClaims: options.hd
161
+ ? (claims) => claims.hd === options.hd
162
+ : undefined,
154
163
  },
155
164
  async getUserInfo(token) {
156
165
  if (options.getUserInfo) {
@@ -160,6 +169,18 @@ export const google = (options: GoogleOptions) => {
160
169
  return null;
161
170
  }
162
171
  const user = decodeJwt(token.idToken) as GoogleProfile;
172
+ // Enforce the configured hosted domain on the callback profile path
173
+ // as well. The `hd` claim must be present and match, since the
174
+ // authorization-time `hd` hint does not restrict which account signs
175
+ // in.
176
+ if (options.hd && user.hd !== options.hd) {
177
+ logger.error(
178
+ `Google sign-in rejected: id token hosted domain (hd) "${
179
+ user.hd ?? "<missing>"
180
+ }" does not match the configured "hd" option "${options.hd}".`,
181
+ );
182
+ return null;
183
+ }
163
184
  const userMap = await options.mapProfileToUser?.(user);
164
185
  return {
165
186
  user: {
@@ -174,7 +195,7 @@ export const google = (options: GoogleOptions) => {
174
195
  };
175
196
  },
176
197
  options,
177
- } satisfies OAuthProvider<GoogleProfile>;
198
+ } satisfies UpstreamProvider<GoogleProfile>;
178
199
  };
179
200
 
180
201
  export const getGooglePublicKey = async (kid: string) => {
@@ -1,8 +1,9 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
- import type { OAuthProvider, ProviderOptions } from "../oauth2";
2
+ import type { ProviderOptions, UpstreamProvider } from "../oauth2";
3
3
  import {
4
4
  createAuthorizationURL,
5
5
  refreshAccessToken,
6
+ resolveRequestedScopes,
6
7
  validateAuthorizationCode,
7
8
  } from "../oauth2";
8
9
 
@@ -42,25 +43,35 @@ export interface HuggingFaceOptions
42
43
  clientId: string;
43
44
  }
44
45
 
46
+ const HUGGINGFACE_DEFAULT_SCOPES = ["openid", "profile", "email"];
47
+
45
48
  export const huggingface = (options: HuggingFaceOptions) => {
46
49
  const tokenEndpoint = "https://huggingface.co/oauth/token";
47
50
  return {
48
51
  id: "huggingface",
49
52
  name: "Hugging Face",
50
- createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) {
51
- const _scopes = options.disableDefaultScope
52
- ? []
53
- : ["openid", "profile", "email"];
54
- if (options.scope) _scopes.push(...options.scope);
55
- if (scopes) _scopes.push(...scopes);
53
+ callbackPath: "/callback/huggingface",
54
+ createAuthorizationURL({
55
+ state,
56
+ scopes,
57
+ codeVerifier,
58
+ redirectURI,
59
+ additionalParams,
60
+ }) {
61
+ const requestedScopes = resolveRequestedScopes(
62
+ options,
63
+ HUGGINGFACE_DEFAULT_SCOPES,
64
+ scopes,
65
+ );
56
66
  return createAuthorizationURL({
57
67
  id: "huggingface",
58
68
  options,
59
69
  authorizationEndpoint: "https://huggingface.co/oauth/authorize",
60
- scopes: _scopes,
70
+ scopes: requestedScopes,
61
71
  state,
62
72
  codeVerifier,
63
73
  redirectURI,
74
+ additionalParams,
64
75
  });
65
76
  },
66
77
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
@@ -115,5 +126,5 @@ export const huggingface = (options: HuggingFaceOptions) => {
115
126
  };
116
127
  },
117
128
  options,
118
- } satisfies OAuthProvider<HuggingFaceProfile>;
129
+ } satisfies UpstreamProvider<HuggingFaceProfile>;
119
130
  };