better-auth 1.6.10 → 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 (90) 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 +5 -5
  12. package/dist/client/index.d.mts +2 -2
  13. package/dist/client/parser.mjs +0 -1
  14. package/dist/client/plugins/index.d.mts +3 -3
  15. package/dist/client/proxy.mjs +2 -1
  16. package/dist/context/helpers.mjs +3 -2
  17. package/dist/cookies/cookie-utils.d.mts +24 -1
  18. package/dist/cookies/cookie-utils.mjs +85 -22
  19. package/dist/cookies/index.d.mts +2 -3
  20. package/dist/cookies/index.mjs +39 -11
  21. package/dist/cookies/session-store.mjs +4 -23
  22. package/dist/db/get-migration.mjs +4 -4
  23. package/dist/db/index.d.mts +2 -2
  24. package/dist/db/index.mjs +3 -2
  25. package/dist/db/internal-adapter.mjs +96 -1
  26. package/dist/db/schema.d.mts +15 -2
  27. package/dist/db/schema.mjs +26 -1
  28. package/dist/db/with-hooks.d.mts +1 -0
  29. package/dist/db/with-hooks.mjs +58 -1
  30. package/dist/index.d.mts +2 -2
  31. package/dist/index.mjs +2 -2
  32. package/dist/oauth2/errors.mjs +16 -1
  33. package/dist/oauth2/link-account.mjs +6 -4
  34. package/dist/oauth2/state.mjs +8 -2
  35. package/dist/package.mjs +1 -1
  36. package/dist/plugins/access/access.d.mts +3 -15
  37. package/dist/plugins/access/access.mjs +11 -6
  38. package/dist/plugins/access/index.d.mts +2 -2
  39. package/dist/plugins/access/types.d.mts +11 -4
  40. package/dist/plugins/admin/access/statement.d.mts +29 -93
  41. package/dist/plugins/admin/admin.mjs +0 -4
  42. package/dist/plugins/admin/client.d.mts +1 -1
  43. package/dist/plugins/admin/routes.mjs +1 -0
  44. package/dist/plugins/anonymous/client.d.mts +1 -0
  45. package/dist/plugins/anonymous/error-codes.d.mts +1 -0
  46. package/dist/plugins/anonymous/error-codes.mjs +1 -0
  47. package/dist/plugins/anonymous/index.d.mts +1 -0
  48. package/dist/plugins/anonymous/index.mjs +16 -2
  49. package/dist/plugins/bearer/index.mjs +4 -9
  50. package/dist/plugins/captcha/index.mjs +2 -2
  51. package/dist/plugins/device-authorization/error-codes.mjs +1 -0
  52. package/dist/plugins/device-authorization/index.d.mts +1 -0
  53. package/dist/plugins/device-authorization/routes.mjs +34 -3
  54. package/dist/plugins/generic-oauth/index.d.mts +1 -1
  55. package/dist/plugins/generic-oauth/index.mjs +6 -6
  56. package/dist/plugins/generic-oauth/routes.mjs +34 -32
  57. package/dist/plugins/generic-oauth/types.d.mts +7 -0
  58. package/dist/plugins/index.d.mts +2 -2
  59. package/dist/plugins/last-login-method/client.mjs +2 -2
  60. package/dist/plugins/magic-link/index.d.mts +8 -1
  61. package/dist/plugins/magic-link/index.mjs +4 -17
  62. package/dist/plugins/mcp/authorize.mjs +8 -2
  63. package/dist/plugins/mcp/index.mjs +73 -34
  64. package/dist/plugins/multi-session/index.mjs +2 -2
  65. package/dist/plugins/oauth-proxy/index.mjs +44 -31
  66. package/dist/plugins/oauth-proxy/utils.mjs +3 -10
  67. package/dist/plugins/oidc-provider/authorize.mjs +8 -2
  68. package/dist/plugins/oidc-provider/index.mjs +63 -37
  69. package/dist/plugins/one-tap/index.mjs +13 -8
  70. package/dist/plugins/open-api/generator.mjs +16 -5
  71. package/dist/plugins/organization/access/statement.d.mts +68 -201
  72. package/dist/plugins/organization/adapter.mjs +61 -56
  73. package/dist/plugins/organization/client.d.mts +3 -1
  74. package/dist/plugins/organization/error-codes.d.mts +2 -0
  75. package/dist/plugins/organization/error-codes.mjs +3 -1
  76. package/dist/plugins/organization/routes/crud-access-control.d.mts +2 -2
  77. package/dist/plugins/organization/routes/crud-invites.mjs +7 -2
  78. package/dist/plugins/organization/types.d.mts +12 -2
  79. package/dist/plugins/two-factor/index.mjs +3 -2
  80. package/dist/plugins/username/index.d.mts +24 -2
  81. package/dist/plugins/username/index.mjs +49 -3
  82. package/dist/state.d.mts +2 -2
  83. package/dist/state.mjs +18 -4
  84. package/dist/test-utils/headers.mjs +2 -7
  85. package/dist/test-utils/test-instance.d.mts +25 -6
  86. package/dist/test-utils/test-instance.mjs +11 -2
  87. package/dist/utils/index.d.mts +1 -1
  88. package/dist/utils/url.d.mts +2 -1
  89. package/dist/utils/url.mjs +9 -3
  90. package/package.json +15 -14
@@ -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,6 +1,6 @@
1
1
  import { InferOptionSchema, InferPluginContext, InferPluginErrorCodes, InferPluginIDs } from "../types/plugins.mjs";
2
2
  import { HIDE_METADATA } from "../utils/hide-metadata.mjs";
3
- import { AccessControl, ArrayElement, Role, Statements, SubArray, Subset } from "./access/types.mjs";
3
+ import { AccessControl, ArrayElement, ExactRoleStatements, Role, RoleAuthorizeRequest, RoleInput, RoleStatements, Statements, SubArray, Subset } from "./access/types.mjs";
4
4
  import { AuthorizeResponse, createAccessControl, role } from "./access/access.mjs";
5
5
  import { OrganizationOptions } from "./organization/types.mjs";
6
6
  import { InferInvitation, InferMember, InferOrganization, InferOrganizationRolesFromOption, InferOrganizationZodRolesFromOption, InferTeam, Invitation, InvitationInput, InvitationStatus, Member, MemberInput, Organization, OrganizationInput, OrganizationRole, OrganizationSchema, Team, TeamInput, TeamMember, TeamMemberInput, defaultRolesSchema, invitationSchema, invitationStatus, memberSchema, organizationRoleSchema, organizationSchema, roleSchema, teamMemberSchema, teamSchema } from "./organization/schema.mjs";
@@ -62,4 +62,4 @@ import { USERNAME_ERROR_CODES } from "./username/error-codes.mjs";
62
62
  import { UsernameOptions, username } from "./username/index.mjs";
63
63
  import { hasPermission } from "./organization/has-permission.mjs";
64
64
  import { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, OrganizationCreator, OrganizationEndpoints, OrganizationPlugin, TeamEndpoints, organization, parseRoles } from "./organization/organization.mjs";
65
- export { AccessControl, AdminOptions, AnonymousOptions, AnonymousSession, ArrayElement, Auth0Options, AuthorizationQuery, AuthorizeResponse, BackupCodeOptions, BaseCaptchaOptions, BaseOAuthProviderOptions, BearerOptions, CaptchaFoxOptions, CaptchaOptions, Client, CloudflareTurnstileOptions, CodeVerificationValue, CustomSessionPluginOptions, DefaultOrganizationPlugin, DeviceAuthorizationOptions, DynamicAccessControlEndpoints, MULTI_SESSION_ERROR_CODES as ERROR_CODES, EmailOTPOptions, FieldSchema, GenericOAuthConfig, GenericOAuthOptions, GoogleRecaptchaOptions, GumroadOptions, HCaptchaOptions, HIDE_METADATA, HaveIBeenPwnedOptions, HubSpotOptions, InferAdminRolesFromOption, InferInvitation, InferMember, InferOptionSchema, InferOrganization, InferOrganizationRolesFromOption, InferOrganizationZodRolesFromOption, InferPluginContext, InferPluginErrorCodes, InferPluginIDs, InferTeam, Invitation, InvitationInput, InvitationStatus, JWKOptions, JWSAlgorithms, Jwk, JwtOptions, KeycloakOptions, LastLoginMethodOptions, LineOptions, LoginResult, MagicLinkOptions, Member, MemberInput, MicrosoftEntraIdOptions, MultiSessionConfig, OAuthAccessToken, OAuthProxyOptions, OIDCMetadata, OIDCOptions, OTPOptions, OktaOptions, OneTapOptions, OneTimeTokenOptions, OpenAPIModelSchema, OpenAPIOptions, Organization, OrganizationCreator, OrganizationEndpoints, OrganizationInput, OrganizationOptions, OrganizationPlugin, OrganizationRole, OrganizationSchema, Path, PatreonOptions, PhoneNumberOptions, Provider, Role, SIWEPluginOptions, SessionWithImpersonatedBy, SlackOptions, Statements, SubArray, Subset, TOTPOptions, TWO_FACTOR_ERROR_CODES, Team, TeamEndpoints, TeamInput, TeamMember, TeamMemberInput, TestCookie, TestHelpers, TestUtilsOptions, TimeString, TokenBody, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, USERNAME_ERROR_CODES, UserWithAnonymous, UserWithPhoneNumber, UserWithRole, UserWithTwoFactor, UsernameOptions, admin, anonymous, auth0, backupCode2fa, bearer, captcha, createAccessControl, createJwk, customSession, defaultRolesSchema, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, encodeBackupCodes, generateBackupCodes, generateExportedKeyPair, generator, genericOAuth, getBackupCodes, getClient, getJwtToken, getMCPProtectedResourceMetadata, getMCPProviderMetadata, getMetadata, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, invitationSchema, invitationStatus, jwt, keycloak, lastLoginMethod, line, magicLink, mcp, memberSchema, microsoftEntraId, ms, multiSession, oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata, oAuthProxy, oidcProvider, okta, oneTap, oneTimeToken, openAPI, organization, organizationRoleSchema, organizationSchema, otp2fa, parseRoles, patreon, phoneNumber, role, roleSchema, sec, signJWT, siwe, slack, teamMemberSchema, teamSchema, testUtils, toExpJWT, totp2fa, twoFactor, twoFactorClient, username, verifyBackupCode, verifyJWT, withMcpAuth };
65
+ export { AccessControl, AdminOptions, AnonymousOptions, AnonymousSession, ArrayElement, Auth0Options, AuthorizationQuery, AuthorizeResponse, BackupCodeOptions, BaseCaptchaOptions, BaseOAuthProviderOptions, BearerOptions, CaptchaFoxOptions, CaptchaOptions, Client, CloudflareTurnstileOptions, CodeVerificationValue, CustomSessionPluginOptions, DefaultOrganizationPlugin, DeviceAuthorizationOptions, DynamicAccessControlEndpoints, MULTI_SESSION_ERROR_CODES as ERROR_CODES, EmailOTPOptions, ExactRoleStatements, FieldSchema, GenericOAuthConfig, GenericOAuthOptions, GoogleRecaptchaOptions, GumroadOptions, HCaptchaOptions, HIDE_METADATA, HaveIBeenPwnedOptions, HubSpotOptions, InferAdminRolesFromOption, InferInvitation, InferMember, InferOptionSchema, InferOrganization, InferOrganizationRolesFromOption, InferOrganizationZodRolesFromOption, InferPluginContext, InferPluginErrorCodes, InferPluginIDs, InferTeam, Invitation, InvitationInput, InvitationStatus, JWKOptions, JWSAlgorithms, Jwk, JwtOptions, KeycloakOptions, LastLoginMethodOptions, LineOptions, LoginResult, MagicLinkOptions, Member, MemberInput, MicrosoftEntraIdOptions, MultiSessionConfig, OAuthAccessToken, OAuthProxyOptions, OIDCMetadata, OIDCOptions, OTPOptions, OktaOptions, OneTapOptions, OneTimeTokenOptions, OpenAPIModelSchema, OpenAPIOptions, Organization, OrganizationCreator, OrganizationEndpoints, OrganizationInput, OrganizationOptions, OrganizationPlugin, OrganizationRole, OrganizationSchema, Path, PatreonOptions, PhoneNumberOptions, Provider, Role, RoleAuthorizeRequest, RoleInput, RoleStatements, SIWEPluginOptions, SessionWithImpersonatedBy, SlackOptions, Statements, SubArray, Subset, TOTPOptions, TWO_FACTOR_ERROR_CODES, Team, TeamEndpoints, TeamInput, TeamMember, TeamMemberInput, TestCookie, TestHelpers, TestUtilsOptions, TimeString, TokenBody, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, USERNAME_ERROR_CODES, UserWithAnonymous, UserWithPhoneNumber, UserWithRole, UserWithTwoFactor, UsernameOptions, admin, anonymous, auth0, backupCode2fa, bearer, captcha, createAccessControl, createJwk, customSession, defaultRolesSchema, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, encodeBackupCodes, generateBackupCodes, generateExportedKeyPair, generator, genericOAuth, getBackupCodes, getClient, getJwtToken, getMCPProtectedResourceMetadata, getMCPProviderMetadata, getMetadata, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, invitationSchema, invitationStatus, jwt, keycloak, lastLoginMethod, line, magicLink, mcp, memberSchema, microsoftEntraId, ms, multiSession, oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata, oAuthProxy, oidcProvider, okta, oneTap, oneTimeToken, openAPI, organization, organizationRoleSchema, organizationSchema, otp2fa, parseRoles, patreon, phoneNumber, role, roleSchema, sec, signJWT, siwe, slack, teamMemberSchema, teamSchema, testUtils, toExpJWT, totp2fa, twoFactor, twoFactorClient, username, verifyBackupCode, verifyJWT, withMcpAuth };
@@ -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
@@ -18,7 +18,14 @@ interface MagicLinkOptions {
18
18
  expiresIn?: number | undefined;
19
19
  /**
20
20
  * Allowed attempts for verifying the magic link token.
21
- * Note: Passing Infinity will allow unlimited attempts.
21
+ *
22
+ * @deprecated Multi-attempt verification is no longer supported. Each
23
+ * magic link token is consumed atomically on the first verification call,
24
+ * so a given token mints at most one session regardless of this value
25
+ * (see GHSA-hc7v-rggr-4hvx). The option is kept for source compatibility
26
+ * and may be removed in a future major; any value other than `1` is
27
+ * ignored and emits a `console.warn` at plugin construction.
28
+ *
22
29
  * @default 1
23
30
  */
24
31
  allowedAttempts?: number;
@@ -27,6 +27,7 @@ const magicLink = (options) => {
27
27
  allowedAttempts: 1,
28
28
  ...options
29
29
  };
30
+ if (options.allowedAttempts !== void 0 && options.allowedAttempts !== 1) console.warn("[better-auth/magic-link] `allowedAttempts` is ignored: tokens are consumed atomically on the first verification call (GHSA-hc7v-rggr-4hvx). Any value other than `1` has no effect; remove the option to silence this warning.");
30
31
  async function storeToken(ctx, token) {
31
32
  if (opts.storeToken === "hashed") return await defaultKeyHasher(token);
32
33
  if (typeof opts.storeToken === "object" && "type" in opts.storeToken && opts.storeToken.type === "custom-hasher") return await opts.storeToken.hash(token);
@@ -59,8 +60,7 @@ const magicLink = (options) => {
59
60
  identifier: storedToken,
60
61
  value: JSON.stringify({
61
62
  email,
62
- name: ctx.body.name,
63
- attempt: 0
63
+ name: ctx.body.name
64
64
  }),
65
65
  expiresAt: new Date(Date.now() + (opts.expiresIn || 300) * 1e3)
66
66
  });
@@ -119,22 +119,9 @@ const magicLink = (options) => {
119
119
  }
120
120
  const newUserCallbackURL = new URL(ctx.query.newUserCallbackURL ? decodeURIComponent(ctx.query.newUserCallbackURL) : callbackURL, ctx.context.baseURL).toString();
121
121
  const storedToken = await storeToken(ctx, token);
122
- const tokenValue = await ctx.context.internalAdapter.findVerificationValue(storedToken);
122
+ const tokenValue = await ctx.context.internalAdapter.consumeVerificationValue(storedToken);
123
123
  if (!tokenValue) redirectWithError("INVALID_TOKEN");
124
- if (tokenValue.expiresAt < /* @__PURE__ */ new Date()) {
125
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(storedToken);
126
- redirectWithError("EXPIRED_TOKEN");
127
- }
128
- const { email, name, attempt = 0 } = JSON.parse(tokenValue.value);
129
- if (attempt >= opts.allowedAttempts) {
130
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(storedToken);
131
- redirectWithError("ATTEMPTS_EXCEEDED");
132
- }
133
- await ctx.context.internalAdapter.updateVerificationByIdentifier(storedToken, { value: JSON.stringify({
134
- email,
135
- name,
136
- attempt: attempt + 1
137
- }) });
124
+ const { email, name } = JSON.parse(tokenValue.value);
138
125
  let isNewUser = false;
139
126
  let user = await ctx.context.internalAdapter.findUserByEmail(email).then((res) => res?.user);
140
127
  if (!user) if (!opts.disableSignUp) {
@@ -72,8 +72,14 @@ async function authorizeMCPOAuth(ctx, options) {
72
72
  });
73
73
  if (invalidScopes.length) throw ctx.redirect(redirectErrorURL(query.redirect_uri, "invalid_scope", `The following scopes are invalid: ${invalidScopes.join(", ")}`));
74
74
  if ((!query.code_challenge || !query.code_challenge_method) && options.requirePKCE) throw ctx.redirect(redirectErrorURL(query.redirect_uri, "invalid_request", "pkce is required"));
75
- if (!query.code_challenge_method) query.code_challenge_method = "plain";
76
- if (!["s256", options.allowPlainCodeChallengeMethod ? "plain" : "s256"].includes(query.code_challenge_method?.toLowerCase() || "")) throw ctx.redirect(redirectErrorURL(query.redirect_uri, "invalid_request", "invalid code_challenge method"));
75
+ if (query.code_challenge_method && !query.code_challenge) throw ctx.redirect(redirectErrorURL(query.redirect_uri, "invalid_request", "code_challenge_method requires code_challenge"));
76
+ if (query.code_challenge) {
77
+ const allowedCodeChallengeMethods = options.allowPlainCodeChallengeMethod ? ["s256", "plain"] : ["s256"];
78
+ let codeChallengeMethod = query.code_challenge_method?.toLowerCase();
79
+ if (!codeChallengeMethod && options.allowPlainCodeChallengeMethod) codeChallengeMethod = "plain";
80
+ if (!codeChallengeMethod || !allowedCodeChallengeMethods.includes(codeChallengeMethod)) throw ctx.redirect(redirectErrorURL(query.redirect_uri, "invalid_request", "invalid code_challenge method"));
81
+ query.code_challenge_method = codeChallengeMethod;
82
+ }
77
83
  const code = generateRandomString(32, "a-z", "A-Z", "0-9");
78
84
  const codeExpiresInMs = opts.codeExpiresIn * 1e3;
79
85
  const expiresAt = new Date(Date.now() + codeExpiresInMs);
@@ -1,5 +1,6 @@
1
1
  import { getBaseURL, isDynamicBaseURLConfig, resolveBaseURL } from "../../utils/url.mjs";
2
2
  import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
3
+ import { constantTimeEqual } from "../../crypto/buffer.mjs";
3
4
  import { generateRandomString } from "../../crypto/random.mjs";
4
5
  import { expireCookie } from "../../cookies/index.mjs";
5
6
  import { resolveDynamicTrustedProxyHeaders } from "../../context/helpers.mjs";
@@ -45,7 +46,7 @@ const getMCPProviderMetadata = (ctx, options) => {
45
46
  grant_types_supported: ["authorization_code", "refresh_token"],
46
47
  acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"],
47
48
  subject_types_supported: ["public"],
48
- id_token_signing_alg_values_supported: ["RS256", "none"],
49
+ id_token_signing_alg_values_supported: ["RS256"],
49
50
  token_endpoint_auth_methods_supported: [
50
51
  "client_secret_basic",
51
52
  "client_secret_post",
@@ -81,7 +82,7 @@ const getMCPProtectedResourceMetadata = (ctx, options) => {
81
82
  "offline_access"
82
83
  ],
83
84
  bearer_methods_supported: ["header"],
84
- resource_signing_alg_values_supported: ["RS256", "none"]
85
+ resource_signing_alg_values_supported: ["RS256"]
85
86
  };
86
87
  };
87
88
  const registerMcpClientBodySchema = z.object({
@@ -122,7 +123,7 @@ const mcp = (options) => {
122
123
  defaultScope: "openid",
123
124
  accessTokenExpiresIn: 3600,
124
125
  refreshTokenExpiresIn: 604800,
125
- allowPlainCodeChallengeMethod: true,
126
+ allowPlainCodeChallengeMethod: false,
126
127
  ...options.oidcConfig,
127
128
  loginPage: options.loginPage,
128
129
  scopes: [
@@ -225,10 +226,6 @@ const mcp = (options) => {
225
226
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"]
226
227
  }
227
228
  }, async (ctx) => {
228
- ctx.setHeader("Access-Control-Allow-Origin", "*");
229
- ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
230
- ctx.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
231
- ctx.setHeader("Access-Control-Max-Age", "86400");
232
229
  let { body } = ctx;
233
230
  if (!body) throw ctx.error("BAD_REQUEST", {
234
231
  error_description: "request body not found",
@@ -241,25 +238,43 @@ const mcp = (options) => {
241
238
  });
242
239
  let { client_id, client_secret } = body;
243
240
  const authorization = ctx.request?.headers.get("authorization") || null;
244
- if (authorization && !client_id && !client_secret && authorization.startsWith("Basic ")) try {
245
- const encoded = authorization.replace("Basic ", "");
246
- const decoded = new TextDecoder().decode(base64.decode(encoded));
247
- if (!decoded.includes(":")) throw new APIError("UNAUTHORIZED", {
241
+ if (authorization && !client_secret && authorization.startsWith("Basic ")) {
242
+ let decoded;
243
+ try {
244
+ const encoded = authorization.replace("Basic ", "");
245
+ decoded = new TextDecoder().decode(base64.decode(encoded));
246
+ } catch {
247
+ throw new APIError("UNAUTHORIZED", {
248
+ error_description: "invalid authorization header format",
249
+ error: "invalid_client"
250
+ });
251
+ }
252
+ const colonIndex = decoded.indexOf(":");
253
+ if (colonIndex === -1) throw new APIError("UNAUTHORIZED", {
248
254
  error_description: "invalid authorization header format",
249
255
  error: "invalid_client"
250
256
  });
251
- const [id, secret] = decoded.split(":");
257
+ let id;
258
+ let secret;
259
+ try {
260
+ id = decodeURIComponent(decoded.slice(0, colonIndex));
261
+ secret = decodeURIComponent(decoded.slice(colonIndex + 1));
262
+ } catch {
263
+ throw new APIError("UNAUTHORIZED", {
264
+ error_description: "invalid authorization header format",
265
+ error: "invalid_client"
266
+ });
267
+ }
252
268
  if (!id || !secret) throw new APIError("UNAUTHORIZED", {
253
269
  error_description: "invalid authorization header format",
254
270
  error: "invalid_client"
255
271
  });
256
- client_id = id;
257
- client_secret = secret;
258
- } catch {
259
- throw new APIError("UNAUTHORIZED", {
260
- error_description: "invalid authorization header format",
272
+ if (client_id && client_id.toString() !== id) throw new APIError("UNAUTHORIZED", {
273
+ error_description: "client_id in body does not match Authorization header",
261
274
  error: "invalid_client"
262
275
  });
276
+ client_id = id;
277
+ client_secret = secret;
263
278
  }
264
279
  const { grant_type, code, redirect_uri, refresh_token, code_verifier } = body;
265
280
  if (grant_type === "refresh_token") {
@@ -286,6 +301,38 @@ const mcp = (options) => {
286
301
  error_description: "refresh token expired",
287
302
  error: "invalid_grant"
288
303
  });
304
+ const refreshClient = await ctx.context.adapter.findOne({
305
+ model: modelName.oauthClient,
306
+ where: [{
307
+ field: "clientId",
308
+ value: client_id.toString()
309
+ }]
310
+ }).then((res) => {
311
+ if (!res) return null;
312
+ return {
313
+ ...res,
314
+ redirectUrls: res.redirectUrls.split(","),
315
+ metadata: res.metadata ? JSON.parse(res.metadata) : {}
316
+ };
317
+ });
318
+ if (!refreshClient) throw new APIError("UNAUTHORIZED", {
319
+ error_description: "invalid client_id",
320
+ error: "invalid_client"
321
+ });
322
+ if (refreshClient.disabled) throw new APIError("UNAUTHORIZED", {
323
+ error_description: "client is disabled",
324
+ error: "invalid_client"
325
+ });
326
+ if (refreshClient.type !== "public") {
327
+ if (!refreshClient.clientSecret || !client_secret) throw new APIError("UNAUTHORIZED", {
328
+ error_description: "client_secret is required for confidential clients",
329
+ error: "invalid_client"
330
+ });
331
+ if (!constantTimeEqual(refreshClient.clientSecret, client_secret.toString())) throw new APIError("UNAUTHORIZED", {
332
+ error_description: "invalid client_secret",
333
+ error: "invalid_client"
334
+ });
335
+ }
289
336
  const accessToken = generateRandomString(32, "a-z", "A-Z");
290
337
  const newRefreshToken = generateRandomString(32, "a-z", "A-Z");
291
338
  const accessTokenExpiresAt = new Date(Date.now() + opts.accessTokenExpiresIn * 1e3);
@@ -320,20 +367,11 @@ const mcp = (options) => {
320
367
  error_description: "code verifier is missing",
321
368
  error: "invalid_request"
322
369
  });
323
- /**
324
- * We need to check if the code is valid before we can proceed
325
- * with the rest of the request.
326
- */
327
- const verificationValue = await ctx.context.internalAdapter.findVerificationValue(code.toString());
370
+ const verificationValue = await ctx.context.internalAdapter.consumeVerificationValue(code.toString());
328
371
  if (!verificationValue) throw new APIError("UNAUTHORIZED", {
329
372
  error_description: "invalid code",
330
373
  error: "invalid_grant"
331
374
  });
332
- if (verificationValue.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", {
333
- error_description: "code expired",
334
- error: "invalid_grant"
335
- });
336
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(code.toString());
337
375
  if (!client_id) throw new APIError("UNAUTHORIZED", {
338
376
  error_description: "client_id is required",
339
377
  error: "invalid_client"
@@ -378,11 +416,11 @@ const mcp = (options) => {
378
416
  error: "invalid_request"
379
417
  });
380
418
  } else {
381
- if (!client_secret) throw new APIError("UNAUTHORIZED", {
419
+ if (!client.clientSecret || !client_secret) throw new APIError("UNAUTHORIZED", {
382
420
  error_description: "client_secret is required for confidential clients",
383
421
  error: "invalid_client"
384
422
  });
385
- if (!(client.clientSecret === client_secret.toString())) throw new APIError("UNAUTHORIZED", {
423
+ if (!constantTimeEqual(client.clientSecret, client_secret.toString())) throw new APIError("UNAUTHORIZED", {
386
424
  error_description: "invalid client_secret",
387
425
  error: "invalid_client"
388
426
  });
@@ -400,12 +438,13 @@ const mcp = (options) => {
400
438
  error_description: "code verifier is missing",
401
439
  error: "invalid_request"
402
440
  });
403
- if ((value.codeChallengeMethod === "plain" ? code_verifier : await createHash("SHA-256", "base64urlnopad").digest(code_verifier)) !== value.codeChallenge) throw new APIError("UNAUTHORIZED", {
404
- error_description: "code verification failed",
405
- error: "invalid_request"
406
- });
441
+ if (value.codeChallenge) {
442
+ if ((value.codeChallengeMethod === "plain" ? code_verifier : await createHash("SHA-256", "base64urlnopad").digest(code_verifier)) !== value.codeChallenge) throw new APIError("UNAUTHORIZED", {
443
+ error_description: "code verification failed",
444
+ error: "invalid_request"
445
+ });
446
+ }
407
447
  const requestedScopes = value.scope;
408
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(code.toString());
409
448
  const accessToken = generateRandomString(32, "a-z", "A-Z");
410
449
  const refreshToken = generateRandomString(32, "A-Z", "a-z");
411
450
  const accessTokenExpiresAt = new Date(Date.now() + opts.accessTokenExpiresIn * 1e3);
@@ -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";