@better-auth/core 1.7.0-beta.4 → 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 (150) 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 +2 -2
  4. package/dist/db/get-tables.mjs +3 -3
  5. package/dist/db/schema/account.d.mts +1 -1
  6. package/dist/db/schema/account.mjs +1 -1
  7. package/dist/env/env-impl.mjs +1 -1
  8. package/dist/error/codes.d.mts +5 -0
  9. package/dist/error/codes.mjs +5 -0
  10. package/dist/index.d.mts +2 -2
  11. package/dist/instrumentation/tracer.mjs +1 -1
  12. package/dist/oauth2/create-authorization-url.d.mts +4 -1
  13. package/dist/oauth2/create-authorization-url.mjs +5 -2
  14. package/dist/oauth2/index.d.mts +4 -2
  15. package/dist/oauth2/index.mjs +3 -1
  16. package/dist/oauth2/oauth-provider.d.mts +128 -9
  17. package/dist/oauth2/refresh-access-token.mjs +1 -1
  18. package/dist/oauth2/scopes.d.mts +76 -0
  19. package/dist/oauth2/scopes.mjs +96 -0
  20. package/dist/oauth2/utils.mjs +2 -1
  21. package/dist/oauth2/verify-id-token.d.mts +26 -0
  22. package/dist/oauth2/verify-id-token.mjs +62 -0
  23. package/dist/oauth2/verify.d.mts +14 -0
  24. package/dist/oauth2/verify.mjs +23 -7
  25. package/dist/social-providers/apple.d.mts +14 -2
  26. package/dist/social-providers/apple.mjs +12 -36
  27. package/dist/social-providers/atlassian.d.mts +5 -1
  28. package/dist/social-providers/atlassian.mjs +4 -4
  29. package/dist/social-providers/cognito.d.mts +13 -2
  30. package/dist/social-providers/cognito.mjs +24 -32
  31. package/dist/social-providers/discord.d.mts +5 -1
  32. package/dist/social-providers/discord.mjs +7 -6
  33. package/dist/social-providers/dropbox.d.mts +5 -1
  34. package/dist/social-providers/dropbox.mjs +5 -5
  35. package/dist/social-providers/facebook.d.mts +21 -2
  36. package/dist/social-providers/facebook.mjs +46 -22
  37. package/dist/social-providers/figma.d.mts +5 -1
  38. package/dist/social-providers/figma.mjs +5 -5
  39. package/dist/social-providers/github.d.mts +5 -1
  40. package/dist/social-providers/github.mjs +4 -4
  41. package/dist/social-providers/gitlab.d.mts +5 -1
  42. package/dist/social-providers/gitlab.mjs +6 -6
  43. package/dist/social-providers/google.d.mts +29 -3
  44. package/dist/social-providers/google.mjs +24 -30
  45. package/dist/social-providers/huggingface.d.mts +5 -1
  46. package/dist/social-providers/huggingface.mjs +8 -8
  47. package/dist/social-providers/index.d.mts +221 -42
  48. package/dist/social-providers/kakao.d.mts +5 -1
  49. package/dist/social-providers/kakao.mjs +8 -8
  50. package/dist/social-providers/kick.d.mts +5 -1
  51. package/dist/social-providers/kick.mjs +4 -4
  52. package/dist/social-providers/line.d.mts +8 -2
  53. package/dist/social-providers/line.mjs +12 -14
  54. package/dist/social-providers/linear.d.mts +5 -1
  55. package/dist/social-providers/linear.mjs +4 -4
  56. package/dist/social-providers/linkedin.d.mts +5 -1
  57. package/dist/social-providers/linkedin.mjs +10 -10
  58. package/dist/social-providers/microsoft-entra-id.d.mts +31 -6
  59. package/dist/social-providers/microsoft-entra-id.mjs +26 -37
  60. package/dist/social-providers/naver.d.mts +5 -1
  61. package/dist/social-providers/naver.mjs +4 -4
  62. package/dist/social-providers/notion.d.mts +5 -1
  63. package/dist/social-providers/notion.mjs +4 -4
  64. package/dist/social-providers/paybin.d.mts +5 -1
  65. package/dist/social-providers/paybin.mjs +10 -10
  66. package/dist/social-providers/paypal.d.mts +5 -2
  67. package/dist/social-providers/paypal.mjs +8 -13
  68. package/dist/social-providers/polar.d.mts +5 -1
  69. package/dist/social-providers/polar.mjs +8 -8
  70. package/dist/social-providers/railway.d.mts +5 -1
  71. package/dist/social-providers/railway.mjs +9 -9
  72. package/dist/social-providers/reddit.d.mts +5 -1
  73. package/dist/social-providers/reddit.mjs +9 -8
  74. package/dist/social-providers/roblox.d.mts +5 -1
  75. package/dist/social-providers/roblox.mjs +5 -5
  76. package/dist/social-providers/salesforce.d.mts +5 -1
  77. package/dist/social-providers/salesforce.mjs +8 -8
  78. package/dist/social-providers/slack.d.mts +5 -1
  79. package/dist/social-providers/slack.mjs +9 -9
  80. package/dist/social-providers/spotify.d.mts +5 -1
  81. package/dist/social-providers/spotify.mjs +5 -5
  82. package/dist/social-providers/tiktok.d.mts +5 -1
  83. package/dist/social-providers/tiktok.mjs +9 -5
  84. package/dist/social-providers/twitch.d.mts +5 -1
  85. package/dist/social-providers/twitch.mjs +4 -4
  86. package/dist/social-providers/twitter.d.mts +6 -4
  87. package/dist/social-providers/twitter.mjs +9 -9
  88. package/dist/social-providers/vercel.d.mts +5 -1
  89. package/dist/social-providers/vercel.mjs +4 -7
  90. package/dist/social-providers/vk.d.mts +5 -1
  91. package/dist/social-providers/vk.mjs +5 -5
  92. package/dist/social-providers/wechat.d.mts +5 -1
  93. package/dist/social-providers/wechat.mjs +9 -5
  94. package/dist/social-providers/zoom.d.mts +6 -1
  95. package/dist/social-providers/zoom.mjs +15 -9
  96. package/dist/types/context.d.mts +10 -8
  97. package/dist/types/index.d.mts +1 -1
  98. package/dist/types/init-options.d.mts +92 -1
  99. package/package.json +5 -5
  100. package/src/db/adapter/factory.ts +10 -2
  101. package/src/db/get-tables.ts +8 -3
  102. package/src/db/schema/account.ts +14 -2
  103. package/src/env/env-impl.ts +1 -2
  104. package/src/error/codes.ts +5 -0
  105. package/src/oauth2/create-authorization-url.ts +2 -2
  106. package/src/oauth2/index.ts +17 -1
  107. package/src/oauth2/oauth-provider.ts +140 -10
  108. package/src/oauth2/refresh-access-token.ts +2 -2
  109. package/src/oauth2/scopes.ts +118 -0
  110. package/src/oauth2/utils.ts +2 -5
  111. package/src/oauth2/verify-id-token.ts +111 -0
  112. package/src/oauth2/verify.ts +62 -11
  113. package/src/social-providers/apple.ts +24 -61
  114. package/src/social-providers/atlassian.ts +12 -8
  115. package/src/social-providers/cognito.ts +25 -47
  116. package/src/social-providers/discord.ts +19 -8
  117. package/src/social-providers/dropbox.ts +13 -7
  118. package/src/social-providers/facebook.ts +97 -51
  119. package/src/social-providers/figma.ts +13 -9
  120. package/src/social-providers/github.ts +12 -8
  121. package/src/social-providers/gitlab.ts +14 -8
  122. package/src/social-providers/google.ts +66 -47
  123. package/src/social-providers/huggingface.ts +12 -8
  124. package/src/social-providers/kakao.ts +16 -8
  125. package/src/social-providers/kick.ts +12 -7
  126. package/src/social-providers/line.ts +37 -37
  127. package/src/social-providers/linear.ts +12 -6
  128. package/src/social-providers/linkedin.ts +14 -10
  129. package/src/social-providers/microsoft-entra-id.ts +65 -64
  130. package/src/social-providers/naver.ts +12 -6
  131. package/src/social-providers/notion.ts +12 -6
  132. package/src/social-providers/paybin.ts +14 -11
  133. package/src/social-providers/paypal.ts +6 -25
  134. package/src/social-providers/polar.ts +12 -8
  135. package/src/social-providers/railway.ts +13 -9
  136. package/src/social-providers/reddit.ts +21 -10
  137. package/src/social-providers/roblox.ts +18 -7
  138. package/src/social-providers/salesforce.ts +12 -8
  139. package/src/social-providers/slack.ts +18 -9
  140. package/src/social-providers/spotify.ts +13 -7
  141. package/src/social-providers/tiktok.ts +13 -7
  142. package/src/social-providers/twitch.ts +12 -8
  143. package/src/social-providers/twitter.ts +17 -8
  144. package/src/social-providers/vercel.ts +16 -10
  145. package/src/social-providers/vk.ts +13 -7
  146. package/src/social-providers/wechat.ts +20 -8
  147. package/src/social-providers/zoom.ts +19 -6
  148. package/src/types/context.ts +8 -8
  149. package/src/types/index.ts +7 -0
  150. package/src/types/init-options.ts +119 -0
@@ -1,13 +1,14 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
2
 
3
- import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
3
+ import { decodeJwt, importJWK } from "jose";
4
4
  import { logger } from "../env";
5
5
  import { APIError, BetterAuthError } from "../error";
6
- import type { OAuthProvider, ProviderOptions } from "../oauth2";
6
+ import type { ProviderOptions, UpstreamProvider } from "../oauth2";
7
7
  import {
8
8
  createAuthorizationURL,
9
9
  getPrimaryClientId,
10
10
  refreshAccessToken,
11
+ resolveRequestedScopes,
11
12
  validateAuthorizationCode,
12
13
  } from "../oauth2";
13
14
  export interface AppleProfile {
@@ -77,29 +78,14 @@ export interface AppleOptions extends ProviderOptions<AppleProfile> {
77
78
  audience?: (string | string[]) | undefined;
78
79
  }
79
80
 
80
- async function sha256Hex(value: string) {
81
- const data = new TextEncoder().encode(value);
82
- const digest = await crypto.subtle.digest("SHA-256", data);
83
- return Array.from(new Uint8Array(digest))
84
- .map((byte) => byte.toString(16).padStart(2, "0"))
85
- .join("");
86
- }
87
-
88
- async function nonceMatches(jwtNonce: unknown, nonce: string) {
89
- if (typeof jwtNonce !== "string") {
90
- return false;
91
- }
92
- if (jwtNonce === nonce) {
93
- return true;
94
- }
95
- return jwtNonce === (await sha256Hex(nonce));
96
- }
81
+ const APPLE_DEFAULT_SCOPES = ["email", "name"];
97
82
 
98
83
  export const apple = (options: AppleOptions) => {
99
84
  const tokenEndpoint = "https://appleid.apple.com/auth/token";
100
85
  return {
101
86
  id: "apple",
102
87
  name: "Apple",
88
+ callbackPath: "/callback/apple",
103
89
  async createAuthorizationURL({
104
90
  state,
105
91
  scopes,
@@ -112,21 +98,22 @@ export const apple = (options: AppleOptions) => {
112
98
  );
113
99
  throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED");
114
100
  }
115
- const _scope = options.disableDefaultScope ? [] : ["email", "name"];
116
- if (options.scope) _scope.push(...options.scope);
117
- if (scopes) _scope.push(...scopes);
118
- const url = await createAuthorizationURL({
101
+ const requestedScopes = resolveRequestedScopes(
102
+ options,
103
+ APPLE_DEFAULT_SCOPES,
104
+ scopes,
105
+ );
106
+ return createAuthorizationURL({
119
107
  id: "apple",
120
108
  options,
121
109
  authorizationEndpoint: "https://appleid.apple.com/auth/authorize",
122
- scopes: _scope,
110
+ scopes: requestedScopes,
123
111
  state,
124
112
  redirectURI,
125
113
  responseMode: "form_post",
126
114
  responseType: "code id_token",
127
115
  additionalParams,
128
116
  });
129
- return url;
130
117
  },
131
118
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
132
119
  return validateAuthorizationCode({
@@ -137,41 +124,17 @@ export const apple = (options: AppleOptions) => {
137
124
  tokenEndpoint,
138
125
  });
139
126
  },
140
- async verifyIdToken(token, nonce) {
141
- if (options.disableIdTokenSignIn) {
142
- return false;
143
- }
144
- if (options.verifyIdToken) {
145
- return options.verifyIdToken(token, nonce);
146
- }
147
- try {
148
- const decodedHeader = decodeProtectedHeader(token);
149
- const { kid, alg: jwtAlg } = decodedHeader;
150
- if (!kid || !jwtAlg) return false;
151
- const publicKey = await getApplePublicKey(kid);
152
- const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
153
- algorithms: [jwtAlg],
154
- issuer: "https://appleid.apple.com",
155
- audience:
156
- options.audience && options.audience.length
157
- ? options.audience
158
- : options.appBundleIdentifier
159
- ? options.appBundleIdentifier
160
- : options.clientId,
161
- maxTokenAge: "1h",
162
- });
163
- ["email_verified", "is_private_email"].forEach((field) => {
164
- if (jwtClaims[field] !== undefined) {
165
- jwtClaims[field] = Boolean(jwtClaims[field]);
166
- }
167
- });
168
- if (nonce && !(await nonceMatches(jwtClaims.nonce, nonce))) {
169
- return false;
170
- }
171
- return !!jwtClaims;
172
- } catch {
173
- return false;
174
- }
127
+ idToken: {
128
+ jwks: (header) => getApplePublicKey(header.kid!),
129
+ issuer: "https://appleid.apple.com",
130
+ audience:
131
+ options.audience && options.audience.length
132
+ ? options.audience
133
+ : options.appBundleIdentifier
134
+ ? options.appBundleIdentifier
135
+ : options.clientId,
136
+ maxTokenAge: "1h",
137
+ nonceComparison: "exact-or-sha256",
175
138
  },
176
139
  refreshAccessToken: options.refreshAccessToken
177
140
  ? options.refreshAccessToken
@@ -226,7 +189,7 @@ export const apple = (options: AppleOptions) => {
226
189
  };
227
190
  },
228
191
  options,
229
- } satisfies OAuthProvider<AppleProfile>;
192
+ } satisfies UpstreamProvider<AppleProfile>;
230
193
  };
231
194
 
232
195
  export const getApplePublicKey = async (kid: string) => {
@@ -1,10 +1,11 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
2
  import { logger } from "../env";
3
3
  import { BetterAuthError } from "../error";
4
- import type { OAuthProvider, ProviderOptions } from "../oauth2";
4
+ import type { ProviderOptions, UpstreamProvider } from "../oauth2";
5
5
  import {
6
6
  createAuthorizationURL,
7
7
  refreshAccessToken,
8
+ resolveRequestedScopes,
8
9
  validateAuthorizationCode,
9
10
  } from "../oauth2";
10
11
 
@@ -29,11 +30,14 @@ export interface AtlassianOptions extends ProviderOptions<AtlassianProfile> {
29
30
  clientId: string;
30
31
  }
31
32
 
33
+ const ATLASSIAN_DEFAULT_SCOPES = ["read:jira-user", "offline_access"];
34
+
32
35
  export const atlassian = (options: AtlassianOptions) => {
33
36
  const tokenEndpoint = "https://auth.atlassian.com/oauth/token";
34
37
  return {
35
38
  id: "atlassian",
36
39
  name: "Atlassian",
40
+ callbackPath: "/callback/atlassian",
37
41
 
38
42
  async createAuthorizationURL({
39
43
  state,
@@ -50,17 +54,17 @@ export const atlassian = (options: AtlassianOptions) => {
50
54
  throw new BetterAuthError("codeVerifier is required for Atlassian");
51
55
  }
52
56
 
53
- const _scopes = options.disableDefaultScope
54
- ? []
55
- : ["read:jira-user", "offline_access"];
56
- if (options.scope) _scopes.push(...options.scope);
57
- if (scopes) _scopes.push(...scopes);
57
+ const requestedScopes = resolveRequestedScopes(
58
+ options,
59
+ ATLASSIAN_DEFAULT_SCOPES,
60
+ scopes,
61
+ );
58
62
 
59
63
  return createAuthorizationURL({
60
64
  id: "atlassian",
61
65
  options,
62
66
  authorizationEndpoint: "https://auth.atlassian.com/authorize",
63
- scopes: _scopes,
67
+ scopes: requestedScopes,
64
68
  state,
65
69
  codeVerifier,
66
70
  redirectURI,
@@ -136,5 +140,5 @@ export const atlassian = (options: AtlassianOptions) => {
136
140
  },
137
141
 
138
142
  options,
139
- } satisfies OAuthProvider<AtlassianProfile>;
143
+ } satisfies UpstreamProvider<AtlassianProfile>;
140
144
  };
@@ -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
 
@@ -57,6 +58,8 @@ export interface CognitoOptions extends ProviderOptions<CognitoProfile> {
57
58
  identityProvider?: string | undefined;
58
59
  }
59
60
 
61
+ const COGNITO_DEFAULT_SCOPES = ["openid", "profile", "email"];
62
+
60
63
  export const cognito = (options: CognitoOptions) => {
61
64
  if (!options.domain || !options.region || !options.userPoolId) {
62
65
  logger.error(
@@ -73,6 +76,7 @@ export const cognito = (options: CognitoOptions) => {
73
76
  return {
74
77
  id: "cognito",
75
78
  name: "Cognito",
79
+ callbackPath: "/callback/cognito",
76
80
  async createAuthorizationURL({
77
81
  state,
78
82
  scopes,
@@ -93,19 +97,19 @@ export const cognito = (options: CognitoOptions) => {
93
97
  );
94
98
  throw new BetterAuthError("CLIENT_SECRET_REQUIRED");
95
99
  }
96
- const _scopes = options.disableDefaultScope
97
- ? []
98
- : ["openid", "profile", "email"];
99
- if (options.scope) _scopes.push(...options.scope);
100
- if (scopes) _scopes.push(...scopes);
100
+ const requestedScopes = resolveRequestedScopes(
101
+ options,
102
+ COGNITO_DEFAULT_SCOPES,
103
+ scopes,
104
+ );
101
105
 
102
- const url = await createAuthorizationURL({
106
+ const { url } = await createAuthorizationURL({
103
107
  id: "cognito",
104
108
  options: {
105
109
  ...options,
106
110
  },
107
111
  authorizationEndpoint,
108
- scopes: _scopes,
112
+ scopes: requestedScopes,
109
113
  state,
110
114
  codeVerifier,
111
115
  redirectURI,
@@ -126,9 +130,12 @@ export const cognito = (options: CognitoOptions) => {
126
130
  // Manually append the scope with proper encoding to the URL
127
131
  const urlString = url.toString();
128
132
  const separator = urlString.includes("?") ? "&" : "?";
129
- return new URL(`${urlString}${separator}scope=${encodedScope}`);
133
+ return {
134
+ url: new URL(`${urlString}${separator}scope=${encodedScope}`),
135
+ requestedScopes,
136
+ };
130
137
  }
131
- return url;
138
+ return { url, requestedScopes };
132
139
  },
133
140
 
134
141
  validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
@@ -155,41 +162,12 @@ export const cognito = (options: CognitoOptions) => {
155
162
  });
156
163
  },
157
164
 
158
- async verifyIdToken(token, nonce) {
159
- if (options.disableIdTokenSignIn) {
160
- return false;
161
- }
162
- if (options.verifyIdToken) {
163
- return options.verifyIdToken(token, nonce);
164
- }
165
-
166
- try {
167
- const decodedHeader = decodeProtectedHeader(token);
168
- const { kid, alg: jwtAlg } = decodedHeader;
169
- if (!kid || !jwtAlg) return false;
170
-
171
- const publicKey = await getCognitoPublicKey(
172
- kid,
173
- options.region,
174
- options.userPoolId,
175
- );
176
- const expectedIssuer = `https://cognito-idp.${options.region}.amazonaws.com/${options.userPoolId}`;
177
-
178
- const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
179
- algorithms: [jwtAlg],
180
- issuer: expectedIssuer,
181
- audience: options.clientId,
182
- maxTokenAge: "1h",
183
- });
184
-
185
- if (nonce && jwtClaims.nonce !== nonce) {
186
- return false;
187
- }
188
- return true;
189
- } catch (error) {
190
- logger.error("Failed to verify ID token:", error);
191
- return false;
192
- }
165
+ idToken: {
166
+ jwks: (header) =>
167
+ getCognitoPublicKey(header.kid!, options.region, options.userPoolId),
168
+ issuer: `https://cognito-idp.${options.region}.amazonaws.com/${options.userPoolId}`,
169
+ audience: options.clientId,
170
+ maxTokenAge: "1h",
193
171
  },
194
172
 
195
173
  async getUserInfo(token) {
@@ -265,7 +243,7 @@ export const cognito = (options: CognitoOptions) => {
265
243
  },
266
244
 
267
245
  options,
268
- } satisfies OAuthProvider<CognitoProfile>;
246
+ } satisfies UpstreamProvider<CognitoProfile>;
269
247
  };
270
248
 
271
249
  export const getCognitoPublicKey = async (
@@ -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
  export interface DiscordProfile extends Record<string, any> {
@@ -83,21 +84,31 @@ export interface DiscordOptions extends ProviderOptions<DiscordProfile> {
83
84
  permissions?: number | undefined;
84
85
  }
85
86
 
87
+ const DISCORD_DEFAULT_SCOPES = ["identify", "email"];
88
+
86
89
  export const discord = (options: DiscordOptions) => {
87
90
  const tokenEndpoint = "https://discord.com/api/oauth2/token";
88
91
  return {
89
92
  id: "discord",
90
93
  name: "Discord",
91
- createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
92
- const _scopes = options.disableDefaultScope ? [] : ["identify", "email"];
93
- if (scopes) _scopes.push(...scopes);
94
- if (options.scope) _scopes.push(...options.scope);
95
- const hasBotScope = _scopes.includes("bot");
94
+ callbackPath: "/callback/discord",
95
+ async createAuthorizationURL({
96
+ state,
97
+ scopes,
98
+ redirectURI,
99
+ additionalParams,
100
+ }) {
101
+ const requestedScopes = resolveRequestedScopes(
102
+ options,
103
+ DISCORD_DEFAULT_SCOPES,
104
+ scopes,
105
+ );
106
+ const hasBotScope = requestedScopes.includes("bot");
96
107
  return createAuthorizationURL({
97
108
  id: "discord",
98
109
  options,
99
110
  authorizationEndpoint: "https://discord.com/api/oauth2/authorize",
100
- scopes: _scopes,
111
+ scopes: requestedScopes,
101
112
  state,
102
113
  redirectURI,
103
114
  prompt: options.prompt || "none",
@@ -170,5 +181,5 @@ export const discord = (options: DiscordOptions) => {
170
181
  };
171
182
  },
172
183
  options,
173
- } satisfies OAuthProvider<DiscordProfile>;
184
+ } satisfies UpstreamProvider<DiscordProfile>;
174
185
  };
@@ -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
 
@@ -25,12 +26,15 @@ export interface DropboxOptions extends ProviderOptions<DropboxProfile> {
25
26
  accessType?: ("offline" | "online" | "legacy") | undefined;
26
27
  }
27
28
 
29
+ const DROPBOX_DEFAULT_SCOPES = ["account_info.read"];
30
+
28
31
  export const dropbox = (options: DropboxOptions) => {
29
32
  const tokenEndpoint = "https://api.dropboxapi.com/oauth2/token";
30
33
 
31
34
  return {
32
35
  id: "dropbox",
33
36
  name: "Dropbox",
37
+ callbackPath: "/callback/dropbox",
34
38
  createAuthorizationURL: async ({
35
39
  state,
36
40
  scopes,
@@ -38,14 +42,16 @@ export const dropbox = (options: DropboxOptions) => {
38
42
  redirectURI,
39
43
  additionalParams,
40
44
  }) => {
41
- const _scopes = options.disableDefaultScope ? [] : ["account_info.read"];
42
- if (options.scope) _scopes.push(...options.scope);
43
- if (scopes) _scopes.push(...scopes);
44
- return await createAuthorizationURL({
45
+ const requestedScopes = resolveRequestedScopes(
46
+ options,
47
+ DROPBOX_DEFAULT_SCOPES,
48
+ scopes,
49
+ );
50
+ return createAuthorizationURL({
45
51
  id: "dropbox",
46
52
  options,
47
53
  authorizationEndpoint: "https://www.dropbox.com/oauth2/authorize",
48
- scopes: _scopes,
54
+ scopes: requestedScopes,
49
55
  state,
50
56
  redirectURI,
51
57
  codeVerifier,
@@ -110,5 +116,5 @@ export const dropbox = (options: DropboxOptions) => {
110
116
  };
111
117
  },
112
118
  options,
113
- } satisfies OAuthProvider<DropboxProfile>;
119
+ } satisfies UpstreamProvider<DropboxProfile>;
114
120
  };
@@ -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,10 +92,13 @@ 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",
101
+ callbackPath: "/callback/facebook",
46
102
  async createAuthorizationURL({
47
103
  state,
48
104
  scopes,
@@ -56,16 +112,16 @@ export const facebook = (options: FacebookOptions) => {
56
112
  );
57
113
  throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED");
58
114
  }
59
- const _scopes = options.disableDefaultScope
60
- ? []
61
- : ["email", "public_profile"];
62
- if (options.scope) _scopes.push(...options.scope);
63
- if (scopes) _scopes.push(...scopes);
64
- return await createAuthorizationURL({
115
+ const requestedScopes = resolveRequestedScopes(
116
+ options,
117
+ FACEBOOK_DEFAULT_SCOPES,
118
+ scopes,
119
+ );
120
+ return createAuthorizationURL({
65
121
  id: "facebook",
66
122
  options,
67
123
  authorizationEndpoint: "https://www.facebook.com/v24.0/dialog/oauth",
68
- scopes: _scopes,
124
+ scopes: requestedScopes,
69
125
  state,
70
126
  redirectURI,
71
127
  loginHint,
@@ -83,46 +139,17 @@ export const facebook = (options: FacebookOptions) => {
83
139
  tokenEndpoint: "https://graph.facebook.com/v24.0/oauth/access_token",
84
140
  });
85
141
  },
86
- async verifyIdToken(token, nonce) {
87
- if (options.disableIdTokenSignIn) {
88
- return false;
89
- }
90
-
91
- if (options.verifyIdToken) {
92
- return options.verifyIdToken(token, nonce);
93
- }
94
-
95
- /* limited login */
96
- // check is limited token
97
- if (token.split(".").length === 3) {
98
- try {
99
- const { payload: jwtClaims } = await jwtVerify(
100
- token,
101
- createRemoteJWKSet(
102
- // https://developers.facebook.com/docs/facebook-login/limited-login/token/#jwks
103
- new URL(
104
- "https://limited.facebook.com/.well-known/oauth/openid/jwks/",
105
- ),
106
- ),
107
- {
108
- algorithms: ["RS256"],
109
- audience: options.clientId,
110
- issuer: "https://www.facebook.com",
111
- },
112
- );
113
-
114
- if (nonce && jwtClaims.nonce !== nonce) {
115
- return false;
116
- }
117
-
118
- return !!jwtClaims;
119
- } catch {
120
- return false;
121
- }
122
- }
123
-
124
- /* access_token */
125
- 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,
126
153
  },
127
154
  refreshAccessToken: options.refreshAccessToken
128
155
  ? options.refreshAccessToken
@@ -183,6 +210,21 @@ export const facebook = (options: FacebookOptions) => {
183
210
  };
184
211
  }
185
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
+
186
228
  const fields = [
187
229
  "id",
188
230
  "name",
@@ -195,13 +237,17 @@ export const facebook = (options: FacebookOptions) => {
195
237
  {
196
238
  auth: {
197
239
  type: "Bearer",
198
- token: token.accessToken,
240
+ token: accessToken,
199
241
  },
200
242
  },
201
243
  );
202
244
  if (error) {
203
245
  return null;
204
246
  }
247
+ // Bind the validated token to the profile it returned.
248
+ if (profile.id !== tokenUserId) {
249
+ return null;
250
+ }
205
251
  const userMap = await options.mapProfileToUser?.(profile);
206
252
  return {
207
253
  user: {
@@ -216,5 +262,5 @@ export const facebook = (options: FacebookOptions) => {
216
262
  };
217
263
  },
218
264
  options,
219
- } satisfies OAuthProvider<FacebookProfile>;
265
+ } satisfies UpstreamProvider<FacebookProfile>;
220
266
  };