better-auth 1.6.11 → 1.6.12

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 (65) hide show
  1. package/dist/api/index.d.mts +8 -2
  2. package/dist/api/routes/callback.d.mts +1 -1
  3. package/dist/api/routes/callback.mjs +36 -40
  4. package/dist/api/routes/email-verification.d.mts +1 -0
  5. package/dist/api/routes/email-verification.mjs +4 -3
  6. package/dist/api/routes/session.mjs +14 -9
  7. package/dist/api/routes/sign-in.d.mts +1 -0
  8. package/dist/api/routes/sign-in.mjs +2 -1
  9. package/dist/api/routes/sign-up.d.mts +1 -0
  10. package/dist/api/routes/sign-up.mjs +9 -7
  11. package/dist/api/routes/update-user.mjs +4 -4
  12. package/dist/client/parser.mjs +0 -1
  13. package/dist/client/plugins/index.d.mts +3 -3
  14. package/dist/client/proxy.mjs +2 -1
  15. package/dist/context/helpers.mjs +3 -2
  16. package/dist/cookies/cookie-utils.d.mts +24 -1
  17. package/dist/cookies/cookie-utils.mjs +85 -22
  18. package/dist/cookies/index.d.mts +2 -3
  19. package/dist/cookies/index.mjs +39 -11
  20. package/dist/cookies/session-store.mjs +4 -23
  21. package/dist/db/get-migration.mjs +4 -4
  22. package/dist/db/index.d.mts +2 -2
  23. package/dist/db/index.mjs +3 -2
  24. package/dist/db/internal-adapter.mjs +37 -30
  25. package/dist/db/schema.d.mts +15 -2
  26. package/dist/db/schema.mjs +26 -1
  27. package/dist/index.d.mts +2 -2
  28. package/dist/index.mjs +2 -2
  29. package/dist/oauth2/errors.mjs +16 -1
  30. package/dist/oauth2/link-account.mjs +3 -3
  31. package/dist/oauth2/state.mjs +8 -2
  32. package/dist/package.mjs +1 -1
  33. package/dist/plugins/access/access.mjs +11 -6
  34. package/dist/plugins/admin/admin.mjs +0 -4
  35. package/dist/plugins/admin/client.d.mts +1 -1
  36. package/dist/plugins/bearer/index.mjs +4 -9
  37. package/dist/plugins/captcha/index.mjs +2 -2
  38. package/dist/plugins/generic-oauth/index.d.mts +1 -1
  39. package/dist/plugins/generic-oauth/index.mjs +6 -6
  40. package/dist/plugins/generic-oauth/routes.mjs +34 -32
  41. package/dist/plugins/generic-oauth/types.d.mts +7 -0
  42. package/dist/plugins/last-login-method/client.mjs +2 -2
  43. package/dist/plugins/magic-link/index.mjs +0 -1
  44. package/dist/plugins/mcp/index.mjs +0 -4
  45. package/dist/plugins/multi-session/index.mjs +2 -2
  46. package/dist/plugins/oauth-proxy/index.mjs +44 -31
  47. package/dist/plugins/oauth-proxy/utils.mjs +3 -10
  48. package/dist/plugins/oidc-provider/index.mjs +0 -4
  49. package/dist/plugins/open-api/generator.mjs +16 -5
  50. package/dist/plugins/organization/adapter.mjs +61 -56
  51. package/dist/plugins/organization/client.d.mts +2 -1
  52. package/dist/plugins/organization/error-codes.d.mts +1 -0
  53. package/dist/plugins/organization/error-codes.mjs +2 -1
  54. package/dist/plugins/organization/routes/crud-invites.mjs +3 -0
  55. package/dist/plugins/two-factor/index.mjs +3 -2
  56. package/dist/plugins/username/index.d.mts +24 -2
  57. package/dist/plugins/username/index.mjs +49 -3
  58. package/dist/state.d.mts +2 -2
  59. package/dist/state.mjs +18 -4
  60. package/dist/test-utils/headers.mjs +2 -7
  61. package/dist/test-utils/test-instance.d.mts +24 -6
  62. package/dist/utils/index.d.mts +1 -1
  63. package/dist/utils/url.d.mts +2 -1
  64. package/dist/utils/url.mjs +9 -3
  65. package/package.json +15 -14
@@ -1,4 +1,5 @@
1
1
  import { generateRandomString } from "../crypto/random.mjs";
2
+ import { redirectOnError } from "./errors.mjs";
2
3
  import { setOAuthState } from "../api/state/oauth.mjs";
3
4
  import { StateError, generateGenericState, parseGenericState } from "../state.mjs";
4
5
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
@@ -36,8 +37,13 @@ async function parseState(c) {
36
37
  parsedData = await parseGenericState(c, state);
37
38
  } catch (error) {
38
39
  c.context.logger.error("Failed to parse state", error);
39
- if (error instanceof StateError && error.code === "state_security_mismatch") throw c.redirect(`${errorURL}?error=state_mismatch`);
40
- throw c.redirect(`${errorURL}?error=please_restart_the_process`);
40
+ let code = "internal_server_error";
41
+ let redirectErrorURL = errorURL;
42
+ if (error instanceof StateError) {
43
+ code = error.code === "state_security_mismatch" ? "state_mismatch" : error.code;
44
+ redirectErrorURL = error.errorURL ?? errorURL;
45
+ }
46
+ redirectOnError(c, redirectErrorURL, code);
41
47
  }
42
48
  if (!parsedData.errorURL) parsedData.errorURL = errorURL;
43
49
  if (parsedData) await setOAuthState(parsedData);
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.6.11";
2
+ var version = "1.6.12";
3
3
  //#endregion
4
4
  export { version };
@@ -6,14 +6,19 @@ function role(statements) {
6
6
  let success = false;
7
7
  for (const [requestedResource, requestedActions] of Object.entries(request)) {
8
8
  const allowedActions = statements[requestedResource];
9
- if (!allowedActions) return {
10
- success: false,
11
- error: `You are not allowed to access resource: ${requestedResource}`
12
- };
13
- if (Array.isArray(requestedActions)) success = requestedActions.every((requestedAction) => allowedActions.includes(requestedAction));
9
+ if (!allowedActions) {
10
+ if (connector === "AND") return {
11
+ success: false,
12
+ error: `You are not allowed to access resource: ${requestedResource}`
13
+ };
14
+ success = false;
15
+ continue;
16
+ }
17
+ if (Array.isArray(requestedActions)) success = requestedActions.length > 0 && requestedActions.every((requestedAction) => allowedActions.includes(requestedAction));
14
18
  else if (typeof requestedActions === "object") {
15
19
  const actions = requestedActions;
16
- if (actions.connector === "OR") success = actions.actions.some((requestedAction) => allowedActions.includes(requestedAction));
20
+ if (!Array.isArray(actions.actions) || actions.actions.length === 0) success = false;
21
+ else if (actions.connector === "OR") success = actions.actions.some((requestedAction) => allowedActions.includes(requestedAction));
17
22
  else success = actions.actions.every((requestedAction) => allowedActions.includes(requestedAction));
18
23
  } else throw new BetterAuthError("Invalid access control request");
19
24
  if (success && connector === "OR") return { success };
@@ -42,10 +42,6 @@ const admin = (options) => {
42
42
  });
43
43
  return;
44
44
  }
45
- if (ctx && (ctx.path.startsWith("/callback") || ctx.path.startsWith("/oauth2/callback"))) {
46
- const redirectURI = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
47
- throw ctx.redirect(`${redirectURI}?error=banned&error_description=${opts.bannedUserMessage}`);
48
- }
49
45
  throw APIError.from("FORBIDDEN", {
50
46
  message: opts.bannedUserMessage,
51
47
  code: "BANNED_USER"
@@ -76,4 +76,4 @@ declare const adminClient: <O extends AdminClientOptions>(options?: O | undefine
76
76
  };
77
77
  };
78
78
  //#endregion
79
- export { adminClient };
79
+ export { AdminClientOptions, adminClient };
@@ -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");
@@ -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,5 +1,6 @@
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 { missingEmailLogMessage, redirectOnError } from "../../oauth2/errors.mjs";
3
4
  import { generateState, parseState } from "../../oauth2/state.mjs";
4
5
  import { setTokenUtil } from "../../oauth2/utils.mjs";
5
6
  import { sessionMiddleware } from "../../api/routes/session.mjs";
@@ -8,7 +9,7 @@ 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,7 @@ 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");
255
251
  let toRedirectTo;
256
252
  try {
257
253
  toRedirectTo = callbackURL.toString();
@@ -260,19 +256,25 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
260
256
  }
261
257
  throw ctx.redirect(toRedirectTo);
262
258
  }
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("_"));
259
+ let result;
260
+ try {
261
+ result = await handleOAuthUserInfo(ctx, {
262
+ userInfo,
263
+ account: {
264
+ providerId: providerConfig.providerId,
265
+ accountId: userInfo.id,
266
+ ...tokens,
267
+ scope: tokens.scopes?.join(",")
268
+ },
269
+ callbackURL,
270
+ disableSignUp: providerConfig.disableImplicitSignUp && !requestSignUp || providerConfig.disableSignUp,
271
+ overrideUserInfo: providerConfig.overrideUserInfo
272
+ });
273
+ } catch (e) {
274
+ if (isAPIError(e) && e.body?.code) redirectOnError(ctx, resolvedErrorURL, e.body.code, e.body.message);
275
+ throw e;
276
+ }
277
+ if (result.error) redirectOnError(ctx, resolvedErrorURL, result.error.split(" ").join("_"));
276
278
  const { session, user } = result.data;
277
279
  await setSessionCookie(ctx, {
278
280
  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);
@@ -372,10 +372,6 @@ const mcp = (options) => {
372
372
  error_description: "invalid code",
373
373
  error: "invalid_grant"
374
374
  });
375
- if (verificationValue.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", {
376
- error_description: "code expired",
377
- error: "invalid_grant"
378
- });
379
375
  if (!client_id) throw new APIError("UNAUTHORIZED", {
380
376
  error_description: "client_id is required",
381
377
  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";
7
+ import { redirectOnError } from "../../oauth2/errors.mjs";
6
8
  import { parseGenericState } from "../../state.mjs";
7
9
  import { handleOAuthUserInfo } from "../../oauth2/link-account.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 };
@@ -499,10 +499,6 @@ const oidcProvider = (options) => {
499
499
  error_description: "invalid code",
500
500
  error: "invalid_grant"
501
501
  });
502
- if (verificationValue.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", {
503
- error_description: "code expired",
504
- error: "invalid_grant"
505
- });
506
502
  if (!client_id) throw new APIError("UNAUTHORIZED", {
507
503
  error_description: "client_id is required",
508
504
  error: "invalid_client"
@@ -1,6 +1,7 @@
1
1
  import { db_exports } from "../../db/index.mjs";
2
2
  import { getEndpoints } from "../../api/index.mjs";
3
3
  import * as z from "zod";
4
+ import { toPascalCase } from "@better-auth/core/utils/string";
4
5
  //#region src/plugins/open-api/generator.ts
5
6
  const allowedType = new Set([
6
7
  "string",
@@ -156,7 +157,7 @@ function getResponse(responses) {
156
157
  } } },
157
158
  description: "Internal Server Error. This is a problem with the server that you cannot fix."
158
159
  },
159
- ...responses
160
+ ...responses ? structuredClone(responses) : {}
160
161
  };
161
162
  }
162
163
  function toOpenApiPath(path) {
@@ -196,6 +197,16 @@ async function generator(ctx, options) {
196
197
  return acc;
197
198
  }, {}) } };
198
199
  const paths = {};
200
+ const seenOperationIds = /* @__PURE__ */ new Set();
201
+ const uniqueOperationId = (operationId, method) => {
202
+ if (!operationId) return void 0;
203
+ const base = seenOperationIds.has(operationId) ? `${operationId}${toPascalCase(method)}` : operationId;
204
+ let result = base;
205
+ let n = 2;
206
+ while (seenOperationIds.has(result)) result = `${base}${n++}`;
207
+ seenOperationIds.add(result);
208
+ return result;
209
+ };
199
210
  Object.entries(baseEndpoints.api).forEach(([_, value]) => {
200
211
  if (!value.path || ctx.options.disabledPaths?.includes(value.path)) return;
201
212
  const options = value.options;
@@ -207,7 +218,7 @@ async function generator(ctx, options) {
207
218
  [method.toLowerCase()]: {
208
219
  tags: ["Default", ...options.metadata?.openapi?.tags || []],
209
220
  description: options.metadata?.openapi?.description,
210
- operationId: options.metadata?.openapi?.operationId,
221
+ operationId: uniqueOperationId(options.metadata?.openapi?.operationId, method),
211
222
  security: [{ bearerAuth: [] }],
212
223
  parameters: getParameters(options),
213
224
  responses: getResponse(options.metadata?.openapi?.responses)
@@ -220,7 +231,7 @@ async function generator(ctx, options) {
220
231
  [method.toLowerCase()]: {
221
232
  tags: ["Default", ...options.metadata?.openapi?.tags || []],
222
233
  description: options.metadata?.openapi?.description,
223
- operationId: options.metadata?.openapi?.operationId,
234
+ operationId: uniqueOperationId(options.metadata?.openapi?.operationId, method),
224
235
  security: [{ bearerAuth: [] }],
225
236
  parameters: getParameters(options),
226
237
  ...body ? { requestBody: body } : { requestBody: { content: { "application/json": { schema: {
@@ -253,7 +264,7 @@ async function generator(ctx, options) {
253
264
  [method.toLowerCase()]: {
254
265
  tags: options.metadata?.openapi?.tags || [plugin.id.charAt(0).toUpperCase() + plugin.id.slice(1)],
255
266
  description: options.metadata?.openapi?.description,
256
- operationId: options.metadata?.openapi?.operationId,
267
+ operationId: uniqueOperationId(options.metadata?.openapi?.operationId, method),
257
268
  security: [{ bearerAuth: [] }],
258
269
  parameters: getParameters(options),
259
270
  responses: getResponse(options.metadata?.openapi?.responses)
@@ -264,7 +275,7 @@ async function generator(ctx, options) {
264
275
  [method.toLowerCase()]: {
265
276
  tags: options.metadata?.openapi?.tags || [plugin.id.charAt(0).toUpperCase() + plugin.id.slice(1)],
266
277
  description: options.metadata?.openapi?.description,
267
- operationId: options.metadata?.openapi?.operationId,
278
+ operationId: uniqueOperationId(options.metadata?.openapi?.operationId, method),
268
279
  security: [{ bearerAuth: [] }],
269
280
  parameters: getParameters(options),
270
281
  requestBody: getRequestBody(options),