better-auth 1.7.2 → 1.7.3

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 (63) hide show
  1. package/dist/api/index.d.mts +6 -22
  2. package/dist/api/index.mjs +2 -0
  3. package/dist/api/routes/account.d.mts +1 -9
  4. package/dist/api/routes/account.mjs +0 -6
  5. package/dist/api/routes/callback.mjs +0 -2
  6. package/dist/api/routes/password.mjs +0 -2
  7. package/dist/api/routes/session.mjs +12 -5
  8. package/dist/api/routes/sign-in.d.mts +2 -2
  9. package/dist/api/routes/sign-in.mjs +1 -5
  10. package/dist/api/routes/sign-up.mjs +0 -2
  11. package/dist/api/routes/update-user.mjs +0 -2
  12. package/dist/api/to-auth-endpoints.mjs +2 -0
  13. package/dist/auth/base.mjs +13 -1
  14. package/dist/auth/trusted-origins.mjs +6 -4
  15. package/dist/client/config.d.mts +2 -0
  16. package/dist/client/config.mjs +1 -0
  17. package/dist/client/vue/index.d.mts +31 -20
  18. package/dist/client/vue/index.mjs +23 -5
  19. package/dist/context/create-context.mjs +2 -0
  20. package/dist/cookies/session-store.d.mts +0 -1
  21. package/dist/cookies/session-store.mjs +16 -5
  22. package/dist/db/adapter-kysely.mjs +1 -2
  23. package/dist/db/get-migration.d.mts +1 -0
  24. package/dist/db/get-migration.mjs +10 -32
  25. package/dist/db/internal-adapter.mjs +14 -17
  26. package/dist/db/schema.d.mts +0 -1
  27. package/dist/index.mjs +0 -1
  28. package/dist/oauth2/account-key.mjs +1 -5
  29. package/dist/oauth2/link-account.d.mts +0 -1
  30. package/dist/oauth2/link-account.mjs +5 -11
  31. package/dist/package.mjs +1 -1
  32. package/dist/plugins/admin/routes.mjs +0 -3
  33. package/dist/plugins/device-authorization/index.d.mts +11 -11
  34. package/dist/plugins/email-otp/routes.mjs +0 -2
  35. package/dist/plugins/generic-oauth/index.mjs +14 -10
  36. package/dist/plugins/generic-oauth/providers/auth0.mjs +2 -4
  37. package/dist/plugins/generic-oauth/providers/keycloak.mjs +6 -9
  38. package/dist/plugins/generic-oauth/providers/line.mjs +0 -1
  39. package/dist/plugins/generic-oauth/providers/okta.mjs +6 -9
  40. package/dist/plugins/generic-oauth/providers/slack.mjs +0 -1
  41. package/dist/plugins/generic-oauth/types.d.mts +0 -9
  42. package/dist/plugins/haveibeenpwned/index.d.mts +9 -1
  43. package/dist/plugins/haveibeenpwned/index.mjs +31 -11
  44. package/dist/plugins/index.d.mts +2 -2
  45. package/dist/plugins/index.mjs +2 -2
  46. package/dist/plugins/last-login-method/index.mjs +1 -0
  47. package/dist/plugins/oauth-proxy/index.d.mts +17 -0
  48. package/dist/plugins/oauth-proxy/index.mjs +145 -99
  49. package/dist/plugins/one-tap/index.mjs +0 -1
  50. package/dist/plugins/open-api/generator.mjs +15 -1
  51. package/dist/plugins/open-api/index.mjs +0 -1
  52. package/dist/plugins/organization/has-permission.mjs +4 -2
  53. package/dist/plugins/phone-number/routes.mjs +0 -3
  54. package/dist/plugins/siwe/index.mjs +0 -3
  55. package/dist/plugins/two-factor/client.d.mts +1 -0
  56. package/dist/plugins/two-factor/error-code.d.mts +1 -0
  57. package/dist/plugins/two-factor/error-code.mjs +1 -0
  58. package/dist/plugins/two-factor/index.d.mts +1 -0
  59. package/dist/plugins/two-factor/index.mjs +3 -2
  60. package/dist/state.d.mts +0 -13
  61. package/dist/test-utils/test-instance.mjs +10 -9
  62. package/package.json +9 -9
  63. package/dist/utils/index.mjs +0 -5
@@ -22,20 +22,17 @@
22
22
  * ```
23
23
  */
24
24
  function okta(options) {
25
- const defaultScopes = [
26
- "openid",
27
- "profile",
28
- "email"
29
- ];
30
- const issuer = options.issuer.replace(/\/$/, "");
31
25
  return {
32
26
  providerId: "okta",
33
- accountIssuer: issuer,
34
- discoveryUrl: `${issuer}/.well-known/openid-configuration`,
27
+ discoveryUrl: `${options.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`,
35
28
  clientId: options.clientId,
36
29
  clientSecret: options.clientSecret,
37
30
  tokenEndpointAuth: options.tokenEndpointAuth,
38
- scopes: options.scopes ?? defaultScopes,
31
+ scopes: options.scopes ?? [
32
+ "openid",
33
+ "profile",
34
+ "email"
35
+ ],
39
36
  redirectURI: options.redirectURI,
40
37
  endSessionEndpoint: options.endSessionEndpoint,
41
38
  postLogoutRedirectURI: options.postLogoutRedirectURI,
@@ -41,7 +41,6 @@ function slack(options) {
41
41
  return {
42
42
  providerId: "slack",
43
43
  accountSubject: ({ profile }) => profile.sub ?? "",
44
- accountIssuer: "https://slack.com",
45
44
  authorizationUrl: "https://slack.com/openid/connect/authorize",
46
45
  tokenUrl: "https://slack.com/api/openid.connect.token",
47
46
  userInfoUrl: "https://slack.com/api/openid.connect.userInfo",
@@ -36,15 +36,6 @@ interface GenericOAuthConfig<ID extends string = string> {
36
36
  * mapping cannot redefine provider identity.
37
37
  */
38
38
  accountSubject?: ((context: OAuthAccountKeyContext<GenericOAuthUserInfo>) => Awaitable<string | number>) | undefined;
39
- /**
40
- * Stable issuer namespace paired with the provider account ID.
41
- *
42
- * Discovery providers use the discovered issuer by default. Set this for
43
- * providers without discovery, provider aliases that share one identity
44
- * namespace, or tenant-specific issuers derived from verified provider data.
45
- * The resolver must not use unverified request input.
46
- */
47
- accountIssuer?: string | ((context: OAuthAccountKeyContext<GenericOAuthUserInfo>) => Awaitable<string>) | undefined;
48
39
  /**
49
40
  * URL to fetch OAuth 2.0 configuration.
50
41
  * If provided, the authorization and token endpoints will be fetched from this URL.
@@ -9,6 +9,14 @@ declare module "@better-auth/core" {
9
9
  };
10
10
  }
11
11
  }
12
+ /**
13
+ * Checks whether a password appears in the Have I Been Pwned password corpus.
14
+ * Only the first five characters of its SHA-1 hash are sent to the service.
15
+ *
16
+ * @returns Whether the password has been compromised.
17
+ * @throws {APIError} When the password could not be checked.
18
+ */
19
+ declare function isPasswordCompromised(password: string): Promise<boolean>;
12
20
  interface HaveIBeenPwnedOptions {
13
21
  /**
14
22
  * Custom error message shown when a compromised password is detected.
@@ -52,4 +60,4 @@ declare const haveIBeenPwned: (options?: HaveIBeenPwnedOptions | undefined) => {
52
60
  };
53
61
  };
54
62
  //#endregion
55
- export { HaveIBeenPwnedOptions, haveIBeenPwned };
63
+ export { HaveIBeenPwnedOptions, haveIBeenPwned, isPasswordCompromised };
@@ -7,26 +7,46 @@ import { createHash } from "@better-auth/utils/hash";
7
7
  import { betterFetch } from "@better-fetch/fetch";
8
8
  //#region src/plugins/haveibeenpwned/index.ts
9
9
  const ERROR_CODES = defineErrorCodes({ PASSWORD_COMPROMISED: "The password you entered has been compromised. Please choose a different password." });
10
- async function checkPasswordCompromise(password, customMessage) {
11
- if (!password) return;
12
- const sha1Hash = (await createHash("SHA-1", "hex").digest(password)).toUpperCase();
13
- const prefix = sha1Hash.substring(0, 5);
14
- const suffix = sha1Hash.substring(5);
10
+ function getPasswordCompromiseCount(response, hashSuffix) {
11
+ const matchingEntryPrefix = `${hashSuffix.toUpperCase()}:`;
12
+ for (const line of response.split(/\r?\n/)) {
13
+ if (line.slice(0, matchingEntryPrefix.length).toUpperCase() !== matchingEntryPrefix) continue;
14
+ const compromiseCountText = line.slice(matchingEntryPrefix.length);
15
+ const compromiseCount = Number(compromiseCountText);
16
+ if (!(Number.isSafeInteger(compromiseCount) && compromiseCount >= 0 && String(compromiseCount) === compromiseCountText)) throw new Error("Invalid password compromise count");
17
+ return compromiseCount;
18
+ }
19
+ return 0;
20
+ }
21
+ /**
22
+ * Checks whether a password appears in the Have I Been Pwned password corpus.
23
+ * Only the first five characters of its SHA-1 hash are sent to the service.
24
+ *
25
+ * @returns Whether the password has been compromised.
26
+ * @throws {APIError} When the password could not be checked.
27
+ */
28
+ async function isPasswordCompromised(password) {
15
29
  try {
30
+ const sha1Hash = (await createHash("SHA-1", "hex").digest(password)).toUpperCase();
31
+ const prefix = sha1Hash.substring(0, 5);
32
+ const suffix = sha1Hash.substring(5);
16
33
  const { data, error } = await betterFetch(`https://api.pwnedpasswords.com/range/${prefix}`, { headers: {
17
34
  "Add-Padding": "true",
18
35
  "User-Agent": "BetterAuth Password Checker"
19
36
  } });
20
37
  if (error) throw new APIError("INTERNAL_SERVER_ERROR", { message: `Failed to check password. Status: ${error.status}` });
21
- if (data.split("\n").some((line) => line.split(":")[0].toUpperCase() === suffix.toUpperCase())) throw APIError.from("BAD_REQUEST", {
22
- message: customMessage || ERROR_CODES.PASSWORD_COMPROMISED.message,
23
- code: ERROR_CODES.PASSWORD_COMPROMISED.code
24
- });
38
+ return getPasswordCompromiseCount(data, suffix) > 0;
25
39
  } catch (error) {
26
40
  if (isAPIError(error)) throw error;
27
41
  throw new APIError("INTERNAL_SERVER_ERROR", { message: "Failed to check password. Please try again later." });
28
42
  }
29
43
  }
44
+ async function rejectCompromisedPassword(password, customMessage) {
45
+ if (await isPasswordCompromised(password)) throw APIError.from("BAD_REQUEST", {
46
+ message: customMessage || ERROR_CODES.PASSWORD_COMPROMISED.message,
47
+ code: ERROR_CODES.PASSWORD_COMPROMISED.code
48
+ });
49
+ }
30
50
  const haveIBeenPwned = (options) => {
31
51
  const paths = options?.paths || [
32
52
  "/sign-up/email",
@@ -48,7 +68,7 @@ const haveIBeenPwned = (options) => {
48
68
  if (options?.enabled === false) return originalHash(password);
49
69
  const c = getCurrentAuthEndpointContext();
50
70
  if (!c.path || !paths.includes(c.path)) return originalHash(password);
51
- await checkPasswordCompromise(password, options?.customPasswordCompromisedMessage);
71
+ await rejectCompromisedPassword(password, options?.customPasswordCompromisedMessage);
52
72
  return originalHash(password);
53
73
  }
54
74
  } } };
@@ -58,4 +78,4 @@ const haveIBeenPwned = (options) => {
58
78
  };
59
79
  };
60
80
  //#endregion
61
- export { haveIBeenPwned };
81
+ export { haveIBeenPwned, isPasswordCompromised };
@@ -31,7 +31,7 @@ import { PatreonOptions, patreon } from "./generic-oauth/providers/patreon.mjs";
31
31
  import { SlackOptions, slack } from "./generic-oauth/providers/slack.mjs";
32
32
  import { YandexOptions, yandex } from "./generic-oauth/providers/yandex.mjs";
33
33
  import { BaseOAuthProviderOptions, genericOAuth } from "./generic-oauth/index.mjs";
34
- import { HaveIBeenPwnedOptions, haveIBeenPwned } from "./haveibeenpwned/index.mjs";
34
+ import { HaveIBeenPwnedOptions, haveIBeenPwned, isPasswordCompromised } from "./haveibeenpwned/index.mjs";
35
35
  import { JWKOptions, JWSAlgorithms, Jwk, JwtOptions, ResolvedSigningKey } from "./jwt/types.mjs";
36
36
  import { getJwtToken, resolveSigningKey, signJWT } from "./jwt/sign.mjs";
37
37
  import { createJwk, generateExportedKeyPair, toExpJWT } from "./jwt/utils.mjs";
@@ -66,4 +66,4 @@ import { USERNAME_ERROR_CODES } from "./username/error-codes.mjs";
66
66
  import { UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, username } from "./username/index.mjs";
67
67
  import { hasPermission } from "./organization/has-permission.mjs";
68
68
  import { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, OrganizationCreator, OrganizationEndpoints, OrganizationPlugin, TeamEndpoints, organization, parseRoles } from "./organization/organization.mjs";
69
- export { AccessControl, AdminOptions, AnonymousOptions, AnonymousSession, ArrayElement, Auth0Options, AuthorizeResponse, BackupCodeOptions, BaseCaptchaOptions, BaseOAuthProviderOptions, BearerOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, CustomSessionPluginOptions, DefaultOrganizationPlugin, DeviceAuthorizationGrant, DeviceAuthorizationGrantAuthorization, DeviceAuthorizationOptions, DeviceAuthorizationPluginOptions, DeviceAuthorizationRequest, DeviceCode, DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, DynamicAccessControlEndpoints, MULTI_SESSION_ERROR_CODES as ERROR_CODES, EmailOTPOptions, ExactRoleStatements, FieldSchema, GenericOAuthConfig, GenericOAuthOptions, GenericOAuthUserInfo, 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, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, OAuthPopupData, OAuthPopupMessage, OAuthProxyOptions, OTPOptions, OktaOptions, OneTapOptions, OneTimeTokenOptions, OpenAPIModelSchema, OpenAPIOptions, OpenAPIParameter, OpenAPISchema, Organization, OrganizationCreator, OrganizationEndpoints, OrganizationInput, OrganizationOptions, OrganizationPlugin, OrganizationRole, OrganizationSchema, POPUP_MARKER_COOKIE, Path, PatreonOptions, PhoneNumberOptions, Provider, ResolvedSigningKey, Role, RoleAuthorizeRequest, RoleInput, RoleStatements, SIWEPluginOptions, SessionWithImpersonatedBy, SlackOptions, Statements, SubArray, Subset, TOTPOptions, TWO_FACTOR_ERROR_CODES, Team, TeamEndpoints, TeamInput, TeamMember, TeamMemberInput, TestCookie, TestHelpers, TestUtilsOptions, TimeString, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, USERNAME_ERROR_CODES, UserWithAnonymous, UserWithPhoneNumber, UserWithRole, UserWithTwoFactor, UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, YandexOptions, admin, anonymous, auth0, backupCode2fa, bearer, captcha, createAccessControl, createJwk, customSession, defaultRolesSchema, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, encodeBackupCodes, generateBackupCodes, generateExportedKeyPair, generator, genericOAuth, getBackupCodes, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, invitationSchema, invitationStatus, jwt, keycloak, lastLoginMethod, line, magicLink, memberSchema, microsoftEntraId, ms, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, organizationRoleSchema, organizationSchema, otp2fa, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, roleSchema, sec, signJWT, siwe, slack, teamMemberSchema, teamSchema, testUtils, toExpJWT, totp2fa, twoFactor, twoFactorClient, username, verifyBackupCode, verifyJWT, yandex };
69
+ export { AccessControl, AdminOptions, AnonymousOptions, AnonymousSession, ArrayElement, Auth0Options, AuthorizeResponse, BackupCodeOptions, BaseCaptchaOptions, BaseOAuthProviderOptions, BearerOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, CustomSessionPluginOptions, DefaultOrganizationPlugin, DeviceAuthorizationGrant, DeviceAuthorizationGrantAuthorization, DeviceAuthorizationOptions, DeviceAuthorizationPluginOptions, DeviceAuthorizationRequest, DeviceCode, DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, DynamicAccessControlEndpoints, MULTI_SESSION_ERROR_CODES as ERROR_CODES, EmailOTPOptions, ExactRoleStatements, FieldSchema, GenericOAuthConfig, GenericOAuthOptions, GenericOAuthUserInfo, 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, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, OAuthPopupData, OAuthPopupMessage, OAuthProxyOptions, OTPOptions, OktaOptions, OneTapOptions, OneTimeTokenOptions, OpenAPIModelSchema, OpenAPIOptions, OpenAPIParameter, OpenAPISchema, Organization, OrganizationCreator, OrganizationEndpoints, OrganizationInput, OrganizationOptions, OrganizationPlugin, OrganizationRole, OrganizationSchema, POPUP_MARKER_COOKIE, Path, PatreonOptions, PhoneNumberOptions, Provider, ResolvedSigningKey, Role, RoleAuthorizeRequest, RoleInput, RoleStatements, SIWEPluginOptions, SessionWithImpersonatedBy, SlackOptions, Statements, SubArray, Subset, TOTPOptions, TWO_FACTOR_ERROR_CODES, Team, TeamEndpoints, TeamInput, TeamMember, TeamMemberInput, TestCookie, TestHelpers, TestUtilsOptions, TimeString, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, USERNAME_ERROR_CODES, UserWithAnonymous, UserWithPhoneNumber, UserWithRole, UserWithTwoFactor, UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, YandexOptions, admin, anonymous, auth0, backupCode2fa, bearer, captcha, createAccessControl, createJwk, customSession, defaultRolesSchema, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, encodeBackupCodes, generateBackupCodes, generateExportedKeyPair, generator, genericOAuth, getBackupCodes, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, invitationSchema, invitationStatus, isPasswordCompromised, jwt, keycloak, lastLoginMethod, line, magicLink, memberSchema, microsoftEntraId, ms, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, organizationRoleSchema, organizationSchema, otp2fa, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, roleSchema, sec, signJWT, siwe, slack, teamMemberSchema, teamSchema, testUtils, toExpJWT, totp2fa, twoFactor, twoFactorClient, username, verifyBackupCode, verifyJWT, yandex };
@@ -25,7 +25,7 @@ import { patreon } from "./generic-oauth/providers/patreon.mjs";
25
25
  import { slack } from "./generic-oauth/providers/slack.mjs";
26
26
  import { yandex } from "./generic-oauth/providers/yandex.mjs";
27
27
  import { genericOAuth } from "./generic-oauth/index.mjs";
28
- import { haveIBeenPwned } from "./haveibeenpwned/index.mjs";
28
+ import { haveIBeenPwned, isPasswordCompromised } from "./haveibeenpwned/index.mjs";
29
29
  import { createJwk, generateExportedKeyPair, toExpJWT } from "./jwt/utils.mjs";
30
30
  import { getJwtToken, resolveSigningKey, signJWT } from "./jwt/sign.mjs";
31
31
  import { verifyJWT } from "./jwt/verify.mjs";
@@ -46,4 +46,4 @@ import { siwe } from "./siwe/index.mjs";
46
46
  import { testUtils } from "./test-utils/index.mjs";
47
47
  import { twoFactor } from "./two-factor/index.mjs";
48
48
  import { username } from "./username/index.mjs";
49
- export { MULTI_SESSION_ERROR_CODES as ERROR_CODES, HIDE_METADATA, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, POPUP_MARKER_COOKIE, TWO_FACTOR_ERROR_CODES, USERNAME_ERROR_CODES, admin, anonymous, auth0, bearer, captcha, createAccessControl, createJwk, customSession, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, generateExportedKeyPair, genericOAuth, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, jwt, keycloak, lastLoginMethod, line, magicLink, microsoftEntraId, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, signJWT, siwe, slack, testUtils, toExpJWT, twoFactor, twoFactorClient, username, verifyJWT, yandex };
49
+ export { MULTI_SESSION_ERROR_CODES as ERROR_CODES, HIDE_METADATA, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, POPUP_MARKER_COOKIE, TWO_FACTOR_ERROR_CODES, USERNAME_ERROR_CODES, admin, anonymous, auth0, bearer, captcha, createAccessControl, createJwk, customSession, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, generateExportedKeyPair, genericOAuth, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, isPasswordCompromised, jwt, keycloak, lastLoginMethod, line, magicLink, microsoftEntraId, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, signJWT, siwe, slack, testUtils, toExpJWT, twoFactor, twoFactorClient, username, verifyJWT, yandex };
@@ -13,6 +13,7 @@ const lastLoginMethod = (userConfig) => {
13
13
  if (path.includes("siwe")) return "siwe";
14
14
  if (path.includes("/passkey/verify-authentication")) return "passkey";
15
15
  if (path.startsWith("/magic-link/verify")) return "magic-link";
16
+ if (path === "/sign-in/email-otp") return "email-otp";
16
17
  return null;
17
18
  };
18
19
  const getResolveContext = (ctx) => {
@@ -58,6 +58,10 @@ declare const oAuthProxy: <O extends OAuthProxyOptions>(opts?: O) => {
58
58
  version: string;
59
59
  options: NoInfer<O>;
60
60
  endpoints: {
61
+ /**
62
+ * @deprecated OAuth proxy callbacks now use `/callback/:id/oauth-proxy`.
63
+ * This endpoint will be removed in the next minor release.
64
+ */
61
65
  oAuthProxy: _$better_call0.StrictEndpoint<"/oauth-proxy-callback", {
62
66
  method: "GET";
63
67
  operationId: string;
@@ -69,6 +73,7 @@ declare const oAuthProxy: <O extends OAuthProxyOptions>(opts?: O) => {
69
73
  metadata: {
70
74
  openapi: {
71
75
  operationId: string;
76
+ deprecated: boolean;
72
77
  description: string;
73
78
  parameters: ({
74
79
  in: "query";
@@ -97,6 +102,18 @@ declare const oAuthProxy: <O extends OAuthProxyOptions>(opts?: O) => {
97
102
  };
98
103
  };
99
104
  }, never>;
105
+ oAuthProxyCompletion: _$better_call0.StrictEndpoint<"/callback/:id/oauth-proxy", {
106
+ method: "GET";
107
+ operationId: string;
108
+ query: z.ZodObject<{
109
+ callbackURL: z.ZodString;
110
+ profile: z.ZodOptional<z.ZodString>;
111
+ }, z.core.$strip>;
112
+ use: _$better_call0.Middleware<_$better_call0.MiddlewareOptions, (inputContext: _$better_call0.MiddlewareInputContext<_$better_call0.MiddlewareOptions>) => Promise<void>>[];
113
+ metadata: {
114
+ scope: "http";
115
+ };
116
+ }, never>;
100
117
  };
101
118
  hooks: {
102
119
  before: {
@@ -8,22 +8,50 @@ import { resolveOAuthAccountKey, toOAuthProfileRecord } from "../../oauth2/accou
8
8
  import { redirectOnError } from "../../oauth2/errors.mjs";
9
9
  import { getOAuthCallbackPath } from "../../oauth2/utils.mjs";
10
10
  import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
11
+ import { setOAuthState } from "../../api/state/oauth.mjs";
11
12
  import { parseGenericState } from "../../state.mjs";
12
13
  import { PACKAGE_VERSION } from "../../version.mjs";
13
14
  import { parseJSON } from "../../client/parser.mjs";
14
15
  import { checkSkipProxy, resolveCurrentURL, stripTrailingSlash } from "./utils.mjs";
16
+ import { accountSchema, userSchema } from "@better-auth/core/db";
15
17
  import { safeJSONParse } from "@better-auth/core/utils/json";
16
18
  import { defu } from "defu";
17
19
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
18
20
  import * as z from "zod";
19
21
  //#region src/plugins/oauth-proxy/index.ts
20
- const consumeOAuthProxyState = async (ctx, state) => {
22
+ /**
23
+ * Passthrough payload containing OAuth profile data.
24
+ * Used to transfer OAuth credentials from production to preview
25
+ * without creating user/session on production.
26
+ * @internal
27
+ */
28
+ const passthroughPayloadSchema = z.looseObject({
29
+ userInfo: z.looseObject(userSchema.omit({
30
+ createdAt: true,
31
+ updatedAt: true
32
+ }).shape),
33
+ account: z.looseObject(accountSchema.omit({
34
+ id: true,
35
+ userId: true,
36
+ createdAt: true,
37
+ updatedAt: true
38
+ }).shape),
39
+ profile: z.record(z.string(), z.unknown()).optional(),
40
+ state: z.string().min(1),
41
+ callbackURL: z.string().min(1),
42
+ newUserURL: z.string().optional(),
43
+ errorURL: z.string().optional(),
44
+ disableSignUp: z.boolean().optional(),
45
+ timestamp: z.number()
46
+ });
47
+ const restoreOAuthProxyState = async (ctx, state) => {
21
48
  try {
22
- await parseGenericState(ctx, state, { skipStateCookieCheck: true });
23
- return true;
49
+ const stateData = await parseGenericState(ctx, state, { skipStateCookieCheck: true });
50
+ await setOAuthState(stateData);
51
+ return stateData;
24
52
  } catch (e) {
25
53
  ctx.context.logger.warn("OAuth proxy state missing or invalid", e);
26
- return false;
54
+ return null;
27
55
  }
28
56
  };
29
57
  const oauthProxyQuerySchema = z.object({
@@ -38,104 +66,122 @@ const oauthCallbackQuerySchema = z.object({
38
66
  const oAuthProxy = (opts) => {
39
67
  const maxAge = opts?.maxAge ?? 60;
40
68
  const getEncryptionKey = (ctx) => opts?.secret ?? ctx.context.secretConfig;
69
+ const oauthProxyCompletion = createAuthEndpoint("/callback/:id/oauth-proxy", {
70
+ method: "GET",
71
+ operationId: "oauthProxyCompletion",
72
+ query: oauthProxyQuerySchema,
73
+ use: [originCheck((ctx) => ctx.query.callbackURL)],
74
+ metadata: { scope: "http" }
75
+ }, async (ctx) => {
76
+ const baseURLStr = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : getOrigin(ctx.context.baseURL) || "";
77
+ const defaultErrorURL = ctx.context.options.onAPIError?.errorURL || `${stripTrailingSlash(baseURLStr)}/api/auth/error`;
78
+ const encryptedProfile = ctx.query.profile;
79
+ if (!encryptedProfile) {
80
+ ctx.context.logger.error("OAuth proxy callback missing profile data");
81
+ throw redirectOnError(ctx, defaultErrorURL, "missing_profile");
82
+ }
83
+ let decryptedPayload;
84
+ try {
85
+ decryptedPayload = await symmetricDecrypt({
86
+ key: getEncryptionKey(ctx),
87
+ data: encryptedProfile
88
+ });
89
+ } catch (e) {
90
+ ctx.context.logger.error("Failed to decrypt OAuth proxy profile", e);
91
+ throw redirectOnError(ctx, defaultErrorURL, "invalid_profile");
92
+ }
93
+ let payload;
94
+ try {
95
+ payload = passthroughPayloadSchema.parse(parseJSON(decryptedPayload));
96
+ } catch (e) {
97
+ ctx.context.logger.error("Failed to parse OAuth proxy payload", e);
98
+ throw redirectOnError(ctx, defaultErrorURL, "invalid_payload");
99
+ }
100
+ const errorURL = payload.errorURL || defaultErrorURL;
101
+ if (ctx.path?.startsWith("/callback/") && ctx.params.id !== payload.account.providerId) {
102
+ ctx.context.logger.warn("OAuth proxy callback provider mismatch");
103
+ throw redirectOnError(ctx, errorURL, "provider_mismatch");
104
+ }
105
+ const age = (Date.now() - payload.timestamp) / 1e3;
106
+ if (age > maxAge || age < -10) {
107
+ ctx.context.logger.error(`OAuth proxy payload expired or invalid (age: ${age}s, maxAge: ${maxAge}s)`);
108
+ throw redirectOnError(ctx, errorURL, "payload_expired");
109
+ }
110
+ if (!await restoreOAuthProxyState(ctx, payload.state)) throw redirectOnError(ctx, errorURL, "state_mismatch");
111
+ let result;
112
+ try {
113
+ result = await handleOAuthUserInfo(ctx, {
114
+ userInfo: payload.userInfo,
115
+ account: payload.account,
116
+ callbackURL: payload.callbackURL,
117
+ disableSignUp: payload.disableSignUp,
118
+ source: {
119
+ method: "oauth",
120
+ oauth: {
121
+ providerId: payload.account.providerId,
122
+ profile: payload.profile
123
+ }
124
+ }
125
+ });
126
+ } catch (e) {
127
+ if (isAPIError(e) && e.body?.code) throw redirectOnError(ctx, errorURL, e.body.code, e.body.message);
128
+ throw e;
129
+ }
130
+ if (result.error) {
131
+ ctx.context.logger.error("OAuth proxy callback error", result.error);
132
+ throw redirectOnError(ctx, errorURL, result.error.split(" ").join("_"));
133
+ }
134
+ if (!result.data) {
135
+ ctx.context.logger.error("OAuth proxy callback missing session data");
136
+ throw redirectOnError(ctx, errorURL, "user_creation_failed");
137
+ }
138
+ await setSessionCookie(ctx, result.data);
139
+ const finalURL = result.isRegister ? payload.newUserURL || payload.callbackURL : payload.callbackURL;
140
+ throw ctx.redirect(finalURL);
141
+ });
41
142
  return {
42
143
  id: "oauth-proxy",
43
144
  version: PACKAGE_VERSION,
44
145
  options: opts,
45
- endpoints: { oAuthProxy: createAuthEndpoint("/oauth-proxy-callback", {
46
- method: "GET",
47
- operationId: "oauthProxyCallback",
48
- query: oauthProxyQuerySchema,
49
- use: [originCheck((ctx) => ctx.query.callbackURL)],
50
- metadata: { openapi: {
146
+ endpoints: {
147
+ /**
148
+ * @deprecated OAuth proxy callbacks now use `/callback/:id/oauth-proxy`.
149
+ * This endpoint will be removed in the next minor release.
150
+ */
151
+ oAuthProxy: createAuthEndpoint("/oauth-proxy-callback", {
152
+ method: "GET",
51
153
  operationId: "oauthProxyCallback",
52
- description: "OAuth Proxy Callback",
53
- parameters: [{
54
- in: "query",
55
- name: "callbackURL",
56
- required: true,
57
- description: "The URL to redirect to after the proxy"
58
- }, {
59
- in: "query",
60
- name: "profile",
61
- required: false,
62
- description: "Encrypted OAuth profile data"
63
- }],
64
- responses: { 302: {
65
- description: "Redirect",
66
- headers: { Location: {
67
- description: "The URL to redirect to",
68
- schema: { type: "string" }
154
+ query: oauthProxyQuerySchema,
155
+ use: [originCheck((ctx) => ctx.query.callbackURL)],
156
+ metadata: { openapi: {
157
+ operationId: "oauthProxyCallback",
158
+ deprecated: true,
159
+ description: "OAuth Proxy Callback",
160
+ parameters: [{
161
+ in: "query",
162
+ name: "callbackURL",
163
+ required: true,
164
+ description: "The URL to redirect to after the proxy"
165
+ }, {
166
+ in: "query",
167
+ name: "profile",
168
+ required: false,
169
+ description: "Encrypted OAuth profile data"
170
+ }],
171
+ responses: { 302: {
172
+ description: "Redirect",
173
+ headers: { Location: {
174
+ description: "The URL to redirect to",
175
+ schema: { type: "string" }
176
+ } }
69
177
  } }
70
178
  } }
71
- } }
72
- }, async (ctx) => {
73
- const baseURLStr = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : getOrigin(ctx.context.baseURL) || "";
74
- const defaultErrorURL = ctx.context.options.onAPIError?.errorURL || `${stripTrailingSlash(baseURLStr)}/api/auth/error`;
75
- const encryptedProfile = ctx.query.profile;
76
- if (!encryptedProfile) {
77
- ctx.context.logger.error("OAuth proxy callback missing profile data");
78
- throw redirectOnError(ctx, defaultErrorURL, "missing_profile");
79
- }
80
- let decryptedPayload;
81
- try {
82
- decryptedPayload = await symmetricDecrypt({
83
- key: getEncryptionKey(ctx),
84
- data: encryptedProfile
85
- });
86
- } catch (e) {
87
- ctx.context.logger.error("Failed to decrypt OAuth proxy profile", e);
88
- throw redirectOnError(ctx, defaultErrorURL, "invalid_profile");
89
- }
90
- let payload;
91
- try {
92
- payload = parseJSON(decryptedPayload);
93
- } catch (e) {
94
- ctx.context.logger.error("Failed to parse OAuth proxy payload", e);
95
- throw redirectOnError(ctx, defaultErrorURL, "invalid_payload");
96
- }
97
- if (typeof payload.timestamp !== "number" || !payload.userInfo || !payload.account || !payload.state || !payload.callbackURL) {
98
- ctx.context.logger.error("Failed to parse OAuth proxy payload");
99
- throw redirectOnError(ctx, defaultErrorURL, "invalid_payload");
100
- }
101
- const errorURL = payload.errorURL || defaultErrorURL;
102
- const age = (Date.now() - payload.timestamp) / 1e3;
103
- if (age > maxAge || age < -10) {
104
- ctx.context.logger.error(`OAuth proxy payload expired or invalid (age: ${age}s, maxAge: ${maxAge}s)`);
105
- throw redirectOnError(ctx, errorURL, "payload_expired");
106
- }
107
- if (!await consumeOAuthProxyState(ctx, payload.state)) throw redirectOnError(ctx, errorURL, "state_mismatch");
108
- let result;
109
- try {
110
- result = await handleOAuthUserInfo(ctx, {
111
- userInfo: payload.userInfo,
112
- account: payload.account,
113
- callbackURL: payload.callbackURL,
114
- disableSignUp: payload.disableSignUp,
115
- source: {
116
- method: "oauth",
117
- oauth: {
118
- providerId: payload.account.providerId,
119
- profile: payload.profile
120
- }
121
- }
122
- });
123
- } catch (e) {
124
- if (isAPIError(e) && e.body?.code) throw redirectOnError(ctx, errorURL, e.body.code, e.body.message);
125
- throw e;
126
- }
127
- if (result.error) {
128
- ctx.context.logger.error("OAuth proxy callback error", result.error);
129
- throw redirectOnError(ctx, errorURL, result.error.split(" ").join("_"));
130
- }
131
- if (!result.data) {
132
- ctx.context.logger.error("OAuth proxy callback missing session data");
133
- throw redirectOnError(ctx, errorURL, "user_creation_failed");
134
- }
135
- await setSessionCookie(ctx, result.data);
136
- const finalURL = result.isRegister ? payload.newUserURL || payload.callbackURL : payload.callbackURL;
137
- throw ctx.redirect(finalURL);
138
- }) },
179
+ }, async (ctx) => oauthProxyCompletion({
180
+ ...ctx,
181
+ params: { id: "oauth-proxy" }
182
+ })),
183
+ oAuthProxyCompletion: oauthProxyCompletion
184
+ },
139
185
  hooks: {
140
186
  before: [{
141
187
  matcher(context) {
@@ -143,6 +189,8 @@ const oAuthProxy = (opts) => {
143
189
  },
144
190
  handler: createAuthMiddleware(async (ctx) => {
145
191
  if (checkSkipProxy(ctx, opts)) return;
192
+ const providerId = ctx.body?.provider;
193
+ if (!providerId) return;
146
194
  const currentURL = resolveCurrentURL(ctx, opts);
147
195
  const productionURL = opts?.productionURL;
148
196
  const originalCallbackURL = ctx.body?.callbackURL || ctx.context.baseURL;
@@ -150,8 +198,7 @@ const oAuthProxy = (opts) => {
150
198
  const productionBaseURL = `${stripTrailingSlash(productionURL)}${ctx.context.options.basePath || "/api/auth"}`;
151
199
  ctx.context.baseURL = productionBaseURL;
152
200
  }
153
- const newCallbackURL = `${stripTrailingSlash(currentURL.origin)}${ctx.context.options.basePath || "/api/auth"}/oauth-proxy-callback?callbackURL=${encodeURIComponent(originalCallbackURL)}`;
154
- if (!ctx.body) return;
201
+ const newCallbackURL = `${stripTrailingSlash(currentURL.origin)}${ctx.context.options.basePath || "/api/auth"}/callback/${providerId}/oauth-proxy?callbackURL=${encodeURIComponent(originalCallbackURL)}`;
155
202
  ctx.body.callbackURL = newCallbackURL;
156
203
  })
157
204
  }, {
@@ -261,7 +308,6 @@ const oAuthProxy = (opts) => {
261
308
  },
262
309
  profile: providerProfile,
263
310
  account: {
264
- providerId: provider.id,
265
311
  ...accountKey,
266
312
  accessToken: tokens.accessToken,
267
313
  refreshToken: tokens.refreshToken,
@@ -342,7 +388,7 @@ const oAuthProxy = (opts) => {
342
388
  },
343
389
  handler: createAuthMiddleware(async (ctx) => {
344
390
  const location = ctx.context.responseHeaders?.get("location");
345
- if (!location?.includes("/oauth-proxy-callback?callbackURL") || !location.startsWith("http")) return;
391
+ if (!location?.includes("/oauth-proxy?callbackURL") && !location?.includes("/oauth-proxy-callback?callbackURL") || !location.startsWith("http")) return;
346
392
  const productionOrigin = getOrigin(opts?.productionURL || (typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : void 0) || ctx.context.baseURL);
347
393
  const locationURL = new URL(location);
348
394
  if (locationURL.origin === productionOrigin) {
@@ -71,7 +71,6 @@ const oneTap = (options) => ({
71
71
  },
72
72
  account: {
73
73
  providerId: "google",
74
- issuer: "https://accounts.google.com",
75
74
  accountId: sub,
76
75
  idToken,
77
76
  scope: "openid,profile,email"
@@ -113,6 +113,20 @@ function schemaAcceptsUndefined(zodType) {
113
113
  }
114
114
  return false;
115
115
  }
116
+ /**
117
+ * Resolve input optionality exactly as Zod's JSON Schema emitter does.
118
+ *
119
+ * @see https://github.com/colinhacks/zod/blob/v4.5.4/packages/zod/src/v4/core/json-schema-processors.ts#L294-L308
120
+ */
121
+ function getZodInputOptionality(zodType) {
122
+ const def = zodType._zod.def;
123
+ if (def.type === "pipe") {
124
+ const pipeDef = def;
125
+ if (pipeDef.in._zod.traits.has("$ZodTransform")) return getZodInputOptionality(pipeDef.out);
126
+ }
127
+ if (def.type === "catch") return getZodInputOptionality(def.innerType);
128
+ return zodType._zod.optin;
129
+ }
116
130
  function isUndefinedOnlySchema(zodType) {
117
131
  return zodType instanceof z.ZodUndefined || zodType instanceof z.ZodVoid;
118
132
  }
@@ -252,7 +266,7 @@ function toOpenApiSchema(zodType) {
252
266
  Object.entries(shape).forEach(([key, value]) => {
253
267
  if (value instanceof z.ZodType) {
254
268
  properties[key] = toOpenApiSchema(value);
255
- if (!schemaAcceptsUndefined(value)) required.push(key);
269
+ if (getZodInputOptionality(value) === void 0) required.push(key);
256
270
  }
257
271
  });
258
272
  return withDescription({
@@ -1,5 +1,4 @@
1
1
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
2
- import "../../utils/index.mjs";
3
2
  import { APIError } from "../../api/index.mjs";
4
3
  import { PACKAGE_VERSION } from "../../version.mjs";
5
4
  import { generator } from "./generator.mjs";
@@ -3,6 +3,7 @@ import { defaultRoles } from "./access/statement.mjs";
3
3
  import { cacheAllRoles, hasPermissionFn } from "./permission.mjs";
4
4
  import * as z from "zod";
5
5
  //#region src/plugins/organization/has-permission.ts
6
+ const rolePermissionsSchema = z.record(z.string(), z.array(z.string()));
6
7
  const hasPermission = async (input, ctx) => {
7
8
  let acRoles = { ...input.options.roles || defaultRoles };
8
9
  if (ctx && input.organizationId && input.options.dynamicAccessControl?.enabled && input.options.ac && !input.useMemoryCache) {
@@ -14,9 +15,10 @@ const hasPermission = async (input, ctx) => {
14
15
  }]
15
16
  });
16
17
  for (const { role, permission: permissionsString } of roles) {
17
- const result = z.record(z.string(), z.array(z.string())).safeParse(JSON.parse(permissionsString));
18
+ const permissions = JSON.parse(permissionsString);
19
+ const result = rolePermissionsSchema.safeParse(permissions);
18
20
  if (!result.success) {
19
- ctx.context.logger.error("[hasPermission] Invalid permissions for role " + role, { permissions: JSON.parse(permissionsString) });
21
+ ctx.context.logger.error("[hasPermission] Invalid permissions for role " + role, { permissions });
20
22
  throw new APIError("INTERNAL_SERVER_ERROR", { message: "Invalid permissions for role " + role });
21
23
  }
22
24
  const merged = { ...acRoles[role]?.statements };
@@ -4,9 +4,7 @@ import { generateRandomString } from "../../crypto/random.mjs";
4
4
  import { setSessionCookie } from "../../cookies/index.mjs";
5
5
  import { getSessionFromCtx } from "../../api/routes/session.mjs";
6
6
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
7
- import "../../utils/index.mjs";
8
7
  import { PHONE_NUMBER_ERROR_CODES } from "./error-codes.mjs";
9
- import { createLocalAccountIssuer } from "@better-auth/core/db";
10
8
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
11
9
  import { createAuthEndpoint } from "@better-auth/core/api";
12
10
  import * as z from "zod";
@@ -476,7 +474,6 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
476
474
  if (!await ctx.context.internalAdapter.findCredentialAccount(user.id)) await ctx.context.internalAdapter.createAccount({
477
475
  userId: user.id,
478
476
  providerId: "credential",
479
- issuer: createLocalAccountIssuer("credential"),
480
477
  accountId: user.id,
481
478
  password: hashedPassword
482
479
  });