better-auth 1.6.11 → 1.6.13

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 (84) hide show
  1. package/dist/api/index.d.mts +12 -48
  2. package/dist/api/routes/account.d.mts +2 -23
  3. package/dist/api/routes/account.mjs +94 -73
  4. package/dist/api/routes/callback.d.mts +1 -1
  5. package/dist/api/routes/callback.mjs +39 -42
  6. package/dist/api/routes/email-verification.d.mts +1 -0
  7. package/dist/api/routes/email-verification.mjs +4 -3
  8. package/dist/api/routes/password.mjs +1 -1
  9. package/dist/api/routes/session.mjs +15 -10
  10. package/dist/api/routes/sign-in.d.mts +1 -0
  11. package/dist/api/routes/sign-in.mjs +3 -2
  12. package/dist/api/routes/sign-up.d.mts +1 -0
  13. package/dist/api/routes/sign-up.mjs +9 -7
  14. package/dist/api/routes/update-user.mjs +7 -7
  15. package/dist/client/fetch-plugins.mjs +2 -1
  16. package/dist/client/parser.mjs +0 -1
  17. package/dist/client/plugins/index.d.mts +3 -3
  18. package/dist/client/proxy.mjs +2 -1
  19. package/dist/context/create-context.mjs +10 -14
  20. package/dist/context/helpers.mjs +3 -2
  21. package/dist/cookies/cookie-utils.d.mts +24 -1
  22. package/dist/cookies/cookie-utils.mjs +85 -22
  23. package/dist/cookies/index.d.mts +2 -3
  24. package/dist/cookies/index.mjs +39 -11
  25. package/dist/cookies/session-store.mjs +4 -23
  26. package/dist/db/get-migration.mjs +4 -4
  27. package/dist/db/index.d.mts +2 -2
  28. package/dist/db/index.mjs +3 -2
  29. package/dist/db/internal-adapter.mjs +56 -50
  30. package/dist/db/schema.d.mts +15 -2
  31. package/dist/db/schema.mjs +26 -1
  32. package/dist/index.d.mts +2 -2
  33. package/dist/index.mjs +2 -2
  34. package/dist/oauth2/errors.mjs +16 -1
  35. package/dist/oauth2/index.d.mts +2 -2
  36. package/dist/oauth2/index.mjs +3 -3
  37. package/dist/oauth2/link-account.d.mts +27 -1
  38. package/dist/oauth2/link-account.mjs +27 -4
  39. package/dist/oauth2/state.mjs +8 -2
  40. package/dist/package.mjs +1 -1
  41. package/dist/plugins/access/access.mjs +11 -6
  42. package/dist/plugins/admin/admin.mjs +0 -4
  43. package/dist/plugins/admin/client.d.mts +1 -1
  44. package/dist/plugins/admin/routes.mjs +3 -3
  45. package/dist/plugins/anonymous/index.mjs +2 -2
  46. package/dist/plugins/bearer/index.mjs +4 -9
  47. package/dist/plugins/captcha/index.mjs +2 -2
  48. package/dist/plugins/email-otp/routes.mjs +1 -1
  49. package/dist/plugins/generic-oauth/index.d.mts +1 -1
  50. package/dist/plugins/generic-oauth/index.mjs +6 -6
  51. package/dist/plugins/generic-oauth/routes.mjs +37 -34
  52. package/dist/plugins/generic-oauth/types.d.mts +7 -0
  53. package/dist/plugins/last-login-method/client.mjs +2 -2
  54. package/dist/plugins/magic-link/index.mjs +0 -1
  55. package/dist/plugins/mcp/index.mjs +2 -5
  56. package/dist/plugins/multi-session/index.mjs +2 -2
  57. package/dist/plugins/oauth-proxy/index.mjs +45 -32
  58. package/dist/plugins/oauth-proxy/utils.mjs +3 -10
  59. package/dist/plugins/oidc-provider/index.mjs +2 -5
  60. package/dist/plugins/one-tap/client.mjs +9 -2
  61. package/dist/plugins/one-tap/index.mjs +16 -39
  62. package/dist/plugins/open-api/generator.mjs +16 -5
  63. package/dist/plugins/organization/adapter.mjs +61 -56
  64. package/dist/plugins/organization/client.d.mts +2 -1
  65. package/dist/plugins/organization/error-codes.d.mts +1 -0
  66. package/dist/plugins/organization/error-codes.mjs +2 -1
  67. package/dist/plugins/organization/routes/crud-invites.mjs +3 -0
  68. package/dist/plugins/organization/routes/crud-org.d.mts +4 -4
  69. package/dist/plugins/organization/routes/crud-org.mjs +2 -2
  70. package/dist/plugins/organization/types.d.mts +3 -3
  71. package/dist/plugins/phone-number/routes.mjs +1 -1
  72. package/dist/plugins/two-factor/backup-codes/index.d.mts +4 -3
  73. package/dist/plugins/two-factor/client.mjs +2 -1
  74. package/dist/plugins/two-factor/index.mjs +3 -2
  75. package/dist/plugins/username/index.d.mts +24 -2
  76. package/dist/plugins/username/index.mjs +49 -3
  77. package/dist/state.d.mts +2 -2
  78. package/dist/state.mjs +18 -4
  79. package/dist/test-utils/headers.mjs +2 -7
  80. package/dist/test-utils/test-instance.d.mts +36 -144
  81. package/dist/utils/index.d.mts +1 -1
  82. package/dist/utils/url.d.mts +2 -1
  83. package/dist/utils/url.mjs +9 -3
  84. package/package.json +15 -14
@@ -102,7 +102,7 @@ const anonymous = (options) => {
102
102
  if (options?.disableDeleteAnonymousUser) throw APIError.from("BAD_REQUEST", ANONYMOUS_ERROR_CODES.DELETE_ANONYMOUS_USER_DISABLED);
103
103
  if (!session.user.isAnonymous) throw APIError.from("FORBIDDEN", ANONYMOUS_ERROR_CODES.USER_IS_NOT_ANONYMOUS);
104
104
  try {
105
- await ctx.context.internalAdapter.deleteSessions(session.user.id);
105
+ await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
106
106
  } catch (error) {
107
107
  ctx.context.logger.error("Failed to delete anonymous user sessions", error);
108
108
  throw APIError.from("INTERNAL_SERVER_ERROR", ANONYMOUS_ERROR_CODES.FAILED_TO_DELETE_ANONYMOUS_USER_SESSIONS);
@@ -154,7 +154,7 @@ const anonymous = (options) => {
154
154
  const newSessionIsAnonymous = Boolean(newSessionUser?.isAnonymous);
155
155
  if (options?.disableDeleteAnonymousUser || isSameUser || newSessionIsAnonymous) return;
156
156
  try {
157
- await ctx.context.internalAdapter.deleteSessions(session.user.id);
157
+ await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
158
158
  await ctx.context.internalAdapter.deleteUser(session.user.id);
159
159
  } catch (error) {
160
160
  ctx.context.logger.error("Failed to clean up anonymous user during post-link cleanup", {
@@ -30,16 +30,11 @@ const bearer = (options) => {
30
30
  if (authHeader.slice(0, 7).toLowerCase() !== BEARER_SCHEME) return;
31
31
  const token = authHeader.slice(7).trim();
32
32
  if (!token) return;
33
- let signedToken;
34
33
  let decodedToken;
35
- if (token.includes(".")) {
36
- const isEncoded = token.includes("%");
37
- signedToken = isEncoded ? token : encodeURIComponent(token);
38
- decodedToken = isEncoded ? tryDecode(token) : token;
39
- } else {
34
+ if (token.includes(".")) decodedToken = token.includes("%") ? tryDecode(token) : token;
35
+ else {
40
36
  if (options?.requireSignature) return;
41
- signedToken = (await serializeSignedCookie("", token, c.context.secret)).replace("=", "");
42
- decodedToken = tryDecode(signedToken);
37
+ decodedToken = tryDecode((await serializeSignedCookie("", token, c.context.secret)).replace("=", ""));
43
38
  }
44
39
  try {
45
40
  if (!await createHMAC("SHA-256", "base64urlnopad").verify(c.context.secret, decodedToken.split(".")[0], decodedToken.split(".")[1])) return;
@@ -48,7 +43,7 @@ const bearer = (options) => {
48
43
  }
49
44
  const existingHeaders = c.request?.headers || c.headers;
50
45
  const headers = new Headers({ ...Object.fromEntries(existingHeaders?.entries()) });
51
- setRequestCookie(headers, c.context.authCookies.sessionToken.name, signedToken);
46
+ setRequestCookie(headers, c.context.authCookies.sessionToken.name, decodedToken);
52
47
  return { context: { headers } };
53
48
  })
54
49
  }],
@@ -21,12 +21,12 @@ const captcha = (options) => ({
21
21
  if (pathname.endsWith("//")) pathname = pathname.slice(0, -1);
22
22
  if (pathname.startsWith("//")) pathname = pathname.slice(1);
23
23
  if (!pathname.startsWith("/")) pathname = "/" + pathname;
24
- const blockedPaths = ["/sign-in/email-otp"].reduce((acc, curr) => {
24
+ const exemptPaths = ["/sign-in/email-otp"].reduce((acc, curr) => {
25
25
  if (options.endpoints?.length && options.endpoints.includes(curr)) return acc;
26
26
  return [...acc, curr];
27
27
  }, []);
28
28
  if (!endpoints.some((endpoint) => {
29
- return pathname.includes(endpoint) && !blockedPaths.includes(endpoint);
29
+ return pathname.includes(endpoint) && !exemptPaths.some((p) => pathname.includes(p));
30
30
  })) return;
31
31
  if (!options.secretKey) throw new Error(INTERNAL_ERROR_CODES.MISSING_SECRET_KEY.message);
32
32
  const captchaResponse = request.headers.get("x-captcha-response");
@@ -585,7 +585,7 @@ const resetPasswordEmailOTP = (opts) => createAuthEndpoint("/email-otp/reset-pas
585
585
  else await ctx.context.internalAdapter.updatePassword(user.user.id, passwordHash);
586
586
  if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user: user.user }, ctx.request);
587
587
  if (!user.user.emailVerified) await ctx.context.internalAdapter.updateUser(user.user.id, { emailVerified: true });
588
- if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteSessions(user.user.id);
588
+ if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(user.user.id);
589
589
  return ctx.json({ success: true });
590
590
  });
591
591
  const requestEmailChangeEmailOTPBodySchema = z.object({
@@ -117,7 +117,7 @@ declare const genericOAuth: (options: GenericOAuthOptions) => {
117
117
  };
118
118
  scope: "server";
119
119
  };
120
- }, void>;
120
+ }, never>;
121
121
  oAuth2LinkAccount: better_call0.StrictEndpoint<"/oauth2/link", {
122
122
  method: "POST";
123
123
  body: zod.ZodObject<{
@@ -11,7 +11,7 @@ import { okta } from "./providers/okta.mjs";
11
11
  import { patreon } from "./providers/patreon.mjs";
12
12
  import { slack } from "./providers/slack.mjs";
13
13
  import { APIError } from "@better-auth/core/error";
14
- import { createAuthorizationURL, refreshAccessToken, validateAuthorizationCode } from "@better-auth/core/oauth2";
14
+ import { applyDefaultAccessTokenExpiry, createAuthorizationURL, refreshAccessToken, validateAuthorizationCode } from "@better-auth/core/oauth2";
15
15
  import { betterFetch } from "@better-fetch/fetch";
16
16
  //#region src/plugins/generic-oauth/index.ts
17
17
  /**
@@ -63,7 +63,7 @@ const genericOAuth = (options) => {
63
63
  });
64
64
  },
65
65
  async validateAuthorizationCode(data) {
66
- if (c.getToken) return c.getToken(data);
66
+ if (c.getToken) return applyDefaultAccessTokenExpiry(await c.getToken(data), c.accessTokenExpiresIn);
67
67
  let finalTokenUrl = c.tokenUrl;
68
68
  if (c.discoveryUrl) {
69
69
  const discovery = await betterFetch(c.discoveryUrl, {
@@ -76,7 +76,7 @@ const genericOAuth = (options) => {
76
76
  }
77
77
  }
78
78
  if (!finalTokenUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.TOKEN_URL_NOT_FOUND);
79
- return validateAuthorizationCode({
79
+ return applyDefaultAccessTokenExpiry(await validateAuthorizationCode({
80
80
  headers: c.authorizationHeaders,
81
81
  code: data.code,
82
82
  codeVerifier: data.codeVerifier,
@@ -88,7 +88,7 @@ const genericOAuth = (options) => {
88
88
  },
89
89
  tokenEndpoint: finalTokenUrl,
90
90
  authentication: c.authentication
91
- });
91
+ }), c.accessTokenExpiresIn);
92
92
  },
93
93
  async refreshAccessToken(refreshToken) {
94
94
  let finalTokenUrl = c.tokenUrl;
@@ -100,7 +100,7 @@ const genericOAuth = (options) => {
100
100
  if (discovery.data) finalTokenUrl = discovery.data.token_endpoint;
101
101
  }
102
102
  if (!finalTokenUrl) throw APIError.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.TOKEN_URL_NOT_FOUND);
103
- return refreshAccessToken({
103
+ return applyDefaultAccessTokenExpiry(await refreshAccessToken({
104
104
  refreshToken,
105
105
  options: {
106
106
  clientId: c.clientId,
@@ -108,7 +108,7 @@ const genericOAuth = (options) => {
108
108
  },
109
109
  authentication: c.authentication,
110
110
  tokenEndpoint: finalTokenUrl
111
- });
111
+ }), c.accessTokenExpiresIn);
112
112
  },
113
113
  async getUserInfo(tokens) {
114
114
  const userInfo = c.getUserInfo ? await c.getUserInfo(tokens) : await getUserInfo(tokens, finalUserInfoUrl);
@@ -1,14 +1,15 @@
1
+ import { isAPIError } from "../../utils/is-api-error.mjs";
1
2
  import { setSessionCookie } from "../../cookies/index.mjs";
2
- import { missingEmailLogMessage } from "../../oauth2/errors.mjs";
3
- import { generateState, parseState } from "../../oauth2/state.mjs";
3
+ import { missingEmailLogMessage, redirectOnError } from "../../oauth2/errors.mjs";
4
4
  import { setTokenUtil } from "../../oauth2/utils.mjs";
5
+ import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
6
+ import { generateState, parseState } from "../../oauth2/state.mjs";
5
7
  import { sessionMiddleware } from "../../api/routes/session.mjs";
6
- import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
7
8
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
8
9
  import { APIError as APIError$1 } from "../../api/index.mjs";
9
10
  import { GENERIC_OAUTH_ERROR_CODES } from "./error-codes.mjs";
10
11
  import { BASE_ERROR_CODES } from "@better-auth/core/error";
11
- import { createAuthorizationURL, validateAuthorizationCode } from "@better-auth/core/oauth2";
12
+ import { applyDefaultAccessTokenExpiry, createAuthorizationURL, validateAuthorizationCode } from "@better-auth/core/oauth2";
12
13
  import { createAuthEndpoint } from "@better-auth/core/api";
13
14
  import * as z from "zod";
14
15
  import { decodeJwt } from "jose";
@@ -132,7 +133,7 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
132
133
  }
133
134
  }, async (ctx) => {
134
135
  const defaultErrorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
135
- if (ctx.query.error || !ctx.query.code) throw ctx.redirect(`${defaultErrorURL}?error=${encodeURIComponent(ctx.query.error || "oAuth_code_missing")}&error_description=${encodeURIComponent(ctx.query.error_description || "")}`);
136
+ if (ctx.query.error || !ctx.query.code) redirectOnError(ctx, defaultErrorURL, ctx.query.error || "oAuth_code_missing", ctx.query.error_description || void 0);
136
137
  const providerId = ctx.params?.providerId;
137
138
  if (!providerId) throw APIError$1.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.PROVIDER_ID_REQUIRED);
138
139
  const providerConfig = options.config.find((p) => p.providerId === providerId);
@@ -140,13 +141,7 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
140
141
  let tokens = void 0;
141
142
  const { callbackURL, codeVerifier, errorURL, requestSignUp, newUserURL, link } = await parseState(ctx);
142
143
  const code = ctx.query.code;
143
- function redirectOnError(error) {
144
- const defaultErrorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
145
- let url = errorURL || defaultErrorURL;
146
- if (url.includes("?")) url = `${url}&error=${encodeURIComponent(error)}`;
147
- else url = `${url}?error=${encodeURIComponent(error)}`;
148
- throw ctx.redirect(url);
149
- }
144
+ const resolvedErrorURL = errorURL || defaultErrorURL;
150
145
  let finalTokenUrl = providerConfig.tokenUrl;
151
146
  let finalUserInfoUrl = providerConfig.userInfoUrl;
152
147
  let expectedIssuer = providerConfig.issuer;
@@ -168,11 +163,11 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
168
163
  expected: expectedIssuer,
169
164
  received: ctx.query.iss
170
165
  });
171
- return redirectOnError("issuer_mismatch");
166
+ redirectOnError(ctx, resolvedErrorURL, "issuer_mismatch");
172
167
  }
173
168
  } else if (providerConfig.requireIssuerValidation) {
174
169
  ctx.context.logger.error("OAuth issuer parameter missing", { expected: expectedIssuer });
175
- return redirectOnError("issuer_missing");
170
+ redirectOnError(ctx, resolvedErrorURL, "issuer_missing");
176
171
  }
177
172
  }
178
173
  try {
@@ -199,25 +194,26 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
199
194
  additionalParams
200
195
  });
201
196
  }
197
+ tokens = applyDefaultAccessTokenExpiry(tokens, providerConfig.accessTokenExpiresIn);
202
198
  } catch (e) {
203
199
  ctx.context.logger.error(e && typeof e === "object" && "name" in e ? e.name : "", e);
204
- throw redirectOnError("oauth_code_verification_failed");
200
+ redirectOnError(ctx, resolvedErrorURL, "oauth_code_verification_failed");
205
201
  }
206
202
  if (!tokens) throw APIError$1.from("BAD_REQUEST", GENERIC_OAUTH_ERROR_CODES.INVALID_OAUTH_CONFIG);
207
203
  const userInfo = await (async function handleUserInfo() {
208
204
  const userInfo = providerConfig.getUserInfo ? await providerConfig.getUserInfo(tokens) : await getUserInfo(tokens, finalUserInfoUrl);
209
- if (!userInfo) throw redirectOnError("user_info_is_missing");
205
+ if (!userInfo) redirectOnError(ctx, resolvedErrorURL, "user_info_is_missing");
210
206
  const mapUser = providerConfig.mapProfileToUser ? await providerConfig.mapProfileToUser(userInfo) : userInfo;
211
207
  const email = mapUser.email ? mapUser.email.toLowerCase() : userInfo.email?.toLowerCase();
212
208
  if (!email) {
213
209
  ctx.context.logger.error(missingEmailLogMessage(providerConfig.providerId, { source: "generic" }), userInfo);
214
- throw redirectOnError("email_is_missing");
210
+ redirectOnError(ctx, resolvedErrorURL, "email_is_missing");
215
211
  }
216
212
  const id = mapUser.id ? String(mapUser.id) : String(userInfo.id);
217
213
  const name = mapUser.name ? mapUser.name : userInfo.name;
218
214
  if (!name) {
219
215
  ctx.context.logger.error("Unable to get user info", userInfo);
220
- throw redirectOnError("name_is_missing");
216
+ redirectOnError(ctx, resolvedErrorURL, "name_is_missing");
221
217
  }
222
218
  return {
223
219
  ...userInfo,
@@ -228,10 +224,10 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
228
224
  };
229
225
  })();
230
226
  if (link) {
231
- if (ctx.context.options.account?.accountLinking?.allowDifferentEmails !== true && link.email.toLowerCase() !== userInfo.email.toLowerCase()) return redirectOnError("email_doesn't_match");
227
+ if (ctx.context.options.account?.accountLinking?.allowDifferentEmails !== true && link.email.toLowerCase() !== userInfo.email.toLowerCase()) redirectOnError(ctx, resolvedErrorURL, "email_doesn't_match");
232
228
  const existingAccount = await ctx.context.internalAdapter.findAccountByProviderId(String(userInfo.id), providerConfig.providerId);
233
229
  if (existingAccount) {
234
- if (existingAccount.userId !== link.userId) return redirectOnError("account_already_linked_to_different_user");
230
+ if (existingAccount.userId !== link.userId) redirectOnError(ctx, resolvedErrorURL, "account_already_linked_to_different_user");
235
231
  const updateData = Object.fromEntries(Object.entries({
236
232
  accessToken: await setTokenUtil(tokens.accessToken, ctx.context),
237
233
  idToken: tokens.idToken,
@@ -251,7 +247,8 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
251
247
  scope: tokens.scopes?.join(","),
252
248
  refreshToken: await setTokenUtil(tokens.refreshToken, ctx.context),
253
249
  idToken: tokens.idToken
254
- })) return redirectOnError("unable_to_link_account");
250
+ })) redirectOnError(ctx, resolvedErrorURL, "unable_to_link_account");
251
+ await applyUpdateUserInfoOnLink(ctx, link.userId, userInfo);
255
252
  let toRedirectTo;
256
253
  try {
257
254
  toRedirectTo = callbackURL.toString();
@@ -260,19 +257,25 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
260
257
  }
261
258
  throw ctx.redirect(toRedirectTo);
262
259
  }
263
- const result = await handleOAuthUserInfo(ctx, {
264
- userInfo,
265
- account: {
266
- providerId: providerConfig.providerId,
267
- accountId: userInfo.id,
268
- ...tokens,
269
- scope: tokens.scopes?.join(",")
270
- },
271
- callbackURL,
272
- disableSignUp: providerConfig.disableImplicitSignUp && !requestSignUp || providerConfig.disableSignUp,
273
- overrideUserInfo: providerConfig.overrideUserInfo
274
- });
275
- if (result.error) return redirectOnError(result.error.split(" ").join("_"));
260
+ let result;
261
+ try {
262
+ result = await handleOAuthUserInfo(ctx, {
263
+ userInfo,
264
+ account: {
265
+ providerId: providerConfig.providerId,
266
+ accountId: userInfo.id,
267
+ ...tokens,
268
+ scope: tokens.scopes?.join(",")
269
+ },
270
+ callbackURL,
271
+ disableSignUp: providerConfig.disableImplicitSignUp && !requestSignUp || providerConfig.disableSignUp,
272
+ overrideUserInfo: providerConfig.overrideUserInfo
273
+ });
274
+ } catch (e) {
275
+ if (isAPIError(e) && e.body?.code) redirectOnError(ctx, resolvedErrorURL, e.body.code, e.body.message);
276
+ throw e;
277
+ }
278
+ if (result.error) redirectOnError(ctx, resolvedErrorURL, result.error.split(" ").join("_"));
276
279
  const { session, user } = result.data;
277
280
  await setSessionCookie(ctx, {
278
281
  session,
@@ -87,6 +87,13 @@ interface GenericOAuthConfig {
87
87
  * Use "offline" to request a refresh token.
88
88
  */
89
89
  accessType?: string | undefined;
90
+ /**
91
+ * Fallback access-token lifetime, in seconds, used only when the provider's
92
+ * token response omits `expires_in`. Set this so `getAccessToken` can track
93
+ * expiry and refresh the token; leave unset if the provider returns
94
+ * `expires_in`.
95
+ */
96
+ accessTokenExpiresIn?: number | undefined;
90
97
  /**
91
98
  * Custom function to exchange authorization code for tokens.
92
99
  * If provided, this function will be used instead of the default token exchange logic.
@@ -1,9 +1,9 @@
1
+ import { parseCookies } from "../../cookies/cookie-utils.mjs";
1
2
  import { PACKAGE_VERSION } from "../../version.mjs";
2
3
  //#region src/plugins/last-login-method/client.ts
3
4
  function getCookieValue(name) {
4
5
  if (typeof document === "undefined") return null;
5
- const cookie = document.cookie.split("; ").find((row) => row.startsWith(`${name}=`));
6
- return cookie ? cookie.split("=")[1] : null;
6
+ return parseCookies(document.cookie).get(name) ?? null;
7
7
  }
8
8
  /**
9
9
  * Client-side plugin to retrieve the last used login method
@@ -121,7 +121,6 @@ const magicLink = (options) => {
121
121
  const storedToken = await storeToken(ctx, token);
122
122
  const tokenValue = await ctx.context.internalAdapter.consumeVerificationValue(storedToken);
123
123
  if (!tokenValue) redirectWithError("INVALID_TOKEN");
124
- if (tokenValue.expiresAt < /* @__PURE__ */ new Date()) redirectWithError("EXPIRED_TOKEN");
125
124
  const { email, name } = JSON.parse(tokenValue.value);
126
125
  let isNewUser = false;
127
126
  let user = await ctx.context.internalAdapter.findUserByEmail(email).then((res) => res?.user);
@@ -14,6 +14,7 @@ import { oidcProvider } from "../oidc-provider/index.mjs";
14
14
  import { authorizeMCPOAuth } from "./authorize.mjs";
15
15
  import { isProduction, logger } from "@better-auth/core/env";
16
16
  import { safeJSONParse } from "@better-auth/core/utils/json";
17
+ import { isSafeUrlScheme } from "@better-auth/core/utils/url";
17
18
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
18
19
  import * as z from "zod";
19
20
  import { base64 } from "@better-auth/utils/base64";
@@ -86,7 +87,7 @@ const getMCPProtectedResourceMetadata = (ctx, options) => {
86
87
  };
87
88
  };
88
89
  const registerMcpClientBodySchema = z.object({
89
- redirect_uris: z.array(z.string()),
90
+ redirect_uris: z.array(z.string().refine(isSafeUrlScheme, { message: "redirect_uri cannot use a javascript:, data:, or vbscript: scheme" })),
90
91
  token_endpoint_auth_method: z.enum([
91
92
  "none",
92
93
  "client_secret_basic",
@@ -372,10 +373,6 @@ const mcp = (options) => {
372
373
  error_description: "invalid code",
373
374
  error: "invalid_grant"
374
375
  });
375
- if (verificationValue.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", {
376
- error_description: "code expired",
377
- error: "invalid_grant"
378
- });
379
376
  if (!client_id) throw new APIError("UNAUTHORIZED", {
380
377
  error_description: "client_id is required",
381
378
  error: "invalid_client"
@@ -1,6 +1,6 @@
1
1
  import { parseSessionOutput, parseUserOutput } from "../../db/schema.mjs";
2
- import { SECURE_COOKIE_PREFIX, parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
3
- import { deleteSessionCookie, expireCookie, parseCookies, setSessionCookie } from "../../cookies/index.mjs";
2
+ import { SECURE_COOKIE_PREFIX, parseCookies, parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
3
+ import { deleteSessionCookie, expireCookie, setSessionCookie } from "../../cookies/index.mjs";
4
4
  import { sessionMiddleware } from "../../api/routes/session.mjs";
5
5
  import { APIError } from "../../api/index.mjs";
6
6
  import { PACKAGE_VERSION } from "../../version.mjs";
@@ -1,13 +1,15 @@
1
+ import { isAPIError } from "../../utils/is-api-error.mjs";
1
2
  import { getOrigin } from "../../utils/url.mjs";
2
3
  import { originCheck } from "../../api/middlewares/origin-check.mjs";
3
4
  import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
4
5
  import { symmetricDecrypt, symmetricEncrypt } from "../../crypto/index.mjs";
5
6
  import { setSessionCookie } from "../../cookies/index.mjs";
6
- import { parseGenericState } from "../../state.mjs";
7
+ import { redirectOnError } from "../../oauth2/errors.mjs";
7
8
  import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
9
+ import { parseGenericState } from "../../state.mjs";
8
10
  import { PACKAGE_VERSION } from "../../version.mjs";
9
11
  import { parseJSON } from "../../client/parser.mjs";
10
- import { checkSkipProxy, redirectOnError, resolveCurrentURL, stripTrailingSlash } from "./utils.mjs";
12
+ import { checkSkipProxy, resolveCurrentURL, stripTrailingSlash } from "./utils.mjs";
11
13
  import { defu } from "defu";
12
14
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
13
15
  import * as z from "zod";
@@ -90,18 +92,28 @@ const oAuthProxy = (opts) => {
90
92
  throw redirectOnError(ctx, errorURL, "payload_expired");
91
93
  }
92
94
  try {
93
- await parseGenericState(ctx, payload.state);
95
+ await parseGenericState(ctx, payload.state, { skipStateCookieCheck: true });
94
96
  } catch (e) {
95
97
  ctx.context.logger.warn("Failed to clean up OAuth state", e);
96
98
  }
97
- const result = await handleOAuthUserInfo(ctx, {
98
- userInfo: payload.userInfo,
99
- account: payload.account,
100
- callbackURL: payload.callbackURL,
101
- disableSignUp: payload.disableSignUp
102
- });
103
- if (result.error || !result.data) {
104
- ctx.context.logger.error("Failed to create user or session", result.error);
99
+ let result;
100
+ try {
101
+ result = await handleOAuthUserInfo(ctx, {
102
+ userInfo: payload.userInfo,
103
+ account: payload.account,
104
+ callbackURL: payload.callbackURL,
105
+ disableSignUp: payload.disableSignUp
106
+ });
107
+ } catch (e) {
108
+ if (isAPIError(e) && e.body?.code) throw redirectOnError(ctx, errorURL, e.body.code, e.body.message);
109
+ throw e;
110
+ }
111
+ if (result.error) {
112
+ ctx.context.logger.error("OAuth proxy callback error", result.error);
113
+ throw redirectOnError(ctx, errorURL, result.error.split(" ").join("_"));
114
+ }
115
+ if (!result.data) {
116
+ ctx.context.logger.error("OAuth proxy callback missing session data");
105
117
  throw redirectOnError(ctx, errorURL, "user_creation_failed");
106
118
  }
107
119
  await setSessionCookie(ctx, result.data);
@@ -141,6 +153,7 @@ const oAuthProxy = (opts) => {
141
153
  data: state
142
154
  }));
143
155
  } catch {
156
+ ctx.context.logger.debug("OAuth proxy: could not decrypt state package, falling back to regular callback");
144
157
  return;
145
158
  }
146
159
  if (!statePackage.isOAuthProxy || !statePackage.state || !statePackage.stateCookie) {
@@ -248,29 +261,29 @@ const oAuthProxy = (opts) => {
248
261
  const oauthURL = new URL(providerURL);
249
262
  const originalState = oauthURL.searchParams.get("state");
250
263
  if (!originalState) return;
251
- let stateCookieValue;
252
- if (ctx.context.oauthConfig.storeStateStrategy === "cookie") {
253
- const setCookieHeader = ctx.context.responseHeaders?.get("set-cookie");
254
- if (setCookieHeader) {
255
- const parsedCookies = parseSetCookieHeader(setCookieHeader);
256
- const stateCookie = ctx.context.createAuthCookie("oauth_state");
257
- stateCookieValue = parsedCookies.get(stateCookie.name)?.value;
258
- }
259
- } else {
260
- const verification = await ctx.context.internalAdapter.findVerificationValue(originalState);
261
- if (verification) stateCookieValue = await symmetricEncrypt({
262
- key: getEncryptionKey(ctx),
263
- data: verification.value
264
- });
265
- }
266
- if (!stateCookieValue) {
267
- ctx.context.logger.warn("No OAuth state cookie value found");
268
- return;
269
- }
270
264
  try {
265
+ let plaintextState;
266
+ if (ctx.context.oauthConfig.storeStateStrategy === "cookie") {
267
+ const setCookieHeader = ctx.context.responseHeaders?.get("set-cookie");
268
+ if (setCookieHeader) {
269
+ const oauthStateCookie = ctx.context.createAuthCookie("oauth_state");
270
+ const encryptedCookieValue = parseSetCookieHeader(setCookieHeader).get(oauthStateCookie.name)?.value;
271
+ if (encryptedCookieValue) plaintextState = await symmetricDecrypt({
272
+ key: ctx.context.secretConfig,
273
+ data: encryptedCookieValue
274
+ });
275
+ }
276
+ } else plaintextState = (await ctx.context.internalAdapter.findVerificationValue(originalState))?.value;
277
+ if (!plaintextState) {
278
+ ctx.context.logger.warn("No OAuth state found for proxy");
279
+ return;
280
+ }
271
281
  const statePackage = {
272
282
  state: originalState,
273
- stateCookie: stateCookieValue,
283
+ stateCookie: await symmetricEncrypt({
284
+ key: getEncryptionKey(ctx),
285
+ data: plaintextState
286
+ }),
274
287
  isOAuthProxy: true
275
288
  };
276
289
  const encryptedPackage = await symmetricEncrypt({
@@ -283,7 +296,7 @@ const oAuthProxy = (opts) => {
283
296
  url: oauthURL.toString()
284
297
  };
285
298
  } catch (e) {
286
- ctx.context.logger.error("Failed to encrypt OAuth proxy state package:", e);
299
+ ctx.context.logger.error("Failed to prepare OAuth proxy state:", e);
287
300
  }
288
301
  })
289
302
  }, {
@@ -1,4 +1,4 @@
1
- import { getOrigin } from "../../utils/url.mjs";
1
+ import { getOrigin, trimTrailingSlashes } from "../../utils/url.mjs";
2
2
  import { env } from "@better-auth/core/env";
3
3
  //#region src/plugins/oauth-proxy/utils.ts
4
4
  /**
@@ -6,7 +6,7 @@ import { env } from "@better-auth/core/env";
6
6
  */
7
7
  function stripTrailingSlash(url) {
8
8
  if (!url) return "";
9
- return url.replace(/\/+$/, "");
9
+ return trimTrailingSlashes(url);
10
10
  }
11
11
  /**
12
12
  * Get base URL from vendor-specific environment variables
@@ -37,12 +37,5 @@ function checkSkipProxy(ctx, opts) {
37
37
  if (!currentURL) return false;
38
38
  return getOrigin(productionURL) === getOrigin(currentURL);
39
39
  }
40
- /**
41
- * Redirect to error URL with error code
42
- */
43
- function redirectOnError(ctx, errorURL, error) {
44
- const sep = errorURL.includes("?") ? "&" : "?";
45
- throw ctx.redirect(`${errorURL}${sep}error=${error}`);
46
- }
47
40
  //#endregion
48
- export { checkSkipProxy, redirectOnError, resolveCurrentURL, stripTrailingSlash };
41
+ export { checkSkipProxy, resolveCurrentURL, stripTrailingSlash };
@@ -15,6 +15,7 @@ import { authorize } from "./authorize.mjs";
15
15
  import { schema } from "./schema.mjs";
16
16
  import { defaultClientSecretHasher } from "./utils.mjs";
17
17
  import { getCurrentAuthContext } from "@better-auth/core/context";
18
+ import { isSafeUrlScheme } from "@better-auth/core/utils/url";
18
19
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
19
20
  import { deprecate } from "@better-auth/core/utils/deprecate";
20
21
  import * as z from "zod";
@@ -101,7 +102,7 @@ const oAuthConsentBodySchema = z.object({
101
102
  });
102
103
  const oAuth2TokenBodySchema = z.record(z.any(), z.any());
103
104
  const registerOAuthApplicationBodySchema = z.object({
104
- redirect_uris: z.array(z.string()).meta({ description: "A list of redirect URIs. Eg: [\"https://client.example.com/callback\"]" }),
105
+ redirect_uris: z.array(z.string().refine(isSafeUrlScheme, { message: "redirect_uri cannot use a javascript:, data:, or vbscript: scheme" })).meta({ description: "A list of redirect URIs. Eg: [\"https://client.example.com/callback\"]" }),
105
106
  token_endpoint_auth_method: z.enum([
106
107
  "none",
107
108
  "client_secret_basic",
@@ -499,10 +500,6 @@ const oidcProvider = (options) => {
499
500
  error_description: "invalid code",
500
501
  error: "invalid_grant"
501
502
  });
502
- if (verificationValue.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", {
503
- error_description: "code expired",
504
- error: "invalid_grant"
505
- });
506
503
  if (!client_id) throw new APIError("UNAUTHORIZED", {
507
504
  error_description: "client_id is required",
508
505
  error: "invalid_client"
@@ -1,4 +1,5 @@
1
1
  import { PACKAGE_VERSION } from "../../version.mjs";
2
+ import { isSafeUrlScheme } from "@better-auth/core/utils/url";
2
3
  //#region src/plugins/one-tap/client.ts
3
4
  let isRequestInProgress = false;
4
5
  function isFedCMSupported() {
@@ -49,7 +50,10 @@ const oneTapClient = (options) => {
49
50
  ...opts?.fetchOptions,
50
51
  ...fetchOptions
51
52
  });
52
- if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) window.location.href = opts?.callbackURL ?? "/";
53
+ if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) {
54
+ const target = opts?.callbackURL ?? "/";
55
+ if (isSafeUrlScheme(target)) window.location.href = target;
56
+ }
53
57
  }
54
58
  const { autoSelect, cancelOnTapOutside, context } = opts ?? {};
55
59
  const contextValue = context ?? options.context ?? "signin";
@@ -82,7 +86,10 @@ const oneTapClient = (options) => {
82
86
  ...opts?.fetchOptions,
83
87
  ...fetchOptions
84
88
  });
85
- if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) window.location.href = opts?.callbackURL ?? "/";
89
+ if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) {
90
+ const target = opts?.callbackURL ?? "/";
91
+ if (isSafeUrlScheme(target)) window.location.href = target;
92
+ }
86
93
  }
87
94
  const { autoSelect, cancelOnTapOutside, context } = opts ?? {};
88
95
  const contextValue = context ?? options.context ?? "signin";
@@ -1,5 +1,6 @@
1
1
  import { parseUserOutput } from "../../db/schema.mjs";
2
2
  import { setSessionCookie } from "../../cookies/index.mjs";
3
+ import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
3
4
  import { APIError } from "../../api/index.mjs";
4
5
  import { PACKAGE_VERSION } from "../../version.mjs";
5
6
  import { toBoolean } from "../../utils/boolean.mjs";
@@ -47,51 +48,27 @@ const oneTap = (options) => ({
47
48
  }
48
49
  const { email: rawEmail, email_verified, name, picture, sub } = payload;
49
50
  if (!rawEmail) return ctx.json({ error: "Email not available in token" });
50
- const email = rawEmail.toLowerCase();
51
- const user = await ctx.context.internalAdapter.findUserByEmail(email);
52
- if (!user) {
53
- if (options?.disableSignup) throw new APIError("BAD_GATEWAY", { message: "User not found" });
54
- const newUser = await ctx.context.internalAdapter.createOAuthUser({
55
- email,
51
+ const result = await handleOAuthUserInfo(ctx, {
52
+ userInfo: {
53
+ id: sub,
54
+ email: rawEmail.toLowerCase(),
56
55
  emailVerified: typeof email_verified === "boolean" ? email_verified : toBoolean(email_verified),
57
- name,
56
+ name: name ?? "",
58
57
  image: picture
59
- }, {
60
- providerId: "google",
61
- accountId: sub
62
- });
63
- if (!newUser) throw new APIError("INTERNAL_SERVER_ERROR", { message: "Could not create user" });
64
- const session = await ctx.context.internalAdapter.createSession(newUser.user.id);
65
- await setSessionCookie(ctx, {
66
- user: newUser.user,
67
- session
68
- });
69
- return ctx.json({
70
- token: session.token,
71
- user: parseUserOutput(ctx.context.options, newUser.user)
72
- });
73
- }
74
- if (!await ctx.context.internalAdapter.findAccount(sub)) {
75
- const accountLinking = ctx.context.options.account?.accountLinking;
76
- const providerEmailVerified = typeof email_verified === "boolean" ? email_verified : toBoolean(email_verified);
77
- const requireLocalEmailVerified = accountLinking?.requireLocalEmailVerified ?? true;
78
- if (accountLinking?.enabled !== false && accountLinking?.disableImplicitLinking !== true && (!requireLocalEmailVerified || user.user.emailVerified) && (ctx.context.trustedProviders.includes("google") || providerEmailVerified)) await ctx.context.internalAdapter.linkAccount({
79
- userId: user.user.id,
58
+ },
59
+ account: {
80
60
  providerId: "google",
81
61
  accountId: sub,
82
- scope: "openid,profile,email",
83
- idToken
84
- });
85
- else throw new APIError("UNAUTHORIZED", { message: "Google identity cannot be linked: implicit account-linking is disabled, the local email is not verified, or the Google email_verified claim is false and Google is not a trusted provider" });
86
- }
87
- const session = await ctx.context.internalAdapter.createSession(user.user.id);
88
- await setSessionCookie(ctx, {
89
- user: user.user,
90
- session
62
+ idToken,
63
+ scope: "openid,profile,email"
64
+ },
65
+ disableSignUp: options?.disableSignup
91
66
  });
67
+ if (result.error) throw new APIError("UNAUTHORIZED", { message: result.error });
68
+ await setSessionCookie(ctx, result.data);
92
69
  return ctx.json({
93
- token: session.token,
94
- user: parseUserOutput(ctx.context.options, user.user)
70
+ token: result.data.session.token,
71
+ user: parseUserOutput(ctx.context.options, result.data.user)
95
72
  });
96
73
  }) },
97
74
  options