Module: BetterAuth::Plugins

Defined in:
lib/better_auth/plugins.rb,
lib/better_auth/plugins/dub.rb,
lib/better_auth/plugins/jwt.rb,
lib/better_auth/plugins/sso.rb,
lib/better_auth/plugins/expo.rb,
lib/better_auth/plugins/i18n.rb,
lib/better_auth/plugins/scim.rb,
lib/better_auth/plugins/siwe.rb,
lib/better_auth/plugin_loader.rb,
lib/better_auth/plugins/admin.rb,
lib/better_auth/plugins/access.rb,
lib/better_auth/plugins/bearer.rb,
lib/better_auth/plugins/stripe.rb,
lib/better_auth/plugins/api_key.rb,
lib/better_auth/plugins/captcha.rb,
lib/better_auth/plugins/one_tap.rb,
lib/better_auth/plugins/passkey.rb,
lib/better_auth/plugins/open_api.rb,
lib/better_auth/plugins/username.rb,
lib/better_auth/plugins/anonymous.rb,
lib/better_auth/plugins/email_otp.rb,
lib/better_auth/plugins/magic_link.rb,
lib/better_auth/plugins/two_factor.rb,
lib/better_auth/plugins/oauth_popup.rb,
lib/better_auth/plugins/oauth_proxy.rb,
lib/better_auth/plugins/admin/schema.rb,
lib/better_auth/plugins/organization.rb,
lib/better_auth/plugins/phone_number.rb,
lib/better_auth/plugins/generic_oauth.rb,
lib/better_auth/plugins/multi_session.rb,
lib/better_auth/plugins/custom_session.rb,
lib/better_auth/plugins/oauth_protocol.rb,
lib/better_auth/plugins/oauth_provider.rb,
lib/better_auth/plugins/one_time_token.rb,
lib/better_auth/plugins/additional_fields.rb,
lib/better_auth/plugins/have_i_been_pwned.rb,
lib/better_auth/plugins/last_login_method.rb,
lib/better_auth/plugins/organization/schema.rb,
lib/better_auth/plugins/device_authorization.rb

Defined Under Namespace

Modules: AdminSchema, JWT, OAuthProtocol, OrganizationSchema Classes: AccessControl, Role

Constant Summary collapse

PLUGIN_FACTORY_LOADERS =
{
  create_access_control: :access,
  createAccessControl: :access
}.freeze
REMOVED_PLUGIN_FACTORIES =
{
  oidc_provider: "BetterAuth::Plugins.oauth_provider (require \"better_auth/oauth_provider\")",
  mcp: "BetterAuth::Plugins.oauth_provider (require \"better_auth/oauth_provider\")"
}.freeze
SIWE_WALLET_PATTERN =
/\A0[xX][a-fA-F0-9]{40}\z/
SIWE_EMAIL_PATTERN =
/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
SIWE_HEADER_PATTERN =
/\A(?:([a-zA-Z][a-zA-Z0-9+.-]*):\/\/)?(\S+) wants you to sign in with your Ethereum account:\z/
SIWE_FIELD_PATTERN =
/\A([A-Za-z ]+): (.*)\z/
PLUGIN_FILES =

Maps BetterAuth::Plugins factory methods and nested modules to source files.

{
  additional_fields: "plugins/additional_fields",
  custom_session: "plugins/custom_session",
  multi_session: "plugins/multi_session",
  last_login_method: "plugins/last_login_method",
  bearer: "plugins/bearer",
  jwt: "plugins/jwt",
  open_api: "plugins/open_api",
  access: "plugins/access",
  username: "plugins/username",
  anonymous: "plugins/anonymous",
  magic_link: "plugins/magic_link",
  email_otp: "plugins/email_otp",
  phone_number: "plugins/phone_number",
  one_time_token: "plugins/one_time_token",
  one_tap: "plugins/one_tap",
  siwe: "plugins/siwe",
  generic_oauth: "plugins/generic_oauth",
  oauth_popup: "plugins/oauth_popup",
  dub: "plugins/dub",
  oauth_proxy: "plugins/oauth_proxy",
  passkey: "plugins/passkey",
  organization_schema: "plugins/organization/schema",
  organization: "plugins/organization",
  admin_schema: "plugins/admin/schema",
  admin: "plugins/admin",
  oauth_protocol: "plugins/oauth_protocol",
  oauth_provider: "plugins/oauth_provider",
  device_authorization: "plugins/device_authorization",
  two_factor: "plugins/two_factor",
  captcha: "plugins/captcha",
  have_i_been_pwned: "plugins/have_i_been_pwned",
  api_key: "plugins/api_key",
  sso: "plugins/sso",
  scim: "plugins/scim",
  stripe: "plugins/stripe",
  expo: "plugins/expo",
  i18n: "plugins/i18n"
}.freeze
PLUGIN_DEPENDENCIES =
{
  organization: %i[organization_schema access],
  admin_schema: %i[organization_schema],
  admin: %i[admin_schema access],
  device_authorization: %i[oauth_protocol]
}.freeze
BOOT_PLUGINS =

Core route metadata helpers; loaded at boot because base endpoints reference OpenAPI.

%i[open_api].freeze
PLUGIN_ID_TO_LOADER =
{
  "additional-fields" => :additional_fields,
  "custom-session" => :custom_session,
  "multi-session" => :multi_session,
  "last-login-method" => :last_login_method,
  "bearer" => :bearer,
  "jwt" => :jwt,
  "open-api" => :open_api,
  "username" => :username,
  "anonymous" => :anonymous,
  "magic-link" => :magic_link,
  "email-otp" => :email_otp,
  "phone-number" => :phone_number,
  "one-time-token" => :one_time_token,
  "one-tap" => :one_tap,
  "siwe" => :siwe,
  "generic-oauth" => :generic_oauth,
  "oauth-popup" => :oauth_popup,
  "dub" => :dub,
  "oauth-proxy" => :oauth_proxy,
  "passkey" => :passkey,
  "organization" => :organization,
  "admin" => :admin,
  "oauth-provider" => :oauth_provider,
  "device-authorization" => :device_authorization,
  "two-factor" => :two_factor,
  "captcha" => :captcha,
  "have-i-been-pwned" => :have_i_been_pwned,
  "api-key" => :api_key,
  "sso" => :sso,
  "scim" => :scim,
  "stripe" => :stripe,
  "expo" => :expo,
  "i18n" => :i18n
}.freeze
NESTED_MODULE_LOADERS =
{
  OAuthProtocol: :oauth_protocol,
  JWT: :jwt,
  OrganizationSchema: :organization_schema,
  AdminSchema: :admin_schema
}.freeze
LAZY_PLUGIN_METHODS =
PLUGIN_FILES.keys.freeze
EXTERNAL_PLUGIN_IMPLEMENTATIONS =
{
  sso: :SSO_PLUGIN_IMPLEMENTATION,
  scim: :SCIM_PLUGIN_IMPLEMENTATION,
  api_key: :API_KEY_PLUGIN_IMPLEMENTATION,
  passkey: :PASSKEY_PLUGIN_IMPLEMENTATION,
  stripe: :STRIPE_PLUGIN_IMPLEMENTATION,
  oauth_provider: :OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION
}.freeze
ADMIN_ERROR_CODES =
{
  "FAILED_TO_CREATE_USER" => "Failed to create user",
  "USER_ALREADY_EXISTS" => "User already exists.",
  "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL" => "User already exists. Use another email.",
  "YOU_CANNOT_BAN_YOURSELF" => "You cannot ban yourself",
  "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE" => "You are not allowed to change users role",
  "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS" => "You are not allowed to create users",
  "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS" => "You are not allowed to list users",
  "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS" => "You are not allowed to list users sessions",
  "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS" => "You are not allowed to ban users",
  "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS" => "You are not allowed to impersonate users",
  "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS" => "You are not allowed to revoke users sessions",
  "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS" => "You are not allowed to delete users",
  "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD" => "You are not allowed to set users password",
  "BANNED_USER" => "You have been banned from this application",
  "YOU_ARE_NOT_ALLOWED_TO_GET_USER" => "You are not allowed to get user",
  "NO_DATA_TO_UPDATE" => "No data to update",
  "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS" => "You are not allowed to update users",
  "YOU_CANNOT_REMOVE_YOURSELF" => "You cannot remove yourself",
  "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE" => "You are not allowed to set a non-existent role value",
  "YOU_CANNOT_IMPERSONATE_ADMINS" => "You cannot impersonate admins",
  "INVALID_ROLE_TYPE" => "Invalid role type",
  "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL" => "You are not allowed to update users email",
  "PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER" => "Password cannot be updated through update-user. Use the set-user-password endpoint instead"
}.freeze
ADMIN_DEFAULT_STATEMENTS =
{
  user: ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"],
  session: ["list", "revoke", "delete"]
}.freeze
ADMIN_DEFAULT_ROLE_STATEMENTS =
{
  user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "set-email", "get", "update"],
  session: ["list", "revoke", "delete"]
}.freeze
BEARER_SCHEME =
"bearer "
CAPTCHA_EXTERNAL_ERROR_CODES =
{
  "VERIFICATION_FAILED" => "Captcha verification failed",
  "MISSING_RESPONSE" => "Missing CAPTCHA response",
  "UNKNOWN_ERROR" => "Something went wrong"
}.freeze
CAPTCHA_INTERNAL_ERROR_CODES =
{
  "MISSING_SECRET_KEY" => "Missing secret key",
  "SERVICE_UNAVAILABLE" => "CAPTCHA service unavailable"
}.freeze
CAPTCHA_DEFAULT_ENDPOINTS =
[
  "/sign-up/email",
  "/sign-in/email",
  "/request-password-reset"
].freeze
CAPTCHA_VERIFY_TIMEOUT_SECONDS =
10
CAPTCHA_SITE_VERIFY_URLS =
{
  "cloudflare-turnstile" => "https://challenges.cloudflare.com/turnstile/v0/siteverify",
  "google-recaptcha" => "https://www.google.com/recaptcha/api/siteverify",
  "hcaptcha" => "https://api.hcaptcha.com/siteverify",
  "captchafox" => "https://api.captchafox.com/siteverify"
}.freeze
USERNAME_ERROR_CODES =
{
  "INVALID_USERNAME_OR_PASSWORD" => "Invalid username or password",
  "EMAIL_NOT_VERIFIED" => "Email not verified",
  "UNEXPECTED_ERROR" => "Unexpected error",
  "USERNAME_IS_ALREADY_TAKEN" => "Username is already taken. Please try another.",
  "USERNAME_TOO_SHORT" => "Username is too short",
  "USERNAME_TOO_LONG" => "Username is too long",
  "INVALID_USERNAME" => "Username is invalid",
  "INVALID_DISPLAY_USERNAME" => "Display username is invalid"
}.freeze
ANONYMOUS_ERROR_CODES =
{
  "INVALID_EMAIL_FORMAT" => "Email was not generated in a valid format",
  "FAILED_TO_CREATE_USER" => "Failed to create user",
  "COULD_NOT_CREATE_SESSION" => "Could not create session",
  "ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY" => "Anonymous users cannot sign in again anonymously",
  "FAILED_TO_DELETE_ANONYMOUS_USER" => "Failed to delete anonymous user",
  "USER_IS_NOT_ANONYMOUS" => "User is not anonymous",
  "DELETE_ANONYMOUS_USER_DISABLED" => "Deleting anonymous users is disabled"
}.freeze
EMAIL_OTP_ERROR_CODES =
{
  "OTP_EXPIRED" => "OTP expired",
  "INVALID_OTP" => "Invalid OTP",
  "TOO_MANY_ATTEMPTS" => "Too many attempts"
}.freeze
TWO_FACTOR_ERROR_CODES =
{
  "OTP_NOT_ENABLED" => "OTP not enabled",
  "OTP_HAS_EXPIRED" => "OTP has expired",
  "TOTP_NOT_ENABLED" => "TOTP not enabled",
  "TWO_FACTOR_NOT_ENABLED" => "Two factor isn't enabled",
  "BACKUP_CODES_NOT_ENABLED" => "Backup codes aren't enabled",
  "INVALID_BACKUP_CODE" => "Invalid backup code",
  "INVALID_CODE" => "Invalid code",
  "TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE" => "Too many attempts. Please request a new code.",
  "ACCOUNT_TEMPORARILY_LOCKED" => "Too many failed verification attempts. Your account is temporarily locked. Please try again later.",
  "INVALID_TWO_FACTOR_COOKIE" => "Invalid two factor cookie"
}.freeze
"two_factor"
"trust_device"
30 * 24 * 60 * 60
10 * 60
TWO_FACTOR_MODEL =
"twoFactor"
DEFAULT_TWO_FACTOR_ALLOWED_ATTEMPTS =
5
DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS =
10
DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS =
15 * 60
BASE32_ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
OAUTH_POPUP_MESSAGE_TYPE =
"better-auth:oauth-popup"
OAUTH_POPUP_DATA_ELEMENT_ID =
"better-auth-oauth-popup"
"oauth_popup"
OAUTH_POPUP_ERROR_CODES =
{
  "POPUP_SIGN_IN_FAILED" => "Popup sign-in failed",
  "POPUP_BLOCKED" => "Sign-in popup was blocked by the browser",
  "POPUP_CLOSED" => "Sign-in popup was closed before completing",
  "POPUP_TIMEOUT" => "Sign-in popup timed out"
}.freeze
OAUTH_POPUP_INTERNAL_STATE_KEYS =
OAuthState::INTERNAL_KEYS
OAUTH_POPUP_COMPLETE_SCRIPT =
[
  "(function () {",
  "\tvar el = document.getElementById(\"better-auth-oauth-popup\");",
  "\tif (!el) return;",
  "\tvar payload;",
  "\ttry {",
  "\t\tpayload = JSON.parse(el.textContent || \"\");",
  "\t} catch (e) {",
  "\t\treturn;",
  "\t}",
  "\tvar target = window.opener || window.parent;",
  "\tif (target && target !== window) {",
  "\t\ttry {",
  "\t\t\ttarget.postMessage(",
  "\t\t\t\t{",
  "\t\t\t\t\ttype: payload.type,",
  "\t\t\t\t\tnonce: payload.nonce,",
  "\t\t\t\t\ttoken: payload.token,",
  "\t\t\t\t\tredirectTo: payload.redirectTo,",
  "\t\t\t\t\terror: payload.error,",
  "\t\t\t\t},",
  "\t\t\t\tpayload.targetOrigin,",
  "\t\t\t);",
  "\t\t} catch (e) {}",
  "\t}",
  "\twindow.close();",
  "})();",
  ""
].join("\n")
OAUTH_POPUP_SCRIPT_CSP_HASH =
"sha256-tIo2K8VBC9SnhvdZ+9GsGkQoZm+jm/JcxL+d+i8b8KQ="
ORGANIZATION_ERROR_CODES =
{
  "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION" => "You are not allowed to create a new organization",
  "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS" => "You have reached the maximum number of organizations",
  "ORGANIZATION_ALREADY_EXISTS" => "Organization already exists",
  "ORGANIZATION_SLUG_ALREADY_TAKEN" => "Organization slug already taken",
  "ORGANIZATION_NOT_FOUND" => "Organization not found",
  "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION" => "User is not a member of the organization",
  "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION" => "You are not allowed to update this organization",
  "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION" => "You are not allowed to delete this organization",
  "ORGANIZATION_DELETION_DISABLED" => "Organization deletion is disabled",
  "NO_ACTIVE_ORGANIZATION" => "No active organization",
  "USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION" => "User is already a member of this organization",
  "MEMBER_NOT_FOUND" => "Member not found",
  "ROLE_NOT_FOUND" => "Role not found",
  "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM" => "You are not allowed to create a new team",
  "TEAM_ALREADY_EXISTS" => "Team already exists",
  "TEAM_NOT_FOUND" => "Team not found",
  "INVALID_TEAM_ID" => "Team IDs cannot contain commas",
  "YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER" => "You cannot leave the organization as the only owner",
  "YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER" => "You cannot leave the organization without an owner",
  "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER" => "You are not allowed to delete this member",
  "YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION" => "You are not allowed to invite users to this organization",
  "USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION" => "User is already invited to this organization",
  "INVITATION_NOT_FOUND" => "Invitation not found",
  "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION" => "You are not the recipient of the invitation",
  "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION" => "Email verification required before accepting or rejecting invitation",
  "EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION" => "Email verification required to view or list invitations for the session email",
  "YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION" => "You are not allowed to cancel this invitation",
  "INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION" => "Inviter is no longer a member of the organization",
  "YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE" => "You are not allowed to invite a user with this role",
  "FAILED_TO_RETRIEVE_INVITATION" => "Failed to retrieve invitation",
  "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS" => "You have reached the maximum number of teams",
  "UNABLE_TO_REMOVE_LAST_TEAM" => "Unable to remove last team",
  "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER" => "You are not allowed to update this member",
  "ORGANIZATION_MEMBERSHIP_LIMIT_REACHED" => "Organization membership limit reached",
  "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION" => "You are not allowed to create teams in this organization",
  "YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION" => "You are not allowed to delete teams in this organization",
  "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM" => "You are not allowed to update this team",
  "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM" => "You are not allowed to delete this team",
  "INVITATION_LIMIT_REACHED" => "Invitation limit reached",
  "TEAM_MEMBER_LIMIT_REACHED" => "Team member limit reached",
  "USER_IS_NOT_A_MEMBER_OF_THE_TEAM" => "User is not a member of the team",
  "YOU_CAN_NOT_ACCESS_THE_MEMBERS_OF_THIS_TEAM" => "You are not allowed to list the members of this team",
  "YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM" => "You do not have an active team",
  "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER" => "You are not allowed to create a new member",
  "YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER" => "You are not allowed to remove a team member",
  "YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION" => "You are not allowed to access this organization as an owner",
  "YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION" => "You are not a member of this organization",
  "MISSING_AC_INSTANCE" => "Dynamic Access Control requires a pre-defined ac instance on the server auth plugin. Read server logs for more information",
  "YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE" => "You must be in an organization to create a role",
  "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE" => "You are not allowed to create a role",
  "YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE" => "You are not allowed to update a role",
  "YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE" => "You are not allowed to delete a role",
  "YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE" => "You are not allowed to read a role",
  "YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE" => "You are not allowed to list a role",
  "YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE" => "You are not allowed to get a role",
  "TOO_MANY_ROLES" => "This organization has too many roles",
  "INVALID_RESOURCE" => "The provided permission includes an invalid resource",
  "ROLE_NAME_IS_ALREADY_TAKEN" => "That role name is already taken",
  "CANNOT_DELETE_A_PRE_DEFINED_ROLE" => "Cannot delete a pre-defined role",
  "ROLE_IS_ASSIGNED_TO_MEMBERS" => "Cannot delete a role that is assigned to members. Please reassign the members to a different role first"
}.freeze
ORGANIZATION_DEFAULT_STATEMENTS =
{
  organization: ["update", "delete"],
  member: ["create", "update", "delete"],
  invitation: ["create", "cancel"],
  team: ["create", "update", "delete"],
  ac: ["create", "read", "update", "delete"]
}.freeze
PHONE_NUMBER_ERROR_CODES =
{
  "INVALID_PHONE_NUMBER" => "Invalid phone number",
  "PHONE_NUMBER_EXIST" => "Phone number already exists",
  "PHONE_NUMBER_NOT_EXIST" => "phone number isn't registered",
  "INVALID_PHONE_NUMBER_OR_PASSWORD" => "Invalid phone number or password",
  "UNEXPECTED_ERROR" => "Unexpected error",
  "OTP_NOT_FOUND" => "OTP not found",
  "OTP_EXPIRED" => "OTP expired",
  "INVALID_OTP" => "Invalid OTP",
  "PHONE_NUMBER_NOT_VERIFIED" => "Phone number not verified",
  "PHONE_NUMBER_CANNOT_BE_UPDATED" => "Phone number cannot be updated",
  "SEND_OTP_NOT_IMPLEMENTED" => "sendOTP not implemented",
  "TOO_MANY_ATTEMPTS" => "Too many attempts"
}.freeze
GENERIC_OAUTH_ERROR_CODES =
{
  "INVALID_OAUTH_CONFIGURATION" => "Invalid OAuth configuration",
  "TOKEN_URL_NOT_FOUND" => "Invalid OAuth configuration. Token URL not found.",
  "PROVIDER_CONFIG_NOT_FOUND" => "No config found for provider",
  "PROVIDER_ID_REQUIRED" => "Provider ID is required",
  "INVALID_OAUTH_CONFIG" => "Invalid OAuth configuration.",
  "SESSION_REQUIRED" => "Session is required",
  "ISSUER_MISMATCH" => "OAuth issuer mismatch. The authorization server issuer does not match the expected value (RFC 9207).",
  "ISSUER_MISSING" => "OAuth issuer parameter missing. The authorization server did not include the required iss parameter (RFC 9207)."
}.freeze
MULTI_SESSION_ERROR_CODES =
{
  "INVALID_SESSION_TOKEN" => "Invalid session token"
}.freeze
HAVE_I_BEEN_PWNED_ERROR_CODES =
{
  "PASSWORD_COMPROMISED" => "The password you entered has been compromised. Please choose a different password."
}.freeze
HAVE_I_BEEN_PWNED_DEFAULT_PATHS =
[
  "/sign-up/email",
  "/change-password",
  "/reset-password"
].freeze
DEVICE_AUTHORIZATION_ERROR_CODES =
{
  "INVALID_DEVICE_CODE" => "Invalid device code",
  "EXPIRED_DEVICE_CODE" => "Device code has expired",
  "EXPIRED_USER_CODE" => "User code has expired",
  "AUTHORIZATION_PENDING" => "Authorization pending",
  "ACCESS_DENIED" => "Access denied",
  "INVALID_USER_CODE" => "Invalid user code",
  "DEVICE_CODE_ALREADY_PROCESSED" => "Device code already processed",
  "DEVICE_CODE_NOT_CLAIMED" => "Device code has not been claimed by a verifying session; call `GET /device` with the `user_code` while signed in before approving or denying",
  "POLLING_TOO_FREQUENTLY" => "Polling too frequently",
  "USER_NOT_FOUND" => "User not found",
  "FAILED_TO_CREATE_SESSION" => "Failed to create session",
  "INVALID_DEVICE_CODE_STATUS" => "Invalid device code status",
  "AUTHENTICATION_REQUIRED" => "Authentication required"
}.freeze

Class Method Summary collapse

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(name) ⇒ Object (private)



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/better_auth/plugins.rb', line 68

def method_missing(name, ...)
  if (replacement = REMOVED_PLUGIN_FACTORIES[name.to_sym])
    raise ArgumentError, "BetterAuth::Plugins.#{name} was removed. Use #{replacement} instead."
  end

  if (loader = plugin_loader_for_method(name))
    load_plugin!(loader)
    return public_send(name, ...) if respond_to?(name, true)

    raise NoMethodError, "plugin file for #{loader} did not define BetterAuth::Plugins.#{name}"
  end

  if (loader = PLUGIN_FACTORY_LOADERS[name.to_sym])
    load_plugin!(loader)
    return public_send(name, ...) if respond_to?(name, true)

    raise NoMethodError, "plugin file for #{loader} did not define BetterAuth::Plugins.#{name}"
  end

  super
end

Class Method Details

.additional_fields(schema = {}) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# File 'lib/better_auth/plugins/additional_fields.rb', line 7

def additional_fields(schema = {})
  config = normalize_hash(schema)
  user_fields = storage_fields(config[:user] || {})
  session_fields = storage_fields(config[:session] || {})

  Plugin.new(
    id: "additional-fields",
    schema: {
      user: {fields: user_fields},
      session: {fields: session_fields}
    },
    init: lambda do |_context|
      {
        options: {
          user: {additional_fields: user_fields},
          session: {additional_fields: session_fields}
        }
      }
    end
  )
end

.additional_input(hash, *exclude) ⇒ Object



1555
1556
1557
1558
1559
# File 'lib/better_auth/plugins/organization.rb', line 1555

def additional_input(hash, *exclude)
  data = normalize_hash(hash)
  additional = normalize_hash(data.delete(:additional_fields))
  extra_input(data, *exclude, :additional_fields).merge(additional)
end

.admin(options = {}) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/better_auth/plugins/admin.rb', line 44

def admin(options = {})
  config = admin_config(options)
  Plugin.new(
    id: "admin",
    init: ->(_context) { {options: {database_hooks: admin_database_hooks(config)}} },
    schema: AdminSchema.build(config[:schema]),
    endpoints: {
      set_role: admin_set_role_endpoint(config),
      get_user: admin_get_user_endpoint(config),
      create_user: admin_create_user_endpoint(config),
      admin_update_user: admin_update_user_endpoint(config),
      list_users: admin_list_users_endpoint(config),
      list_user_sessions: admin_list_user_sessions_endpoint(config),
      unban_user: admin_unban_user_endpoint(config),
      ban_user: admin_ban_user_endpoint(config),
      impersonate_user: admin_impersonate_user_endpoint(config),
      stop_impersonating: admin_stop_impersonating_endpoint,
      revoke_user_session: admin_revoke_user_session_endpoint(config),
      revoke_user_sessions: admin_revoke_user_sessions_endpoint(config),
      remove_user: admin_remove_user_endpoint(config),
      set_user_password: admin_set_user_password_endpoint(config),
      user_has_permission: admin_has_permission_endpoint(config)
    },
    hooks: {
      after: [
        {
          matcher: ->(ctx) { ctx.path == "/list-sessions" },
          handler: lambda do |ctx|
            next unless ctx.returned.is_a?(Array)

            ctx.json(ctx.returned.reject { |session| session["impersonatedBy"] || session[:impersonatedBy] })
          end
        }
      ]
    },
    error_codes: ADMIN_ERROR_CODES,
    options: config
  )
end

.admin_ban_user_endpoint(config) ⇒ Object



378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/better_auth/plugins/admin.rb', line 378

def admin_ban_user_endpoint(config)
  Endpoint.new(
    path: "/admin/ban-user",
    method: "POST",
    metadata: admin_user_mutation_openapi(
      operation_id: "banUser",
      description: "Ban a user",
      response_description: "User banned",
      properties: {
        userId: {type: "string", description: "The user id"},
        banReason: {type: ["string", "null"], description: "The reason for the ban"},
        banExpiresIn: {type: ["number", "null"], description: "The number of seconds until the ban expires"}
      },
      required: ["userId"]
    )
  ) do |ctx|
    session = admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
    body = normalize_hash(ctx.body)
    found = ctx.context.internal_adapter.find_user_by_id(body[:user_id])
    raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless found
    raise APIError.new("BAD_REQUEST", code: "YOU_CANNOT_BAN_YOURSELF", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_BAN_YOURSELF")) if body[:user_id] == session[:user]["id"]
    expires_in = body[:ban_expires_in] || config[:default_ban_expires_in]
    user = ctx.context.internal_adapter.update_user(body[:user_id], banned: true, banReason: body[:ban_reason] || config[:default_ban_reason] || "No reason", banExpires: expires_in ? Time.now + expires_in.to_i : nil, updatedAt: Time.now)
    ctx.context.internal_adapter.delete_user_sessions(body[:user_id])
    ctx.json({user: Schema.parse_output(ctx.context.options, "user", user)})
  end
end

.admin_config(options) ⇒ Object

Raises:



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/better_auth/plugins/admin.rb', line 84

def admin_config(options)
  config = normalize_hash(options)
  config[:roles_configured] = config.key?(:roles)
  config[:default_role] ||= "user"
  config[:admin_roles] = Array(config[:admin_roles] || ["admin"]).flat_map { |role| role.to_s.split(",") }
  config[:banned_user_message] ||= "You have been banned from this application. Please contact support if you believe this is an error."
  config[:impersonation_session_duration] ||= 60 * 60
  config[:ac] ||= create_access_control(ADMIN_DEFAULT_STATEMENTS)
  config[:roles] ||= admin_default_roles(config)
  valid_roles = config[:roles].keys.map { |role| role.to_s.downcase }
  invalid = config[:admin_roles].reject { |role| valid_roles.include?(role.to_s.downcase) }
  raise Error, "Invalid admin roles: #{invalid.join(", ")}. Admin roles must be defined in the 'roles' configuration." if invalid.any?

  config
end

.admin_create_user_endpoint(config) ⇒ Object



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/better_auth/plugins/admin.rb', line 201

def admin_create_user_endpoint(config)
  Endpoint.new(
    path: "/admin/create-user",
    method: "POST",
    metadata: admin_user_mutation_openapi(
      operation_id: "createUser",
      description: "Create a new user",
      response_description: "User created",
      properties: {
        email: {type: "string", description: "The email of the user"},
        password: {type: ["string", "null"], description: "The password of the user"},
        name: {type: "string", description: "The name of the user"},
        role: {type: ["string", "array", "null"], description: "The role or roles of the user"},
        data: {type: ["object", "null"], description: "Additional user data"}
      },
      required: ["email", "name"]
    )
  ) do |ctx|
    session = Routes.current_session(ctx, allow_nil: true, sensitive: true)
    if session
      unless admin_permission?(session[:user], session[:user]["role"], {user: ["create"]}, config)
        raise APIError.new("FORBIDDEN", code: "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS"))
      end
    elsif !ctx.headers.empty?
      raise APIError.new("UNAUTHORIZED")
    end

    body = normalize_hash(ctx.body)
    email = body[:email].to_s.downcase
    raise APIError.new("BAD_REQUEST", code: "INVALID_EMAIL", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email)

    if ctx.context.internal_adapter.find_user_by_email(email)
      raise APIError.new("BAD_REQUEST", code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL", message: ADMIN_ERROR_CODES.fetch("USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"))
    end
    data = normalize_hash(body[:data]).each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value }
    requested_role = body.key?(:role) ? body[:role] : data.delete("role")
    if !requested_role.nil? && session
      admin_require_permission!(ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE"))
    end
    if session && ["banned", "banReason", "banExpires"].any? { |field| data.key?(field) }
      admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
    end
    user = ctx.context.internal_adapter.create_user(data.merge(
      name: body[:name].to_s,
      email: email,
      role: requested_role.nil? ? config[:default_role] : admin_validate_roles!(requested_role, config)
    ).merge(body.key?(:image) ? {image: body[:image]} : {}), context: ctx)
    raise APIError.new("INTERNAL_SERVER_ERROR", code: "FAILED_TO_CREATE_USER", message: ADMIN_ERROR_CODES.fetch("FAILED_TO_CREATE_USER")) unless user

    if body[:password].to_s != ""
      ctx.context.internal_adapter.(userId: user["id"], providerId: "credential", accountId: user["id"], password: Routes.hash_password(ctx, body[:password]))
    end
    ctx.json({user: Schema.parse_output(ctx.context.options, "user", user)})
  end
end

.admin_database_hooks(config) ⇒ Object



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/better_auth/plugins/admin.rb', line 108

def admin_database_hooks(config)
  {
    user: {
      create: {
        before: lambda do |user, _ctx|
          {data: {"role" => config[:default_role]}.merge(user)}
        end
      }
    },
    session: {
      create: {
        before: lambda do |session, ctx|
          next unless ctx

          user = ctx.context.internal_adapter.find_user_by_id(session["userId"] || session[:userId])
          next unless user && user["banned"]

          if user["banExpires"] && Time.parse(user["banExpires"].to_s) < Time.now
            ctx.context.internal_adapter.update_user(user["id"], banned: false, banReason: nil, banExpires: nil, updatedAt: Time.now)
            next
          end

          if ctx.path.to_s.start_with?("/callback", "/oauth2/callback")
            error_url = ctx.context.options.on_api_error[:error_url] || "#{ctx.context.base_url}/error"
            url = "#{error_url}?error=banned&error_description=#{URI.encode_www_form_component(config[:banned_user_message])}"
            raise ctx.redirect(url)
          end

          raise APIError.new("FORBIDDEN", message: config[:banned_user_message], code: "BANNED_USER")
        end
      }
    }
  }
end

.admin_default_roles(config = {}) ⇒ Object



100
101
102
103
104
105
106
# File 'lib/better_auth/plugins/admin.rb', line 100

def admin_default_roles(config = {})
  ac = config[:ac] || create_access_control(ADMIN_DEFAULT_STATEMENTS)
  {
    "admin" => ac.new_role(ADMIN_DEFAULT_ROLE_STATEMENTS),
    "user" => ac.new_role(user: [], session: [])
  }
end

.admin_filter_users(users, query) ⇒ Object



857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
# File 'lib/better_auth/plugins/admin.rb', line 857

def admin_filter_users(users, query)
  result = users
  search_value = query[:search_value].to_s
  if !search_value.empty?
    field = (query[:search_field] || "email").to_s
    result = result.select { |user| user[field].to_s.downcase.include?(search_value.downcase) }
  end
  filter_field = (query[:filter_field] || query.dig(:filter, :field)).to_s
  if !filter_field.empty?
    filter_value = if query.key?(:filter_value)
      query[:filter_value]
    else
      query.dig(:filter, :value)
    end
    operator = (query[:filter_operator] || query.dig(:filter, :operator) || "eq").to_s
    field = (filter_field == "_id") ? "id" : Schema.storage_key(filter_field)
    result = result.select do |user|
      current = user[field]
      case operator
      when "ne" then current != filter_value
      when "contains" then current.to_s.include?(filter_value.to_s)
      else current == filter_value
      end
    end
  end
  result
end

.admin_get_user_endpoint(config) ⇒ Object



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/better_auth/plugins/admin.rb', line 170

def admin_get_user_endpoint(config)
  Endpoint.new(
    path: "/admin/get-user",
    method: "GET",
    metadata: {
      openapi: {
        operationId: "getUser",
        description: "Get an existing user",
        parameters: [
          {name: "id", in: "query", required: false, schema: {type: "string"}},
          {name: "userId", in: "query", required: false, schema: {type: "string"}},
          {name: "email", in: "query", required: false, schema: {type: "string"}}
        ],
        responses: {
          "200" => OpenAPI.json_response("User", {type: "object", "$ref": "#/components/schemas/User"})
        }
      }
    }
  ) do |ctx|
    admin_require_permission!(ctx, config, {user: ["get"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_GET_USER"))
    query = normalize_hash(ctx.query)
    user = if query[:id] || query[:user_id]
      ctx.context.internal_adapter.find_user_by_id(query[:id] || query[:user_id])
    elsif query[:email]
      ctx.context.internal_adapter.find_user_by_email(query[:email])&.fetch(:user)
    end
    raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user
    ctx.json(Schema.parse_output(ctx.context.options, "user", user))
  end
end

.admin_has_permission_endpoint(config) ⇒ Object



648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
# File 'lib/better_auth/plugins/admin.rb', line 648

def admin_has_permission_endpoint(config)
  Endpoint.new(
    path: "/admin/has-permission",
    method: "POST",
    body_schema: ->(body) { body.is_a?(Hash) ? body : false },
    metadata: {
      openapi: {
        operationId: "hasPermission",
        description: "Check if the user has permission",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              permissions: {type: "object", description: "The permissions to check"},
              userId: {type: ["string", "null"], description: "The user id"},
              role: {type: ["string", "null"], description: "The role to check"}
            },
            required: ["permissions"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "Success",
            OpenAPI.object_schema(
              {
                error: {type: ["string", "null"]},
                success: {type: "boolean"}
              },
              required: ["success"]
            )
          )
        }
      }
    }
  ) do |ctx|
    session = Routes.current_session(ctx, allow_nil: true, sensitive: true)
    body = normalize_hash(ctx.body)
    permissions = body[:permissions] || body[:permission]
    unless permissions
      raise APIError.new("BAD_REQUEST", message: "invalid permission check. no permission(s) were passed.")
    end

    if session
      user = session[:user]
      role = user["role"]
    elsif !ctx.headers.empty?
      raise APIError.new("UNAUTHORIZED")
    elsif body.key?(:role)
      role = body[:role]
      user = {"id" => body[:user_id].to_s, "role" => role}
    elsif body.key?(:user_id)
      user_id = body[:user_id].to_s
      raise APIError.new("BAD_REQUEST", message: "user id or role is required") if user_id.empty?

      user = ctx.context.internal_adapter.find_user_by_id(user_id)
      raise APIError.new("BAD_REQUEST", message: "user not found") unless user

      role = user["role"]
    else
      raise APIError.new("BAD_REQUEST", message: "user id or role is required")
    end
    ctx.json({error: nil, success: admin_permission?(user, role, permissions, config)})
  end
end

.admin_impersonate_user_endpoint(config) ⇒ Object



429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
# File 'lib/better_auth/plugins/admin.rb', line 429

def admin_impersonate_user_endpoint(config)
  Endpoint.new(
    path: "/admin/impersonate-user",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "impersonateUser",
        description: "Impersonate a user",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              userId: {type: "string", description: "The user id"}
            },
            required: ["userId"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Impersonation session created", OpenAPI.session_response_schema_pair)
        }
      }
    }
  ) do |ctx|
    session = admin_require_permission!(ctx, config, {user: ["impersonate"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS"))
    body = normalize_hash(ctx.body)
    target = ctx.context.internal_adapter.find_user_by_id(body[:user_id])
    raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless target
    can_impersonate_admins = config[:allow_impersonating_admins] ||
      admin_permission?(session[:user], session[:user]["role"], {user: ["impersonate-admins"]}, config)
    if !can_impersonate_admins && admin_user?(target, config)
      raise APIError.new("FORBIDDEN", code: "YOU_CANNOT_IMPERSONATE_ADMINS", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_IMPERSONATE_ADMINS"))
    end
    impersonated = ctx.context.internal_adapter.create_session(target["id"], true, {impersonatedBy: session[:user]["id"], expiresAt: Time.now + config[:impersonation_session_duration].to_i}, true, ctx)
    raise APIError.new("INTERNAL_SERVER_ERROR", message: ADMIN_ERROR_CODES.fetch("FAILED_TO_CREATE_USER")) unless impersonated

    dont_remember_cookie = ctx.get_signed_cookie(ctx.context.auth_cookies[:dont_remember].name, ctx.context.secret)
    Cookies.delete_session_cookie(ctx)
    admin_cookie = ctx.context.create_auth_cookie("admin_session")
    ctx.set_signed_cookie(admin_cookie.name, "#{session[:session]["token"]}:#{dont_remember_cookie}", ctx.context.secret, ctx.context.auth_cookies[:session_token].attributes)
    Cookies.set_session_cookie(ctx, {session: impersonated, user: target}, true)
    ctx.json({
      session: Schema.parse_output(ctx.context.options, "session", impersonated),
      user: Schema.parse_output(ctx.context.options, "user", target)
    })
  end
end

.admin_list_user_sessions_endpoint(config) ⇒ Object



362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/better_auth/plugins/admin.rb', line 362

def admin_list_user_sessions_endpoint(config)
  Endpoint.new(
    path: "/admin/list-user-sessions",
    method: "POST",
    metadata: admin_sessions_openapi(
      operation_id: "adminListUserSessions",
      description: "List user sessions",
      response_description: "List of user sessions"
    )
  ) do |ctx|
    admin_require_permission!(ctx, config, {session: ["list"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS"))
    sessions = ctx.context.internal_adapter.list_sessions(normalize_hash(ctx.body)[:user_id])
    ctx.json({sessions: sessions.map { |session| Schema.parse_output(ctx.context.options, "session", session) }})
  end
end

.admin_list_users_endpoint(config) ⇒ Object



319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'lib/better_auth/plugins/admin.rb', line 319

def admin_list_users_endpoint(config)
  Endpoint.new(
    path: "/admin/list-users",
    method: "GET",
    metadata: {
      openapi: {
        operationId: "listUsers",
        description: "List users",
        parameters: admin_list_users_parameters,
        responses: {
          "200" => OpenAPI.json_response(
            "List of users",
            OpenAPI.object_schema(
              {
                users: {type: "array", items: {type: "object", "$ref": "#/components/schemas/User"}},
                total: {type: "number"},
                limit: {type: ["number", "null"]},
                offset: {type: ["number", "null"]}
              },
              required: ["users", "total"]
            )
          )
        }
      }
    }
  ) do |ctx|
    admin_require_permission!(ctx, config, {user: ["list"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_LIST_USERS"))
    query = normalize_hash(ctx.query)
    where = admin_user_where(query)
    sort_by = admin_user_sort(query)
    limit = query.key?(:limit) ? query[:limit].to_i : nil
    offset = query.key?(:offset) ? query[:offset].to_i : nil
    users = ctx.context.internal_adapter.list_users(limit: limit, offset: offset, sort_by: sort_by, where: where)
    total = ctx.context.internal_adapter.count_total_users(where: where)
    ctx.json({
      users: users.map { |user| Schema.parse_output(ctx.context.options, "user", user) },
      total: total,
      limit: limit,
      offset: offset
    })
  end
end

.admin_list_users_parametersObject



755
756
757
758
759
760
761
762
763
764
765
766
767
768
# File 'lib/better_auth/plugins/admin.rb', line 755

def admin_list_users_parameters
  [
    {name: "searchValue", in: "query", required: false, schema: {type: "string"}},
    {name: "searchField", in: "query", required: false, schema: {type: "string"}},
    {name: "searchOperator", in: "query", required: false, schema: {type: "string"}},
    {name: "limit", in: "query", required: false, schema: {type: "number"}},
    {name: "offset", in: "query", required: false, schema: {type: "number"}},
    {name: "sortBy", in: "query", required: false, schema: {type: "string"}},
    {name: "sortDirection", in: "query", required: false, schema: {type: "string"}},
    {name: "filterField", in: "query", required: false, schema: {type: "string"}},
    {name: "filterValue", in: "query", required: false, schema: {type: "string"}},
    {name: "filterOperator", in: "query", required: false, schema: {type: "string"}}
  ]
end

.admin_paginate_users(users, query) ⇒ Object



895
896
897
898
899
900
# File 'lib/better_auth/plugins/admin.rb', line 895

def admin_paginate_users(users, query)
  offset = query[:offset].to_i
  limit = query[:limit]
  result = offset.positive? ? users.drop(offset) : users
  limit ? result.first(limit.to_i) : result
end

.admin_parse_roles(roles) ⇒ Object



798
799
800
# File 'lib/better_auth/plugins/admin.rb', line 798

def admin_parse_roles(roles)
  Array(roles).join(",")
end

.admin_permission?(user, role_string, permissions, config) ⇒ Boolean

Returns:

  • (Boolean)


780
781
782
783
784
785
786
787
788
789
# File 'lib/better_auth/plugins/admin.rb', line 780

def admin_permission?(user, role_string, permissions, config)
  return true if user && Array(config[:admin_user_ids]).map(&:to_s).include?(user["id"].to_s)
  return false unless permissions

  roles = (config[:roles] || admin_default_roles(config)).transform_keys(&:to_s)
  selected_roles = role_string.to_s.empty? ? [config[:default_role].to_s] : role_string.to_s.split(",")
  selected_roles.any? do |role|
    admin_role_for(roles, role)&.authorize(permissions || {})&.fetch(:success, false)
  end
end

.admin_remove_user_endpoint(config) ⇒ Object



572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
# File 'lib/better_auth/plugins/admin.rb', line 572

def admin_remove_user_endpoint(config)
  Endpoint.new(
    path: "/admin/remove-user",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "removeUser",
        description: "Remove a user",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              userId: {type: "string", description: "The user id"}
            },
            required: ["userId"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("User removed", OpenAPI.success_response_schema)
        }
      }
    }
  ) do |ctx|
    session = admin_require_permission!(ctx, config, {user: ["delete"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS"))
    user_id = normalize_hash(ctx.body)[:user_id]
    raise APIError.new("BAD_REQUEST", code: "YOU_CANNOT_REMOVE_YOURSELF", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_REMOVE_YOURSELF")) if user_id == session[:user]["id"]
    raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id)
    ctx.context.internal_adapter.delete_user(user_id)
    ctx.json({success: true})
  end
end

.admin_require_permission!(ctx, config, permissions, message) ⇒ Object

Raises:



770
771
772
773
774
775
776
777
778
# File 'lib/better_auth/plugins/admin.rb', line 770

def admin_require_permission!(ctx, config, permissions, message)
  session = Routes.current_session(ctx, sensitive: true)
  return session if admin_permission?(session[:user], session[:user]["role"], permissions, config)

  code = ADMIN_ERROR_CODES.key(message)
  raise Error, "Unknown admin permission error: #{message}" unless code

  raise APIError.new("FORBIDDEN", code: code, message: message)
end

.admin_revoke_user_session_endpoint(config) ⇒ Object



516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
# File 'lib/better_auth/plugins/admin.rb', line 516

def admin_revoke_user_session_endpoint(config)
  Endpoint.new(
    path: "/admin/revoke-user-session",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "revokeUserSession",
        description: "Revoke a user session",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              sessionToken: {type: "string", description: "The session token"}
            },
            required: ["sessionToken"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Session revoked", OpenAPI.success_response_schema)
        }
      }
    }
  ) do |ctx|
    admin_require_permission!(ctx, config, {session: ["revoke"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS"))
    ctx.context.internal_adapter.delete_session(normalize_hash(ctx.body)[:session_token])
    ctx.json({success: true})
  end
end

.admin_revoke_user_sessions_endpoint(config) ⇒ Object



544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
# File 'lib/better_auth/plugins/admin.rb', line 544

def admin_revoke_user_sessions_endpoint(config)
  Endpoint.new(
    path: "/admin/revoke-user-sessions",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "revokeUserSessions",
        description: "Revoke all user sessions",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              userId: {type: "string", description: "The user id"}
            },
            required: ["userId"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Sessions revoked", OpenAPI.success_response_schema)
        }
      }
    }
  ) do |ctx|
    admin_require_permission!(ctx, config, {session: ["revoke"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS"))
    ctx.context.internal_adapter.delete_user_sessions(normalize_hash(ctx.body)[:user_id])
    ctx.json({success: true})
  end
end

.admin_role_for(roles, role) ⇒ Object



819
820
821
# File 'lib/better_auth/plugins/admin.rb', line 819

def admin_role_for(roles, role)
  roles[role.to_s] || roles.find { |key, _value| key.to_s.downcase == role.to_s.downcase }&.last
end

.admin_sessions_openapi(operation_id:, description:, response_description:) ⇒ Object



731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
# File 'lib/better_auth/plugins/admin.rb', line 731

def admin_sessions_openapi(operation_id:, description:, response_description:)
  {
    openapi: {
      operationId: operation_id,
      description: description,
      requestBody: OpenAPI.json_request_body(
        OpenAPI.object_schema(
          {userId: {type: "string", description: "The user id"}},
          required: ["userId"]
        )
      ),
      responses: {
        "200" => OpenAPI.json_response(
          response_description,
          OpenAPI.object_schema(
            {sessions: {type: "array", items: {type: "object", "$ref": "#/components/schemas/Session"}}},
            required: ["sessions"]
          )
        )
      }
    }
  }
end

.admin_set_role_endpoint(config) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/better_auth/plugins/admin.rb', line 143

def admin_set_role_endpoint(config)
  Endpoint.new(
    path: "/admin/set-role",
    method: "POST",
    metadata: admin_user_mutation_openapi(
      operation_id: "setUserRole",
      description: "Set the role of a user",
      response_description: "User role updated",
      properties: {
        userId: {type: "string", description: "The user id"},
        role: {type: ["string", "array"], description: "The role or roles to set"}
      },
      required: ["userId", "role"]
    )
  ) do |ctx|
    admin_require_permission!(ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE"))
    body = normalize_hash(ctx.body)
    user_id = body[:user_id].to_s
    raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty?
    update = {role: admin_validate_roles!(body[:role], config)}
    raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id)

    user = ctx.context.internal_adapter.update_user(user_id, update)
    ctx.json({user: Schema.parse_output(ctx.context.options, "user", user || {})})
  end
end

.admin_set_user_password_endpoint(config) ⇒ Object



603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
# File 'lib/better_auth/plugins/admin.rb', line 603

def admin_set_user_password_endpoint(config)
  Endpoint.new(
    path: "/admin/set-user-password",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "setUserPassword",
        description: "Set a user's password",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              userId: {type: "string", description: "The user id"},
              newPassword: {type: "string", description: "The new password"}
            },
            required: ["userId", "newPassword"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Password set", OpenAPI.status_response_schema)
        }
      }
    }
  ) do |ctx|
    admin_require_permission!(ctx, config, {user: ["set-password"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD"))
    body = normalize_hash(ctx.body)
    user_id = body[:user_id].to_s
    password = body[:new_password].to_s
    raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty?
    min = ctx.context.options.email_and_password[:min_password_length]
    max = ctx.context.options.email_and_password[:max_password_length]
    raise APIError.new("BAD_REQUEST", code: "PASSWORD_TOO_SHORT", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_SHORT")) if password.length < min
    raise APIError.new("BAD_REQUEST", code: "PASSWORD_TOO_LONG", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_LONG")) if password.length > max
    raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id)

    hashed_password = Routes.hash_password(ctx, password)
     = ctx.context.internal_adapter.find_accounts(user_id).find { || ["providerId"] == "credential" }
    if 
      ctx.context.internal_adapter.update_password(user_id, hashed_password)
    else
      ctx.context.internal_adapter.(userId: user_id, providerId: "credential", accountId: user_id, password: hashed_password)
    end
    ctx.json({status: true})
  end
end

.admin_sort_users(users, query) ⇒ Object



885
886
887
888
889
890
891
892
893
# File 'lib/better_auth/plugins/admin.rb', line 885

def admin_sort_users(users, query)
  sort_field = query[:sort_by] || query[:sort_field]
  return users unless sort_field

  field = Schema.storage_key(sort_field)
  sorted = users.sort_by { |user| user[field].to_s }
  direction = (query[:sort_direction] || query[:sort_order] || "asc").to_s
  (direction.downcase == "desc") ? sorted.reverse : sorted
end

.admin_stop_impersonating_endpointObject



475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
# File 'lib/better_auth/plugins/admin.rb', line 475

def admin_stop_impersonating_endpoint
  Endpoint.new(
    path: "/admin/stop-impersonating",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "stopImpersonating",
        description: "Stop impersonating a user",
        requestBody: OpenAPI.empty_request_body,
        responses: {
          "200" => OpenAPI.json_response("Impersonation stopped", OpenAPI.session_response_schema_pair)
        }
      }
    }
  ) do |ctx|
    session = Routes.current_session(ctx, sensitive: true)
    admin_id = session[:session]["impersonatedBy"]
    raise APIError.new("BAD_REQUEST", message: "You are not impersonating anyone") unless admin_id
    admin = ctx.context.internal_adapter.find_user_by_id(admin_id)
    raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to find user") unless admin

    admin_cookie = ctx.context.create_auth_cookie("admin_session")
    admin_cookie_value = ctx.get_signed_cookie(admin_cookie.name, ctx.context.secret)
    raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to find admin session") unless admin_cookie_value

    admin_session_token, dont_remember_cookie = admin_cookie_value.split(":", 2)
    admin_session = ctx.context.internal_adapter.find_session(admin_session_token)
    if !admin_session || admin_session[:session]["userId"] != admin["id"]
      raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to find admin session")
    end

    ctx.context.internal_adapter.delete_session(session[:session]["token"])
    Cookies.set_session_cookie(ctx, admin_session, !dont_remember_cookie.to_s.empty?)
    Cookies.expire_cookie(ctx, admin_cookie)
    ctx.json({
      session: Schema.parse_output(ctx.context.options, "session", admin_session[:session]),
      user: Schema.parse_output(ctx.context.options, "user", admin_session[:user])
    })
  end
end

.admin_unban_user_endpoint(config) ⇒ Object



406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# File 'lib/better_auth/plugins/admin.rb', line 406

def admin_unban_user_endpoint(config)
  Endpoint.new(
    path: "/admin/unban-user",
    method: "POST",
    metadata: admin_user_mutation_openapi(
      operation_id: "unbanUser",
      description: "Unban a user",
      response_description: "User unbanned",
      properties: {
        userId: {type: "string", description: "The user id"}
      },
      required: ["userId"]
    )
  ) do |ctx|
    admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
    user_id = normalize_hash(ctx.body)[:user_id]
    raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id)

    user = ctx.context.internal_adapter.update_user(user_id, banned: false, banReason: nil, banExpires: nil, updatedAt: Time.now)
    ctx.json({user: Schema.parse_output(ctx.context.options, "user", user)})
  end
end

.admin_update_user_endpoint(config) ⇒ Object



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/better_auth/plugins/admin.rb', line 257

def admin_update_user_endpoint(config)
  Endpoint.new(
    path: "/admin/update-user",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "adminUpdateUser",
        description: "Update a user's details",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              userId: {type: "string", description: "The user id"},
              data: {type: "object", description: "The user data to update"}
            },
            required: ["userId", "data"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("User updated", {type: "object", "$ref": "#/components/schemas/User"})
        }
      }
    }
  ) do |ctx|
    admin_require_permission!(ctx, config, {user: ["update"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS"))
    body = normalize_hash(ctx.body)
    data = normalize_hash(body[:data] || body).except(:user_id, :data)
    raise APIError.new("BAD_REQUEST", code: "NO_DATA_TO_UPDATE", message: ADMIN_ERROR_CODES.fetch("NO_DATA_TO_UPDATE")) if data.empty?
    if data.key?(:password)
      raise APIError.new("BAD_REQUEST", code: "PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER", message: ADMIN_ERROR_CODES.fetch("PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER"))
    end
    if data.key?(:role)
      admin_require_permission!(ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE"))
      data[:role] = admin_validate_roles!(data[:role], config)
    end
    if [:banned, :ban_reason, :ban_expires].any? { |field| data.key?(field) }
      session = admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
      if data[:banned] == true && body[:user_id] == session[:user]["id"]
        raise APIError.new("BAD_REQUEST", code: "YOU_CANNOT_BAN_YOURSELF", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_BAN_YOURSELF"))
      end
    end
    if data.key?(:email) || data.key?(:email_verified)
      admin_require_permission!(ctx, config, {user: ["set-email"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL"))
      if data.key?(:email)
        email = data[:email].to_s.downcase
        raise APIError.new("BAD_REQUEST", code: "INVALID_EMAIL", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email)

        existing = ctx.context.internal_adapter.find_user_by_email(email)
        if existing && existing[:user]["id"] != body[:user_id]
          raise APIError.new("BAD_REQUEST", code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL", message: ADMIN_ERROR_CODES.fetch("USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"))
        end
        data[:email] = email
      end
    end
    raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(body[:user_id])

    stored_data = data.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value }
    user = ctx.context.internal_adapter.update_user(body[:user_id], stored_data)
    ctx.context.internal_adapter.delete_user_sessions(body[:user_id]) if data[:banned] == true
    ctx.json(Schema.parse_output(ctx.context.options, "user", user))
  end
end

.admin_user?(user, config) ⇒ Boolean

Returns:

  • (Boolean)


791
792
793
794
795
796
# File 'lib/better_auth/plugins/admin.rb', line 791

def admin_user?(user, config)
  return true if Array(config[:admin_user_ids]).map(&:to_s).include?(user["id"].to_s)

  admin_roles = config[:admin_roles].map { |role| role.to_s.downcase }
  user["role"].to_s.split(",").any? { |role| admin_roles.include?(role.to_s.downcase) }
end

.admin_user_mutation_openapi(operation_id:, description:, response_description:, properties:, required:) ⇒ Object



712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
# File 'lib/better_auth/plugins/admin.rb', line 712

def admin_user_mutation_openapi(operation_id:, description:, response_description:, properties:, required:)
  {
    openapi: {
      operationId: operation_id,
      description: description,
      requestBody: OpenAPI.json_request_body(OpenAPI.object_schema(properties, required: required)),
      responses: {
        "200" => OpenAPI.json_response(
          response_description,
          OpenAPI.object_schema(
            {user: {type: "object", "$ref": "#/components/schemas/User"}},
            required: ["user"]
          )
        )
      }
    }
  }
end

.admin_user_sort(query) ⇒ Object



847
848
849
850
851
852
853
854
855
# File 'lib/better_auth/plugins/admin.rb', line 847

def admin_user_sort(query)
  sort_field = query[:sort_by] || query[:sort_field]
  return nil unless sort_field

  {
    field: sort_field,
    direction: query[:sort_direction] || query[:sort_order] || "asc"
  }
end

.admin_user_where(query) ⇒ Object



823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
# File 'lib/better_auth/plugins/admin.rb', line 823

def admin_user_where(query)
  where = []
  search_value = query[:search_value]
  if search_value && !search_value.to_s.empty?
    where << {
      field: query[:search_field] || "email",
      operator: query[:search_operator] || "contains",
      value: search_value
    }
  end

  filter_value_defined = query.key?(:filter_value) || (query[:filter].is_a?(Hash) && query[:filter].key?(:value))
  if filter_value_defined
    filter_field = query[:filter_field] || query.dig(:filter, :field) || "email"
    where << {
      field: (filter_field.to_s == "_id") ? "id" : filter_field,
      operator: query[:filter_operator] || query.dig(:filter, :operator) || "eq",
      value: query.key?(:filter_value) ? query[:filter_value] : query.dig(:filter, :value)
    }
  end

  where
end

.admin_validate_roles!(roles, config) ⇒ Object



802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
# File 'lib/better_auth/plugins/admin.rb', line 802

def admin_validate_roles!(roles, config)
  unless Array(roles).all? { |role| role.is_a?(String) || role.is_a?(Symbol) }
    raise APIError.new("BAD_REQUEST", code: "INVALID_ROLE_TYPE", message: ADMIN_ERROR_CODES.fetch("INVALID_ROLE_TYPE"))
  end

  parsed = admin_parse_roles(roles)
  if config[:roles_configured]
    defined_roles = (config[:roles] || {}).transform_keys(&:to_s)
    invalid = parsed.split(",", -1).reject { |role| admin_role_for(defined_roles, role) }
    if invalid.any?
      raise APIError.new("BAD_REQUEST", code: "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE"))
    end
  end

  parsed
end

.all_jwks(ctx, config, force_refresh: false) ⇒ Object



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/better_auth/plugins/jwt.rb', line 267

def all_jwks(ctx, config, force_refresh: false)
  cache = config[:local_jwks_cache] ||= JWT::LocalJwksCache.new
  if !force_refresh && cache.expires_at && cache.expires_at > Time.now
    return cache.keys
  end

  adapter = config[:adapter]
  keys = if adapter && adapter[:get_jwks].respond_to?(:call)
    Array(adapter[:get_jwks].call(ctx)).map { |entry| stringify_payload(entry) }
  else
    ctx.context.adapter.find_many(model: "jwks")
  end
  cache.keys = keys
  cache.expires_at = Time.now + 300
  keys
end

.anonymous(options = {}) ⇒ Object



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/better_auth/plugins/anonymous.rb', line 19

def anonymous(options = {})
  config = normalize_hash(options)

  Plugin.new(
    id: "anonymous",
    endpoints: {
      sign_in_anonymous: (config),
      delete_anonymous_user: delete_anonymous_user_endpoint(config)
    },
    hooks: {
      after: [
        {
          matcher: ->(ctx) { anonymous_link_path?(ctx.path) },
          handler: ->(ctx) { link_anonymous_user(ctx, config) }
        }
      ]
    },
    schema: anonymous_schema(config),
    error_codes: ANONYMOUS_ERROR_CODES,
    options: config
  )
end

.anonymous_email(config) ⇒ Object



145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/better_auth/plugins/anonymous.rb', line 145

def anonymous_email(config)
  generator = config[:generate_random_email]
  email = generator.call if generator.respond_to?(:call)
  if email && email != ""
    unless email.is_a?(String) && !email.empty? && Routes::EMAIL_PATTERN.match?(email)
      raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["INVALID_EMAIL_FORMAT"])
    end
    return email
  end

  id = SecureRandom.hex(16)
  domain = config[:email_domain_name]
  domain ? "temp-#{id}@#{domain}" : "temp@#{id}.com"
end

Returns:

  • (Boolean)


207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/better_auth/plugins/anonymous.rb', line 207

def anonymous_link_path?(path)
  path.to_s.start_with?(
    "/sign-in",
    "/sign-up",
    "/callback",
    "/oauth2/callback",
    "/magic-link/verify",
    "/email-otp/verify-email",
    "/one-tap/callback",
    "/passkey/verify-authentication",
    "/phone-number/verify"
  )
end

.anonymous_name(ctx, config) ⇒ Object



160
161
162
163
164
165
166
# File 'lib/better_auth/plugins/anonymous.rb', line 160

def anonymous_name(ctx, config)
  generator = config[:generate_name]
  name = generator.call(ctx) if generator.respond_to?(:call)
  return name if present_string?(name)

  "Anonymous"
end

.anonymous_schema(config) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/better_auth/plugins/anonymous.rb', line 128

def anonymous_schema(config)
  field_name = anonymous_schema_field_name(config) || "is_anonymous"
  {
    user: {
      fields: {
        isAnonymous: {
          type: "boolean",
          required: false,
          input: false,
          default_value: false,
          field_name: field_name
        }
      }
    }
  }
end

.anonymous_schema_field_name(config) ⇒ Object



221
222
223
224
225
226
227
228
# File 'lib/better_auth/plugins/anonymous.rb', line 221

def anonymous_schema_field_name(config)
  fields = config.dig(:schema, :user, :fields) || {}
  mapping = fields[:is_anonymous] || fields[:isAnonymous] || fields["isAnonymous"]
  return mapping if mapping.is_a?(String)
  return mapping[:field_name] || mapping[:fieldName] if mapping.is_a?(Hash)

  nil
end

.api_key(*args, &block) ⇒ Object



9
10
11
# File 'lib/better_auth/plugins/api_key.rb', line 9

def api_key(*args, &block)
  call_external_plugin!(:api_key, *args, implementation_constant: :API_KEY_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-api-key", entry: "lib/better_auth/api_key.rb", &block)
end

.apply_bearer_token(ctx, config) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/better_auth/plugins/bearer.rb', line 38

def apply_bearer_token(ctx, config)
  auth_header = authorization_header(ctx).to_s
  return unless auth_header[0, BEARER_SCHEME.length].to_s.downcase == BEARER_SCHEME

  token = auth_header[BEARER_SCHEME.length..].to_s.strip
  return if token.empty?

  signed_token = if token.include?(".")
    normalize_signed_bearer_token(token)
  else
    sign_bearer_token(ctx, token, config)
  end
  return unless signed_token && valid_signed_token?(ctx, signed_token)

  cookie_name = ctx.context.auth_cookies[:session_token].name
  cookie = Cookies.set_request_cookie(ctx.headers["cookie"], cookie_name, signed_token)
  {context: {headers: ctx.headers.merge("cookie" => cookie)}}
end

.apply_i18n_translation(ctx, config) ⇒ Object



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/better_auth/plugins/i18n.rb', line 160

def apply_i18n_translation(ctx, config)
  error = ctx.returned
  return nil unless error.is_a?(BetterAuth::APIError)

  error_code = resolve_i18n_error_code(error, ctx)
  return nil unless error_code

  locale = detect_i18n_locale(ctx, config)
  translation = config.dig(:translations, locale, error_code)
  return nil unless translation

  raise BetterAuth::APIError.new(
    error.status,
    message: translation,
    headers: error.headers,
    code: error.code,
    body: {
      code: error_code,
      message: translation,
      originalMessage: error.message
    }
  )
end

.apply_last_login_method(ctx, config) ⇒ Object



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/better_auth/plugins/last_login_method.rb', line 46

def (ctx, config)
  method = (ctx, config)
  return unless method

  set_cookie = ctx.response_headers["set-cookie"].to_s
  return unless set_cookie.include?(ctx.context.auth_cookies[:session_token].name)

  attributes = ctx.context.auth_cookies[:session_token].attributes.merge(max_age: config[:max_age], http_only: false)
  ctx.set_cookie(config[:cookie_name], method, attributes)

  if config[:store_in_database] && ctx.context.new_session&.dig(:user, "id")
    updated = ctx.context.internal_adapter.update_user(ctx.context.new_session[:user]["id"], lastLoginMethod: method)
    ctx.context.new_session[:user].merge!(updated) if updated
  end
  nil
end

.auth0(options = {}) ⇒ Object



48
49
50
51
52
53
54
55
56
57
# File 'lib/better_auth/plugins/generic_oauth.rb', line 48

def auth0(options = {})
  data = normalize_hash(options)
  domain = data.fetch(:domain).to_s.sub(%r{\Ahttps?://}, "")
  generic_oauth_provider_config(
    data,
    provider_id: "auth0",
    discovery_url: "https://#{domain}/.well-known/openid-configuration",
    scopes: ["openid", "profile", "email"]
  )
end

.authorization_header(ctx) ⇒ Object



34
35
36
# File 'lib/better_auth/plugins/bearer.rb', line 34

def authorization_header(ctx)
  ctx.headers["authorization"] || ctx.headers["Authorization"]
end

.base32_decode(value) ⇒ Object



771
772
773
774
775
# File 'lib/better_auth/plugins/two_factor.rb', line 771

def base32_decode(value)
  clean = value.to_s.upcase.gsub(/[^A-Z2-7]/, "")
  bits = clean.chars.map { |char| BASE32_ALPHABET.index(char).to_i.to_s(2).rjust(5, "0") }.join
  bits.scan(/.{8}/).map { |byte| byte.to_i(2).chr }.join
end

.base32_encode(bytes) ⇒ Object



766
767
768
769
# File 'lib/better_auth/plugins/two_factor.rb', line 766

def base32_encode(bytes)
  bits = bytes.bytes.map { |byte| byte.to_s(2).rjust(8, "0") }.join
  bits.scan(/.{1,5}/).map { |chunk| BASE32_ALPHABET[chunk.ljust(5, "0").to_i(2)] }.join
end

.base64url_bn(number) ⇒ Object



510
511
512
513
514
# File 'lib/better_auth/plugins/jwt.rb', line 510

def base64url_bn(number)
  hex = number.to_s(16)
  hex = "0#{hex}" if hex.length.odd?
  Crypto.base64url_encode([hex].pack("H*"))
end

.bearer(options = {}) ⇒ Object



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/better_auth/plugins/bearer.rb', line 11

def bearer(options = {})
  config = normalize_hash(options)

  Plugin.new(
    id: "bearer",
    hooks: {
      before: [
        {
          matcher: ->(ctx) { authorization_header(ctx) },
          handler: ->(ctx) { apply_bearer_token(ctx, config) }
        }
      ],
      after: [
        {
          matcher: ->(_ctx) { true },
          handler: ->(ctx) { expose_auth_token(ctx) }
        }
      ]
    },
    options: config
  )
end

.call_email_verification_option(ctx, key, user) ⇒ Object



734
735
736
737
# File 'lib/better_auth/plugins/email_otp.rb', line 734

def call_email_verification_option(ctx, key, user)
  callback = ctx.context.options.email_verification[key]
  callback.call(user, ctx.request) if callback.respond_to?(:call)
end

.call_external_plugin!(method_name, *args, implementation_constant:, gem_name:, entry:, &block) ⇒ Object



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/better_auth/plugin_loader.rb', line 130

def call_external_plugin!(method_name, *args, implementation_constant:, gem_name:, entry:, &block)
  loader = method(method_name)
  ensure_external_plugin_loaded!(
    gem_name: gem_name,
    entry: entry,
    implementation_constant: implementation_constant
  )
  resolved = method(method_name)
  if resolved == loader
    raise LoadError,
      "BetterAuth::Plugins.#{method_name} requires the #{gem_name} gem. Add it to your Gemfile and require its entrypoint."
  end

  resolved.call(*args, &block)
end

.camelize_lower(key) ⇒ Object



1431
1432
1433
1434
# File 'lib/better_auth/plugins/organization.rb', line 1431

def camelize_lower(key)
  parts = key.to_s.split("_")
  ([parts.first] + parts.drop(1).map(&:capitalize)).join
end


226
227
228
229
# File 'lib/better_auth/plugins/multi_session.rb', line 226

def canonical_multi_session_cookie_name(name)
  prefix = "__Secure-"
  name.to_s.sub(/\A#{Regexp.escape(prefix)}/i, prefix)
end

.captcha(options = {}) ⇒ Object



37
38
39
40
41
42
43
44
45
46
47
# File 'lib/better_auth/plugins/captcha.rb', line 37

def captcha(options = {})
  config = normalize_hash(options)
  Plugin.new(
    id: "captcha",
    on_request: lambda do |request, context|
      captcha_on_request(request, context, config)
    end,
    error_codes: CAPTCHA_EXTERNAL_ERROR_CODES,
    options: config
  )
end

.captcha_http_verify(params) ⇒ Object



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/better_auth/plugins/captcha.rb', line 106

def captcha_http_verify(params)
  verifier = captcha_verifier_params(params)
  uri = URI.parse(verifier[:url])
  request = Net::HTTP::Post.new(uri)
  request["Content-Type"] = verifier[:content_type]
  request.body = if verifier[:content_type] == "application/json"
    JSON.generate(verifier[:payload])
  else
    URI.encode_www_form(verifier[:payload])
  end
  response = HTTPClient.request(uri, request, open_timeout: CAPTCHA_VERIFY_TIMEOUT_SECONDS, read_timeout: CAPTCHA_VERIFY_TIMEOUT_SECONDS)
  raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"] unless response.is_a?(Net::HTTPSuccess)

  JSON.parse(response.body.to_s)
rescue JSON::ParserError
  raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"]
end

.captcha_log(context, message) ⇒ Object



168
169
170
171
172
173
174
175
# File 'lib/better_auth/plugins/captcha.rb', line 168

def captcha_log(context, message)
  logger = context.logger
  if logger.respond_to?(:call)
    logger.call(:error, message)
  elsif logger.respond_to?(:error)
    logger.error(message)
  end
end

.captcha_normalize_verifier_response(value) ⇒ Object



154
155
156
157
158
# File 'lib/better_auth/plugins/captcha.rb', line 154

def captcha_normalize_verifier_response(value)
  return value.transform_keys(&:to_s) if value.is_a?(Hash)

  {}
end

.captcha_on_request(request, context, config) ⇒ Object



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/better_auth/plugins/captcha.rb', line 49

def captcha_on_request(request, context, config)
  endpoints = Array(config[:endpoints]).empty? ? CAPTCHA_DEFAULT_ENDPOINTS : Array(config[:endpoints])
  path = request.path_info.to_s
  email_otp_path = "/sign-in/email-otp"
  email_otp_is_explicit = Array(config[:endpoints]).map(&:to_s).include?(email_otp_path)
  return nil if path.include?(email_otp_path) && !email_otp_is_explicit
  return nil unless endpoints.any? { |endpoint| path.include?(endpoint.to_s) }

  raise CAPTCHA_INTERNAL_ERROR_CODES["MISSING_SECRET_KEY"] if config[:secret_key].to_s.empty?

  response_token = request.get_header("HTTP_X_CAPTCHA_RESPONSE")
  if response_token.to_s.empty?
    return {response: captcha_response(400, "MISSING_RESPONSE", CAPTCHA_EXTERNAL_ERROR_CODES["MISSING_RESPONSE"])}
  end

  result = captcha_verify(config, response_token, captcha_remote_ip(request, context))
  return nil if captcha_success?(config, result)

  {response: captcha_response(403, "VERIFICATION_FAILED", CAPTCHA_EXTERNAL_ERROR_CODES["VERIFICATION_FAILED"])}
rescue => error
  captcha_log(context, error.message)
  {response: captcha_response(500, "UNKNOWN_ERROR", CAPTCHA_EXTERNAL_ERROR_CODES["UNKNOWN_ERROR"])}
end

.captcha_payload(provider, params) ⇒ Object



124
125
126
127
128
129
130
131
132
133
134
# File 'lib/better_auth/plugins/captcha.rb', line 124

def captcha_payload(provider, params)
  payload = {
    "secret" => params[:secret_key],
    "response" => params[:captcha_response]
  }
  payload["sitekey"] = params[:site_key] if params[:site_key] && ["hcaptcha", "captchafox"].include?(provider)
  if params[:remote_ip]
    payload[(provider == "captchafox") ? "remoteIp" : "remoteip"] = params[:remote_ip]
  end
  payload
end

.captcha_remote_ip(request, context) ⇒ Object



164
165
166
# File 'lib/better_auth/plugins/captcha.rb', line 164

def captcha_remote_ip(request, context)
  RequestIP.client_ip(request, context.options)
end

.captcha_response(status, code, message) ⇒ Object



160
161
162
# File 'lib/better_auth/plugins/captcha.rb', line 160

def captcha_response(status, code, message)
  [status, {"content-type" => "application/json"}, [JSON.generate({code: code, message: message})]]
end

.captcha_success?(config, result) ⇒ Boolean

Returns:

  • (Boolean)


136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/better_auth/plugins/captcha.rb', line 136

def captcha_success?(config, result)
  return false unless result && result["success"]

  if config[:provider].to_s == "google-recaptcha" && result.key?("score")
    return false if result["score"].to_f < (config[:min_score] || 0.5).to_f
  end

  if ["google-recaptcha", "cloudflare-turnstile"].include?(config[:provider].to_s)
    expected_action = config[:expected_action].to_s
    return false if !expected_action.empty? && result["action"] != expected_action

    allowed_hostnames = Array(config[:allowed_hostnames]).map(&:to_s)
    return false if allowed_hostnames.any? && !allowed_hostnames.include?(result["hostname"].to_s)
  end

  true
end

.captcha_verifier_params(params) ⇒ Object



94
95
96
97
98
99
100
101
102
103
104
# File 'lib/better_auth/plugins/captcha.rb', line 94

def captcha_verifier_params(params)
  provider = params.fetch(:provider)
  payload = captcha_payload(provider, params)
  content_type = (provider == "cloudflare-turnstile") ? "application/json" : "application/x-www-form-urlencoded"
  {
    url: params.fetch(:site_verify_url),
    content_type: content_type,
    payload: payload,
    provider: provider
  }
end

.captcha_verify(config, response_token, remote_ip) ⇒ Object



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/better_auth/plugins/captcha.rb', line 73

def captcha_verify(config, response_token, remote_ip)
  provider = config[:provider].to_s
  url = config[:site_verify_url_override] || CAPTCHA_SITE_VERIFY_URLS.fetch(provider)
  params = {
    site_verify_url: url,
    secret_key: config[:secret_key],
    captcha_response: response_token,
    remote_ip: remote_ip,
    site_key: config[:site_key],
    min_score: config[:min_score],
    provider: provider
  }
  Timeout.timeout(CAPTCHA_VERIFY_TIMEOUT_SECONDS) do
    if config[:verifier].respond_to?(:call)
      captcha_normalize_verifier_response(config[:verifier].call(captcha_verifier_params(params)))
    else
      captcha_http_verify(params)
    end
  end
end

.change_email_email_otp_endpoint(config) ⇒ Object



390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# File 'lib/better_auth/plugins/email_otp.rb', line 390

def change_email_email_otp_endpoint(config)
  Endpoint.new(
    path: "/email-otp/change-email",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "changeEmailWithEmailOTP",
        description: "Change the current user's email with an OTP",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              newEmail: {type: "string"},
              otp: {type: "string"}
            },
            required: ["newEmail", "otp"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Email changed", OpenAPI.success_response_schema)
        }
      }
    }
  ) do |ctx|
    email_otp_change_email_enabled!(config)
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    current_email = session[:user]["email"].to_s.downcase
    new_email = body[:new_email].to_s.downcase
    validate_email_otp_email!(new_email)
    raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == current_email

    email_otp_verify!(ctx, config, email: "#{current_email}-#{new_email}", type: "change-email", otp: body[:otp].to_s)
    raise APIError.new("BAD_REQUEST", message: "Email already in use") if ctx.context.internal_adapter.find_user_by_email(new_email)

    current = ctx.context.internal_adapter.find_user_by_email(current_email)
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless current

    call_email_verification_option(ctx, :before_email_verification, current[:user])
    updated = ctx.context.internal_adapter.update_user(current[:user]["id"], email: new_email, emailVerified: true)
    call_email_verification_option(ctx, :after_email_verification, updated)
    Cookies.set_session_cookie(ctx, {session: session[:session], user: updated})
    ctx.json({success: true})
  end
end

.check_verification_otp_endpoint(config) ⇒ Object



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/better_auth/plugins/email_otp.rb', line 184

def check_verification_otp_endpoint(config)
  Endpoint.new(
    path: "/email-otp/check-verification-otp",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "checkVerificationOTP",
        description: "Check an email verification OTP without consuming it",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              email: {type: "string"},
              type: {type: "string"},
              otp: {type: "string"}
            },
            required: ["email", "type", "otp"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("OTP is valid", OpenAPI.success_response_schema)
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    email = body[:email].to_s.downcase
    type = body[:type].to_s
    otp = body[:otp].to_s
    validate_email_otp_type!(type)
    validate_email_otp_email!(email)
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless ctx.context.internal_adapter.find_user_by_email(email)

    email_otp_verify!(ctx, config, email: email, type: type, otp: otp, consume: false)
    ctx.json({success: true})
  end
end

.clear_active_organization_session(ctx, session, organization_id) ⇒ Object



1360
1361
1362
1363
1364
1365
1366
# File 'lib/better_auth/plugins/organization.rb', line 1360

def clear_active_organization_session(ctx, session, organization_id)
  return unless session[:session]["activeOrganizationId"] == organization_id

  update = {activeOrganizationId: nil, activeTeamId: nil}
  updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], update)
  Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => nil, "activeTeamId" => nil), user: session[:user]})
end

.clear_multi_session_cookies(ctx) ⇒ Object



186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/better_auth/plugins/multi_session.rb', line 186

def clear_multi_session_cookies(ctx)
  tokens = []
  multi_cookie_names(ctx).each do |name|
    token = ctx.get_signed_cookie(name, ctx.context.secret)
    next unless token

    tokens << token if token
    expire_cookie(ctx, canonical_multi_session_cookie_name(name))
  end
  ctx.context.internal_adapter.delete_sessions(tokens) unless tokens.empty?
  nil
end

.const_missing(name) ⇒ Object



96
97
98
99
100
101
# File 'lib/better_auth/plugins.rb', line 96

def const_missing(name)
  load_plugin_for_constant!(name)
  return const_get(name) if const_defined?(name, false)

  super
end


54
55
56
# File 'lib/better_auth/plugins.rb', line 54

def cookie_header_from_set_cookie(set_cookie)
  set_cookie.to_s.lines.map { |line| line.split(";").first }.join("; ")
end

.count_members_with_role(ctx, organization_id, role) ⇒ Object



1127
1128
1129
1130
1131
1132
1133
# File 'lib/better_auth/plugins/organization.rb', line 1127

def count_members_with_role(ctx, organization_id, role)
  count = 0
  organization_each_adapter_record(ctx.context.adapter, "member", where: [{field: "organizationId", value: organization_id}]) do |member|
    count += 1 if normalized_role_names(member["role"]).include?(role)
  end
  count
end

.create_access_control(statements) ⇒ Object Also known as: createAccessControl



78
79
80
# File 'lib/better_auth/plugins/access.rb', line 78

def create_access_control(statements)
  AccessControl.new(statements)
end

.create_default_team(ctx, config, organization, session) ⇒ Object



1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
# File 'lib/better_auth/plugins/organization.rb', line 1368

def create_default_team(ctx, config, organization, session)
  custom = config.dig(:teams, :default_team, :custom_create_default_team)
  team_data = {organizationId: organization["id"], name: organization["name"], createdAt: Time.now}
  merge_hook_data!(team_data, run_org_hook(config, :before_create_team, {team: team_data, user: session[:user], organization: organization_wire(ctx, organization)}, ctx))
  team = if custom.respond_to?(:call)
    custom.call(organization_wire(ctx, organization), ctx)
  else
    ctx.context.adapter.create(model: "team", data: team_data, force_allow_id: true)
  end
  ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now})
  run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
  team
end

.create_jwk(ctx, config) ⇒ Object



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/better_auth/plugins/jwt.rb', line 320

def create_jwk(ctx, config)
  adapter = config[:adapter]
  alg = (config.dig(:jwks, :key_pair_config, :alg) || "EdDSA").to_s
  pair = generate_key_pair(alg)
  public_key = public_key_for(pair)
  public_pem = public_key_pem(public_key)
  data = {
    "id" => Crypto.uuid,
    "publicKey" => public_pem,
    "privateKey" => jwk_private_key_for_storage(ctx, private_key_pem(pair), config),
    "createdAt" => Time.now,
    "alg" => alg,
    "pem" => public_pem
  }
  data.merge!(public_key_jwk_fields(public_key, alg))
  data["expiresAt"] = Time.now + config.dig(:jwks, :rotation_interval).to_i if config.dig(:jwks, :rotation_interval)

  created = if adapter && adapter[:create_jwk].respond_to?(:call)
    stringify_payload(adapter[:create_jwk].call(data, ctx))
  else
    ctx.context.adapter.create(model: "jwks", data: data, force_allow_id: true)
  end
  cache = config[:local_jwks_cache]
  cache.keys = nil if cache
  cache.expires_at = nil if cache
  created
end

.create_verification_otp_endpoint(config) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/better_auth/plugins/email_otp.rb', line 120

def create_verification_otp_endpoint(config)
  Endpoint.new(method: "POST", metadata: {server_only: true}) do |ctx|
    body = normalize_hash(ctx.body)
    email = body[:email].to_s.downcase
    type = body[:type].to_s
    validate_email_otp_type!(type)

    otp = email_otp_generate(config, email: email, type: type, ctx: ctx)
    email_otp_store(ctx, config, email: email, type: type, otp: otp)
    otp
  end
end

.custom_session(resolver, options = nil, plugin_options = nil, **keywords) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/better_auth/plugins/custom_session.rb', line 7

def custom_session(resolver, options = nil, plugin_options = nil, **keywords)
  config = normalize_hash(plugin_options || {})
  config = config.merge(normalize_hash(options)) if options && !options.key?(:plugins)
  config = config.merge(normalize_hash(keywords))

  Plugin.new(
    id: "custom-session",
    endpoints: {
      get_session: Endpoint.new(
        path: "/get-session",
        method: "GET",
        query_schema: ->(query) { query || {} },
        metadata: {
          CUSTOM_SESSION: true,
          openapi: {
            description: "Get custom session data",
            responses: {
              "200" => {
                description: "Success",
                content: {
                  "application/json" => {
                    schema: {
                      type: "object",
                      nullable: true
                    }
                  }
                }
              }
            }
          }
        }
      ) do |ctx|
        session = Session.find_current(
          ctx,
          disable_cookie_cache: truthy_value?(fetch_value(ctx.query, "disableCookieCache")),
          disable_refresh: truthy_value?(fetch_value(ctx.query, "disableRefresh"))
        )
        next ctx.json(nil) unless session

        Cookies.set_session_cookie(ctx, session, false) if ctx.response_headers["set-cookie"].to_s.empty?
        ctx.json(resolver.call(Routes.parsed_session_response(ctx, session), ctx))
      end
    },
    hooks: {
      after: [
        {
          matcher: ->(ctx) { ctx.path == "/multi-session/list-device-sessions" && config[:should_mutate_list_device_sessions_endpoint] },
          handler: lambda do |ctx|
            list = Array(ctx.returned)
            ctx.json(list.map { |entry| resolver.call(symbolize_session(entry), ctx) })
          end
        }
      ]
    },
    options: config
  )
end

.deep_merge(base, override) ⇒ Object



516
517
518
519
520
521
522
523
524
# File 'lib/better_auth/plugins/jwt.rb', line 516

def deep_merge(base, override)
  normalize_hash(base || {}).merge(normalize_hash(override || {})) do |_key, old_value, new_value|
    if old_value.is_a?(Hash) && new_value.is_a?(Hash)
      deep_merge(old_value, new_value)
    else
      new_value
    end
  end
end

.deep_merge_hashes(base, override) ⇒ Object



44
45
46
47
48
49
50
51
52
# File 'lib/better_auth/plugins.rb', line 44

def deep_merge_hashes(base, override)
  base.merge(override) do |_key, old_value, new_value|
    if old_value.is_a?(Hash) && new_value.is_a?(Hash)
      deep_merge_hashes(old_value, new_value)
    else
      new_value
    end
  end
end

.default_username_valid?(username) ⇒ Boolean

Returns:

  • (Boolean)


368
369
370
# File 'lib/better_auth/plugins/username.rb', line 368

def default_username_valid?(username)
  username.match?(/\A[a-zA-Z0-9_.]+\z/)
end

.delete_anonymous_user_endpoint(config) ⇒ Object



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/better_auth/plugins/anonymous.rb', line 92

def delete_anonymous_user_endpoint(config)
  Endpoint.new(
    path: "/delete-anonymous-user",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "deleteAnonymousUser",
        description: "Delete the current anonymous user",
        requestBody: OpenAPI.empty_request_body,
        responses: {
          "200" => OpenAPI.json_response("Anonymous user deleted", OpenAPI.success_response_schema)
        }
      }
    }
  ) do |ctx|
    session = Routes.current_session(ctx, sensitive: true)

    if config[:disable_delete_anonymous_user]
      raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["DELETE_ANONYMOUS_USER_DISABLED"])
    end

    unless session[:user]["isAnonymous"]
      raise APIError.new("FORBIDDEN", message: ANONYMOUS_ERROR_CODES["USER_IS_NOT_ANONYMOUS"])
    end

    begin
      ctx.context.internal_adapter.delete_user(session[:user]["id"])
    rescue
      raise APIError.new("INTERNAL_SERVER_ERROR", message: ANONYMOUS_ERROR_CODES["FAILED_TO_DELETE_ANONYMOUS_USER"])
    end

    Cookies.delete_session_cookie(ctx)
    ctx.json({success: true})
  end
end

.detect_i18n_locale(ctx, config) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/better_auth/plugins/i18n.rb', line 107

def detect_i18n_locale(ctx, config)
  available_locales = config[:translations].keys

  config[:detection].each do |strategy|
    locale = case strategy
    when "header"
      detect_locale_from_header(ctx, available_locales)
    when "cookie"
      detect_locale_from_cookie(ctx, config, available_locales)
    when "session"
      detect_locale_from_session(ctx, config, available_locales)
    when "callback"
      detect_locale_from_callback(ctx, config, available_locales)
    end
    return locale if locale && available_locales.include?(locale)
  end

  config[:default_locale]
end

.detect_locale_from_callback(ctx, config, available_locales) ⇒ Object



151
152
153
154
155
156
157
158
# File 'lib/better_auth/plugins/i18n.rb', line 151

def detect_locale_from_callback(ctx, config, available_locales)
  callback = config[:get_locale]
  return nil unless callback.respond_to?(:call)

  locale = callback.call(ctx)
  locale = locale.to_s
  locale if locale && !locale.empty? && available_locales.include?(locale)
end


133
134
135
136
137
# File 'lib/better_auth/plugins/i18n.rb', line 133

def detect_locale_from_cookie(ctx, config, available_locales)
  value = ctx.get_cookie(config[:locale_cookie])
  locale = value.to_s
  locale if value && available_locales.include?(locale)
end

.detect_locale_from_header(ctx, available_locales) ⇒ Object



127
128
129
130
131
# File 'lib/better_auth/plugins/i18n.rb', line 127

def detect_locale_from_header(ctx, available_locales)
  parse_accept_language(ctx.headers["accept-language"]).find do |locale|
    available_locales.include?(locale)
  end
end

.detect_locale_from_session(ctx, config, available_locales) ⇒ Object



139
140
141
142
143
144
145
146
147
148
149
# File 'lib/better_auth/plugins/i18n.rb', line 139

def detect_locale_from_session(ctx, config, available_locales)
  session = ctx.context.current_session || ctx.context.new_session
  return nil unless session

  user = session[:user] || session["user"]
  return nil unless user.is_a?(Hash)

  locale = fetch_value(user, config[:user_locale_field])
  locale = locale.to_s
  locale if locale && !locale.empty? && available_locales.include?(locale)
end

.device_approve_endpointObject



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/better_auth/plugins/device_authorization.rb', line 239

def device_approve_endpoint
  Endpoint.new(
    path: "/device/approve",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "approveDevice",
        description: "Approve a device authorization request",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              userCode: {type: "string", description: "User code shown on the device"}
            },
            required: ["userCode"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Success", OpenAPI.success_response_schema)
        }
      }
    }
  ) do |ctx|
    session = Routes.current_session(ctx, allow_nil: true)
    raise device_authorization_error("UNAUTHORIZED", "unauthorized", DEVICE_AUTHORIZATION_ERROR_CODES["AUTHENTICATION_REQUIRED"]) unless session

    process_device_decision(ctx, session, "approved")
  end
end

.device_authorization(options = {}) ⇒ Object



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/better_auth/plugins/device_authorization.rb', line 26

def device_authorization(options = {})
  config = {
    expires_in: "30m",
    interval: "5s",
    device_code_length: 40,
    user_code_length: 8
  }.merge(normalize_hash(options))
  validate_device_authorization_options!(config)

  Plugin.new(
    id: "device-authorization",
    endpoints: {
      device_code: device_code_endpoint(config),
      device_token: device_token_endpoint(config),
      device_verify: device_verify_endpoint,
      device_approve: device_approve_endpoint,
      device_deny: device_deny_endpoint
    },
    schema: device_authorization_schema(config[:schema]),
    error_codes: DEVICE_AUTHORIZATION_ERROR_CODES,
    options: config
  )
end

.device_authorization_error(status, error, description) ⇒ Object



440
441
442
# File 'lib/better_auth/plugins/device_authorization.rb', line 440

def device_authorization_error(status, error, description)
  APIError.new(status, code: error, message: description, body: {error: error, error_description: description})
end

.device_authorization_schema(custom_schema = nil) ⇒ Object



450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
# File 'lib/better_auth/plugins/device_authorization.rb', line 450

def device_authorization_schema(custom_schema = nil)
  base = {
    deviceCode: {
      fields: {
        deviceCode: {type: "string", required: true},
        userCode: {type: "string", required: true},
        userId: {type: "string", required: false},
        expiresAt: {type: "date", required: true},
        status: {type: "string", required: true},
        lastPolledAt: {type: "date", required: false},
        pollingInterval: {type: "number", required: false},
        clientId: {type: "string", required: false},
        scope: {type: "string", required: false}
      }
    }
  }
  return base unless custom_schema.is_a?(Hash)

  deep_merge_hashes(base, normalize_hash(custom_schema))
end

.device_authorization_time(value) ⇒ Object



444
445
446
447
448
# File 'lib/better_auth/plugins/device_authorization.rb', line 444

def device_authorization_time(value)
  return value if value.is_a?(Time)

  Time.parse(value.to_s)
end

.device_authorization_user_code_candidates(value) ⇒ Object



431
432
433
434
435
436
437
438
# File 'lib/better_auth/plugins/device_authorization.rb', line 431

def device_authorization_user_code_candidates(value)
  original = value.to_s
  upper = original.upcase
  clean = original.delete("-")
  upper_clean = upper.delete("-")
  dashed = (upper_clean.length == 8) ? "#{upper_clean[0, 4]}-#{upper_clean[4, 4]}" : upper_clean
  [original, upper, clean, upper_clean, dashed].uniq
end

.device_code_endpoint(config) ⇒ Object



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/better_auth/plugins/device_authorization.rb', line 50

def device_code_endpoint(config)
  Endpoint.new(
    path: "/device/code",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "requestDeviceCode",
        description: "Request a device and user code",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              client_id: {type: "string", description: "OAuth client ID"},
              user_id: {type: "string", description: "Optional user ID to pre-bind to the device code"},
              scope: {type: "string", description: "Requested scopes"}
            }
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Success", device_code_response_schema)
        }
      }
    }
  ) do |ctx|
    body = OAuthProtocol.stringify_keys(ctx.body)
    client_id = body["client_id"]
    if config[:validate_client] && !config[:validate_client].call(client_id)
      raise device_authorization_error("BAD_REQUEST", "invalid_client", "Invalid client ID")
    end

    config[:on_device_auth_request].call(client_id, body["scope"]) if config[:on_device_auth_request].respond_to?(:call)

    device_code = generate_device_authorization_device_code(config)
    user_code = generate_device_authorization_user_code(config)
    expires_in = duration_seconds(config[:expires_in])
    interval = duration_seconds(config[:interval])
    ctx.context.adapter.create(
      model: "deviceCode",
      data: {
        "deviceCode" => device_code,
        "userCode" => user_code,
        "userId" => body["user_id"].to_s.empty? ? nil : body["user_id"],
        "expiresAt" => Time.now + expires_in,
        "status" => "pending",
        "pollingInterval" => interval * 1000,
        "clientId" => client_id,
        "scope" => body["scope"]
      }
    )
    verification_uri = verification_uri(ctx, config)
    complete = OAuthProtocol.redirect_uri_with_params(verification_uri, user_code: user_code)
    ctx.json({
      device_code: device_code,
      user_code: user_code,
      verification_uri: verification_uri,
      verification_uri_complete: complete,
      expires_in: expires_in,
      interval: interval
    }, headers: {"Cache-Control" => "no-store"})
  end
end

.device_code_response_schemaObject



327
328
329
330
331
332
333
334
335
336
337
338
339
# File 'lib/better_auth/plugins/device_authorization.rb', line 327

def device_code_response_schema
  OpenAPI.object_schema(
    {
      device_code: {type: "string", description: "The device verification code"},
      user_code: {type: "string", description: "The user code to display"},
      verification_uri: {type: "string", format: "uri"},
      verification_uri_complete: {type: "string", format: "uri"},
      expires_in: {type: "number"},
      interval: {type: "number"}
    },
    required: ["device_code", "user_code", "verification_uri", "verification_uri_complete", "expires_in", "interval"]
  )
end

.device_deny_endpointObject



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/better_auth/plugins/device_authorization.rb', line 268

def device_deny_endpoint
  Endpoint.new(
    path: "/device/deny",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "denyDevice",
        description: "Deny a device authorization request",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              userCode: {type: "string", description: "User code shown on the device"}
            },
            required: ["userCode"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Success", OpenAPI.success_response_schema)
        }
      }
    }
  ) do |ctx|
    session = Routes.current_session(ctx, allow_nil: true)
    raise device_authorization_error("UNAUTHORIZED", "unauthorized", DEVICE_AUTHORIZATION_ERROR_CODES["AUTHENTICATION_REQUIRED"]) unless session

    process_device_decision(ctx, session, "denied")
  end
end

.device_token_endpoint(config) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/better_auth/plugins/device_authorization.rb', line 111

def device_token_endpoint(config)
  Endpoint.new(
    path: "/device/token",
    method: "POST",
    metadata: {
      allowed_media_types: ["application/x-www-form-urlencoded", "application/json"],
      openapi: {
        operationId: "exchangeDeviceToken",
        description: "Exchange device code for access token",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              grant_type: {type: "string", enum: [OAuthProtocol::DEVICE_CODE_GRANT]},
              device_code: {type: "string"},
              client_id: {type: "string"}
            },
            required: ["grant_type", "device_code"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Success", device_token_response_schema)
        }
      }
    }
  ) do |ctx|
    body = OAuthProtocol.stringify_keys(ctx.body)
    raise device_authorization_error("BAD_REQUEST", "invalid_request", "Unsupported grant type") unless body["grant_type"] == OAuthProtocol::DEVICE_CODE_GRANT
    if config[:validate_client] && !config[:validate_client].call(body["client_id"])
      raise device_authorization_error("BAD_REQUEST", "invalid_grant", "Invalid client ID")
    end

    record = find_device_code(ctx, body["device_code"])
    raise device_authorization_error("BAD_REQUEST", "invalid_grant", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_DEVICE_CODE"]) unless record
    record = OAuthProtocol.stringify_keys(record)
    if record["clientId"] && record["clientId"] != body["client_id"]
      raise device_authorization_error("BAD_REQUEST", "invalid_grant", "Client ID mismatch")
    end

    if record["lastPolledAt"] && record["pollingInterval"].to_i.positive?
      elapsed = ((Time.now - device_authorization_time(record["lastPolledAt"])) * 1000).to_i
      raise device_authorization_error("BAD_REQUEST", "slow_down", DEVICE_AUTHORIZATION_ERROR_CODES["POLLING_TOO_FREQUENTLY"]) if elapsed < record["pollingInterval"].to_i
    end

    ctx.context.adapter.update(model: "deviceCode", where: [{field: "id", value: record["id"]}], update: {"lastPolledAt" => Time.now})

    if device_authorization_time(record["expiresAt"]) <= Time.now
      ctx.context.adapter.delete(model: "deviceCode", where: [{field: "id", value: record["id"]}])
      raise device_authorization_error("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_DEVICE_CODE"])
    end

    case record["status"]
    when "pending"
      raise device_authorization_error("BAD_REQUEST", "authorization_pending", DEVICE_AUTHORIZATION_ERROR_CODES["AUTHORIZATION_PENDING"])
    when "denied"
      ctx.context.adapter.delete(model: "deviceCode", where: [{field: "id", value: record["id"]}])
      raise device_authorization_error("BAD_REQUEST", "access_denied", DEVICE_AUTHORIZATION_ERROR_CODES["ACCESS_DENIED"])
    when "approved"
      claimed = ctx.context.adapter.consume_one(
        model: "deviceCode",
        where: [
          {field: "deviceCode", value: body["device_code"].to_s},
          {field: "status", value: "approved"}
        ]
      )
      unless claimed && claimed["userId"]
        raise device_authorization_error("BAD_REQUEST", "invalid_grant", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_DEVICE_CODE"])
      end

      record = OAuthProtocol.stringify_keys(claimed)
      user = ctx.context.internal_adapter.find_user_by_id(record["userId"])
      raise device_authorization_error("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["USER_NOT_FOUND"]) unless user

      session = ctx.context.internal_adapter.create_session(user["id"])
      raise device_authorization_error("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session

      session_data = {session: session, user: user}
      ctx.context.set_new_session(session_data) if ctx.context.respond_to?(:set_new_session)
      ctx.json({
        access_token: session["token"],
        token_type: "Bearer",
        expires_in: [session["expiresAt"].to_i - Time.now.to_i, 0].max,
        scope: record["scope"].to_s
      }, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
    else
      raise device_authorization_error("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_DEVICE_CODE_STATUS"])
    end
  end
end

.device_token_response_schemaObject



341
342
343
344
345
346
347
348
349
350
351
# File 'lib/better_auth/plugins/device_authorization.rb', line 341

def device_token_response_schema
  OpenAPI.object_schema(
    {
      access_token: {type: "string"},
      token_type: {type: "string"},
      expires_in: {type: "number"},
      scope: {type: "string"}
    },
    required: ["access_token", "token_type", "expires_in"]
  )
end

.device_verification_response_schemaObject



353
354
355
356
357
358
359
360
361
# File 'lib/better_auth/plugins/device_authorization.rb', line 353

def device_verification_response_schema
  OpenAPI.object_schema(
    {
      user_code: {type: "string"},
      status: {type: "string"}
    },
    required: ["user_code", "status"]
  )
end

.device_verify_endpointObject



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/better_auth/plugins/device_authorization.rb', line 200

def device_verify_endpoint
  Endpoint.new(
    path: "/device",
    method: "GET",
    metadata: {
      openapi: {
        operationId: "getDeviceVerification",
        description: "Get device verification status",
        responses: {
          "200" => OpenAPI.json_response("Success", device_verification_response_schema)
        }
      }
    }
  ) do |ctx|
    code = normalize_user_code(OAuthProtocol.stringify_keys(ctx.query)["user_code"])
    record = find_device_user_code(ctx, code)
    raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_USER_CODE"]) unless record
    record = OAuthProtocol.stringify_keys(record)
    raise device_authorization_error("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_USER_CODE"]) if device_authorization_time(record["expiresAt"]) <= Time.now

    session = Routes.current_session(ctx, allow_nil: true)
    if session && !record["userId"] && record["status"] == "pending"
      claimed = ctx.context.adapter.increment_one(
        model: "deviceCode",
        where: [
          {field: "id", value: record["id"]},
          {field: "status", value: "pending"},
          {field: "userId", value: nil}
        ],
        increment: {},
        set: {"userId" => session[:user]["id"]}
      )
      record = OAuthProtocol.stringify_keys(claimed) if claimed
    end

    ctx.json({user_code: code, status: record["status"]})
  end
end

.dub(options = {}) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/better_auth/plugins/dub.rb', line 7

def dub(options = {})
  config = normalize_hash(options)
  oauth_plugin = dub_oauth_plugin(config[:oauth])
  endpoints = {dub_link: dub_link_endpoint(oauth_plugin)}
  endpoints[:dub_oauth2_callback] = oauth_plugin.endpoints.fetch(:oauth2_callback) if oauth_plugin

  Plugin.new(
    id: "dub",
    endpoints: endpoints,
    init: ->(_context) {
      {
        options: {
          database_hooks: {
            user: {
              create: {
                after: ->(user, ctx) { dub_track_lead(config, user, ctx) }
              }
            }
          }
        }
      }
    },
    options: config
  )
end

.dub_default_lead_track(config, user, dub_id, ctx) ⇒ Object



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/better_auth/plugins/dub.rb', line 115

def dub_default_lead_track(config, user, dub_id, ctx)
  track = config[:dub_client]&.track
  return unless track&.respond_to?(:lead)

  dub_invoke_lead(
    track,
    click_id: dub_id,
    event_name: config[:lead_event_name] || "Sign Up",
    customer_external_id: fetch_value(user, "id"),
    customer_name: fetch_value(user, "name"),
    customer_email: fetch_value(user, "email"),
    customer_avatar: fetch_value(user, "image")
  )
rescue => error
  dub_log_error(ctx, error)
end

.dub_invoke_lead(track, payload) ⇒ Object



141
142
143
144
145
146
147
# File 'lib/better_auth/plugins/dub.rb', line 141

def dub_invoke_lead(track, payload)
  if track.method(:lead).parameters.any? { |type, name| [:key, :keyreq].include?(type) && name == :request }
    track.lead(request: dub_lead_request_body(payload))
  else
    track.lead(payload)
  end
end

.dub_lead_request_body(payload) ⇒ Object



149
150
151
152
153
154
# File 'lib/better_auth/plugins/dub.rb', line 149

def dub_lead_request_body(payload)
  klass = defined?(::OpenApiSDK::Models::Operations::TrackLeadRequestBody) && ::OpenApiSDK::Models::Operations::TrackLeadRequestBody
  return payload unless klass

  klass.new(**payload)
end


33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/better_auth/plugins/dub.rb', line 33

def dub_link_endpoint(oauth_plugin)
  Endpoint.new(
    path: "/dub/link",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "dubLink",
        description: "Link a Dub OAuth account",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              callbackURL: {type: "string", description: "The URL to redirect to after linking"},
              callback_url: {type: "string", description: "The URL to redirect to after linking"}
            }
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "Authorization URL generated successfully for linking a Dub account",
            OpenAPI.object_schema(
              {
                url: {type: "string"},
                redirect: {type: "boolean"}
              },
              required: ["url", "redirect"]
            )
          )
        }
      }
    }
  ) do |ctx|
    unless oauth_plugin
      raise APIError.new("NOT_FOUND", message: "Dub OAuth is not configured")
    end

    body = normalize_hash(ctx.body)
    callback_url = body[:callback_url] || body[:callbackURL]
    if callback_url.to_s.empty?
      raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"])
    end
    Routes.validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")

    ctx.body = body.merge(provider_id: "dub", callback_url: callback_url)
    oauth_plugin.endpoints.fetch(:oauth2_link_account).call(ctx)
  end
end

.dub_log_error(ctx, error) ⇒ Object



132
133
134
135
136
137
138
139
# File 'lib/better_auth/plugins/dub.rb', line 132

def dub_log_error(ctx, error)
  logger = ctx.context.logger
  if logger.respond_to?(:error)
    logger.error(error)
  elsif logger.respond_to?(:call)
    logger.call(:error, error)
  end
end

.dub_oauth_plugin(oauth_options) ⇒ Object



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/better_auth/plugins/dub.rb', line 80

def dub_oauth_plugin(oauth_options)
  oauth = normalize_hash(oauth_options || {})
  return nil if oauth.empty?

  generic_oauth(
    config: [
      {
        provider_id: "dub",
        authorization_url: "https://app.dub.co/oauth/authorize",
        token_url: "https://api.dub.co/oauth/token",
        client_id: oauth[:client_id],
        client_secret: oauth[:client_secret],
        pkce: oauth.key?(:pkce) ? oauth[:pkce] : true
      }
    ]
  )
end

.dub_track_lead(config, user, ctx) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/better_auth/plugins/dub.rb', line 98

def dub_track_lead(config, user, ctx)
  return unless ctx

  dub_id = ctx.get_cookie("dub_id")
  return if dub_id.to_s.empty?
  return if config[:disable_lead_tracking]

  custom = config[:custom_lead_track]
  if custom.respond_to?(:call)
    custom.call(user, ctx)
  else
    dub_default_lead_track(config, user, dub_id, ctx)
  end

  ctx.set_cookie("dub_id", "", expires: Time.at(0), max_age: 0)
end

.duration_seconds(value) ⇒ Object

Raises:



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/better_auth/plugins/device_authorization.rb', line 399

def duration_seconds(value)
  return value if value.is_a?(Integer)

  match = value.to_s.match(/\A(\d+)(ms|s|m|min|h|d)?\z/)
  raise Error, "Invalid time string" unless match

  amount = match[1].to_i
  case match[2]
  when "ms" then (amount / 1000.0).ceil
  when "m", "min" then amount * 60
  when "h" then amount * 3600
  when "d" then amount * 86_400
  else amount
  end
end

.ec_curve_for_alg(alg) ⇒ Object



443
444
445
# File 'lib/better_auth/plugins/jwt.rb', line 443

def ec_curve_for_alg(alg)
  (alg == "ES512") ? "P-521" : "P-256"
end

.email_otp(options = {}) ⇒ Object



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/better_auth/plugins/email_otp.rb', line 13

def email_otp(options = {})
  config = {
    expires_in: 5 * 60,
    otp_length: 6,
    store_otp: "plain",
    allowed_attempts: 3
  }.merge(normalize_hash(options))

  Plugin.new(
    id: "email-otp",
    init: email_otp_init(config),
    endpoints: {
      send_verification_otp: send_verification_otp_endpoint(config),
      create_verification_otp: create_verification_otp_endpoint(config),
      get_verification_otp: get_verification_otp_endpoint(config),
      check_verification_otp: check_verification_otp_endpoint(config),
      verify_email_otp: verify_email_otp_endpoint(config),
      sign_in_email_otp: (config),
      request_password_reset_email_otp: request_password_reset_email_otp_endpoint(config),
      reset_password_email_otp: reset_password_email_otp_endpoint(config),
      request_email_change_email_otp: request_email_change_email_otp_endpoint(config),
      change_email_email_otp: change_email_email_otp_endpoint(config)
    },
    hooks: {
      after: [
        {
          matcher: ->(ctx) { ctx.path.to_s.start_with?("/sign-up") && config[:send_verification_on_sign_up] && !config[:override_default_email_verification] },
          handler: ->(ctx) { (ctx, config) }
        }
      ]
    },
    rate_limit: email_otp_rate_limits(config),
    error_codes: EMAIL_OTP_ERROR_CODES,
    options: config
  )
end

.email_otp_after_sign_up(ctx, config) ⇒ Object



511
512
513
514
515
516
517
518
519
520
521
# File 'lib/better_auth/plugins/email_otp.rb', line 511

def (ctx, config)
  response = ctx.returned
  user = fetch_value(response, :user)
  email = fetch_value(user, :email).to_s.downcase
  return unless Routes::EMAIL_PATTERN.match?(email)

  otp = email_otp_generate(config, email: email, type: "email-verification", ctx: ctx)
  email_otp_store(ctx, config, email: email, type: "email-verification", otp: otp)
  email_otp_deliver(config, {email: email, otp: otp, type: "email-verification"}, ctx)
  nil
end

.email_otp_change_email_enabled!(config) ⇒ Object

Raises:



728
729
730
731
732
# File 'lib/better_auth/plugins/email_otp.rb', line 728

def email_otp_change_email_enabled!(config)
  return if config.dig(:change_email, :enabled)

  raise APIError.new("BAD_REQUEST", message: "Change email with OTP is disabled")
end

.email_otp_deliver(config, data, ctx) ⇒ Object



701
702
703
704
# File 'lib/better_auth/plugins/email_otp.rb', line 701

def email_otp_deliver(config, data, ctx)
  sender = config[:send_verification_otp]
  sender.call(data, ctx) if sender.respond_to?(:call)
end

.email_otp_generate(config, email:, type:, ctx:) ⇒ Object



629
630
631
632
633
634
635
# File 'lib/better_auth/plugins/email_otp.rb', line 629

def email_otp_generate(config, email:, type:, ctx:)
  generator = config[:generate_otp]
  generated = generator.call({email: email, type: type}, ctx) if generator.respond_to?(:call)
  return generated.to_s if generated && !generated.to_s.empty?

  Array.new(config[:otp_length].to_i) { SecureRandom.random_number(10).to_s }.join
end

.email_otp_identifier(email, type) ⇒ Object



706
707
708
# File 'lib/better_auth/plugins/email_otp.rb', line 706

def email_otp_identifier(email, type)
  "#{type}-otp-#{email}"
end

.email_otp_init(config) ⇒ Object



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/better_auth/plugins/email_otp.rb', line 50

def email_otp_init(config)
  lambda do |context|
    next unless config[:override_default_email_verification]

    {
      options: {
        email_verification: {
          send_verification_email: lambda do |data, request = nil|
            user = fetch_value(data, :user) || data
            email = fetch_value(user, :email).to_s
            endpoint_context = Endpoint::Context.new(
              path: "/send-verification-email",
              method: "POST",
              query: {},
              body: {"email" => email, "type" => "email-verification"},
              params: {},
              headers: {},
              context: context,
              request: request
            )
            email_otp_send_verification(endpoint_context, config, email: email, type: "email-verification")
          end
        }
      }
    }
  end
end

.email_otp_matches?(ctx, config, stored_otp, otp) ⇒ Boolean

Returns:

  • (Boolean)


673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
# File 'lib/better_auth/plugins/email_otp.rb', line 673

def email_otp_matches?(ctx, config, stored_otp, otp)
  storage = config[:store_otp]
  actual, expected = if storage.to_s == "hashed"
    [Crypto.sha256(otp, encoding: :base64url), stored_otp]
  elsif storage.to_s == "encrypted"
    [Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_otp), otp]
  elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
    [storage[:hash].call(otp), stored_otp]
  elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
    [storage[:decrypt].call(stored_otp), otp]
  else
    [otp, stored_otp]
  end
  return false unless actual
  return false unless actual.to_s.bytesize == expected.to_s.bytesize

  Crypto.constant_time_compare(actual.to_s, expected.to_s)
end

.email_otp_password_reset_request(ctx, config) ⇒ Object



523
524
525
526
527
528
529
530
531
532
533
534
535
536
# File 'lib/better_auth/plugins/email_otp.rb', line 523

def email_otp_password_reset_request(ctx, config)
  body = normalize_hash(ctx.body)
  email = body[:email].to_s.downcase
  otp = email_otp_resolve(ctx, config, email: email, type: "forget-password")

  found = ctx.context.internal_adapter.find_user_by_email(email)
  unless found
    ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier(email, "forget-password"))
    return ctx.json({success: true})
  end

  email_otp_deliver(config, {email: email, otp: otp, type: "forget-password"}, ctx)
  ctx.json({success: true})
end

.email_otp_plain_value(ctx, config, stored_otp) ⇒ Object



692
693
694
695
696
697
698
699
# File 'lib/better_auth/plugins/email_otp.rb', line 692

def email_otp_plain_value(ctx, config, stored_otp)
  storage = config[:store_otp]
  return stored_otp if storage.to_s == "plain" || storage.nil?
  return Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_otp) if storage.to_s == "encrypted"
  return storage[:decrypt].call(stored_otp) if storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)

  nil
end

.email_otp_rate_limits(config) ⇒ Object



739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
# File 'lib/better_auth/plugins/email_otp.rb', line 739

def email_otp_rate_limits(config)
  rate_limit = normalize_hash(config[:rate_limit] || {})
  window = rate_limit[:window] || 60
  max = rate_limit[:max] || 3
  %w[
    /email-otp/send-verification-otp
    /email-otp/check-verification-otp
    /email-otp/verify-email
    /sign-in/email-otp
    /email-otp/request-password-reset
    /email-otp/reset-password
    /email-otp/request-email-change
    /email-otp/change-email
  ].map do |path|
    {
      path_matcher: ->(request_path) { request_path == path },
      window: window,
      max: max
    }
  end
end

.email_otp_resolve(ctx, config, email:, type:, identifier_email: email) ⇒ Object



565
566
567
568
569
570
571
572
573
574
# File 'lib/better_auth/plugins/email_otp.rb', line 565

def email_otp_resolve(ctx, config, email:, type:, identifier_email: email)
  if config[:resend_strategy].to_s == "reuse"
    reused = email_otp_reuse(ctx, config, email: identifier_email, type: type)
    return reused if reused
  end

  otp = email_otp_generate(config, email: email, type: type, ctx: ctx)
  email_otp_store(ctx, config, email: identifier_email, type: type, otp: otp)
  otp
end

.email_otp_reuse(ctx, config, email:, type:) ⇒ Object



576
577
578
579
580
581
582
583
584
585
586
587
588
589
# File 'lib/better_auth/plugins/email_otp.rb', line 576

def email_otp_reuse(ctx, config, email:, type:)
  identifier = email_otp_identifier(email, type)
  verification = ctx.context.internal_adapter.find_verification_value(identifier)
  return nil unless verification && !Routes.expired_time?(verification["expiresAt"])

  stored_otp, attempts = email_otp_split(verification["value"])
  return nil if attempts.to_i >= config[:allowed_attempts].to_i

  plain = email_otp_plain_value(ctx, config, stored_otp)
  return nil unless plain

  ctx.context.internal_adapter.update_verification_value(verification["id"], expiresAt: Time.now + config[:expires_in].to_i)
  plain
end

.email_otp_send_verification(ctx, config, email:, type:) ⇒ Object



538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
# File 'lib/better_auth/plugins/email_otp.rb', line 538

def email_otp_send_verification(ctx, config, email:, type:)
  otp = email_otp_resolve(ctx, config, email: email, type: type)
  found = ctx.context.internal_adapter.find_user_by_email(email)

  unless found
    if type == "sign-in" && !config[:disable_sign_up]
      # Upstream allows sign-in OTP creation for new users when sign-up is enabled.
    else
      ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier(email, type))
      return
    end
  end

  email_otp_deliver(config, {email: email, otp: otp, type: type}, ctx)
end

.email_otp_sign_up_user_data(ctx, body, email) ⇒ Object



637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
# File 'lib/better_auth/plugins/email_otp.rb', line 637

def (ctx, body, email)
  reserved = %i[email otp name image callback_url callbackURL callbackUrl]
  user_fields = Schema.auth_tables(ctx.context.options).fetch("user").fetch(:fields)
  core_fields = %w[id name email emailVerified image createdAt updatedAt]
  additional = body.each_with_object({}) do |(key, value), result|
    next if reserved.include?(key.to_sym)

    field = Schema.storage_key(key)
    attributes = user_fields[field]
    next unless attributes
    next if core_fields.include?(field)
    next if attributes[:input] == false

    result[field] = value
  end
  additional.merge(
    "email" => email,
    "emailVerified" => true,
    "name" => body[:name].to_s,
    "image" => body[:image]
  )
end

.email_otp_split(value) ⇒ Object



710
711
712
713
714
715
716
# File 'lib/better_auth/plugins/email_otp.rb', line 710

def email_otp_split(value)
  string = value.to_s
  index = string.rindex(":")
  return [string, ""] unless index

  [string[0...index], string[(index + 1)..]]
end

.email_otp_store(ctx, config, email:, type:, otp:) ⇒ Object



554
555
556
557
558
559
560
561
562
563
# File 'lib/better_auth/plugins/email_otp.rb', line 554

def email_otp_store(ctx, config, email:, type:, otp:)
  stored = email_otp_stored_value(ctx, config, otp)
  identifier = email_otp_identifier(email, type)
  ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
  ctx.context.internal_adapter.create_verification_value(
    identifier: identifier,
    value: "#{stored}:0",
    expiresAt: Time.now + config[:expires_in].to_i
  )
end

.email_otp_stored_value(ctx, config, otp) ⇒ Object



660
661
662
663
664
665
666
667
668
669
670
671
# File 'lib/better_auth/plugins/email_otp.rb', line 660

def email_otp_stored_value(ctx, config, otp)
  storage = config[:store_otp]
  return Crypto.sha256(otp, encoding: :base64url) if storage.to_s == "hashed"
  return Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: otp) if storage.to_s == "encrypted"

  if storage.is_a?(Hash)
    return storage[:hash].call(otp) if storage[:hash].respond_to?(:call)
    return storage[:encrypt].call(otp) if storage[:encrypt].respond_to?(:call)
  end

  otp
end

.email_otp_verify!(ctx, config, email:, type:, otp:, consume: true) ⇒ Object

Raises:



591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
# File 'lib/better_auth/plugins/email_otp.rb', line 591

def email_otp_verify!(ctx, config, email:, type:, otp:, consume: true)
  identifier = email_otp_identifier(email, type)
  existing = ctx.context.internal_adapter.find_verification_value(identifier)

  if existing && Routes.expired_time?(existing["expiresAt"])
    ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
    raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["OTP_EXPIRED"])
  end

  verification = ctx.context.internal_adapter.consume_verification_value(identifier)
  raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) unless verification

  otp_value, attempts = email_otp_split(verification["value"])
  attempts_count = attempts.to_i
  if attempts_count >= config[:allowed_attempts].to_i
    raise APIError.new("FORBIDDEN", message: EMAIL_OTP_ERROR_CODES["TOO_MANY_ATTEMPTS"])
  end

  unless email_otp_matches?(ctx, config, otp_value, otp)
    ctx.context.internal_adapter.create_verification_value(
      identifier: identifier,
      value: "#{otp_value}:#{attempts_count + 1}",
      expiresAt: verification["expiresAt"]
    )
    raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"])
  end

  unless consume
    ctx.context.internal_adapter.create_verification_value(
      identifier: identifier,
      value: verification["value"],
      expiresAt: verification["expiresAt"]
    )
  end

  true
end

.encode_eddsa_jwt(payload, private_key, kid) ⇒ Object



447
448
449
450
451
452
453
454
455
# File 'lib/better_auth/plugins/jwt.rb', line 447

def encode_eddsa_jwt(payload, private_key, kid)
  header = {"alg" => "EdDSA", "kid" => kid}
  signing_input = [
    Crypto.base64url_encode(JSON.generate(header)),
    Crypto.base64url_encode(JSON.generate(payload))
  ].join(".")
  signature = private_key.sign(nil, signing_input)
  "#{signing_input}.#{Crypto.base64url_encode(signature)}"
end

.ensure_external_plugin_loaded!(gem_name:, entry:, implementation_constant:) ⇒ Object



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/better_auth/plugin_loader.rb', line 114

def ensure_external_plugin_loaded!(gem_name:, entry:, implementation_constant:)
  return if const_defined?(implementation_constant, false)

  spec = Gem.loaded_specs[gem_name] || Gem::Specification.find_by_name(gem_name)
  entry_path = File.join(spec.full_gem_path, entry)
  load entry_path unless $LOADED_FEATURES.include?(entry_path)

  return if const_defined?(implementation_constant, false)

  raise LoadError,
    "BetterAuth requires the #{gem_name} gem. Add it to your Gemfile and require its entrypoint."
rescue Gem::MissingSpecError
  raise LoadError,
    "BetterAuth requires the #{gem_name} gem. Add it to your Gemfile and require its entrypoint."
end

.ensure_not_last_owner!(ctx, member, adapter: ctx.context.adapter) ⇒ Object

Raises:



1350
1351
1352
1353
1354
1355
1356
1357
1358
# File 'lib/better_auth/plugins/organization.rb', line 1350

def ensure_not_last_owner!(ctx, member, adapter: ctx.context.adapter)
  return unless member["role"].to_s.split(",").include?("owner")

  owner_count = 0
  organization_each_adapter_record(adapter, "member", where: [{field: "organizationId", value: member["organizationId"]}]) do |entry|
    owner_count += 1 if entry["role"].to_s.split(",").include?("owner")
  end
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER")) if owner_count <= 1
end

.ensure_organization_member_capacity!(ctx, config, user, organization, adapter: ctx.context.adapter) ⇒ Object

Raises:



1277
1278
1279
1280
1281
1282
1283
# File 'lib/better_auth/plugins/organization.rb', line 1277

def ensure_organization_member_capacity!(ctx, config, user, organization, adapter: ctx.context.adapter)
  limit = organization_membership_capacity_limit(ctx, config, user, organization)
  count = adapter.count(model: "member", where: [{field: "organizationId", value: organization["id"]}])
  return if count < limit

  raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_MEMBERSHIP_LIMIT_REACHED"))
end

.ensure_plugin_loaded_for!(plugin) ⇒ Object



176
177
178
179
180
181
# File 'lib/better_auth/plugin_loader.rb', line 176

def ensure_plugin_loaded_for!(plugin)
  return unless plugin.is_a?(BetterAuth::Plugin)

  loader = PLUGIN_ID_TO_LOADER[plugin.id]
  load_plugin!(loader) if loader
end

.ensure_team_member_capacity!(ctx, config, team_ids, adapter: ctx.context.adapter, lock: false) ⇒ Object



1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
# File 'lib/better_auth/plugins/organization.rb', line 1307

def ensure_team_member_capacity!(ctx, config, team_ids, adapter: ctx.context.adapter, lock: false)
  max_members = config.dig(:teams, :maximum_members_per_team)
  return unless max_members && team_ids.any?

  team_ids.each do |team_id|
    team = adapter.find_one(model: "team", where: [{field: "id", value: team_id}])
    lock_team_capacity!(adapter, team) if lock && team
    count = adapter.count(model: "teamMember", where: [{field: "teamId", value: team_id}])
    if count >= max_members.to_i
      raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_MEMBER_LIMIT_REACHED"))
    end
  end
end


222
223
224
# File 'lib/better_auth/plugins/multi_session.rb', line 222

def expire_cookie(ctx, name)
  ctx.set_cookie(name, "", ctx.context.auth_cookies[:session_token].attributes.merge(max_age: 0))
end

.expired_bearer_cookie?(cookie) ⇒ Boolean

Returns:

  • (Boolean)


89
90
91
92
# File 'lib/better_auth/plugins/bearer.rb', line 89

def expired_bearer_cookie?(cookie)
  max_age = cookie[:attributes]["max-age"]
  max_age.to_s.strip.match?(/\A[+-]?\d+\z/) && max_age.to_i == 0
end

.expo(options = {}) ⇒ Object



10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# File 'lib/better_auth/plugins/expo.rb', line 10

def expo(options = {})
  config = normalize_hash(options)
  Plugin.new(
    id: "expo",
    init: ->(_ctx) { expo_development_environment? ? {options: {trusted_origins: ["exp://"]}} : nil },
    on_request: expo_on_request(config),
    hooks: {
      after: [
        {
          matcher: ->(ctx) { %w[/callback /oauth2/callback /magic-link/verify /verify-email].any? { |path| ctx.path.to_s.start_with?(path) } },
          handler: ->(ctx) { expo_inject_cookie_into_deep_link(ctx) }
        }
      ]
    },
    endpoints: {
      expo_authorization_proxy: expo_authorization_proxy_endpoint
    },
    options: config
  )
end

.expo_authorization_proxy_endpointObject



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/better_auth/plugins/expo.rb', line 31

def expo_authorization_proxy_endpoint
  Endpoint.new(
    path: "/expo-authorization-proxy",
    method: "GET",
    metadata: {
      openapi: {
        operationId: "expoAuthorizationProxy",
        description: "Proxy an Expo authorization redirect",
        parameters: [
          {in: "query", name: "authorizationURL", required: true, schema: {type: "string", format: "uri"}},
          {in: "query", name: "oauthState", required: false, schema: {type: "string"}}
        ],
        responses: {
          "302" => {description: "Redirects to the authorization URL"}
        }
      }
    }
  ) do |ctx|
    authorization_url = ctx.query[:authorizationURL] || ctx.query["authorizationURL"] || ctx.query[:authorization_url] || ctx.query["authorization_url"]
    oauth_state = ctx.query[:oauthState] || ctx.query["oauthState"] || ctx.query[:oauth_state] || ctx.query["oauth_state"]
    raise APIError.new("BAD_REQUEST", message: "Unexpected error") if authorization_url.to_s.empty?

    if oauth_state
      cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
      ctx.set_cookie(cookie.name, oauth_state, cookie.attributes)
    else
      state = URI.parse(authorization_url).then { |uri| Rack::Utils.parse_query(uri.query)["state"] }
      raise APIError.new("BAD_REQUEST", message: "Unexpected error") if state.to_s.empty?

      cookie = ctx.context.create_auth_cookie("state", max_age: 300)
      ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
    end
    [302, ctx.response_headers.merge("location" => authorization_url), [""]]
  rescue URI::InvalidURIError
    raise APIError.new("BAD_REQUEST", message: "Unexpected error")
  end
end

.expo_development_environment?Boolean

Returns:

  • (Boolean)


100
101
102
# File 'lib/better_auth/plugins/expo.rb', line 100

def expo_development_environment?
  [ENV["RACK_ENV"], ENV["RAILS_ENV"], ENV["APP_ENV"]].include?("development")
end


82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/better_auth/plugins/expo.rb', line 82

def expo_inject_cookie_into_deep_link(ctx)
  location = ctx.response_headers["location"]
  cookie = ctx.response_headers["set-cookie"]
  return unless location && cookie
  return if location.include?("/oauth-proxy-callback")

  uri = URI.parse(location)
  return if %w[http https].include?(uri.scheme)
  return unless ctx.context.trusted_origin?(location)

  query = Rack::Utils.parse_query(uri.query)
  query["cookie"] = cookie
  uri.query = URI.encode_www_form(query)
  ctx.set_header("location", uri.to_s)
rescue URI::InvalidURIError
  nil
end

.expo_on_request(config) ⇒ Object



69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/better_auth/plugins/expo.rb', line 69

def expo_on_request(config)
  lambda do |request, _context|
    next if config[:disable_origin_override] || request.get_header("HTTP_ORIGIN")

    expo_origin = request.get_header("HTTP_EXPO_ORIGIN")
    next unless expo_origin

    env = request.env.dup
    env["HTTP_ORIGIN"] = expo_origin
    {request: Rack::Request.new(env)}
  end
end

.expose_auth_token(ctx) ⇒ Object



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/better_auth/plugins/bearer.rb', line 94

def expose_auth_token(ctx)
  token_name = ctx.context.auth_cookies[:session_token].name
  session_cookie = Cookies.split_set_cookie_header(ctx.response_headers["set-cookie"]).reverse_each.filter_map do |line|
    cookie = Cookies.parse_set_cookie(line)
    next unless cookie && cookie[:name] == token_name

    cookie
  end.first
  return unless session_cookie
  return if session_cookie[:value].empty? || expired_bearer_cookie?(session_cookie)

  token = session_cookie[:value]

  exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?)
  exposed << "set-auth-token"
  ctx.set_header("set-auth-token", token)
  ctx.set_header("access-control-expose-headers", exposed.uniq.join(", "))
  nil
end

.extra_input(hash, *exclude) ⇒ Object



1561
1562
1563
# File 'lib/better_auth/plugins/organization.rb', line 1561

def extra_input(hash, *exclude)
  normalize_hash(hash).except(*exclude.map(&:to_sym))
end

.fetch_i18n_option(options, name) ⇒ Object



46
47
48
49
50
# File 'lib/better_auth/plugins/i18n.rb', line 46

def fetch_i18n_option(options, name)
  snake = name.to_s
  camel = snake.split("_").map.with_index { |part, index| index.zero? ? part : part.capitalize }.join
  options[snake.to_sym] || options[snake] || options[camel.to_sym] || options[camel]
end

.fetch_value(data, key) ⇒ Object



38
39
40
41
42
# File 'lib/better_auth/plugins.rb', line 38

def fetch_value(data, key)
  return nil unless data.respond_to?(:[])

  data[key] || data[key.to_s] || data[Schema.storage_key(key)] || data[Schema.storage_key(key).to_sym] || data[normalize_key(key)]
end

.field_schema(attributes) ⇒ Object



400
401
402
403
404
405
406
407
408
409
410
411
412
# File 'lib/better_auth/plugins/open_api.rb', line 400

def field_schema(attributes)
  type = case attributes[:type].to_s
  when "date" then "string"
  when "number" then "number"
  when "boolean" then "boolean"
  else "string"
  end
  schema = {type: type}
  schema[:format] = "date-time" if attributes[:type].to_s == "date"
  schema[:default] = attributes[:default_value].respond_to?(:call) ? "Generated at runtime" : attributes[:default_value] if attributes.key?(:default_value)
  schema[:readOnly] = true if attributes[:input] == false
  schema
end

.find_device_code(ctx, code) ⇒ Object



363
364
365
# File 'lib/better_auth/plugins/device_authorization.rb', line 363

def find_device_code(ctx, code)
  ctx.context.adapter.find_one(model: "deviceCode", where: [{field: "deviceCode", value: code.to_s}])
end

.find_device_user_code(ctx, code) ⇒ Object



367
368
369
370
371
372
373
# File 'lib/better_auth/plugins/device_authorization.rb', line 367

def find_device_user_code(ctx, code)
  device_authorization_user_code_candidates(code).each do |candidate|
    record = ctx.context.adapter.find_one(model: "deviceCode", where: [{field: "userCode", value: candidate}])
    return record if record
  end
  nil
end

.find_member_by_email(ctx, organization_id, email) ⇒ Object



1202
1203
1204
1205
# File 'lib/better_auth/plugins/organization.rb', line 1202

def find_member_by_email(ctx, organization_id, email)
  user = ctx.context.adapter.find_one(model: "user", where: [{field: "email", value: email.to_s.downcase}])
  user && require_member(ctx, user["id"], organization_id)
end

.generate_device_authorization_device_code(config) ⇒ Object



379
380
381
382
383
# File 'lib/better_auth/plugins/device_authorization.rb', line 379

def generate_device_authorization_device_code(config)
  return config[:generate_device_code].call.to_s if config[:generate_device_code].respond_to?(:call)

  SecureRandom.alphanumeric(config[:device_code_length].to_i)
end

.generate_device_authorization_user_code(config) ⇒ Object



385
386
387
388
389
390
# File 'lib/better_auth/plugins/device_authorization.rb', line 385

def generate_device_authorization_user_code(config)
  return config[:generate_user_code].call.to_s if config[:generate_user_code].respond_to?(:call)

  charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
  Array.new(config[:user_code_length].to_i) { charset[SecureRandom.random_number(charset.length)] }.join
end

.generate_key_pair(alg) ⇒ Object



385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'lib/better_auth/plugins/jwt.rb', line 385

def generate_key_pair(alg)
  case alg
  when "EdDSA"
    OpenSSL::PKey.generate_key("ED25519")
  when "RS256", "PS256"
    OpenSSL::PKey::RSA.generate(2048)
  when "ES256"
    OpenSSL::PKey::EC.generate("prime256v1")
  when "ES512"
    OpenSSL::PKey::EC.generate("secp521r1")
  else
    raise Error, "JWT/JWKS algorithm #{alg} is not supported by the Ruby server"
  end
end

.generate_one_time_token_endpoint(config) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/better_auth/plugins/one_time_token.rb', line 31

def generate_one_time_token_endpoint(config)
  Endpoint.new(
    path: "/one-time-token/generate",
    method: "GET",
    metadata: {
      openapi: {
        operationId: "generateOneTimeToken",
        description: "Generate a one-time token for the current session",
        responses: {
          "200" => OpenAPI.json_response(
            "One-time token",
            OpenAPI.object_schema(
              {
                token: {type: "string"}
              },
              required: ["token"]
            )
          )
        }
      }
    }
  ) do |ctx|
    if config[:disable_client_request] && ctx.request
      raise APIError.new("BAD_REQUEST", message: "Client requests are disabled")
    end

    session = Routes.current_session(ctx)
    token = one_time_token_create(ctx, config, session)
    ctx.json({token: token})
  end
end

.generic_oauth(options = {}) ⇒ Object



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/better_auth/plugins/generic_oauth.rb', line 23

def generic_oauth(options = {})
  config = normalize_hash(options)
  providers = Array(config[:config]).map { |provider| normalize_hash(provider) }
  generic_oauth_warn_duplicate_providers(providers)
  config[:config] = providers

  Plugin.new(
    id: "generic-oauth",
    init: ->(context) {
      {
        options: {
          social_providers: context.social_providers.merge(generic_oauth_social_providers(config, context))
        }
      }
    },
    endpoints: {
      sign_in_with_oauth2: (config),
      oauth2_callback: oauth2_callback_endpoint(config),
      oauth2_link_account: (config)
    },
    error_codes: GENERIC_OAUTH_ERROR_CODES,
    options: config
  )
end

.generic_oauth_account_info(ctx, provider_id, account_id, tokens) ⇒ Object



557
558
559
560
561
562
563
564
565
566
567
568
569
# File 'lib/better_auth/plugins/generic_oauth.rb', line 557

def (ctx, provider_id, , tokens)
  data = normalize_hash(tokens || {})
  {
    "providerId" => provider_id,
    "accountId" => ,
    "accessToken" => generic_oauth_token_for_storage(ctx, data[:access_token] || data[:accessToken]),
    "refreshToken" => generic_oauth_token_for_storage(ctx, data[:refresh_token] || data[:refreshToken]),
    "idToken" => data[:id_token] || data[:idToken],
    "accessTokenExpiresAt" => data[:access_token_expires_at] || data[:accessTokenExpiresAt],
    "refreshTokenExpiresAt" => data[:refresh_token_expires_at] || data[:refreshTokenExpiresAt],
    "scope" => Array(data[:scopes] || data[:scope]).join(",")
  }
end

.generic_oauth_authorization_url(ctx, provider, body, link:, code_verifier: nil, preserve_additional_data: false) ⇒ Object

Raises:



402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
# File 'lib/better_auth/plugins/generic_oauth.rb', line 402

def generic_oauth_authorization_url(ctx, provider, body, link:, code_verifier: nil, preserve_additional_data: false)
  authorization_url = provider[:authorization_url] || generic_oauth_discovery(provider)["authorization_endpoint"]
  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
  raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIGURATION"]) if authorization_url.to_s.empty? || token_url.to_s.empty?

  code_verifier ||= Crypto.random_string(43)
  additional_data = if preserve_additional_data
    raw = body[:additional_data] || body[:additionalData]
    raw.is_a?(Hash) ? raw.each_with_object({}) { |(key, value), data| data[key.to_s] = value } : {}
  else
    normalize_hash(body[:additional_data] || body[:additionalData]).transform_keys(&:to_s)
  end
  additional_data = additional_data.reject { |key, _value| OAuthState::INTERNAL_KEYS.include?(key.to_s) }
  state_data = additional_data.merge(
    "callbackURL" => body[:callback_url] || body[:callbackURL] || "/",
    "errorURL" => body[:error_callback_url] || body[:errorCallbackURL],
    "newUserURL" => body[:new_user_callback_url] || body[:newUserCallbackURL],
    "requestSignUp" => body[:request_sign_up] || body[:requestSignUp],
    "codeVerifier" => provider[:pkce] ? code_verifier : nil,
    "link" => link,
    "expiresAt" => Time.now.to_i + 600
  )
  state = generic_oauth_generate_state(ctx, state_data)

  uri = URI.parse(authorization_url.to_s)
  params = URI.decode_www_form(uri.query.to_s)
  generic_oauth_set_query_param(params, "client_id", provider[:client_id].to_s)
  generic_oauth_set_query_param(params, "response_type", provider[:response_type] || "code")
  generic_oauth_set_query_param(params, "redirect_uri", generic_oauth_redirect_uri(ctx, provider))
  generic_oauth_set_query_param(params, "state", state)
  scopes = Array(body[:scopes]) + Array(provider[:scopes])
  generic_oauth_set_query_param(params, "scope", scopes.join(" ")) unless scopes.empty?
  if provider[:pkce]
    generic_oauth_set_query_param(params, "code_challenge", generic_oauth_pkce_challenge(code_verifier))
    generic_oauth_set_query_param(params, "code_challenge_method", "S256")
  end
  generic_oauth_set_query_param(params, "prompt", provider[:prompt]) if provider[:prompt]
  generic_oauth_set_query_param(params, "access_type", provider[:access_type]) if provider[:access_type]
  generic_oauth_set_query_param(params, "response_mode", provider[:response_mode]) if provider[:response_mode]
  authorization_params = if provider[:authorization_url_params].respond_to?(:call)
    provider[:authorization_url_params].call(ctx)
  else
    provider[:authorization_url_params]
  end
  normalize_hash(authorization_params || {}).each { |key, value| generic_oauth_set_query_param(params, key, value) }
  uri.query = URI.encode_www_form(params)
  uri.to_s
end

.generic_oauth_discovery(provider) ⇒ Object



610
611
612
613
614
615
616
617
618
619
620
621
622
623
# File 'lib/better_auth/plugins/generic_oauth.rb', line 610

def generic_oauth_discovery(provider)
  return {} if provider[:discovery_url].to_s.empty?
  return provider[:_discovery] if provider[:_discovery]

  uri = URI(provider[:discovery_url])
  request = Net::HTTP::Get.new(uri)
  normalize_hash(provider[:discovery_headers] || provider[:discoveryHeaders]).each do |key, value|
    request[key.to_s.tr("_", "-")] = value.to_s
  end
  response = HTTPClient.request(uri, request)
  provider[:_discovery] = response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : {}
rescue
  {}
end

.generic_oauth_error_url(base_url, error, description = nil) ⇒ Object



816
817
818
819
820
821
822
823
824
825
826
827
# File 'lib/better_auth/plugins/generic_oauth.rb', line 816

def generic_oauth_error_url(base_url, error, description = nil)
  uri = URI.parse(base_url.to_s)
  query = URI.decode_www_form(uri.query.to_s)
  generic_oauth_set_query_param(query, "error", error)
  if description.to_s.empty?
    query.delete_if { |name, _value| name == "error_description" }
  else
    generic_oauth_set_query_param(query, "error_description", description)
  end
  uri.query = URI.encode_www_form(query)
  uri.to_s
end

.generic_oauth_exchange_token(ctx, provider, code, state_data) ⇒ Object

Raises:



468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/better_auth/plugins/generic_oauth.rb', line 468

def generic_oauth_exchange_token(ctx, provider, code, state_data)
  token_callback = provider[:get_token]
  if token_callback.respond_to?(:call)
    return generic_oauth_normalize_tokens(token_callback.call(
      code: code,
      redirectURI: generic_oauth_redirect_uri(ctx, provider),
      redirect_uri: generic_oauth_redirect_uri(ctx, provider),
      codeVerifier: provider[:pkce] ? state_data["codeVerifier"] : nil,
      code_verifier: provider[:pkce] ? state_data["codeVerifier"] : nil
    ), access_token_expires_in: provider[:access_token_expires_in])
  end

  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
  raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty?

  generic_oauth_post_token(ctx, token_url, provider, code, provider[:pkce] ? state_data["codeVerifier"] : nil, generic_oauth_redirect_uri(ctx, provider))
end

.generic_oauth_expiry_time(seconds) ⇒ Object



689
690
691
692
693
# File 'lib/better_auth/plugins/generic_oauth.rb', line 689

def generic_oauth_expiry_time(seconds)
  return nil if seconds.to_i <= 0

  Time.now + seconds.to_i
end

.generic_oauth_fetch_json(url, headers = {}) ⇒ Object



716
717
718
719
720
721
722
723
724
725
726
# File 'lib/better_auth/plugins/generic_oauth.rb', line 716

def generic_oauth_fetch_json(url, headers = {})
  uri = URI(url)
  request = Net::HTTP::Get.new(uri)
  normalize_hash(headers).each { |key, value| request[key.to_s.tr("_", "-")] = value.to_s }
  response = HTTPClient.request(uri, request)
  return nil unless response.is_a?(Net::HTTPSuccess)

  JSON.parse(response.body)
rescue
  nil
end

.generic_oauth_generate_state(ctx, state_data) ⇒ Object



464
465
466
# File 'lib/better_auth/plugins/generic_oauth.rb', line 464

def generic_oauth_generate_state(ctx, state_data)
  OAuthState.generate(ctx, state_data)
end


539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
# File 'lib/better_auth/plugins/generic_oauth.rb', line 539

def (ctx, provider, tokens, , link, redirect_error)
  if !ctx.context.options..dig(:account_linking, :allow_different_emails) &&
      link["email"].to_s.downcase != fetch_value(, "email").to_s.downcase
    redirect_error.call("email_doesn't_match")
  end

   = fetch_value(, "id").to_s
   = ctx.context.internal_adapter.(, provider[:provider_id].to_s)
   = (ctx, provider[:provider_id].to_s, , tokens).merge("userId" => link["user_id"])
  if 
    redirect_error.call("account_already_linked_to_different_user") if ["userId"] != link["user_id"]
     = ctx.context.internal_adapter.(["id"], )
  else
     = ctx.context.internal_adapter.()
  end
  Cookies.(ctx, ) if 
end

.generic_oauth_map_user(provider, user_info) ⇒ Object



533
534
535
536
537
# File 'lib/better_auth/plugins/generic_oauth.rb', line 533

def generic_oauth_map_user(provider, )
  mapper = provider[:map_profile_to_user]
  mapped = mapper.respond_to?(:call) ? mapper.call() : 
  normalize_hash().merge(normalize_hash(mapped || {}))
end

.generic_oauth_normalize_tokens(data, access_token_expires_in: nil) ⇒ Object



674
675
676
677
678
679
680
681
682
683
684
685
686
687
# File 'lib/better_auth/plugins/generic_oauth.rb', line 674

def generic_oauth_normalize_tokens(data, access_token_expires_in: nil)
  token_data = normalize_hash(data)
  access_token_expires_at = token_data[:access_token_expires_at] || generic_oauth_expiry_time(token_data[:expires_in])
  access_token_expires_at ||= generic_oauth_expiry_time(access_token_expires_in)
  token_data.merge(
    access_token: token_data[:access_token],
    refresh_token: token_data[:refresh_token],
    id_token: token_data[:id_token],
    access_token_expires_at: access_token_expires_at,
    refresh_token_expires_at: token_data[:refresh_token_expires_at] || generic_oauth_expiry_time(token_data[:refresh_token_expires_in]),
    scopes: generic_oauth_token_scopes(token_data[:scopes] || token_data[:scope]),
    raw: token_data
  ).compact
end

.generic_oauth_normalize_user_info(data) ⇒ Object



706
707
708
709
710
711
712
713
714
# File 'lib/better_auth/plugins/generic_oauth.rb', line 706

def (data)
  profile = normalize_hash(data)
  profile.merge(
    id: profile[:id] || profile[:sub],
    email_verified: profile[:email_verified] || false,
    emailVerified: profile[:email_verified] || false,
    image: profile[:image] || profile[:picture]
  )
end

.generic_oauth_parse_state(ctx, state) ⇒ Object



486
487
488
489
490
491
492
# File 'lib/better_auth/plugins/generic_oauth.rb', line 486

def generic_oauth_parse_state(ctx, state)
  OAuthState.parse(ctx, state)
rescue OAuthState::Error => error
  code = (error.code == "state_not_found") ? "please_restart_the_process" : error.code
  error_url = error.error_url || generic_oauth_state_error_url(ctx)
  raise ctx.redirect(generic_oauth_error_url(error_url, code))
end

.generic_oauth_pkce_challenge(code_verifier) ⇒ Object



702
703
704
# File 'lib/better_auth/plugins/generic_oauth.rb', line 702

def generic_oauth_pkce_challenge(code_verifier)
  Crypto.base64url_encode(OpenSSL::Digest.digest("SHA256", code_verifier.to_s))
end

.generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token) ⇒ Object

Raises:



795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
# File 'lib/better_auth/plugins/generic_oauth.rb', line 795

def generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token)
  uri = URI(token_url)
  request = Net::HTTP::Post.new(uri)
  form_data = {grant_type: "refresh_token", refresh_token: refresh_token}
  authentication = (provider[:authentication] || "post").to_s
  if authentication == "basic"
    request["authorization"] = "Basic #{Base64.strict_encode64("#{provider[:client_id]}:#{provider[:client_secret]}")}"
  else
    form_data[:client_id] = provider[:client_id]
    form_data[:client_secret] = provider[:client_secret] if provider[:client_secret]
  end
  token_url_params = provider[:token_url_params] || provider[:tokenUrlParams]
  token_url_params = token_url_params.call(ctx) if token_url_params.respond_to?(:call)
  normalize_hash(token_url_params || {}).each { |key, value| form_data[key] = value }
  request.set_form_data(form_data.compact)
  response = HTTPClient.request(uri, request)
  raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIG"]) unless response.is_a?(Net::HTTPSuccess)

  generic_oauth_normalize_tokens(JSON.parse(response.body), access_token_expires_in: provider[:access_token_expires_in])
end

.generic_oauth_post_token(ctx, token_url, provider, code, code_verifier, redirect_uri) ⇒ Object



625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
# File 'lib/better_auth/plugins/generic_oauth.rb', line 625

def generic_oauth_post_token(ctx, token_url, provider, code, code_verifier, redirect_uri)
  uri = URI(token_url)
  request = Net::HTTP::Post.new(uri)
  normalize_hash(provider[:authorization_headers] || provider[:authorizationHeaders]).each do |key, value|
    request[key.to_s.tr("_", "-")] = value.to_s
  end
  form_data = {
    grant_type: "authorization_code",
    code: code,
    redirect_uri: redirect_uri
  }.compact
  form_data[:code_verifier] = code_verifier if code_verifier
  authentication = (provider[:authentication] || "post").to_s
  if authentication == "basic"
    request["authorization"] = "Basic #{Base64.strict_encode64("#{provider[:client_id]}:#{provider[:client_secret]}")}"
  else
    form_data[:client_id] = provider[:client_id]
    form_data[:client_secret] = provider[:client_secret] if provider[:client_secret]
  end
  token_url_params = if provider[:token_url_params].respond_to?(:call)
    provider[:token_url_params].call(ctx)
  else
    provider[:token_url_params] || provider[:tokenUrlParams]
  end
  normalize_hash(token_url_params || {}).each do |key, value|
    form_data[key] = value unless form_data.key?(key)
  end
  request.set_form_data(form_data)
  response = HTTPClient.request(uri, request)
  return nil unless response.is_a?(Net::HTTPSuccess)

  generic_oauth_normalize_tokens(JSON.parse(response.body), access_token_expires_in: provider[:access_token_expires_in])
rescue
  nil
end

.generic_oauth_provider(config, provider_id) ⇒ Object



592
593
594
# File 'lib/better_auth/plugins/generic_oauth.rb', line 592

def generic_oauth_provider(config, provider_id)
  Array(config[:config]).find { |provider| provider[:provider_id].to_s == provider_id.to_s }
end

.generic_oauth_provider!(config, provider_id) ⇒ Object

Raises:



585
586
587
588
589
590
# File 'lib/better_auth/plugins/generic_oauth.rb', line 585

def generic_oauth_provider!(config, provider_id)
  provider = generic_oauth_provider(config, provider_id)
  raise APIError.new("BAD_REQUEST", message: "#{GENERIC_OAUTH_ERROR_CODES["PROVIDER_CONFIG_NOT_FOUND"]} #{provider_id}") unless provider

  provider
end

.generic_oauth_provider_config(options, defaults) ⇒ Object



737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
# File 'lib/better_auth/plugins/generic_oauth.rb', line 737

def generic_oauth_provider_config(options, defaults)
  data = normalize_hash(options)
  config = defaults.merge(
    client_id: data[:client_id],
    client_secret: data[:client_secret],
    redirect_uri: data[:redirect_uri],
    pkce: data[:pkce],
    disable_implicit_sign_up: data[:disable_implicit_sign_up],
    disable_sign_up: data[:disable_sign_up],
    override_user_info: data[:override_user_info],
    access_token_expires_in: data[:access_token_expires_in]
  )
  config[:scopes] = data[:scopes] if data[:scopes]
  config.compact
end

.generic_oauth_provider_user_info(provider, tokens) ⇒ Object



778
779
780
781
782
783
784
785
786
# File 'lib/better_auth/plugins/generic_oauth.rb', line 778

def (provider, tokens)
   = (provider, tokens)
  return nil unless 

  {
    user: generic_oauth_map_user(provider, ),
    data: 
  }
end

.generic_oauth_recovered_state_error_url(ctx, state_data) ⇒ Object



498
499
500
# File 'lib/better_auth/plugins/generic_oauth.rb', line 498

def generic_oauth_recovered_state_error_url(ctx, state_data)
  state_data["errorURL"] || state_data["errorCallbackURL"] || generic_oauth_state_error_url(ctx)
end

.generic_oauth_redirect_uri(ctx, provider) ⇒ Object



596
597
598
# File 'lib/better_auth/plugins/generic_oauth.rb', line 596

def generic_oauth_redirect_uri(ctx, provider)
  provider[:redirect_uri] || provider[:redirectURI] || "#{ctx.context.canonical_base_url}/oauth2/callback/#{provider[:provider_id]}"
end

.generic_oauth_refresh_access_token(ctx, provider, refresh_token) ⇒ Object

Raises:



788
789
790
791
792
793
# File 'lib/better_auth/plugins/generic_oauth.rb', line 788

def generic_oauth_refresh_access_token(ctx, provider, refresh_token)
  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
  raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty?

  generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token)
end


578
579
580
581
582
583
# File 'lib/better_auth/plugins/generic_oauth.rb', line 578

def (ctx, provider_id, , user_id)
   = ctx.context.internal_adapter.find_accounts(user_id).find do |entry|
    entry["providerId"] == provider_id && entry["accountId"] == 
  end
  Cookies.(ctx, ) if 
end

.generic_oauth_set_query_param(params, key, value) ⇒ Object



451
452
453
454
455
456
457
458
459
460
461
462
# File 'lib/better_auth/plugins/generic_oauth.rb', line 451

def generic_oauth_set_query_param(params, key, value)
  name = key.to_s
  first_index = params.index { |param_name, _param_value| param_name == name }
  if first_index
    params[first_index] = [name, value.to_s]
    (params.length - 1).downto(first_index + 1) do |index|
      params.delete_at(index) if params[index].first == name
    end
  else
    params << [name, value.to_s]
  end
end

.generic_oauth_social_providers(config, context) ⇒ Object



753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
# File 'lib/better_auth/plugins/generic_oauth.rb', line 753

def generic_oauth_social_providers(config, context)
  Array(config[:config]).each_with_object({}) do |provider, result|
    provider_id = provider[:provider_id].to_s
    result[provider_id.to_sym] = {
      id: provider_id,
      name: provider_id,
      get_user_info: ->(tokens) { (provider, tokens) },
      refresh_access_token: ->(refresh_token) { generic_oauth_refresh_access_token(context, provider, refresh_token) },
      oauth_popup_authorization_url: lambda do |ctx, data|
        additional_data = data[:additionalData] || data["additionalData"] || data[:additional_data] || data["additional_data"]
        normalized = normalize_hash(data)
        normalized[:additional_data] = additional_data if additional_data.is_a?(Hash)
        generic_oauth_authorization_url(
          ctx,
          provider,
          normalized,
          link: nil,
          code_verifier: normalized[:code_verifier],
          preserve_additional_data: true
        )
      end
    }
  end
end

.generic_oauth_start_body_schemaObject



387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'lib/better_auth/plugins/generic_oauth.rb', line 387

def generic_oauth_start_body_schema
  OpenAPI.object_schema(
    {
      providerId: {type: "string", description: "OAuth provider ID"},
      callbackURL: {type: "string", description: "URL to redirect to after success"},
      errorCallbackURL: {type: "string", description: "URL to redirect to after error"},
      newUserCallbackURL: {type: "string", description: "URL to redirect to for new users"},
      requestSignUp: {type: "boolean", description: "Whether this request is a sign-up flow"},
      scopes: {type: "array", items: {type: "string"}, description: "Additional OAuth scopes"},
      disableRedirect: {type: "boolean", description: "Return the URL instead of redirecting"},
      additionalData: {type: "object", additionalProperties: true}
    }
  )
end

.generic_oauth_state_error_url(ctx) ⇒ Object



494
495
496
# File 'lib/better_auth/plugins/generic_oauth.rb', line 494

def generic_oauth_state_error_url(ctx)
  ctx.context.options.on_api_error[:error_url] || "#{ctx.context.base_url}/error"
end

.generic_oauth_token_for_storage(ctx, token) ⇒ Object



571
572
573
574
575
576
# File 'lib/better_auth/plugins/generic_oauth.rb', line 571

def generic_oauth_token_for_storage(ctx, token)
  return token if token.to_s.empty?
  return token unless ctx.context.options.[:encrypt_oauth_tokens]

  Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: token)
end

.generic_oauth_token_scopes(scope) ⇒ Object



695
696
697
698
699
700
# File 'lib/better_auth/plugins/generic_oauth.rb', line 695

def generic_oauth_token_scopes(scope)
  return [] unless scope
  return scope if scope.is_a?(Array)

  scope.to_s.split(/\s+/)
end

.generic_oauth_url_response_schemaObject



377
378
379
380
381
382
383
384
385
# File 'lib/better_auth/plugins/generic_oauth.rb', line 377

def generic_oauth_url_response_schema
  OpenAPI.object_schema(
    {
      url: {type: "string"},
      redirect: {type: "boolean"}
    },
    required: ["url", "redirect"]
  )
end

.generic_oauth_user_from_id_token(id_token) ⇒ Object



661
662
663
664
665
666
667
668
669
670
671
672
# File 'lib/better_auth/plugins/generic_oauth.rb', line 661

def generic_oauth_user_from_id_token(id_token)
  payload = ::JWT.decode(id_token, nil, false).first
  normalize_hash(
    id: payload["sub"],
    email: payload["email"],
    emailVerified: payload["email_verified"],
    name: payload["name"],
    image: payload["picture"]
  )
rescue
  nil
end

.generic_oauth_user_info(provider, tokens) ⇒ Object



502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
# File 'lib/better_auth/plugins/generic_oauth.rb', line 502

def (provider, tokens)
  callback = provider[:get_user_info]
  if callback.respond_to?(:call)
     = callback.call(tokens)
    return nil if .nil?

    return normalize_hash()
  end

  id_token = tokens[:id_token] || tokens[:idToken]
  if id_token
    token_user = generic_oauth_user_from_id_token(id_token)
    id = fetch_value(token_user, "id")
    email = fetch_value(token_user, "email")
    return token_user if !id.to_s.empty? && !email.to_s.empty?
  end

   = provider[:user_info_url] || generic_oauth_discovery(provider)["userinfo_endpoint"]
  return nil if .to_s.empty?

  uri = URI()
  request = Net::HTTP::Get.new(uri)
  request["authorization"] = "Bearer #{fetch_value(tokens, "accessToken")}"
  response = HTTPClient.request(uri, request)
  return nil unless response.is_a?(Net::HTTPSuccess)

  (JSON.parse(response.body))
rescue
  nil
end

.generic_oauth_validate_issuer!(ctx, provider, query, redirect_error) ⇒ Object



600
601
602
603
604
605
606
607
608
# File 'lib/better_auth/plugins/generic_oauth.rb', line 600

def generic_oauth_validate_issuer!(ctx, provider, query, redirect_error)
  expected = provider[:issuer] || generic_oauth_discovery(provider)["issuer"]
  return if expected.to_s.empty?
  return if query[:iss].to_s == expected.to_s
  return redirect_error.call("issuer_missing") if query[:iss].to_s.empty? && provider[:require_issuer_validation]
  return if query[:iss].to_s.empty?

  redirect_error.call("issuer_mismatch")
end

.generic_oauth_warn_duplicate_providers(providers) ⇒ Object



829
830
831
832
# File 'lib/better_auth/plugins/generic_oauth.rb', line 829

def generic_oauth_warn_duplicate_providers(providers)
  duplicates = providers.group_by { |provider| provider[:provider_id].to_s }.select { |id, entries| !id.empty? && entries.length > 1 }.keys
  warn "Duplicate provider IDs found: #{duplicates.join(", ")}" unless duplicates.empty?
end

.generic_oidc_helper_provider(options, provider_id, issuer, discovery_url, _user_info_url) ⇒ Object



728
729
730
731
732
733
734
735
# File 'lib/better_auth/plugins/generic_oauth.rb', line 728

def generic_oidc_helper_provider(options, provider_id, issuer, discovery_url, )
  generic_oauth_provider_config(
    options,
    provider_id: provider_id,
    discovery_url: discovery_url,
    scopes: ["openid", "profile", "email"]
  )
end

.get_jwks_endpoint(config, path) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/better_auth/plugins/jwt.rb', line 112

def get_jwks_endpoint(config, path)
  Endpoint.new(
    path: path,
    method: "GET",
    metadata: {
      openapi: {
        operationId: "getJSONWebKeySet",
        description: "Get the JSON Web Key Set",
        responses: {
          "200" => OpenAPI.json_response(
            "JSON Web Key Set retrieved successfully",
            OpenAPI.object_schema(
              {keys: {type: "array", description: "Array of public JSON Web Keys", items: {type: "object"}}},
              required: ["keys"]
            )
          )
        }
      }
    }
  ) do |ctx|
    raise APIError.new("NOT_FOUND") if config.dig(:jwks, :remote_url)

    create_jwk(ctx, config) if all_jwks(ctx, config).empty?
    ctx.json({keys: public_jwks(ctx, config).map { |key| public_jwk(key, config) }})
  end
end

.get_siwe_nonce_endpoint(config, path:, operation_id:) ⇒ Object



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/better_auth/plugins/siwe.rb', line 30

def get_siwe_nonce_endpoint(config, path:, operation_id:)
  Endpoint.new(
    path: path,
    method: "POST",
    body_schema: ->(body) { siwe_nonce_body(body) },
    metadata: {
      openapi: {
        operationId: operation_id,
        description: "Generate a nonce for Sign-In with Ethereum",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              address: {type: "string"},
              walletAddress: {type: "string"},
              chainId: {type: ["number", "string", "null"]}
            }
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "SIWE nonce",
            OpenAPI.object_schema(
              {
                nonce: {type: "string"}
              },
              required: ["nonce"]
            )
          )
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    wallet_address = siwe_normalize_wallet!(body[:wallet_address] || body[:address])
    chain_id = siwe_chain_id(body[:chain_id])
    nonce_callback = config[:get_nonce]
    raise APIError.new("INTERNAL_SERVER_ERROR", message: "SIWE nonce callback is required") unless nonce_callback.respond_to?(:call)

    nonce = nonce_callback.call.to_s
    ctx.context.internal_adapter.create_verification_value(
      identifier: siwe_identifier(wallet_address, chain_id),
      value: nonce,
      expiresAt: Time.now + (15 * 60)
    )
    ctx.json({nonce: nonce})
  end
end

.get_token_endpoint(config) ⇒ Object



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/better_auth/plugins/jwt.rb', line 139

def get_token_endpoint(config)
  Endpoint.new(
    path: "/token",
    method: "GET",
    metadata: {
      openapi: {
        operationId: "getJSONWebToken",
        description: "Get a JWT token",
        responses: {
          "200" => OpenAPI.json_response(
            "Success",
            OpenAPI.object_schema({token: {type: "string"}}, required: ["token"])
          )
        }
      }
    }
  ) do |ctx|
    session = Session.find_current(ctx)
    raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"]) unless session

    ctx.json({token: jwt_token(ctx, session, config)})
  end
end

.get_verification_otp_endpoint(config) ⇒ Object



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/better_auth/plugins/email_otp.rb', line 133

def get_verification_otp_endpoint(config)
  Endpoint.new(
    method: "GET",
    metadata: {
      server_only: true,
      openapi: {
        operationId: "getVerificationOTP",
        description: "Get a stored verification OTP when storage allows plaintext access",
        parameters: [
          {name: "email", in: "query", required: true, schema: {type: "string"}},
          {name: "type", in: "query", required: true, schema: {type: "string"}}
        ],
        responses: {
          "200" => OpenAPI.json_response(
            "Stored OTP",
            OpenAPI.object_schema(
              {
                otp: {type: ["string", "null"]}
              }
            )
          )
        }
      }
    }
  ) do |ctx|
    query = normalize_hash(ctx.query)
    email = query[:email].to_s.downcase
    type = query[:type].to_s
    validate_email_otp_type!(type)
    verification = ctx.context.internal_adapter.find_verification_value(email_otp_identifier(email, type))
    next ctx.json({otp: nil}) unless verification && !Routes.expired_time?(verification["expiresAt"])

    stored_otp, = email_otp_split(verification["value"])
    case config[:store_otp].to_s
    when "hashed"
      raise APIError.new("BAD_REQUEST", message: "OTP is hashed, cannot return the plain text OTP")
    when "encrypted"
      next ctx.json({otp: Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_otp)})
    end

    storage = config[:store_otp]
    if storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
      raise APIError.new("BAD_REQUEST", message: "OTP is hashed, cannot return the plain text OTP")
    elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
      next ctx.json({otp: storage[:decrypt].call(stored_otp)})
    end

    ctx.json({otp: stored_otp})
  end
end

.gumroad(options = {}) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/better_auth/plugins/generic_oauth.rb', line 59

def gumroad(options = {})
  data = normalize_hash(options)
  generic_oauth_provider_config(
    data,
    provider_id: "gumroad",
    authorization_url: "https://gumroad.com/oauth/authorize",
    token_url: "https://api.gumroad.com/oauth/token",
    scopes: ["view_profile"],
    get_user_info: ->(tokens) {
      profile = generic_oauth_fetch_json("https://api.gumroad.com/v2/user", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
      user = fetch_value(profile, "user")
      return nil unless fetch_value(profile, "success") && user

      {
        id: fetch_value(user, "user_id"),
        name: fetch_value(user, "name"),
        email: fetch_value(user, "email"),
        image: fetch_value(user, "profile_url"),
        emailVerified: false
      }
    }
  )
end

.have_i_been_pwned(options = {}) ⇒ Object



21
22
23
24
25
26
27
28
29
30
31
# File 'lib/better_auth/plugins/have_i_been_pwned.rb', line 21

def have_i_been_pwned(options = {})
  config = normalize_hash(options)
  config[:paths] = Array(config[:paths]).empty? ? HAVE_I_BEEN_PWNED_DEFAULT_PATHS : Array(config[:paths])

  Plugin.new(
    id: "have-i-been-pwned",
    init: ->(context) { have_i_been_pwned_wrap_password_hasher!(context, config) },
    error_codes: HAVE_I_BEEN_PWNED_ERROR_CODES,
    options: config
  )
end

.have_i_been_pwned_check_password!(password, config) ⇒ Object



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/better_auth/plugins/have_i_been_pwned.rb', line 55

def have_i_been_pwned_check_password!(password, config)
  return if password.to_s.empty?

  hash = OpenSSL::Digest.hexdigest("SHA1", password.to_s).upcase
  prefix = hash[0, 5]
  suffix = hash[5..]
  data = if config[:range_lookup].respond_to?(:call)
    config[:range_lookup].call(prefix)
  else
    have_i_been_pwned_range_lookup(prefix)
  end

  found = data.to_s.lines.any? { |line| line.split(":").first.to_s.upcase == suffix }
  return unless found

  raise APIError.new(
    "BAD_REQUEST",
    message: config[:custom_password_compromised_message] || HAVE_I_BEEN_PWNED_ERROR_CODES["PASSWORD_COMPROMISED"],
    code: "PASSWORD_COMPROMISED"
  )
rescue APIError
  raise
rescue
  raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Please try again later.")
end

.have_i_been_pwned_range_lookup(prefix) ⇒ Object



81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/better_auth/plugins/have_i_been_pwned.rb', line 81

def have_i_been_pwned_range_lookup(prefix)
  uri = URI.parse("https://api.pwnedpasswords.com/range/#{prefix}")
  request = Net::HTTP::Get.new(uri)
  request["Add-Padding"] = "true"
  request["User-Agent"] = "BetterAuth Password Checker"
  response = HTTPClient.request(uri, request)
  unless response.is_a?(Net::HTTPSuccess)
    raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Status: #{response.code}")
  end

  response.body.to_s
end

.have_i_been_pwned_wrap_password_hasher!(context, config) ⇒ Object



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/better_auth/plugins/have_i_been_pwned.rb', line 33

def have_i_been_pwned_wrap_password_hasher!(context, config)
  email_config = context.options.email_and_password
  password_config = email_config[:password] ||= {}
  original_hasher = password_config[:hash]
  algorithm = context.options.password_hasher
  password_config[:hash] = lambda do |password, hash_ctx = nil|
    if config[:enabled] != false && hash_ctx && config[:paths].include?(hash_ctx.path)
      have_i_been_pwned_check_password!(password, config)
    end

    if original_hasher.respond_to?(:call)
      arity = original_hasher.arity
      return original_hasher.call(password, hash_ctx) if arity != 1 && arity != -1

      return original_hasher.call(password)
    end

    Password.hash(password, algorithm: algorithm)
  end
  nil
end

.hubspot(options = {}) ⇒ Object



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/better_auth/plugins/generic_oauth.rb', line 83

def hubspot(options = {})
  data = normalize_hash(options)
  generic_oauth_provider_config(
    data,
    provider_id: "hubspot",
    authorization_url: "https://app.hubspot.com/oauth/authorize",
    token_url: "https://api.hubapi.com/oauth/v1/token",
    scopes: ["oauth"],
    authentication: "post",
    get_user_info: ->(tokens) {
      profile = generic_oauth_fetch_json("https://api.hubapi.com/oauth/v1/access-tokens/#{fetch_value(tokens, "accessToken")}", "Content-Type" => "application/json")
      return nil unless profile

      id = fetch_value(profile, "user_id") || fetch_value(fetch_value(profile, "signed_access_token"), "userId")
      return nil if id.to_s.empty?

      {id: id, name: fetch_value(profile, "user"), email: fetch_value(profile, "user"), emailVerified: false}
    }
  )
end

.i18n(options = {}) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# File 'lib/better_auth/plugins/i18n.rb', line 7

def i18n(options = {})
  config = normalize_i18n_options(options)
  validate_i18n_translations!(config)

  Plugin.new(
    id: "i18n",
    hooks: {
      after: [
        {
          matcher: ->(_ctx) { true },
          handler: ->(ctx) { apply_i18n_translation(ctx, config) }
        }
      ]
    },
    options: config
  )
end

.invitation_by_id(ctx, id) ⇒ Object



1219
1220
1221
1222
1223
# File 'lib/better_auth/plugins/organization.rb', line 1219

def invitation_by_id(ctx, id)
  return nil if id.to_s.empty?

  ctx.context.adapter.find_one(model: "invitation", where: [{field: "id", value: id}])
end

.invitation_wire(ctx, invitation) ⇒ Object



1334
1335
1336
# File 'lib/better_auth/plugins/organization.rb', line 1334

def invitation_wire(ctx, invitation)
  Schema.parse_output(ctx.context.options, "invitation", invitation)
end

.is_username_available_endpoint(config) ⇒ Object



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/better_auth/plugins/username.rb', line 150

def is_username_available_endpoint(config)
  Endpoint.new(
    path: "/is-username-available",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "isUsernameAvailable",
        description: "Check whether a username is available",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              username: {type: "string"}
            },
            required: ["username"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "Username availability",
            OpenAPI.object_schema(
              {
                available: {type: "boolean"}
              },
              required: ["available"]
            )
          )
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    username = body[:username].to_s
    raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) if username.empty?

    validate_username!(username, config, status: "UNPROCESSABLE_ENTITY")
    user = ctx.context.adapter.find_one(
      model: "user",
      where: [{field: "username", value: normalize_username(username, config)}]
    )
    ctx.json({available: user.nil?})
  end
end

.jwk_expired?(key) ⇒ Boolean

Returns:

  • (Boolean)


480
481
482
483
# File 'lib/better_auth/plugins/jwt.rb', line 480

def jwk_expired?(key)
  expires_at = normalize_time(key["expiresAt"])
  expires_at && expires_at < Time.now
end

.jwk_private_key_for_storage(ctx, private_key, config) ⇒ Object



364
365
366
367
368
# File 'lib/better_auth/plugins/jwt.rb', line 364

def jwk_private_key_for_storage(ctx, private_key, config)
  return private_key if config.dig(:jwks, :disable_private_key_encryption)

  Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: private_key)
end

.jwk_private_key_value(ctx, key, _config) ⇒ Object



370
371
372
373
# File 'lib/better_auth/plugins/jwt.rb', line 370

def jwk_private_key_value(ctx, key, _config)
  value = key["privateKey"]
  Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: value) || value
end

.jwt(options = {}) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/better_auth/plugins/jwt.rb', line 44

def jwt(options = {})
  config = normalize_hash(options)
  validate_jwt_options!(config)
  # Mutable caches are owned by this plugin configuration, which is scoped
  # to one auth instance even when endpoint overrides are merged.
  config[:local_jwks_cache] = JWT::LocalJwksCache.new
  config[:remote_jwks_cache] = JWT::RemoteJwksCache.new({})
  jwks_path = config.dig(:jwks, :jwks_path) || "/jwks"

  Plugin.new(
    id: "jwt",
    endpoints: {
      get_jwks: get_jwks_endpoint(config, jwks_path),
      get_token: get_token_endpoint(config),
      sign_jwt: sign_jwt_endpoint(config),
      verify_jwt: verify_jwt_endpoint(config)
    },
    hooks: {
      after: [
        {
          matcher: ->(ctx) { ctx.path == "/get-session" },
          handler: ->(ctx) { set_jwt_header(ctx, config) }
        }
      ]
    },
    schema: {
      jwks: {
        fields: {
          id: {type: "string", required: true},
          publicKey: {type: "string", required: true},
          privateKey: {type: "string", required: true},
          createdAt: {type: "date", required: true},
          expiresAt: {type: "date", required: false},
          alg: {type: "string", required: false},
          kty: {type: "string", required: false},
          crv: {type: "string", required: false},
          x: {type: "string", required: false},
          y: {type: "string", required: false},
          pem: {type: "string", required: false},
          n: {type: "string", required: false},
          e: {type: "string", required: false}
        }
      }
    },
    options: config
  )
end

.jwt_expiration(value, iat) ⇒ Object



485
486
487
488
489
490
# File 'lib/better_auth/plugins/jwt.rb', line 485

def jwt_expiration(value, iat)
  return value.to_i if value.is_a?(Integer)
  return value.to_i if value.is_a?(Time)

  iat.to_i + parse_duration(value.to_s)
end

.jwt_payload_valid?(payload) ⇒ Boolean

Returns:

  • (Boolean)


375
376
377
378
379
380
381
382
383
# File 'lib/better_auth/plugins/jwt.rb', line 375

def jwt_payload_valid?(payload)
  return false if payload["sub"].to_s.empty?

  audience = payload["aud"]
  return false if audience.nil?
  return false if audience.respond_to?(:empty?) && audience.empty?

  true
end

.jwt_token(ctx, session, config) ⇒ Object



194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/better_auth/plugins/jwt.rb', line 194

def jwt_token(ctx, session, config)
  jwt_config = config[:jwt] || {}
  payload = if jwt_config[:define_payload].respond_to?(:call)
    jwt_config[:define_payload].call(session)
  else
    session[:user]
  end
  subject = if jwt_config[:get_subject].respond_to?(:call)
    jwt_config[:get_subject].call(session)
  else
    session[:user]["id"]
  end
  sign_jwt_payload(ctx, stringify_payload(payload).merge("sub" => subject), config)
end

.key_type_for_alg(alg) ⇒ Object



437
438
439
440
441
# File 'lib/better_auth/plugins/jwt.rb', line 437

def key_type_for_alg(alg)
  return "OKP" if alg == "EdDSA"

  alg.to_s.start_with?("ES") ? "EC" : "RSA"
end

.keycloak(options = {}) ⇒ Object



104
105
106
107
108
# File 'lib/better_auth/plugins/generic_oauth.rb', line 104

def keycloak(options = {})
  data = normalize_hash(options)
  issuer = data.fetch(:issuer).to_s.sub(%r{/\z}, "")
  generic_oidc_helper_provider(data, "keycloak", issuer, "#{issuer}/.well-known/openid-configuration", "#{issuer}/protocol/openid-connect/userinfo")
end

.last_login_method(options = {}) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# File 'lib/better_auth/plugins/last_login_method.rb', line 7

def (options = {})
  config = {
    cookie_name: "better-auth.last_used_login_method",
    max_age: 60 * 60 * 24 * 30
  }.merge(normalize_hash(options))

  Plugin.new(
    id: "last-login-method",
    schema: (config),
    hooks: {
      after: [
        {
          matcher: ->(_ctx) { true },
          handler: ->(ctx) { (ctx, config) }
        }
      ]
    },
    options: config
  )
end

.last_login_method_schema(config) ⇒ Object



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/better_auth/plugins/last_login_method.rb', line 28

def (config)
  return {} unless config[:store_in_database]

  field_name = config.dig(:schema, :user, :last_login_method) || "lastLoginMethod"
  {
    user: {
      fields: {
        lastLoginMethod: {
          type: "string",
          input: false,
          required: false,
          field_name: field_name
        }
      }
    }
  }
end

.latest_jwk(ctx, config) ⇒ Object



247
248
249
# File 'lib/better_auth/plugins/jwt.rb', line 247

def latest_jwk(ctx, config)
  all_jwks(ctx, config, force_refresh: true).max_by { |entry| normalize_time(entry["createdAt"]) || Time.at(0) }
end

.lazy_plugin_method?(name) ⇒ Boolean

Returns:

  • (Boolean)


183
184
185
# File 'lib/better_auth/plugin_loader.rb', line 183

def lazy_plugin_method?(name)
  !plugin_loader_for_method(name).nil?
end

.line(options = {}) ⇒ Object



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/better_auth/plugins/generic_oauth.rb', line 110

def line(options = {})
  data = normalize_hash(options)
  generic_oauth_provider_config(
    data,
    provider_id: data[:provider_id] || "line",
    authorization_url: "https://access.line.me/oauth2/v2.1/authorize",
    token_url: "https://api.line.me/oauth2/v2.1/token",
    user_info_url: "https://api.line.me/oauth2/v2.1/userinfo",
    scopes: ["openid", "profile", "email"],
    get_user_info: ->(tokens) {
      profile = generic_oauth_user_from_id_token(fetch_value(tokens, "idToken"))
      profile ||= generic_oauth_fetch_json("https://api.line.me/oauth2/v2.1/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
      return nil unless profile

      {
        id: fetch_value(profile, "sub") || fetch_value(profile, "id"),
        name: fetch_value(profile, "name"),
        email: fetch_value(profile, "email"),
        image: fetch_value(profile, "picture") || fetch_value(profile, "image"),
        emailVerified: false
      }
    }
  )
end


168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/better_auth/plugins/anonymous.rb', line 168

def link_anonymous_user(ctx, config)
  set_cookie = ctx.response_headers["set-cookie"].to_s
  return if set_cookie.empty?
  return unless set_cookie_value(set_cookie, ctx.context.auth_cookies[:session_token].name)

  anonymous_session = Session.find_current(ctx, disable_refresh: true)
  return unless anonymous_session&.dig(:user, "isAnonymous")

  new_session = ctx.context.new_session
  return unless new_session && new_session[:user] && new_session[:session]

   = config[:on_link_account]
  if .respond_to?(:call)
    .call(
      anonymous_user: anonymous_session,
      new_user: new_session,
      ctx: ctx
    )
  end

  new_user = new_session[:user]
  return if config[:disable_delete_anonymous_user]
  return if new_user["id"] == anonymous_session[:user]["id"]
  return if new_user["isAnonymous"]

  ctx.context.internal_adapter.delete_user(anonymous_session[:user]["id"])
  nil
end

.list_device_sessions_endpointObject



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/better_auth/plugins/multi_session.rb', line 38

def list_device_sessions_endpoint
  Endpoint.new(
    path: "/multi-session/list-device-sessions",
    method: "GET",
    metadata: {
      openapi: {
        operationId: "listDeviceSessions",
        description: "List device sessions",
        responses: {
          "200" => OpenAPI.json_response(
            "Device sessions",
            {
              type: "array",
              items: OpenAPI.session_response_schema_pair
            }
          )
        }
      }
    }
  ) do |ctx|
    tokens = verified_multi_session_tokens(ctx)
    sessions = ctx.context.internal_adapter.find_sessions(tokens)
      .reject { |entry| entry[:session]["expiresAt"] && entry[:session]["expiresAt"] <= Time.now }
    unique = sessions.each_with_object({}) { |entry, by_user| by_user[entry[:user]["id"]] ||= entry }.values

    ctx.json(unique.map { |entry| parsed_session(ctx, entry) })
  end
end

.list_members_for(ctx, organization_id, query = {}, config = nil, user = nil) ⇒ Object



1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
# File 'lib/better_auth/plugins/organization.rb', line 1243

def list_members_for(ctx, organization_id, query = {}, config = nil, user = nil)
  where = [{field: "organizationId", value: organization_id}]
  if query[:filter_field]
    where << {field: query[:filter_field], value: query[:filter_value], operator: query[:filter_operator]}
  elsif query[:filter].is_a?(Hash)
    filter = normalize_hash(query[:filter])
    where << {field: filter[:field], value: filter[:value], operator: filter[:operator]}
  end
  limit = member_list_limit(ctx, organization_id, query, config, user)
  members = ctx.context.adapter.find_many(
    model: "member",
    where: where,
    limit: limit,
    offset: query[:offset],
    sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_direction] || query[:sort_order] || "asc"} : nil
  )
  users_by_id = member_users_by_id(ctx, members)
  {
    members: members.map { |entry| member_wire(ctx, entry, users_by_id: users_by_id) },
    total: ctx.context.adapter.count(model: "member", where: where)
  }
end

.load_boot_plugins!Object



216
217
218
# File 'lib/better_auth/plugin_loader.rb', line 216

def load_boot_plugins!
  BOOT_PLUGINS.each { |plugin| load_plugin!(plugin) }
end

.load_plugin!(name) ⇒ Object



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/better_auth/plugin_loader.rb', line 146

def load_plugin!(name)
  name = name.to_sym
  return true if @loaded_plugins[name]
  implementation_constant = EXTERNAL_PLUGIN_IMPLEMENTATIONS[name]
  if implementation_constant && const_defined?(implementation_constant, false)
    @loaded_plugins[name] = true
    return true
  end

  Array(PLUGIN_DEPENDENCIES[name]).each { |dependency| load_plugin!(dependency) }

  relative_path = PLUGIN_FILES.fetch(name) do
    raise ArgumentError, "Unknown plugin loader: #{name}"
  end

  absolute_path = File.expand_path(relative_path, __dir__)
  unless File.file?("#{absolute_path}.rb")
    @loaded_plugins[name] = true
    return false
  end

  require_relative relative_path
  @loaded_plugins[name] = true
  true
end

.load_plugin_for_constant!(name) ⇒ Object



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/better_auth/plugin_loader.rb', line 198

def load_plugin_for_constant!(name)
  if (loader = NESTED_MODULE_LOADERS[name])
    return load_plugin!(loader)
  end

  if name == :OpenAPI
    return load_plugin!(:open_api)
  end

  constant = name.to_s
  if constant.end_with?("_ERROR_CODES")
    plugin_name = constant.delete_suffix("_ERROR_CODES").downcase.to_sym
    return load_plugin!(plugin_name) if PLUGIN_FILES.key?(plugin_name)
  end

  false
end

.lock_organization_capacity!(adapter, organization) ⇒ Object



1540
1541
1542
# File 'lib/better_auth/plugins/organization.rb', line 1540

def lock_organization_capacity!(adapter, organization)
  adapter.update(model: "organization", where: [{field: "id", value: organization["id"]}], update: {name: organization["name"]})
end

.lock_team_capacity!(adapter, team) ⇒ Object



1544
1545
1546
# File 'lib/better_auth/plugins/organization.rb', line 1544

def lock_team_capacity!(adapter, team)
  adapter.update(model: "team", where: [{field: "id", value: team["id"]}], update: {name: team["name"]})
end


10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/better_auth/plugins/magic_link.rb', line 10

def magic_link(options = {})
  config = {store_token: "plain", allowed_attempts: 1}.merge(normalize_hash(options))
  if options.key?(:allowed_attempts) && options[:allowed_attempts] != 1
    warn "[better-auth/magic-link] `allowed_attempts` is ignored: tokens are consumed atomically on the first verification call. Any value other than `1` has no effect; remove the option to silence this warning."
  end

  Plugin.new(
    id: "magic-link",
    endpoints: {
      sign_in_magic_link: (config),
      magic_link_verify: magic_link_verify_endpoint(config)
    },
    rate_limit: [
      {
        path_matcher: ->(path) { path.start_with?("/sign-in/magic-link", "/magic-link/verify") },
        window: config.dig(:rate_limit, :window) || 60,
        max: config.dig(:rate_limit, :max) || 5
      }
    ],
    options: config
  )
end

Returns:

  • (Boolean)


195
196
197
198
199
200
# File 'lib/better_auth/plugins/magic_link.rb', line 195

def magic_link_attempts_exceeded?(attempt, config)
  allowed = config[:allowed_attempts]
  return false if allowed.respond_to?(:infinite?) && allowed.infinite?

  attempt >= allowed.to_i
end


231
232
233
234
235
236
237
238
239
# File 'lib/better_auth/plugins/magic_link.rb', line 231

def magic_link_error_url(url, error)
  uri = URI.parse(url.to_s.empty? ? "/" : url.to_s)
  query = URI.decode_www_form(uri.query.to_s)
  query << ["error", error]
  uri.query = URI.encode_www_form(query)
  uri.to_s
rescue URI::InvalidURIError
  "/?error=#{URI.encode_www_form_component(error)}"
end


188
189
190
191
192
193
# File 'lib/better_auth/plugins/magic_link.rb', line 188

def magic_link_token(email, config)
  generator = config[:generate_token]
  return generator.call(email) if generator.respond_to?(:call)

  Crypto.random_string(32, alphabet: Crypto::ALPHABETIC_ALPHABET)
end


214
215
216
217
218
219
220
221
222
# File 'lib/better_auth/plugins/magic_link.rb', line 214

def magic_link_url(base_url, token, body)
  params = {
    token: token,
    callbackURL: body[:callback_url] || "/"
  }
  params[:newUserCallbackURL] = body[:new_user_callback_url] if body[:new_user_callback_url]
  params[:errorCallbackURL] = body[:error_callback_url] if body[:error_callback_url]
  "#{base_url}/magic-link/verify?#{URI.encode_www_form(params)}"
end


83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/better_auth/plugins/magic_link.rb', line 83

def magic_link_verify_endpoint(config)
  Endpoint.new(
    path: "/magic-link/verify",
    method: "GET",
    metadata: {
      openapi: {
        operationId: "magicLinkVerify",
        description: "Verify a magic link token",
        parameters: [
          {
            name: "token",
            in: "query",
            required: true,
            schema: {type: "string"}
          },
          {
            name: "callbackURL",
            in: "query",
            required: false,
            schema: {type: "string"}
          }
        ],
        responses: {
          "200" => OpenAPI.json_response(
            "Magic link verified",
            OpenAPI.object_schema(
              {
                token: {type: "string"},
                user: {type: "object", "$ref": "#/components/schemas/User"},
                session: {type: "object", "$ref": "#/components/schemas/Session"}
              },
              required: ["token", "user", "session"]
            )
          ),
          "302" => {description: "Redirects to callback URL when callbackURL is provided"}
        }
      }
    }
  ) do |ctx|
    query = normalize_hash(ctx.query)
    token = query[:token].to_s
    callback_url = query[:callback_url] || "/"
    error_callback_url = query[:error_callback_url] || callback_url
    new_user_callback_url = query[:new_user_callback_url] || callback_url

    validate_magic_link_callback!(ctx, callback_url, "callbackURL")
    validate_magic_link_callback!(ctx, error_callback_url, "errorCallbackURL")
    validate_magic_link_callback!(ctx, new_user_callback_url, "newUserCallbackURL")

    redirect_with_error = lambda do |error|
      raise ctx.redirect(magic_link_error_url(error_callback_url, error))
    end

    stored_token = store_magic_link_token(token, config)
    verification = ctx.context.internal_adapter.consume_verification_value(stored_token)
    redirect_with_error.call("INVALID_TOKEN") unless verification

    payload = JSON.parse(verification["value"])
    email = payload.fetch("email").to_s.downcase
    name = payload["name"]
    found = ctx.context.internal_adapter.find_user_by_email(email)
    user = found && found[:user]
    new_user = false

    unless user
      redirect_with_error.call("new_user_signup_disabled") if config[:disable_sign_up]

      user = ctx.context.internal_adapter.create_user(
        email: email,
        emailVerified: true,
        name: name || "",
        context: ctx
      )
      new_user = true
      redirect_with_error.call("failed_to_create_user") unless user
    end

    unless user["emailVerified"]
      user = ctx.context.adapter.transaction do
        ctx.context.internal_adapter.(user["id"])
        updated = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
        raise Error, "Failed to verify magic-link user" unless updated

        updated
      end
    end

    session = ctx.context.internal_adapter.create_session(user["id"])
    redirect_with_error.call("failed_to_create_session") unless session

    Cookies.set_session_cookie(ctx, {session: session, user: user})
    unless query.key?(:callback_url)
      next ctx.json({
        token: session["token"],
        user: Schema.parse_output(ctx.context.options, "user", user),
        session: Schema.parse_output(ctx.context.options, "session", session)
      })
    end

    raise ctx.redirect(new_user ? new_user_callback_url : callback_url)
  rescue JSON::ParserError, KeyError
    raise ctx.redirect(magic_link_error_url(error_callback_url || "/", "INVALID_TOKEN"))
  end
end

.max_username_length(config) ⇒ Object



364
365
366
# File 'lib/better_auth/plugins/username.rb', line 364

def max_username_length(config)
  (config[:max_username_length] || 30).to_i
end

.member_by_id(ctx, id) ⇒ Object



1196
1197
1198
1199
1200
# File 'lib/better_auth/plugins/organization.rb', line 1196

def member_by_id(ctx, id)
  return nil if id.to_s.empty?

  ctx.context.adapter.find_one(model: "member", where: [{field: "id", value: id}])
end

.member_list_limit(ctx, organization_id, query, config, user) ⇒ Object



1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
# File 'lib/better_auth/plugins/organization.rb', line 1266

def member_list_limit(ctx, organization_id, query, config, user)
  configured = config && config[:membership_limit]
  configured = 100 if configured.nil?
  default = numeric_member_limit(configured)
  default = 100 unless default.positive?
  requested = query[:limit].to_i if query.key?(:limit) && !query[:limit].to_s.empty?
  return default unless requested&.positive?

  [requested, default].min
end

.member_users_by_id(ctx, members) ⇒ Object



1298
1299
1300
1301
1302
1303
1304
1305
# File 'lib/better_auth/plugins/organization.rb', line 1298

def member_users_by_id(ctx, members)
  user_ids = members.map { |member| member["userId"] }.compact.uniq
  return {} if user_ids.empty?

  ctx.context.adapter.find_many(model: "user", where: [{field: "id", operator: "in", value: user_ids}]).each_with_object({}) do |user, result|
    result[user["id"]] = user
  end
end

.member_wire(ctx, member, users_by_id: nil) ⇒ Object



1321
1322
1323
1324
1325
1326
# File 'lib/better_auth/plugins/organization.rb', line 1321

def member_wire(ctx, member, users_by_id: nil)
  data = Schema.parse_output(ctx.context.options, "member", member)
  user = users_by_id ? users_by_id[member["userId"]] : ctx.context.internal_adapter.find_user_by_id(member["userId"])
  data["user"] = user.slice("id", "name", "email", "image") if user
  data
end

.merge_hook_data!(target, response) ⇒ Object



1436
1437
1438
1439
1440
1441
1442
# File 'lib/better_auth/plugins/organization.rb', line 1436

def merge_hook_data!(target, response)
  data = if response.is_a?(Hash)
    normalize_hash(response)[:data]
  end
  target.merge!(normalize_hash(data)) if data.is_a?(Hash)
  target
end

.merge_permissions(base, extra) ⇒ Object



1170
1171
1172
1173
1174
# File 'lib/better_auth/plugins/organization.rb', line 1170

def merge_permissions(base, extra)
  stringify_permission(base).merge(stringify_permission(extra)) do |_resource, base_actions, extra_actions|
    (Array(base_actions) + Array(extra_actions)).map(&:to_s).uniq
  end
end

.merged_i18n_error_catalog(ctx) ⇒ Object



202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/better_auth/plugins/i18n.rb', line 202

def merged_i18n_error_catalog(ctx)
  catalog = BetterAuth::BASE_ERROR_CODES.dup
  ctx.context.options.plugins.each do |plugin|
    next unless plugin.respond_to?(:error_codes)

    codes = plugin.error_codes
    next if codes.nil? || codes.empty?

    catalog.merge!(codes)
  end
  catalog
end

.method_missing(name) ⇒ Object



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/better_auth/plugins.rb', line 68

def method_missing(name, ...)
  if (replacement = REMOVED_PLUGIN_FACTORIES[name.to_sym])
    raise ArgumentError, "BetterAuth::Plugins.#{name} was removed. Use #{replacement} instead."
  end

  if (loader = plugin_loader_for_method(name))
    load_plugin!(loader)
    return public_send(name, ...) if respond_to?(name, true)

    raise NoMethodError, "plugin file for #{loader} did not define BetterAuth::Plugins.#{name}"
  end

  if (loader = PLUGIN_FACTORY_LOADERS[name.to_sym])
    load_plugin!(loader)
    return public_send(name, ...) if respond_to?(name, true)

    raise NoMethodError, "plugin file for #{loader} did not define BetterAuth::Plugins.#{name}"
  end

  super
end

.microsoft_entra_id(options = {}) ⇒ Object



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/better_auth/plugins/generic_oauth.rb', line 135

def microsoft_entra_id(options = {})
  data = normalize_hash(options)
  tenant_id = data.fetch(:tenant_id).to_s
  generic_oauth_provider_config(
    data,
    provider_id: "microsoft-entra-id",
    authorization_url: "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/authorize",
    token_url: "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token",
    user_info_url: "https://graph.microsoft.com/oidc/userinfo",
    scopes: ["openid", "profile", "email"],
    get_user_info: ->(tokens) {
      profile = generic_oauth_fetch_json("https://graph.microsoft.com/oidc/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
      return nil unless profile

      {
        id: fetch_value(profile, "sub"),
        name: fetch_value(profile, "name") || [fetch_value(profile, "given_name"), fetch_value(profile, "family_name")].compact.join(" ").strip,
        email: fetch_value(profile, "email") || fetch_value(profile, "preferred_username"),
        image: fetch_value(profile, "picture"),
        emailVerified: fetch_value(profile, "email_verified") || false
      }
    }
  )
end

.min_username_length(config) ⇒ Object



360
361
362
# File 'lib/better_auth/plugins/username.rb', line 360

def min_username_length(config)
  (config[:min_username_length] || 3).to_i
end

.mirror_username_fields!(ctx) ⇒ Object



309
310
311
312
313
314
# File 'lib/better_auth/plugins/username.rb', line 309

def mirror_username_fields!(ctx)
  body = normalize_hash(ctx.body)
  body[:display_username] = body[:username] if present?(body[:username]) && !present?(body[:display_username])
  ctx.body = body
  nil
end


203
204
205
# File 'lib/better_auth/plugins/multi_session.rb', line 203

def multi_cookie_names(ctx)
  ctx.cookies.keys.select { |name| multi_session_cookie?(name) }
end

.multi_session(options = {}) ⇒ Object



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/better_auth/plugins/multi_session.rb', line 11

def multi_session(options = {})
  config = {maximum_sessions: 5}.merge(normalize_hash(options))

  Plugin.new(
    id: "multi-session",
    endpoints: {
      list_device_sessions: list_device_sessions_endpoint,
      set_active_session: set_active_session_endpoint,
      revoke_device_session: revoke_device_session_endpoint
    },
    hooks: {
      after: [
        {
          matcher: ->(_ctx) { true },
          handler: ->(ctx) { set_multi_session_cookie(ctx, config) }
        },
        {
          matcher: ->(ctx) { ctx.path == "/sign-out" },
          handler: ->(ctx) { clear_multi_session_cookies(ctx) }
        }
      ]
    },
    error_codes: MULTI_SESSION_ERROR_CODES,
    options: config
  )
end

.multi_session_cookie?(name) ⇒ Boolean

Returns:

  • (Boolean)


207
208
209
# File 'lib/better_auth/plugins/multi_session.rb', line 207

def multi_session_cookie?(name)
  name.to_s.include?("_multi-")
end


211
212
213
# File 'lib/better_auth/plugins/multi_session.rb', line 211

def multi_session_cookie_name(ctx, token)
  "#{ctx.context.auth_cookies[:session_token].name}_multi-#{token.to_s.downcase}"
end

.normalize_display_username(display_username, config) ⇒ Object



346
347
348
349
# File 'lib/better_auth/plugins/username.rb', line 346

def normalize_display_username(display_username, config)
  normalizer = config[:display_username_normalization]
  normalizer.respond_to?(:call) ? normalizer.call(display_username) : display_username
end

.normalize_field(value) ⇒ Object



31
32
33
34
35
36
# File 'lib/better_auth/plugins.rb', line 31

def normalize_field(value)
  data = normalize_hash(value || {})
  data[:default_value] = data.delete(:defaultValue) if data.key?(:defaultValue)
  data[:field_name] = data.delete(:fieldName) if data.key?(:fieldName)
  data
end

.normalize_hash(value) ⇒ Object



9
10
11
12
13
14
15
# File 'lib/better_auth/plugins.rb', line 9

def normalize_hash(value)
  return {} unless value.is_a?(Hash)

  value.each_with_object({}) do |(key, object), result|
    result[normalize_key(key)] = object.is_a?(Hash) ? normalize_hash(object) : object
  end
end

.normalize_i18n_detection(detection) ⇒ Object



69
70
71
72
73
# File 'lib/better_auth/plugins/i18n.rb', line 69

def normalize_i18n_detection(detection)
  return ["header"] if detection.nil?

  Array(detection).map { |strategy| strategy.to_s.downcase }
end

.normalize_i18n_options(options) ⇒ Object



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/better_auth/plugins/i18n.rb', line 25

def normalize_i18n_options(options)
  return {} unless options.is_a?(Hash)

  translations = normalize_i18n_translations(fetch_i18n_option(options, :translations))
  default_locale = fetch_i18n_option(options, :default_locale)
  detection = normalize_i18n_detection(fetch_i18n_option(options, :detection))
  locale_cookie = fetch_i18n_option(options, :locale_cookie) || "locale"
  user_locale_field = fetch_i18n_option(options, :user_locale_field) || "locale"
  get_locale = fetch_i18n_option(options, :get_locale)
  available_locales = translations.keys

  {
    translations: translations,
    default_locale: resolve_i18n_default_locale(default_locale, available_locales),
    detection: detection,
    locale_cookie: locale_cookie.to_s,
    user_locale_field: user_locale_field.to_s,
    get_locale: get_locale
  }
end

.normalize_i18n_translations(translations) ⇒ Object



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/better_auth/plugins/i18n.rb', line 52

def normalize_i18n_translations(translations)
  return {} unless translations.is_a?(Hash)

  translations.each_with_object({}) do |(locale, codes), result|
    locale_key = locale.to_s
    next if locale_key.empty?

    normalized_codes = {}
    next unless codes.is_a?(Hash)

    codes.each do |(code, message)|
      normalized_codes[code.to_s.tr("-", "_").upcase] = message
    end
    result[locale_key] = normalized_codes
  end
end

.normalize_key(key) ⇒ Object



17
18
19
20
21
22
23
# File 'lib/better_auth/plugins.rb', line 17

def normalize_key(key)
  key.to_s
    .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
    .tr("-", "_")
    .downcase
    .to_sym
end

.normalize_remote_jwk(entry) ⇒ Object



313
314
315
316
317
318
# File 'lib/better_auth/plugins/jwt.rb', line 313

def normalize_remote_jwk(entry)
  data = stringify_payload(entry || {})
  data["id"] ||= data["kid"]
  data["publicKey"] ||= data["pem"]
  data
end

.normalize_signed_bearer_token(token) ⇒ Object



73
74
75
# File 'lib/better_auth/plugins/bearer.rb', line 73

def normalize_signed_bearer_token(token)
  token.include?("%") ? safe_decode_bearer_token(token) : safe_decode_bearer_token(safe_encode_bearer_token(token))
end

.normalize_time(value) ⇒ Object



533
534
535
536
537
538
# File 'lib/better_auth/plugins/jwt.rb', line 533

def normalize_time(value)
  return value if value.is_a?(Time)
  return nil if value.nil?

  Time.parse(value.to_s)
end

.normalize_user_code(value) ⇒ Object



375
376
377
# File 'lib/better_auth/plugins/device_authorization.rb', line 375

def normalize_user_code(value)
  value.to_s
end

.normalize_username(username, config) ⇒ Object



338
339
340
341
342
343
344
# File 'lib/better_auth/plugins/username.rb', line 338

def normalize_username(username, config)
  normalizer = config[:username_normalization]
  return username if normalizer == false
  return normalizer.call(username) if normalizer.respond_to?(:call)

  username.downcase
end

.normalized_role_names(roles) ⇒ Object



1106
1107
1108
# File 'lib/better_auth/plugins/organization.rb', line 1106

def normalized_role_names(roles)
  Array(roles).flat_map { |role| role.to_s.split(",") }.map(&:strip).reject(&:empty?)
end

.numeric_member_limit(value) ⇒ Object



1291
1292
1293
1294
1295
1296
# File 'lib/better_auth/plugins/organization.rb', line 1291

def numeric_member_limit(value)
  return value.to_i if value.is_a?(Numeric)
  return value.to_i if value.to_s.match?(/\A-?\d+\z/)

  100
end

.oauth2_callback_endpoint(config) ⇒ Object



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'lib/better_auth/plugins/generic_oauth.rb', line 299

def oauth2_callback_endpoint(config)
  Endpoint.new(
    path: "/oauth2/callback/:providerId",
    method: "GET",
    metadata: {
      allowed_media_types: ["application/x-www-form-urlencoded", "application/json"],
      openapi: {
        operationId: "oauth2Callback",
        description: "OAuth2 callback",
        responses: {
          "200" => OpenAPI.json_response(
            "OAuth2 callback",
            OpenAPI.object_schema({url: {type: "string"}}, required: ["url"])
          )
        }
      }
    }
  ) do |ctx|
    query = normalize_hash(ctx.query)
    provider_id = (fetch_value(ctx.params, "providerId") || query[:provider_id]).to_s
    raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["PROVIDER_ID_REQUIRED"]) if provider_id.empty?

    provider = generic_oauth_provider!(config, provider_id)
    state_data = generic_oauth_parse_state(ctx, query[:state].to_s)
    error_url = state_data["errorURL"] || state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error"
    redirect_error = ->(error, description = nil) { raise ctx.redirect(generic_oauth_error_url(error_url, error, description)) }

    redirect_error.call(query[:error] || "oAuth_code_missing", query[:error_description]) if query[:error] || query[:code].to_s.empty?
    generic_oauth_validate_issuer!(ctx, provider, query, redirect_error)

    tokens = begin
      generic_oauth_exchange_token(ctx, provider, query[:code].to_s, state_data)
    rescue
      nil
    end
    redirect_error.call("oauth_code_verification_failed") unless tokens
     = (provider, tokens)
    redirect_error.call("user_info_is_missing") unless 

    mapped_user = generic_oauth_map_user(provider, )
    email = fetch_value(mapped_user, "email").to_s.downcase
    name = fetch_value(mapped_user, "name").to_s
     = fetch_value(mapped_user, "id").to_s
    redirect_error.call("email_is_missing") if email.empty?
    redirect_error.call("name_is_missing") if name.empty?
    redirect_error.call("user_info_is_missing") if Routes.blank_remote_id?()

    link = state_data["link"]
    callback_url = state_data["callbackURL"] || "/"
    if link
      (ctx, provider, tokens, mapped_user, link, redirect_error)
      raise ctx.redirect(callback_url)
    end

    existing = ctx.context.internal_adapter.find_oauth_user(email, , provider_id)
    if !existing && (provider[:disable_sign_up] || (provider[:disable_implicit_sign_up] && !state_data["requestSignUp"]))
      redirect_error.call("signup_disabled")
    end
    session_data = begin
      Routes.persist_social_user(
        ctx,
        provider_id,
        mapped_user.merge("email" => email, "name" => name, "id" => ),
        (ctx, provider_id, , tokens),
        override_user_info: provider[:override_user_info]
      )
    rescue APIError => error
      raise if error.code.to_s.empty?

      redirect_error.call(error.code, error.message)
    end
    redirect_error.call(session_data[:error].tr(" ", "_")) if session_data[:error]
    (ctx, provider_id, , session_data[:user]["id"])
    Cookies.set_session_cookie(ctx, session_data)
    raise ctx.redirect(existing ? callback_url : (state_data["newUserURL"] || state_data["newUserCallbackURL"] || callback_url))
  end
end


268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/better_auth/plugins/generic_oauth.rb', line 268

def (config)
  Endpoint.new(
    path: "/oauth2/link",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "linkOAuth2",
        description: "Link an OAuth2 account to the current user session",
        requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
        responses: {
          "200" => OpenAPI.json_response("Authorization URL generated successfully for linking an OAuth2 account", generic_oauth_url_response_schema)
        }
      }
    }
  ) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    provider_id = body[:provider_id].to_s
    provider = generic_oauth_provider(config, provider_id)
    raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider

    auth_url = generic_oauth_authorization_url(
      ctx,
      provider,
      body,
      link: {user_id: session[:user]["id"], email: session[:user]["email"]}
    )
    ctx.json({url: auth_url, redirect: true})
  end
end

.oauth_popupObject



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/better_auth/plugins/oauth_popup.rb', line 51

def oauth_popup
  Plugin.new(
    id: "oauth-popup",
    endpoints: {
      oauth_popup_start: oauth_popup_start_endpoint
    },
    hooks: {
      after: [
        {
          matcher: ->(ctx) { oauth_popup_callback_path?(ctx.path) },
          handler: ->(ctx) { oauth_popup_after_callback(ctx) }
        }
      ]
    },
    error_codes: OAUTH_POPUP_ERROR_CODES
  )
end

.oauth_popup_additional_data(value) ⇒ Object



202
203
204
205
206
207
208
209
# File 'lib/better_auth/plugins/oauth_popup.rb', line 202

def oauth_popup_additional_data(value)
  parsed = value.is_a?(Hash) ? value : JSON.parse(value.to_s)
  return {} unless parsed.is_a?(Hash)

  parsed.reject { |key, _entry| OAUTH_POPUP_INTERNAL_STATE_KEYS.include?(key.to_s) }
rescue JSON::ParserError
  {}
end

.oauth_popup_after_callback(ctx) ⇒ Object



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/better_auth/plugins/oauth_popup.rb', line 225

def oauth_popup_after_callback(ctx)
  location = ctx.response_headers["location"].to_s
  return if location.empty?

  marker_cookie = ctx.context.create_auth_cookie(OAUTH_POPUP_MARKER_COOKIE)
  marker = ctx.get_signed_cookie(marker_cookie.name, ctx.context.secret)
  return unless marker

  Cookies.expire_cookie(ctx, marker_cookie)
  data = JSON.parse(marker)
  popup_origin = data.fetch("popupOrigin").to_s
  nonce = data["popupNonce"].to_s
  return if popup_origin.empty?

  token = oauth_popup_session_token(ctx)
  message = if token
    {nonce: nonce, token: token, redirectTo: location}
  else
    error = oauth_popup_redirect_error(location)
    return unless error

    {nonce: nonce, error: error}
  end

  oauth_popup_completion(ctx, popup_origin, message)
rescue JSON::ParserError, KeyError
  nil
end

.oauth_popup_callback_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


221
222
223
# File 'lib/better_auth/plugins/oauth_popup.rb', line 221

def oauth_popup_callback_path?(path)
  path.to_s.start_with?("/callback/", "/oauth2/callback/")
end

.oauth_popup_completion(ctx, popup_origin, message) ⇒ Object



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/better_auth/plugins/oauth_popup.rb', line 279

def oauth_popup_completion(ctx, popup_origin, message)
  oauth_popup_warn_missing_bearer(ctx) if message[:token]
  payload = {
    type: OAUTH_POPUP_MESSAGE_TYPE,
    targetOrigin: popup_origin
  }.merge(message)
  serialized = JSON.generate(payload)
    .gsub("<", "\\u003c")
    .gsub("\u2028", "\\u2028")
    .gsub("\u2029", "\\u2029")
  html = <<~HTML
    <!doctype html>
    <html>
    <head><meta charset="utf-8"><title>Completing sign-in</title></head>
    <body>
    <script type="application/json" id="#{OAUTH_POPUP_DATA_ELEMENT_ID}">#{serialized}</script>
    <script>#{OAUTH_POPUP_COMPLETE_SCRIPT}</script>
    </body>
    </html>
  HTML
  headers = ctx.response_headers.merge(
    "content-type" => "text/html; charset=utf-8",
    "content-security-policy" => "default-src 'none'; script-src '#{OAUTH_POPUP_SCRIPT_CSP_HASH}'; base-uri 'none'",
    "cache-control" => "no-store",
    "pragma" => "no-cache"
  )
  headers.delete("location")
  Endpoint::Result.new(response: html, status: 200, headers: headers)
end

.oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/better_auth/plugins/oauth_popup.rb', line 183

def oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce)
  {
    callback_url: "invalid_callback_url",
    error_callback_url: "invalid_error_callback_url",
    new_user_callback_url: "invalid_new_user_callback_url"
  }.each do |key, code|
    value = query[key]
    next if value.to_s.empty?
    next if ctx.context.trusted_origin?(value.to_s, allow_relative_paths: true)

    oauth_popup_log(ctx.context, :error, "OAuth popup redirect URL is not trusted")
    return oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: {
      code: code,
      description: "Untrusted redirect URL"
    })
  end
  nil
end

.oauth_popup_log(context, level, message, *details) ⇒ Object



321
322
323
324
325
326
327
328
# File 'lib/better_auth/plugins/oauth_popup.rb', line 321

def oauth_popup_log(context, level, message, *details)
  logger = context.logger
  if logger.respond_to?(:call)
    logger.call(level, message, *details)
  elsif logger.respond_to?(level)
    logger.public_send(level, message, *details)
  end
end

.oauth_popup_redirect_error(location) ⇒ Object



269
270
271
272
273
274
275
276
277
# File 'lib/better_auth/plugins/oauth_popup.rb', line 269

def oauth_popup_redirect_error(location)
  uri = URI.parse(location)
  params = URI.decode_www_form(uri.query.to_s).to_h
  return if params["error"].to_s.empty?

  {code: params["error"], description: params["error_description"]}.compact
rescue URI::InvalidURIError
  nil
end

.oauth_popup_session_token(ctx) ⇒ Object



254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/better_auth/plugins/oauth_popup.rb', line 254

def oauth_popup_session_token(ctx)
  name = ctx.context.auth_cookies[:session_token].name
  Cookies.split_set_cookie_header(ctx.response_headers["set-cookie"]).each do |line|
    cookie = Cookies.parse_set_cookie(line)
    next unless cookie && cookie[:name] == name
    max_age = cookie.dig(:attributes, "max-age")
    next if cookie[:value].empty? || (max_age.to_s.strip.match?(/\A[+-]?\d+\z/) && max_age.to_i == 0)

    return URI.decode_uri_component(cookie[:value])
  rescue ArgumentError
    return cookie[:value]
  end
  nil
end

.oauth_popup_set_marker(ctx, popup_origin, nonce) ⇒ Object



211
212
213
214
215
216
217
218
219
# File 'lib/better_auth/plugins/oauth_popup.rb', line 211

def oauth_popup_set_marker(ctx, popup_origin, nonce)
  cookie = ctx.context.create_auth_cookie(OAUTH_POPUP_MARKER_COOKIE, max_age: 600)
  ctx.set_signed_cookie(
    cookie.name,
    JSON.generate({popupOrigin: popup_origin, popupNonce: nonce}),
    ctx.context.secret,
    cookie.attributes
  )
end

.oauth_popup_start_endpointObject



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/better_auth/plugins/oauth_popup.rb', line 69

def oauth_popup_start_endpoint
  Endpoint.new(
    path: "/oauth-popup/start",
    method: "GET",
    query_schema: Routes.request_query_schema(
      required_strings: %w[provider popupOrigin],
      optional_strings: %w[popupNonce callbackURL errorCallbackURL newUserCallbackURL scopes requestSignUp additionalData]
    ),
    metadata: {
      hide: true,
      openapi: {
        parameters: [
          {name: "provider", in: "query", required: true, schema: {type: "string"}},
          {name: "popupOrigin", in: "query", required: true, schema: {type: "string"}},
          {name: "popupNonce", in: "query", required: false, schema: {type: "string"}},
          {name: "callbackURL", in: "query", required: false, schema: {type: "string"}},
          {name: "errorCallbackURL", in: "query", required: false, schema: {type: "string"}},
          {name: "newUserCallbackURL", in: "query", required: false, schema: {type: "string"}},
          {name: "scopes", in: "query", required: false, schema: {type: "string"}},
          {name: "requestSignUp", in: "query", required: false, schema: {type: "string"}},
          {name: "additionalData", in: "query", required: false, schema: {type: "string"}}
        ]
      }
    }
  ) do |ctx|
    query = normalize_hash(ctx.query)
    popup_origin = oauth_popup_validate_origin!(ctx, query[:popup_origin].to_s)

    nonce = query[:popup_nonce].to_s
    invalid_redirect = oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce)
    next invalid_redirect if invalid_redirect

    provider_id = query[:provider].to_s
    provider = Routes.social_provider(ctx.context, provider_id)
    unless provider
      next oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: {
        code: "provider_not_found",
        description: "Unknown provider: #{provider_id}"
      })
    end

    code_verifier = Crypto.random_string(128)
    scopes = query[:scopes]&.to_s&.split(",")
    additional_data = oauth_popup_additional_data(query[:additional_data])
    callback_url = query[:callback_url].to_s
    callback_url = ctx.context.base_url if callback_url.empty?
     = (query[:request_sign_up].to_s == "true") ? true : nil

    begin
      authorization_url = if (start = fetch_value(provider, "oauthPopupAuthorizationUrl")).respond_to?(:call)
        start.call(
          ctx,
          callbackURL: callback_url,
          errorCallbackURL: query[:error_callback_url],
          newUserCallbackURL: query[:new_user_callback_url],
          requestSignUp: ,
          scopes: scopes,
          additionalData: additional_data,
          codeVerifier: code_verifier
        )
      else
        state_data = additional_data.merge(
          "callbackURL" => callback_url,
          "errorURL" => query[:error_callback_url],
          "newUserURL" => query[:new_user_callback_url],
          "requestSignUp" => ,
          "codeVerifier" => code_verifier,
          "expiresAt" => Time.now.to_i + 600
        ).compact
        state = OAuthState.generate(ctx, state_data)
        Routes.call_provider(provider, :create_authorization_url, {
          state: state,
          codeVerifier: code_verifier,
          code_verifier: code_verifier,
          redirectURI: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
          redirect_uri: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
          scopes: scopes
        })
      end

      oauth_popup_set_marker(ctx, popup_origin, nonce)
    rescue => error
      oauth_popup_log(ctx.context, :error, "OAuth popup failed to start", error)
      next oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: {
        code: "popup_sign_in_failed",
        description: "Failed to start the OAuth flow."
      })
    end

    raise ctx.redirect(authorization_url.to_s)
  end
end

.oauth_popup_validate_origin!(ctx, popup_origin) ⇒ Object



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/better_auth/plugins/oauth_popup.rb', line 162

def oauth_popup_validate_origin!(ctx, popup_origin)
  uri = URI.parse(popup_origin)
  valid_scheme = %w[http https].include?(uri.scheme)
  valid_path = uri.path.to_s.empty? || uri.path == "/"
  absolute = valid_scheme && !uri.host.to_s.empty?
  canonical = Configuration.origin_for(uri) if absolute
  valid = absolute &&
    uri.userinfo.nil? &&
    valid_path &&
    uri.query.nil? &&
    uri.fragment.nil? &&
    !popup_origin.match?(/[?*]/) &&
    ctx.context.trusted_origin?(canonical, allow_relative_paths: false)
  return canonical if valid

  oauth_popup_log(ctx.context, :error, "OAuth popup origin is not trusted")
  raise APIError.new("FORBIDDEN", code: "INVALID_ORIGIN", message: BASE_ERROR_CODES["INVALID_ORIGIN"])
rescue URI::InvalidURIError
  raise APIError.new("FORBIDDEN", code: "INVALID_ORIGIN", message: BASE_ERROR_CODES["INVALID_ORIGIN"])
end

.oauth_popup_warn_missing_bearer(ctx) ⇒ Object



309
310
311
312
313
314
315
316
317
318
319
# File 'lib/better_auth/plugins/oauth_popup.rb', line 309

def oauth_popup_warn_missing_bearer(ctx)
  return if ctx.context.options.plugins.any? { |plugin| plugin.id == "bearer" }
  return if @oauth_popup_warned_missing_bearer

  @oauth_popup_warned_missing_bearer = true
  oauth_popup_log(
    ctx.context,
    :warn,
    "OAuth popup hands the session token back via postMessage, but the `bearer` plugin is not registered. Add bearer() for embedded cross-site authentication."
  )
end

.oauth_provider(*args, &block) ⇒ Object



9
10
11
# File 'lib/better_auth/plugins/oauth_provider.rb', line 9

def oauth_provider(*args, &block)
  call_external_plugin!(:oauth_provider, *args, implementation_constant: :OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-oauth-provider", entry: "lib/better_auth/oauth_provider.rb", &block)
end

.oauth_proxy(options = {}) ⇒ Object



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 11

def oauth_proxy(options = {})
  config = {max_age: 60}.merge(normalize_hash(options))

  Plugin.new(
    id: "oauth-proxy",
    endpoints: {
      oauth_proxy: oauth_proxy_endpoint(config)
    },
    hooks: {
      before: [
        {
          matcher: ->(ctx) { (ctx.path) },
          handler: ->(ctx) { (ctx, config) }
        },
        {
          matcher: ->(ctx) { oauth_proxy_callback_path?(ctx.path) },
          handler: ->(ctx) { oauth_proxy_restore_state_package(ctx, config) }
        }
      ],
      after: [
        {
          matcher: ->(ctx) { (ctx.path) },
          handler: ->(ctx) { (ctx, config) }
        },
        {
          matcher: ->(ctx) { oauth_proxy_callback_path?(ctx.path) },
          handler: ->(ctx) { oauth_proxy_after_callback(ctx, config) }
        }
      ]
    },
    options: config
  )
end

.oauth_proxy_after_callback(ctx, config) ⇒ Object



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 162

def oauth_proxy_after_callback(ctx, config)
  location = ctx.response_headers["location"]
  return unless location.to_s.include?("/oauth-proxy-callback?callbackURL")
  return unless location.to_s.start_with?("http")

  location_uri = URI.parse(location)
  production = oauth_proxy_production_uri(ctx, config)
  if location_uri.origin == production.origin
    original = Rack::Utils.parse_query(location_uri.query).fetch("callbackURL", nil)
    oauth_proxy_set_location(ctx, original) if original
    return nil
  end

  set_cookie = ctx.response_headers["set-cookie"]
  return if set_cookie.to_s.empty?

  encrypted = Crypto.symmetric_encrypt(
    key: oauth_proxy_secret(ctx, config),
    data: JSON.generate({
      cookies: set_cookie,
      timestamp: (Time.now.to_f * 1000).to_i
    })
  )
  separator = location.include?("?") ? "&" : "?"
  oauth_proxy_set_location(ctx, "#{location}#{separator}cookies=#{URI.encode_www_form_component(encrypted)}")
  nil
rescue URI::InvalidURIError
  nil
end

.oauth_proxy_after_sign_in(ctx, config) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 123

def (ctx, config)
  return if oauth_proxy_skip?(ctx, config)
  return unless ctx.context.options.[:store_state_strategy].to_s == "cookie"
  return unless ctx.returned.is_a?(Hash)

  provider_url = fetch_value(ctx.returned, "url").to_s
  return if provider_url.empty?

  uri = URI.parse(provider_url)
  params = Rack::Utils.parse_query(uri.query)
  original_state = params["state"]
  return if original_state.to_s.empty?

  state_cookie = oauth_proxy_state_cookie_value(ctx)
  return if state_cookie.to_s.empty?

  encrypted_package = Crypto.symmetric_encrypt(
    key: oauth_proxy_secret(ctx, config),
    data: JSON.generate({
      state: original_state,
      stateCookie: state_cookie,
      isOAuthProxy: true
    })
  )
  params["state"] = encrypted_package
  uri.query = URI.encode_www_form(params)

  response = ctx.returned.dup
  if response.key?(:url)
    response[:url] = uri.to_s
  else
    response["url"] = uri.to_s
  end
  ctx.returned = response
  ctx.json(response)
rescue URI::InvalidURIError
  nil
end

.oauth_proxy_before_sign_in(ctx, config) ⇒ Object



91
92
93
94
95
96
97
98
99
100
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 91

def (ctx, config)
  return if oauth_proxy_skip?(ctx, config)
  return unless ctx.body.is_a?(Hash)

  original_callback = ctx.body["callbackURL"] || ctx.body["callbackUrl"] || ctx.body["callback_url"] || ctx.body[:callbackURL] || ctx.body[:callback_url] || ctx.context.base_url
  current = oauth_proxy_current_uri(ctx, config)
  callback = "#{oauth_proxy_strip_trailing(current.origin)}#{ctx.context.options.base_path}/oauth-proxy-callback?callbackURL=#{URI.encode_www_form_component(original_callback)}"
  ctx.body = ctx.body.merge("callbackURL" => callback, :callback_url => callback)
  nil
end

.oauth_proxy_callback_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


207
208
209
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 207

def oauth_proxy_callback_path?(path)
  path.to_s.start_with?("/callback", "/oauth2/callback")
end

.oauth_proxy_current_uri(ctx, config) ⇒ Object



219
220
221
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 219

def oauth_proxy_current_uri(ctx, config)
  URI.parse((config[:current_url] || ctx.context.options.base_url || ctx.context.base_url).to_s)
end

.oauth_proxy_endpoint(config) ⇒ Object



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 45

def oauth_proxy_endpoint(config)
  Endpoint.new(
    path: "/oauth-proxy-callback",
    method: "GET",
    metadata: {
      openapi: {
        operationId: "oauthProxyCallback",
        description: "OAuth Proxy Callback",
        parameters: [
          {in: "query", name: "callbackURL", required: true, schema: {type: "string", format: "uri"}},
          {in: "query", name: "cookies", required: true, schema: {type: "string"}}
        ],
        responses: {
          "302" => {description: "Redirects to the callback URL"}
        }
      }
    }
  ) do |ctx|
    query = normalize_hash(ctx.query)
    callback_url = query[:callback_url] || "/"
    oauth_proxy_validate_callback!(ctx, callback_url)

    decrypted = Crypto.symmetric_decrypt(key: oauth_proxy_secret(ctx, config), data: query[:cookies].to_s)
    raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid cookies or secret")) unless decrypted

    payload = JSON.parse(decrypted)
    cookies = payload["cookies"]
    timestamp = payload["timestamp"]
    unless cookies.is_a?(String) && timestamp.is_a?(Numeric)
      raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid payload structure"))
    end

    age = ((Time.now.to_f * 1000) - timestamp.to_f) / 1000
    if age > config[:max_age].to_i || age < -10
      raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Payload expired or invalid"))
    end

    oauth_proxy_parse_set_cookie(cookies).each do |cookie|
      ctx.set_cookie(cookie[:name], cookie[:value], cookie[:options])
    end
    raise ctx.redirect(callback_url)
  rescue JSON::ParserError
    raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid payload format"))
  end
end

.oauth_proxy_error_url(ctx, message) ⇒ Object



238
239
240
241
242
243
244
245
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 238

def oauth_proxy_error_url(ctx, message)
  base = ctx.context.options.on_api_error[:error_url] || "#{oauth_proxy_strip_trailing(ctx.context.base_url)}/error"
  uri = URI.parse(base)
  params = URI.decode_www_form(uri.query.to_s)
  params << ["error", message]
  uri.query = URI.encode_www_form(params)
  uri.to_s
end


255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 255

def oauth_proxy_parse_set_cookie(header)
  header.to_s.split(/\n|,(?=\s*[^;,]+=)/).filter_map do |line|
    parts = line.strip.split(/;\s*/)
    name, value = parts.shift.to_s.split("=", 2)
    next if name.to_s.empty?

    options = {}
    parts.each do |part|
      key, option_value = part.split("=", 2)
      case key.to_s.downcase
      when "path" then options[:path] = option_value
      when "expires" then options[:expires] = option_value
      when "samesite" then options[:same_site] = option_value
      when "httponly" then options[:http_only] = true
      when "secure" then options[:secure] = true
      when "max-age" then options[:max_age] = option_value
      end
    end
    {name: Cookies.strip_secure_cookie_prefix(name), value: URI.decode_www_form_component(value.to_s), options: options}
  end
end

.oauth_proxy_production_uri(ctx, config) ⇒ Object



223
224
225
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 223

def oauth_proxy_production_uri(ctx, config)
  URI.parse((config[:production_url] || ctx.context.options.base_url || ctx.context.base_url).to_s)
end

.oauth_proxy_restore_state_package(ctx, config) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 102

def oauth_proxy_restore_state_package(ctx, config)
  state = fetch_value(ctx.query, "state") || fetch_value(ctx.body, "state")
  return if state.to_s.empty?

  decrypted = Crypto.symmetric_decrypt(key: oauth_proxy_secret(ctx, config), data: state.to_s)
  return unless decrypted

  package = JSON.parse(decrypted)
  return unless package["isOAuthProxy"] && package["state"] && package["stateCookie"]

  cookie = ctx.context.create_auth_cookie("oauth_state")
  current_cookie = ctx.headers["cookie"].to_s
  restored_cookie = "#{cookie.name}=#{package["stateCookie"]}"
  ctx.headers["cookie"] = current_cookie.empty? ? restored_cookie : "#{current_cookie}; #{restored_cookie}"
  ctx.query = ctx.query.merge(:state => package["state"], "state" => package["state"])
  ctx.body = ctx.body.merge(:state => package["state"], "state" => package["state"]) if ctx.body.is_a?(Hash)
  nil
rescue JSON::ParserError
  nil
end

.oauth_proxy_secret(ctx, config) ⇒ Object



199
200
201
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 199

def oauth_proxy_secret(ctx, config)
  config[:secret] || ctx.context.secret_config
end

.oauth_proxy_set_location(ctx, location) ⇒ Object



247
248
249
250
251
252
253
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 247

def oauth_proxy_set_location(ctx, location)
  ctx.set_header("location", location)
  return unless ctx.returned.is_a?(APIError)

  headers = ctx.returned.headers.merge("location" => location)
  ctx.returned.instance_variable_set(:@headers, headers)
end

.oauth_proxy_sign_in_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


203
204
205
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 203

def (path)
  path.to_s.start_with?("/sign-in/social", "/sign-in/oauth2")
end

.oauth_proxy_skip?(ctx, config) ⇒ Boolean

Returns:

  • (Boolean)


211
212
213
214
215
216
217
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 211

def oauth_proxy_skip?(ctx, config)
  current = oauth_proxy_current_uri(ctx, config)
  production = oauth_proxy_production_uri(ctx, config)
  current.origin == production.origin
rescue URI::InvalidURIError
  false
end


192
193
194
195
196
197
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 192

def oauth_proxy_state_cookie_value(ctx)
  cookie = ctx.context.create_auth_cookie("oauth_state")
  parsed = oauth_proxy_parse_set_cookie(ctx.response_headers["set-cookie"])
  exact = parsed.find { |entry| entry[:name] == cookie.name || entry[:name] == Cookies.strip_secure_cookie_prefix(cookie.name) }
  exact && exact[:value]
end

.oauth_proxy_strip_trailing(value) ⇒ Object



227
228
229
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 227

def oauth_proxy_strip_trailing(value)
  value.to_s.sub(%r{/+\z}, "")
end

.oauth_proxy_validate_callback!(ctx, callback_url) ⇒ Object

Raises:



231
232
233
234
235
236
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 231

def oauth_proxy_validate_callback!(ctx, callback_url)
  return if callback_url.to_s.empty?
  return if ctx.context.trusted_origin?(callback_url.to_s, allow_relative_paths: true)

  raise APIError.new("FORBIDDEN", message: "Invalid callbackURL")
end

.okta(options = {}) ⇒ Object



160
161
162
163
164
# File 'lib/better_auth/plugins/generic_oauth.rb', line 160

def okta(options = {})
  data = normalize_hash(options)
  issuer = data.fetch(:issuer).to_s.sub(%r{/\z}, "")
  generic_oidc_helper_provider(data, "okta", issuer, "#{issuer}/.well-known/openid-configuration", "#{issuer}/oauth2/v1/userinfo")
end

.one_tap(options = {}) ⇒ Object



10
11
12
13
14
15
16
17
18
19
20
# File 'lib/better_auth/plugins/one_tap.rb', line 10

def one_tap(options = {})
  config = normalize_hash(options)

  Plugin.new(
    id: "one-tap",
    endpoints: {
      one_tap_callback: one_tap_callback_endpoint(config)
    },
    options: config
  )
end

.one_tap_blank_audience?(audience) ⇒ Boolean

Returns:

  • (Boolean)


145
146
147
# File 'lib/better_auth/plugins/one_tap.rb', line 145

def one_tap_blank_audience?(audience)
  Array(audience).empty? || Array(audience).all? { |value| value.to_s.strip.empty? }
end

.one_tap_boolean_value(value) ⇒ Object



168
169
170
# File 'lib/better_auth/plugins/one_tap.rb', line 168

def one_tap_boolean_value(value)
  value == true || value.to_s.downcase == "true"
end

.one_tap_callback_endpoint(config) ⇒ Object



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/better_auth/plugins/one_tap.rb', line 22

def one_tap_callback_endpoint(config)
  Endpoint.new(
    path: "/one-tap/callback",
    method: "POST",
    body_schema: ->(body) {
      data = normalize_hash(body)
      data[:id_token].to_s.empty? ? false : data
    },
    metadata: {
      openapi: {
        operationId: "oneTapCallback",
        summary: "One tap callback",
        description: "Use this endpoint to authenticate with Google One Tap",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              id_token: {type: "string", description: "Google One Tap ID token"}
            },
            required: ["id_token"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Success", OpenAPI.session_response_schema_pair)
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    id_token = body[:id_token].to_s
    payload = one_tap_verify_id_token(ctx, config, id_token)
    email = fetch_value(payload, "email").to_s.downcase
    raise APIError.new("BAD_REQUEST", message: "invalid id token") if Routes.blank_remote_id?(fetch_value(payload, "sub"))

    if email.empty?
      next ctx.json({error: "Email not available in token"})
    end

    sub = fetch_value(payload, "sub").to_s
    session_data = Routes.persist_social_user(
      ctx,
      "google",
      {
        id: sub,
        email: email,
        emailVerified: one_tap_boolean_value(fetch_value(payload, "email_verified")),
        name: fetch_value(payload, "name").to_s,
        image: fetch_value(payload, "picture")
      },
      {
        providerId: "google",
        accountId: sub,
        idToken: id_token,
        scope: "openid,profile,email"
      },
      disable_sign_up: config[:disable_signup]
    )
    one_tap_raise_persistence_error!(session_data[:error]) if session_data[:error]

    Cookies.set_session_cookie(ctx, session_data)
    ctx.json({
      token: session_data[:session]["token"],
      user: Schema.parse_output(ctx.context.options, "user", session_data[:user])
    })
  end
end

.one_tap_google_jwksObject



133
134
135
136
137
138
139
140
141
142
143
# File 'lib/better_auth/plugins/one_tap.rb', line 133

def one_tap_google_jwks
  cached = @one_tap_google_jwks_cache
  return cached[:jwks] if cached && cached[:expires_at] > Time.now

  payload = HTTPClient.get_json("https://www.googleapis.com/oauth2/v3/certs")
  raise "Unable to fetch Google JWKS" unless payload

  jwks = ::JWT::JWK::Set.new(payload)
  @one_tap_google_jwks_cache = {jwks: jwks, expires_at: Time.now + 300}
  jwks
end

.one_tap_raise_persistence_error!(error) ⇒ Object



149
150
151
152
153
154
155
156
157
158
# File 'lib/better_auth/plugins/one_tap.rb', line 149

def one_tap_raise_persistence_error!(error)
  case error
  when "signup disabled"
    raise APIError.new("BAD_GATEWAY", message: "User not found")
  when "account not linked", "banned"
    raise APIError.new("UNAUTHORIZED", message: "Google sub doesn't match")
  else
    raise APIError.new("INTERNAL_SERVER_ERROR", message: "Could not create user")
  end
end

.one_tap_stringify_payload(payload) ⇒ Object



160
161
162
163
164
165
166
# File 'lib/better_auth/plugins/one_tap.rb', line 160

def one_tap_stringify_payload(payload)
  raise "Invalid payload" unless payload.is_a?(Hash)

  payload.each_with_object({}) do |(key, value), result|
    result[key.to_s] = value
  end
end

.one_tap_verify_google_id_token(id_token, audience) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/better_auth/plugins/one_tap.rb', line 120

def one_tap_verify_google_id_token(id_token, audience)
  jwks = one_tap_google_jwks
  options = {
    algorithms: ["RS256"],
    iss: ["https://accounts.google.com", "accounts.google.com"],
    verify_iss: true
  }
  options[:aud] = audience
  options[:verify_aud] = true
  payload, = ::JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
  payload
end

.one_tap_verify_id_token(ctx, config, id_token) ⇒ Object



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/better_auth/plugins/one_tap.rb', line 88

def one_tap_verify_id_token(ctx, config, id_token)
  google_provider = ctx.context.options.social_providers[:google] || {}
  google_options = fetch_value(google_provider, "options") || {}
  audience = config[:client_id]
  audience = fetch_value(google_provider, "client_id") if one_tap_blank_audience?(audience)
  if one_tap_blank_audience?(audience)
    raise APIError.new(
      "BAD_REQUEST",
      message: "Google client ID is required for One Tap. Set it on the one_tap plugin (client_id) or on social_providers.google."
    )
  end

  begin
    verifier = config[:verify_id_token]
    payload = if verifier.respond_to?(:call)
      verifier.call(id_token, ctx, audience: audience)
    else
      one_tap_verify_google_id_token(id_token, audience)
    end
    payload = one_tap_stringify_payload(payload)
    hosted_domain = fetch_value(google_provider, "hd")
    hosted_domain = fetch_value(google_options, "hd") if hosted_domain.nil?
    unless SocialProviders.google_hosted_domain_allowed?(hosted_domain, payload["hd"])
      raise "Invalid Google hosted domain"
    end

    payload
  rescue
    raise APIError.new("BAD_REQUEST", message: "invalid id token")
  end
end

.one_time_token(options = {}) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# File 'lib/better_auth/plugins/one_time_token.rb', line 7

def one_time_token(options = {})
  config = {
    expires_in: 3,
    store_token: "plain"
  }.merge(normalize_hash(options))

  Plugin.new(
    id: "one-time-token",
    endpoints: {
      generate_one_time_token: generate_one_time_token_endpoint(config),
      verify_one_time_token: verify_one_time_token_endpoint(config)
    },
    hooks: {
      after: [
        {
          matcher: ->(_ctx) { true },
          handler: ->(ctx) { one_time_token_after_response(ctx, config) }
        }
      ]
    },
    options: config
  )
end

.one_time_token_after_response(ctx, config) ⇒ Object



100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/better_auth/plugins/one_time_token.rb', line 100

def one_time_token_after_response(ctx, config)
  return unless config[:set_ott_header_on_new_session]

  session = ctx.context.new_session
  return unless session && session[:session] && session[:user]

  token = one_time_token_create(ctx, config, session)
  existing = ctx.response_headers["access-control-expose-headers"].to_s
  exposed = existing.split(",").map(&:strip).reject(&:empty?)
  exposed << "set-ott"
  ctx.set_header("set-ott", token)
  ctx.set_header("access-control-expose-headers", exposed.uniq.join(", "))
  nil
end

.one_time_token_create(ctx, config, session) ⇒ Object



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/better_auth/plugins/one_time_token.rb', line 115

def one_time_token_create(ctx, config, session)
  generator = config[:generate_token]
  token = if generator.respond_to?(:call)
    generator.call(session, ctx)
  else
    Crypto.random_string(32)
  end.to_s
  stored_token = one_time_token_stored_value(config, token)
  ctx.context.internal_adapter.create_verification_value(
    identifier: "one-time-token:#{stored_token}",
    value: session[:session]["token"],
    expiresAt: Time.now + config[:expires_in].to_i * 60
  )
  token
end

.one_time_token_stored_value(config, token) ⇒ Object



131
132
133
134
135
136
137
138
139
140
141
# File 'lib/better_auth/plugins/one_time_token.rb', line 131

def one_time_token_stored_value(config, token)
  storage = config[:store_token]
  return Crypto.sha256(token, encoding: :base64url) if storage.to_s == "hashed"

  if storage.is_a?(Hash) && storage[:type].to_s.tr("_", "-") == "custom-hasher"
    hasher = storage[:hash]
    return hasher.call(token) if hasher.respond_to?(:call)
  end

  token
end

.open_api(options = {}) ⇒ Object



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/better_auth/plugins/open_api.rb', line 235

def open_api(options = {})
  config = {path: "/reference", theme: "default"}.merge(normalize_hash(options))

  Plugin.new(
    id: "open-api",
    endpoints: {
      generate_openapi_schema: Endpoint.new(path: "/open-api/generate-schema", method: "GET") do |ctx|
        ctx.json(open_api_schema(ctx.context))
      end,
      open_api_reference: Endpoint.new(path: config[:path], method: "GET", metadata: {hide: true}) do |ctx|
        raise APIError.new("NOT_FOUND") if config[:disable_default_reference]

        [200, {"content-type" => "text/html"}, [open_api_html(open_api_schema(ctx.context), config)]]
      end
    },
    options: config
  )
end

.open_api_components(options) ⇒ Object



381
382
383
384
385
386
# File 'lib/better_auth/plugins/open_api.rb', line 381

def open_api_components(options)
  Schema.auth_tables(options).each_with_object({}) do |(model, table), schemas|
    name = model.to_s.split(/[_-]/).map(&:capitalize).join
    schemas[name.to_sym] = schema_for_table(table)
  end
end

.open_api_documented_hidden_endpoint?(key, endpoint, tag) ⇒ Boolean

Returns:

  • (Boolean)


335
336
337
# File 'lib/better_auth/plugins/open_api.rb', line 335

def open_api_documented_hidden_endpoint?(key, endpoint, tag)
  tag == "Default" && [:ok, :error].include?(key) && endpoint.[:openapi]
end

.open_api_encode_uri_component(value) ⇒ Object



461
462
463
464
465
466
# File 'lib/better_auth/plugins/open_api.rb', line 461

def open_api_encode_uri_component(value)
  value.to_s.bytes.map do |byte|
    char = byte.chr
    char.match?(/[A-Za-z0-9\-_.!~*'()]/) ? char : "%%%02X" % byte
  end.join
end

.open_api_endpoints(options) ⇒ Object



287
288
289
290
291
292
293
294
295
# File 'lib/better_auth/plugins/open_api.rb', line 287

def open_api_endpoints(options)
  Core.base_endpoints.map { |key, endpoint| [key, endpoint, "Default"] } +
    options.plugins.flat_map do |plugin|
      next [] if plugin.id == "open-api"

      tag = plugin.id.to_s.split("-").map(&:capitalize).join("-")
      plugin.endpoints.map { |key, endpoint| [key, endpoint, tag] }
    end
end

.open_api_html(schema, config) ⇒ Object



414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
# File 'lib/better_auth/plugins/open_api.rb', line 414

def open_api_html(schema, config)
  nonce = config[:nonce] ? " nonce=\"#{config[:nonce]}\"" : ""
  nonce_attr = config[:nonce] ? "nonce=\"#{config[:nonce]}\"" : ""
   = open_api_encode_uri_component()
  <<~HTML
    <!doctype html>
    <html>
      <head>
        <title>Scalar API Reference</title>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </head>
      <body>
        <script
          id="api-reference"
          type="application/json">
        #{JSON.generate(schema)}
        </script>
        <script #{nonce_attr}>
          var configuration = {
            favicon: "data:image/svg+xml;utf8,#{}",
            theme: "#{config[:theme] || "default"}",
            metaData: {
              title: "Better Auth API",
              description: "API Reference for your Better Auth Instance",
            }
          }

          document.getElementById('api-reference').dataset.configuration =
            JSON.stringify(configuration)
        </script>
        <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"#{nonce}></script>
      </body>
    </html>
  HTML
end

.open_api_logoObject



451
452
453
454
455
456
457
458
459
# File 'lib/better_auth/plugins/open_api.rb', line 451

def 
  <<~SVG
    <svg width="75" height="75" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg">
      <rect width="75" height="75" rx="14" fill="#050505"/>
      <path d="M19 50V25h15.5c5.5 0 9 2.6 9 6.6 0 2.7-1.6 4.7-4.1 5.7 3.2.9 5.2 3 5.2 6.1 0 4.2-3.7 6.6-9.6 6.6H19Zm7.1-14.8h7.2c2.1 0 3.2-.9 3.2-2.4s-1.1-2.4-3.2-2.4h-7.2v4.8Zm0 9.5h7.9c2.2 0 3.5-.9 3.5-2.5s-1.3-2.6-3.5-2.6h-7.9v5.1Z" fill="white"/>
      <path d="M47 50V25h7.1v25H47Z" fill="white"/>
    </svg>
  SVG
end

.open_api_operation(endpoint, method, tag, used_operation_ids) ⇒ Object



339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/better_auth/plugins/open_api.rb', line 339

def open_api_operation(endpoint, method, tag, used_operation_ids)
   = endpoint.[:openapi] || {}
  operation = {
    tags: Array([:tags] || [tag]),
    description: [:description],
    operationId: open_api_operation_id([:operationId], method, used_operation_ids),
    security: [
      {
        bearerAuth: []
      }
    ],
    parameters: [:parameters] || [],
    responses: OpenAPI.responses([:responses])
  }

  if %w[POST PATCH PUT].include?(method)
    operation[:requestBody] = [:requestBody] || OpenAPI.empty_request_body
  end

  operation
end

.open_api_operation_id(base_id, method, used_operation_ids) ⇒ Object



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
# File 'lib/better_auth/plugins/open_api.rb', line 361

def open_api_operation_id(base_id, method, used_operation_ids)
  return if base_id.to_s.empty?

  operation_id = base_id.to_s
  unless used_operation_ids.include?(operation_id)
    used_operation_ids << operation_id
    return operation_id
  end

  suffix = method.to_s.downcase.capitalize
  candidate = "#{operation_id}#{suffix}"
  duplicate_index = 2
  while used_operation_ids.include?(candidate)
    candidate = "#{operation_id}#{suffix}#{duplicate_index}"
    duplicate_index += 1
  end
  used_operation_ids << candidate
  candidate
end

.open_api_path(path) ⇒ Object



331
332
333
# File 'lib/better_auth/plugins/open_api.rb', line 331

def open_api_path(path)
  path.split("/").map { |part| part.start_with?(":") ? "{#{part.delete_prefix(":")}}" : part }.join("/")
end

.open_api_paths(endpoints, options) ⇒ Object



313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/better_auth/plugins/open_api.rb', line 313

def open_api_paths(endpoints, options)
  disabled_paths = Array(options.disabled_paths).map(&:to_s)
  used_operation_ids = Set.new
  endpoints.each_with_object({}) do |(key, endpoint, tag), paths|
    next unless endpoint.path
    next if endpoint.[:exclude_from_openapi] || endpoint.[:SERVER_ONLY] || endpoint.[:server_only]
    next if endpoint.[:hide] && !open_api_documented_hidden_endpoint?(key, endpoint, tag)
    next if disabled_paths.include?(endpoint.path)
    next if key == :set_password

    path = open_api_path(endpoint.path)
    paths[path] ||= {}
    endpoint.methods.reject { |method| method == "*" }.each do |method|
      paths[path][method.downcase.to_sym] = open_api_operation(endpoint, method, tag, used_operation_ids)
    end
  end
end

.open_api_schema(context) ⇒ Object



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/better_auth/plugins/open_api.rb', line 254

def open_api_schema(context)
  {
    openapi: "3.1.1",
    info: {
      title: "Better Auth",
      description: "API Reference for your Better Auth Instance",
      version: "1.1.0"
    },
    components: {
      schemas: open_api_components(context.options),
      securitySchemes: open_api_security_schemes
    },
    security: [
      {
        apiKeyCookie: [],
        bearerAuth: []
      }
    ],
    servers: [
      {
        url: context.canonical_base_url
      }
    ],
    tags: [
      {
        name: "Default",
        description: "Default endpoints that are included with Better Auth by default. These endpoints are not part of any plugin."
      }
    ],
    paths: open_api_paths(open_api_endpoints(context.options), context.options)
  }
end

.open_api_security_schemesObject



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/better_auth/plugins/open_api.rb', line 297

def open_api_security_schemes
  {
    apiKeyCookie: {
      type: "apiKey",
      in: "cookie",
      name: "apiKeyCookie",
      description: "API Key authentication via cookie"
    },
    bearerAuth: {
      type: "http",
      scheme: "bearer",
      description: "Bearer token authentication"
    }
  }
end

.org_hook_candidates(options, key) ⇒ Object



1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
# File 'lib/better_auth/plugins/organization.rb', line 1409

def org_hook_candidates(options, key)
  data = normalize_hash(options || {})
  camel_key = camelize_lower(key)
  candidates = [
    data.dig(:organization_hooks, key),
    data.dig(:organization_hooks, camel_key),
    data.dig(:hooks, key),
    data.dig(:hooks, camel_key)
  ]
  if options.respond_to?(:dig)
    candidates << options.dig("organizationHooks", camel_key)
    candidates << options.dig("organizationHooks", key.to_s)
    candidates << options.dig(:organizationHooks, camel_key)
    candidates << options.dig(:organizationHooks, key.to_s)
  end
  candidates
end

.org_truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


1565
1566
1567
# File 'lib/better_auth/plugins/organization.rb', line 1565

def org_truthy?(value)
  value == true || value.to_s == "true"
end

.organization(options = {}) ⇒ Object



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/better_auth/plugins/organization.rb', line 80

def organization(options = {})
  config = organization_config(options)
  endpoints = {
    create_organization: organization_create_endpoint(config),
    update_organization: organization_update_endpoint(config),
    delete_organization: organization_delete_endpoint(config),
    check_organization_slug: organization_check_slug_endpoint,
    set_active_organization: organization_set_active_endpoint,
    get_full_organization: organization_get_full_endpoint(config),
    list_organizations: organization_list_endpoint,
    create_invitation: organization_invite_endpoint(config),
    cancel_invitation: organization_cancel_invitation_endpoint(config),
    accept_invitation: organization_accept_invitation_endpoint(config),
    reject_invitation: organization_reject_invitation_endpoint(config),
    get_invitation: organization_get_invitation_endpoint(config),
    list_invitations: organization_list_invitations_endpoint(config),
    list_user_invitations: organization_list_user_invitations_endpoint,
    add_member: organization_add_member_endpoint(config),
    remove_member: organization_remove_member_endpoint(config),
    update_member_role: organization_update_member_role_endpoint(config),
    get_active_member: organization_get_active_member_endpoint(config),
    leave_organization: organization_leave_endpoint(config),
    list_members: organization_list_members_endpoint(config),
    get_active_member_role: organization_get_active_member_role_endpoint(config),
    has_permission: organization_has_permission_endpoint(config)
  }

  if org_truthy?(config.dig(:teams, :enabled))
    endpoints.merge!(
      create_team: organization_create_team_endpoint(config),
      remove_team: organization_remove_team_endpoint(config),
      update_team: organization_update_team_endpoint(config),
      list_organization_teams: organization_list_teams_endpoint(config),
      set_active_team: organization_set_active_team_endpoint(config),
      list_user_teams: organization_list_user_teams_endpoint,
      list_team_members: organization_list_team_members_endpoint(config),
      add_team_member: organization_add_team_member_endpoint(config),
      remove_team_member: organization_remove_team_member_endpoint(config)
    )
  end

  if org_truthy?(config.dig(:dynamic_access_control, :enabled))
    endpoints.merge!(
      create_org_role: organization_create_role_endpoint(config),
      delete_org_role: organization_delete_role_endpoint(config),
      list_org_roles: organization_list_roles_endpoint(config),
      get_org_role: organization_get_role_endpoint(config),
      update_org_role: organization_update_role_endpoint(config)
    )
  end

  Plugin.new(
    id: "organization",
    schema: OrganizationSchema.build(config),
    endpoints: endpoints,
    error_codes: ORGANIZATION_ERROR_CODES,
    options: config
  )
end

.organization_accept_invitation_endpoint(config) ⇒ Object



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
# File 'lib/better_auth/plugins/organization.rb', line 385

def organization_accept_invitation_endpoint(config)
  Endpoint.new(path: "/organization/accept-invitation", method: "POST", metadata: organization_openapi("acceptOrganizationInvitation", "Accept an organization invitation", response: organization_accept_invitation_schema)) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    invitation = invitation_by_id(ctx, body[:invitation_id] || body[:id])
    require_pending_invitation!(invitation)
    require_invitation_recipient!(invitation, session)
    require_verified_invitation_recipient!(ctx, config, session, "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION")
    organization = organization_by_id(ctx, invitation["organizationId"])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization

    run_org_hook(config, :before_accept_invitation, {invitation: invitation_wire(ctx, invitation), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    member = nil
    updated = nil
    organization_transaction(ctx) do |adapter|
      lock_organization_capacity!(adapter, organization)
      accepted = adapter.increment_one(
        model: "invitation",
        where: [{field: "id", value: invitation["id"]}, {field: "status", value: "pending"}],
        increment: {},
        set: {status: "accepted"}
      )
      raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless accepted

      validate_organization_roles!(ctx, config, invitation["organizationId"], normalized_role_names(invitation["role"]), adapter: adapter)
      ensure_organization_member_capacity!(ctx, config, session[:user], organization, adapter: adapter)
      team_ids = organization_team_ids(invitation["teamId"])
      validate_stored_invitation_teams!(adapter, invitation["organizationId"], team_ids)
      ensure_team_member_capacity!(ctx, config, team_ids, adapter: adapter, lock: true)
      if adapter.find_one(model: "member", where: [{field: "userId", value: session[:user]["id"]}, {field: "organizationId", value: invitation["organizationId"]}])
        raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION"))
      end
      member = adapter.create(model: "member", data: {organizationId: invitation["organizationId"], userId: session[:user]["id"], role: invitation["role"], createdAt: Time.now})
      team_ids.each do |team_id|
        adapter.create(model: "teamMember", data: {teamId: team_id, userId: session[:user]["id"], createdAt: Time.now})
      end
      updated = accepted
    end
    run_org_hook(config, :after_accept_invitation, {invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    ctx.json({invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member)})
  end
end

.organization_accept_invitation_schemaObject



1039
1040
1041
1042
1043
1044
1045
1046
1047
# File 'lib/better_auth/plugins/organization.rb', line 1039

def organization_accept_invitation_schema
  OpenAPI.object_schema(
    {
      invitation: organization_ref_schema("Invitation"),
      member: organization_ref_schema("Member")
    },
    required: ["invitation", "member"]
  )
end

.organization_active_member_role_schemaObject



1049
1050
1051
1052
1053
1054
1055
1056
1057
# File 'lib/better_auth/plugins/organization.rb', line 1049

def organization_active_member_role_schema
  OpenAPI.object_schema(
    {
      role: {type: "string"},
      member: organization_ref_schema("Member")
    },
    required: ["role", "member"]
  )
end

.organization_add_member_endpoint(config) ⇒ Object



497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
# File 'lib/better_auth/plugins/organization.rb', line 497

def organization_add_member_endpoint(config)
  Endpoint.new(path: "/organization/add-member", method: "POST", metadata: organization_openapi("addOrganizationMember", "Add an organization member", response: organization_ref_schema("Member"))) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id

    organization = organization_by_id(ctx, organization_id)
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization

    require_org_permission!(ctx, config, session, organization_id, {member: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_MEMBERSHIP_LIMIT_REACHED"))
    user_id = body[:user_id].to_s
    raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty?

    user = ctx.context.internal_adapter.find_user_by_id(user_id)
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user

    if require_member(ctx, user_id, organization_id)
      raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION"))
    end
    team_id = body[:team_id]
    if team_id
      unless org_truthy?(config.dig(:teams, :enabled))
        raise APIError.new("BAD_REQUEST", message: "Teams are not enabled")
      end
      team = team_by_id(ctx, team_id)
      unless team && team["organizationId"] == organization_id
        raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND"))
      end
    end

    member_data = {organizationId: organization_id, userId: user_id, role: parse_roles(body[:role] || "member"), createdAt: Time.now}.merge(additional_input(body, :organization_id, :user_id, :role, :team_id))
    merge_hook_data!(member_data, run_org_hook(config, :before_add_member, {member: member_data, user: user, organization: organization_wire(ctx, organization)}, ctx))
    member = organization_transaction(ctx) do |adapter|
      lock_organization_capacity!(adapter, organization)
      validate_organization_roles!(ctx, config, organization_id, normalized_role_names(member_data[:role]), adapter: adapter)
      ensure_organization_member_capacity!(ctx, config, user, organization, adapter: adapter)
      ensure_team_member_capacity!(ctx, config, [team_id], adapter: adapter, lock: true) if team_id
      if adapter.find_one(model: "member", where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}])
        raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION"))
      end
      created = adapter.create(model: "member", data: member_data)
      adapter.create(model: "teamMember", data: {teamId: team_id, userId: user_id, createdAt: Time.now}) if team_id
      created
    end
    run_org_hook(config, :after_add_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx)
    ctx.json(member_wire(ctx, member))
  end
end

.organization_add_team_member_endpoint(config) ⇒ Object



818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
# File 'lib/better_auth/plugins/organization.rb', line 818

def organization_add_team_member_endpoint(config)
  Endpoint.new(path: "/organization/add-team-member", method: "POST", metadata: organization_openapi("addTeamMember", "Add a team member", response: organization_ref_schema("TeamMember"))) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    team = team_by_id(ctx, body[:team_id])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
    require_org_permission!(ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER"))
    user_id = body[:user_id].to_s
    require_member!(ctx, user_id, team["organizationId"])
    user = ctx.context.internal_adapter.find_user_by_id(user_id)
    organization = organization_by_id(ctx, team["organizationId"])
    member_data = {teamId: team["id"], userId: user_id, createdAt: Time.now}
    run_org_hook(config, :before_add_team_member, {team_member: member_data, team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx)
    member = organization_transaction(ctx) do |adapter|
      existing = adapter.find_one(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: user_id}])
      next existing if existing

      ensure_team_member_capacity!(ctx, config, [team["id"]], adapter: adapter, lock: true)
      adapter.create(model: "teamMember", data: member_data)
    end
    run_org_hook(config, :after_add_team_member, {team_member: team_member_wire(ctx, member), team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx)
    ctx.json(team_member_wire(ctx, member))
  end
end

.organization_array_schema(name) ⇒ Object



1032
1033
1034
1035
1036
1037
# File 'lib/better_auth/plugins/organization.rb', line 1032

def organization_array_schema(name)
  {
    type: "array",
    items: organization_ref_schema(name)
  }
end

.organization_by_id(ctx, id) ⇒ Object



1207
1208
1209
1210
1211
# File 'lib/better_auth/plugins/organization.rb', line 1207

def organization_by_id(ctx, id)
  return nil if id.to_s.empty?

  ctx.context.adapter.find_one(model: "organization", where: [{field: "id", value: id}])
end

.organization_by_slug(ctx, slug) ⇒ Object



1213
1214
1215
1216
1217
# File 'lib/better_auth/plugins/organization.rb', line 1213

def organization_by_slug(ctx, slug)
  return nil if slug.to_s.empty?

  ctx.context.adapter.find_one(model: "organization", where: [{field: "slug", value: slug}])
end

.organization_can_delegate_permissions?(ctx, config, role_string, permissions, organization_id) ⇒ Boolean

Returns:

  • (Boolean)


1155
1156
1157
1158
1159
1160
1161
# File 'lib/better_auth/plugins/organization.rb', line 1155

def organization_can_delegate_permissions?(ctx, config, role_string, permissions, organization_id)
  permissions.all? do |resource, actions|
    actions.all? do |action|
      organization_permission?(ctx, config, role_string, {resource => [action]}, organization_id)
    end
  end
end

.organization_cancel_invitation_endpoint(config) ⇒ Object



444
445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'lib/better_auth/plugins/organization.rb', line 444

def organization_cancel_invitation_endpoint(config)
  Endpoint.new(path: "/organization/cancel-invitation", method: "POST", metadata: organization_openapi("cancelOrganizationInvitation", "Cancel an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx|
    session = Routes.current_session(ctx)
    invitation = invitation_by_id(ctx, normalize_hash(ctx.body)[:invitation_id])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation
    require_org_permission!(ctx, config, session, invitation["organizationId"], {invitation: ["cancel"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION"))
    organization = organization_by_id(ctx, invitation["organizationId"])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
    run_org_hook(config, :before_cancel_invitation, {invitation: invitation_wire(ctx, invitation), cancelled_by: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "canceled"})
    run_org_hook(config, :after_cancel_invitation, {invitation: invitation_wire(ctx, updated), cancelled_by: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    ctx.json(invitation_wire(ctx, updated))
  end
end

.organization_check_slug_endpointObject



209
210
211
212
213
214
215
216
217
218
# File 'lib/better_auth/plugins/organization.rb', line 209

def organization_check_slug_endpoint
  Endpoint.new(path: "/organization/check-slug", method: "POST", metadata: organization_openapi("checkOrganizationSlug", "Check if an organization slug is available", response: OpenAPI.status_response_schema)) do |ctx|
    Routes.request_only_session(ctx)
    slug = normalize_hash(ctx.body)[:slug].to_s
    if slug.empty? || organization_by_slug(ctx, slug)
      raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_SLUG_ALREADY_TAKEN"))
    end
    ctx.json({status: true})
  end
end

.organization_config(options) ⇒ Object



140
141
142
143
144
145
146
147
148
149
150
# File 'lib/better_auth/plugins/organization.rb', line 140

def organization_config(options)
  config = normalize_hash(options)
  config[:allow_user_to_create_organization] = true unless config.key?(:allow_user_to_create_organization)
  config[:creator_role] ||= "owner"
  config[:membership_limit] ||= 100
  config[:invitation_expires_in] ||= 60 * 60 * 48
  config[:invitation_limit] ||= 100
  config[:ac] ||= create_access_control(ORGANIZATION_DEFAULT_STATEMENTS)
  config[:roles] ||= organization_default_roles(config)
  config
end

.organization_create_endpoint(config) ⇒ Object



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/better_auth/plugins/organization.rb', line 161

def organization_create_endpoint(config)
  Endpoint.new(path: "/organization/create", method: "POST", metadata: organization_openapi("createOrganization", "Create an organization", response: organization_ref_schema("Organization"))) do |ctx|
    body = normalize_hash(ctx.body)
    session = Routes.current_session(ctx, allow_nil: true)
    user = session ? session[:user] : ctx.context.internal_adapter.find_user_by_id(body[:user_id])
    raise APIError.new("UNAUTHORIZED") unless user
    name = body[:name].to_s
    slug = body[:slug].to_s
    raise APIError.new("BAD_REQUEST", message: "name is required") if name.empty?
    raise APIError.new("BAD_REQUEST", message: "slug is required") if slug.empty?

    allowed = config[:allow_user_to_create_organization]
    allowed = allowed.call(user) if allowed.respond_to?(:call)
    raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION")) unless allowed

    if config[:organization_limit]
      limit_reached = config[:organization_limit].respond_to?(:call) ? config[:organization_limit].call(user) : organization_created_count(ctx, user["id"]) >= config[:organization_limit].to_i
      raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS")) if limit_reached
    end

    if organization_by_slug(ctx, slug)
      raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_ALREADY_EXISTS"))
    end

    data = {
      name: name,
      slug: slug,
      logo: body[:logo],
      metadata: (body[:metadata]),
      createdAt: Time.now
    }.merge(additional_input(body, :name, :slug, :logo, :metadata, :keep_current_active_organization, :user_id))
    merge_hook_data!(data, run_org_hook(config, :before_create_organization, {organization: data, user: user}, ctx))
    organization = ctx.context.adapter.create(model: "organization", data: data, force_allow_id: true)
    member_data = {organizationId: organization["id"], userId: user["id"], role: config[:creator_role], createdAt: Time.now}
    merge_hook_data!(member_data, run_org_hook(config, :before_add_member, {member: member_data, user: user, organization: organization_wire(ctx, organization)}, ctx))
    member = ctx.context.adapter.create(model: "member", data: member_data)
    run_org_hook(config, :after_add_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx)
    default_team = create_default_team(ctx, config, organization, {user: user}) if org_truthy?(config.dig(:teams, :enabled)) && config.dig(:teams, :default_team, :enabled) != false
    run_org_hook(config, :after_create_organization, {organization: organization_wire(ctx, organization), member: member, user: user}, ctx)
    if session && !org_truthy?(body[:keep_current_active_organization])
      update = {activeOrganizationId: organization["id"], activeTeamId: default_team && default_team["id"]}
      updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], update)
      Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge(update.transform_keys(&:to_s)), user: user})
    end
    ctx.json(organization_wire(ctx, organization).merge(members: [member_wire(ctx, member)]))
  end
end

.organization_create_role_endpoint(config) ⇒ Object



861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
# File 'lib/better_auth/plugins/organization.rb', line 861

def organization_create_role_endpoint(config)
  Endpoint.new(path: "/organization/create-role", method: "POST", metadata: organization_openapi("createOrganizationRole", "Create an organization role", response: organization_role_action_schema)) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
    require_org_permission!(ctx, config, session, organization_id, {ac: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE"))
    role_name = (body[:role] || body[:role_name]).to_s
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NAME_IS_ALREADY_TAKEN")) if organization_roles(config).key?(role_name) || organization_role_by_name(ctx, organization_id, role_name)
    permission = stringify_permission(body[:permission] || body[:permissions])
    validate_permission_resources!(config, permission)
    unless organization_can_delegate_permissions?(ctx, config, require_member!(ctx, session[:user]["id"], organization_id)["role"], permission, organization_id)
      raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE"))
    end
    role = ctx.context.adapter.create(model: "organizationRole", data: {organizationId: organization_id, role: role_name, permission: JSON.generate(permission), createdAt: Time.now}.merge(additional_input(body, :organization_id, :role, :role_name, :permission, :permissions)))
    wired = organization_role_wire(role)
    ctx.json({success: true, roleData: wired, statements: (config[:ac] || create_access_control(ORGANIZATION_DEFAULT_STATEMENTS)).new_role(permission).statements})
  end
end

.organization_create_team_endpoint(config) ⇒ Object



695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
# File 'lib/better_auth/plugins/organization.rb', line 695

def organization_create_team_endpoint(config)
  Endpoint.new(path: "/organization/create-team", method: "POST", metadata: organization_openapi("createOrganizationTeam", "Create an organization team", response: organization_ref_schema("Team"))) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
    require_org_permission!(ctx, config, session, organization_id, {team: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION"))
    organization = organization_by_id(ctx, organization_id)
    team_data = {organizationId: organization_id, name: body[:name].to_s, createdAt: Time.now}.merge(additional_input(body, :organization_id, :name))
    merge_hook_data!(team_data, run_org_hook(config, :before_create_team, {team: team_data, user: session[:user], organization: organization_wire(ctx, organization)}, ctx))
    team = organization_transaction(ctx) do |adapter|
      lock_organization_capacity!(adapter, organization)
      max_teams = config.dig(:teams, :maximum_teams)
      if max_teams && adapter.count(model: "team", where: [{field: "organizationId", value: organization_id}]) >= max_teams.to_i
        raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS"))
      end
      created = adapter.create(model: "team", data: team_data, force_allow_id: true)
      adapter.create(model: "teamMember", data: {teamId: created["id"], userId: session[:user]["id"], createdAt: Time.now})
      created
    end
    run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    ctx.json(team_wire(ctx, team))
  end
end

.organization_created_count(ctx, user_id) ⇒ Object



1382
1383
1384
1385
1386
1387
1388
# File 'lib/better_auth/plugins/organization.rb', line 1382

def organization_created_count(ctx, user_id)
  count = 0
  organization_each_adapter_record(ctx.context.adapter, "member", where: [{field: "userId", value: user_id}]) do |member|
    count += 1 if member["role"].to_s.split(",").include?("owner")
  end
  count
end

.organization_default_roles(config = {}) ⇒ Object



152
153
154
155
156
157
158
159
# File 'lib/better_auth/plugins/organization.rb', line 152

def organization_default_roles(config = {})
  ac = config[:ac] || create_access_control(ORGANIZATION_DEFAULT_STATEMENTS)
  {
    "admin" => ac.new_role(organization: ["update"], invitation: ["create", "cancel"], member: ["create", "update", "delete"], team: ["create", "update", "delete"], ac: ["create", "read", "update", "delete"]),
    "owner" => ac.new_role(organization: ["update", "delete"], member: ["create", "update", "delete"], invitation: ["create", "cancel"], team: ["create", "update", "delete"], ac: ["create", "read", "update", "delete"]),
    "member" => ac.new_role(organization: [], member: [], invitation: [], team: [], ac: ["read"])
  }
end

.organization_delete_endpoint(config) ⇒ Object



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/better_auth/plugins/organization.rb', line 255

def organization_delete_endpoint(config)
  Endpoint.new(path: "/organization/delete", method: "POST", metadata: organization_openapi("deleteOrganization", "Delete an organization", response: OpenAPI.status_response_schema)) do |ctx|
    if org_truthy?(config[:disable_organization_deletion])
      raise APIError.new("NOT_FOUND", code: "ORGANIZATION_DELETION_DISABLED", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_DELETION_DISABLED"))
    end
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization = organization_by_id(ctx, body[:organization_id]) || organization_by_slug(ctx, body[:organization_slug])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
    require_org_permission!(ctx, config, session, organization["id"], {organization: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION"))
    run_org_hook(config, :before_delete_organization, {organization: organization_wire(ctx, organization), user: session[:user]}, ctx)
    organization_transaction(ctx) do |adapter|
      if org_truthy?(config.dig(:teams, :enabled))
        team_ids = adapter.find_many(model: "team", where: [{field: "organizationId", value: organization["id"]}]).map { |team| team["id"] }
        adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any?
        adapter.delete_many(model: "team", where: [{field: "organizationId", value: organization["id"]}])
      end
      adapter.delete_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}])
      adapter.delete_many(model: "member", where: [{field: "organizationId", value: organization["id"]}])
      adapter.delete_many(model: "organizationRole", where: [{field: "organizationId", value: organization["id"]}]) if org_truthy?(config.dig(:dynamic_access_control, :enabled))
      adapter.delete(model: "organization", where: [{field: "id", value: organization["id"]}])
    end
    clear_active_organization_session(ctx, session, organization["id"])
    run_org_hook(config, :after_delete_organization, {organization: organization_wire(ctx, organization), user: session[:user]}, ctx)
    ctx.json({status: true})
  end
end

.organization_delete_role_endpoint(config) ⇒ Object



928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
# File 'lib/better_auth/plugins/organization.rb', line 928

def organization_delete_role_endpoint(config)
  Endpoint.new(path: "/organization/delete-role", method: "POST", metadata: organization_openapi("deleteOrganizationRole", "Delete an organization role", response: OpenAPI.success_response_schema)) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
    require_org_permission!(ctx, config, session, organization_id, {ac: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE"))
    role_name = body[:role] || body[:role_name]
    if role_name && organization_roles(config).key?(role_name.to_s)
      raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("CANNOT_DELETE_A_PRE_DEFINED_ROLE"))
    end
    role = organization_role_by_id(ctx, body[:role_id]) || organization_role_by_name(ctx, organization_id, role_name)
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND")) unless role
    assigned = ctx.context.adapter.find_many(model: "member", where: [{field: "organizationId", value: organization_id}]).any? do |member|
      member["role"].to_s.split(",").map(&:strip).include?(role["role"])
    end
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_IS_ASSIGNED_TO_MEMBERS")) if assigned
    ctx.context.adapter.delete(model: "organizationRole", where: [{field: "id", value: role["id"]}])
    ctx.json({success: true})
  end
end

.organization_each_adapter_record(adapter, model, where:, page_size: 100) ⇒ Object



1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
# File 'lib/better_auth/plugins/organization.rb', line 1390

def organization_each_adapter_record(adapter, model, where:, page_size: 100)
  offset = 0
  loop do
    records = adapter.find_many(model: model, where: where, limit: page_size, offset: offset)
    break if records.empty?

    records.each { |record| yield record }
    break if records.length < page_size

    offset += records.length
  end
end

.organization_get_active_member_endpoint(_config) ⇒ Object



630
631
632
633
634
635
636
637
638
# File 'lib/better_auth/plugins/organization.rb', line 630

def organization_get_active_member_endpoint(_config)
  Endpoint.new(path: "/organization/get-active-member", method: "GET", metadata: organization_openapi("getActiveOrganizationMember", "Get the active organization member", response: organization_ref_schema("Member"))) do |ctx|
    session = Routes.current_session(ctx)
    organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"]
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
    member = require_member!(ctx, session[:user]["id"], organization_id)
    ctx.json(member_wire(ctx, member))
  end
end

.organization_get_active_member_role_endpoint(_config) ⇒ Object



640
641
642
643
644
645
646
647
648
649
650
# File 'lib/better_auth/plugins/organization.rb', line 640

def organization_get_active_member_role_endpoint(_config)
  Endpoint.new(path: "/organization/get-active-member-role", method: "GET", metadata: organization_openapi("getActiveOrganizationMemberRole", "Get the active organization member role", response: organization_active_member_role_schema)) do |ctx|
    session = Routes.current_session(ctx)
    query = normalize_hash(ctx.query)
    organization_id = query[:organization_id] || session[:session]["activeOrganizationId"]
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
    require_member!(ctx, session[:user]["id"], organization_id)
    member = require_member!(ctx, query[:user_id] || session[:user]["id"], organization_id)
    ctx.json({role: member["role"], member: member_wire(ctx, member)})
  end
end

.organization_get_full_endpoint(config) ⇒ Object



302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/better_auth/plugins/organization.rb', line 302

def organization_get_full_endpoint(config)
  Endpoint.new(path: "/organization/get-full-organization", method: "GET", metadata: organization_openapi("getOrganization", "Get the full organization", response: organization_nullable_schema("Organization"))) do |ctx|
    session = Routes.current_session(ctx)
    query = normalize_hash(ctx.query)
    explicit_lookup = query.key?(:organization_slug) || query.key?(:organization_id)
    organization = organization_by_slug(ctx, query[:organization_slug]) || organization_by_id(ctx, query[:organization_id] || session[:session]["activeOrganizationId"])
    unless organization
      raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) if explicit_lookup

      next ctx.json(nil)
    end

    require_member!(ctx, session[:user]["id"], organization["id"])
    members = list_members_for(ctx, organization["id"], {limit: query[:members_limit] || config[:membership_limit]})
    invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}])
    result = organization_wire(ctx, organization).merge(
      members: members.fetch(:members),
      invitations: invitations.map { |entry| invitation_wire(ctx, entry) }
    )
    if org_truthy?(config.dig(:teams, :enabled))
      teams = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: organization["id"]}])
      result[:teams] = teams.map { |team| team_wire(ctx, team) }
    end
    ctx.json(result)
  end
end

.organization_get_invitation_endpoint(config) ⇒ Object



459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
# File 'lib/better_auth/plugins/organization.rb', line 459

def organization_get_invitation_endpoint(config)
  Endpoint.new(path: "/organization/get-invitation", method: "GET", metadata: organization_openapi("getOrganizationInvitation", "Get an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx|
    session = Routes.current_session(ctx)
    invitation = invitation_by_id(ctx, normalize_hash(ctx.query)[:id] || normalize_hash(ctx.query)[:invitation_id])
    require_pending_invitation!(invitation)
    require_invitation_recipient!(invitation, session)
    require_verified_invitation_recipient!(ctx, config, session, "EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION")
    organization = organization_by_id(ctx, invitation["organizationId"])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
    inviter = require_member(ctx, invitation["inviterId"], invitation["organizationId"])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION")) unless inviter
    inviter_user = ctx.context.internal_adapter.find_user_by_id(invitation["inviterId"])
    ctx.json(invitation_wire(ctx, invitation).merge(organizationName: organization["name"], organizationSlug: organization["slug"], inviterEmail: inviter_user && inviter_user["email"]))
  end
end

.organization_get_role_endpoint(config) ⇒ Object



891
892
893
894
895
896
897
898
899
900
901
# File 'lib/better_auth/plugins/organization.rb', line 891

def organization_get_role_endpoint(config)
  Endpoint.new(path: "/organization/get-role", method: "GET", metadata: organization_openapi("getOrganizationRole", "Get an organization role", response: organization_role_schema)) do |ctx|
    session = Routes.current_session(ctx)
    query = normalize_hash(ctx.query)
    organization_id = query[:organization_id] || session[:session]["activeOrganizationId"]
    require_org_permission!(ctx, config, session, organization_id, {ac: ["read"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE"))
    role = organization_role_by_id(ctx, query[:role_id]) || organization_role_by_name(ctx, organization_id, query[:role])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND")) unless role
    ctx.json(organization_role_wire(role))
  end
end

.organization_has_permission_endpoint(config) ⇒ Object



683
684
685
686
687
688
689
690
691
692
693
# File 'lib/better_auth/plugins/organization.rb', line 683

def organization_has_permission_endpoint(config)
  Endpoint.new(path: "/organization/has-permission", method: "POST", metadata: organization_openapi("hasOrganizationPermission", "Check if the member has organization permission", response: organization_permission_response_schema)) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
    member = require_member!(ctx, session[:user]["id"], organization_id)
    permissions = body[:permissions] || body[:permission]
    ctx.json({error: nil, success: organization_permission?(ctx, config, member["role"], permissions, organization_id)})
  end
end

.organization_invite_endpoint(config) ⇒ Object



329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'lib/better_auth/plugins/organization.rb', line 329

def organization_invite_endpoint(config)
  Endpoint.new(path: "/organization/invite-member", method: "POST", metadata: organization_openapi("createOrganizationInvitation", "Create an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization = organization_by_id(ctx, body[:organization_id] || session[:session]["activeOrganizationId"]) || organization_by_slug(ctx, body[:organization_slug])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
    require_org_permission!(ctx, config, session, organization["id"], {invitation: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION"))
    email = body[:email].to_s.downcase
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email)
    role = parse_roles(body[:role] || "member")
    role.split(",").each do |entry|
      unless organization_roles(config).key?(entry) || organization_role_by_name(ctx, organization["id"], entry)
        raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND"))
      end
    end
    existing_member = find_member_by_email(ctx, organization["id"], email)
    raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION")) if existing_member
    team_ids = organization_team_ids(body[:team_id] || body[:team_ids])
    validate_invitation_team_ids!(ctx, config, organization["id"], body[:team_id] || body[:team_ids], team_ids)
    invitation_data = {
      organizationId: organization["id"],
      email: email,
      role: role,
      status: "pending",
      expiresAt: Time.now + config[:invitation_expires_in].to_i,
      inviterId: session[:user]["id"],
      teamId: team_ids.any? ? team_ids.join(",") : nil,
      createdAt: Time.now
    }.merge(additional_input(body, :organization_id, :organization_slug, :email, :role, :team_id, :team_ids))
    merge_hook_data!(invitation_data, run_org_hook(config, :before_create_invitation, {invitation: invitation_data, inviter: session[:user], organization: organization_wire(ctx, organization)}, ctx))
    invitation = organization_transaction(ctx) do |adapter|
      lock_organization_capacity!(adapter, organization)
      pending = adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}, {field: "email", value: email}, {field: "status", value: "pending"}])
      if pending.any?
        if config[:cancel_pending_invitations_on_re_invite]
          pending.each { |entry| adapter.update(model: "invitation", where: [{field: "id", value: entry["id"]}], update: {status: "canceled"}) }
        else
          raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION"))
        end
      end
      pending_count = adapter.count(model: "invitation", where: [{field: "organizationId", value: organization["id"]}, {field: "status", value: "pending"}])
      limit = config[:invitation_limit]
      if limit && pending_count >= limit.to_i
        raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_LIMIT_REACHED"))
      end
      validate_stored_invitation_teams!(adapter, organization["id"], team_ids)
      ensure_team_member_capacity!(ctx, config, team_ids, adapter: adapter)
      adapter.create(model: "invitation", data: invitation_data, force_allow_id: true)
    end
    sender = config[:send_invitation_email]
    sender.call({id: invitation["id"], role: role, email: email, organization: organization_wire(ctx, organization), invitation: invitation_wire(ctx, invitation), inviter: require_member!(ctx, session[:user]["id"], organization["id"])}, ctx.request) if sender.respond_to?(:call)
    run_org_hook(config, :after_create_invitation, {invitation: invitation_wire(ctx, invitation), inviter: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    ctx.json(invitation_wire(ctx, invitation))
  end
end

.organization_leave_endpoint(config) ⇒ Object



652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
# File 'lib/better_auth/plugins/organization.rb', line 652

def organization_leave_endpoint(config)
  Endpoint.new(path: "/organization/leave", method: "POST", metadata: organization_openapi("leaveOrganization", "Leave an organization", response: OpenAPI.status_response_schema)) do |ctx|
    session = Routes.current_session(ctx)
    organization_id = normalize_hash(ctx.body)[:organization_id]
    member = require_member!(ctx, session[:user]["id"], organization_id)
    organization = organization_by_id(ctx, organization_id)
    organization_transaction(ctx) do |adapter|
      lock_organization_capacity!(adapter, organization)
      ensure_not_last_owner!(ctx, member, adapter: adapter)
      adapter.delete(model: "member", where: [{field: "id", value: member["id"]}])
      if org_truthy?(config.dig(:teams, :enabled))
        team_ids = adapter.find_many(model: "team", where: [{field: "organizationId", value: organization_id}]).map { |team| team["id"] }
        adapter.delete_many(model: "teamMember", where: [{field: "userId", value: session[:user]["id"]}, {field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any?
      end
    end
    clear_active_organization_session(ctx, session, organization_id)
    ctx.json({status: true})
  end
end

.organization_list_endpointObject



220
221
222
223
224
225
226
227
# File 'lib/better_auth/plugins/organization.rb', line 220

def organization_list_endpoint
  Endpoint.new(path: "/organization/list", method: "GET", metadata: organization_openapi("listOrganizations", "List organizations", response: organization_array_schema("Organization"))) do |ctx|
    session = Routes.current_session(ctx)
    members = ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: session[:user]["id"]}])
    organizations = members.filter_map { |member| organization_by_id(ctx, member["organizationId"]) }
    ctx.json(organizations.map { |entry| organization_wire(ctx, entry) })
  end
end

.organization_list_invitations_endpoint(config) ⇒ Object



475
476
477
478
479
480
481
482
483
484
# File 'lib/better_auth/plugins/organization.rb', line 475

def organization_list_invitations_endpoint(config)
  Endpoint.new(path: "/organization/list-invitations", method: "GET", metadata: organization_openapi("listOrganizationInvitations", "List organization invitations", response: organization_array_schema("Invitation"))) do |ctx|
    session = Routes.current_session(ctx)
    organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"]
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
    require_org_permission!(ctx, config, session, organization_id, {invitation: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION"))
    invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization_id}])
    ctx.json(invitations.map { |entry| invitation_wire(ctx, entry) })
  end
end

.organization_list_members_endpoint(config) ⇒ Object



672
673
674
675
676
677
678
679
680
681
# File 'lib/better_auth/plugins/organization.rb', line 672

def organization_list_members_endpoint(config)
  Endpoint.new(path: "/organization/list-members", method: "GET", metadata: organization_openapi("listOrganizationMembers", "List organization members", response: organization_members_response_schema)) do |ctx|
    session = Routes.current_session(ctx)
    query = normalize_hash(ctx.query)
    organization_id = query[:organization_id] || organization_by_slug(ctx, query[:organization_slug])&.fetch("id") || session[:session]["activeOrganizationId"]
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
    require_member!(ctx, session[:user]["id"], organization_id)
    ctx.json(list_members_for(ctx, organization_id, query, config, session[:user]))
  end
end

.organization_list_roles_endpoint(config) ⇒ Object



880
881
882
883
884
885
886
887
888
889
# File 'lib/better_auth/plugins/organization.rb', line 880

def organization_list_roles_endpoint(config)
  Endpoint.new(path: "/organization/list-roles", method: "GET", metadata: organization_openapi("listOrganizationRoles", "List organization roles", response: {type: "array", items: organization_role_schema})) do |ctx|
    session = Routes.current_session(ctx)
    organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"]
    require_org_permission!(ctx, config, session, organization_id, {ac: ["read"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE"))
    defaults = organization_roles(config).keys.map { |role| {"role" => role, "permission" => {}} }
    dynamic = ctx.context.adapter.find_many(model: "organizationRole", where: [{field: "organizationId", value: organization_id}]).map { |role| organization_role_wire(role) }
    ctx.json(defaults + dynamic)
  end
end

.organization_list_team_members_endpoint(_config) ⇒ Object



806
807
808
809
810
811
812
813
814
815
816
# File 'lib/better_auth/plugins/organization.rb', line 806

def organization_list_team_members_endpoint(_config)
  Endpoint.new(path: "/organization/list-team-members", method: "GET", metadata: organization_openapi("listTeamMembers", "List team members", response: organization_array_schema("TeamMember"))) do |ctx|
    session = Routes.current_session(ctx)
    team_id = normalize_hash(ctx.query)[:team_id] || session[:session]["activeTeamId"]
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM")) unless team_id
    team = team_by_id(ctx, team_id)
    require_member!(ctx, session[:user]["id"], team["organizationId"])
    members = ctx.context.adapter.find_many(model: "teamMember", where: [{field: "teamId", value: team_id}])
    ctx.json(members.map { |entry| team_member_wire(ctx, entry) })
  end
end

.organization_list_teams_endpoint(_config) ⇒ Object



719
720
721
722
723
724
725
726
727
# File 'lib/better_auth/plugins/organization.rb', line 719

def organization_list_teams_endpoint(_config)
  Endpoint.new(path: "/organization/list-teams", method: "GET", metadata: organization_openapi("listOrganizationTeams", "List organization teams", response: organization_array_schema("Team"))) do |ctx|
    session = Routes.current_session(ctx)
    organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"]
    require_member!(ctx, session[:user]["id"], organization_id)
    teams = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: organization_id}])
    ctx.json(teams.map { |team| team_wire(ctx, team) })
  end
end

.organization_list_user_invitations_endpointObject



486
487
488
489
490
491
492
493
494
495
# File 'lib/better_auth/plugins/organization.rb', line 486

def organization_list_user_invitations_endpoint
  Endpoint.new(path: "/organization/list-user-invitations", method: "GET", metadata: organization_openapi("listUserInvitations", "List user invitations", response: organization_array_schema("Invitation"))) do |ctx|
    session = Routes.current_session(ctx)
    unless session[:user]["emailVerified"]
      raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION"))
    end
    invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "email", value: session[:user]["email"].to_s.downcase}, {field: "status", value: "pending"}])
    ctx.json(invitations.map { |entry| invitation_wire(ctx, entry) })
  end
end

.organization_list_user_teams_endpointObject



798
799
800
801
802
803
804
# File 'lib/better_auth/plugins/organization.rb', line 798

def organization_list_user_teams_endpoint
  Endpoint.new(path: "/organization/list-user-teams", method: "GET", metadata: organization_openapi("listUserTeams", "List user teams", response: organization_array_schema("Team"))) do |ctx|
    session = Routes.current_session(ctx)
    memberships = ctx.context.adapter.find_many(model: "teamMember", where: [{field: "userId", value: session[:user]["id"]}])
    ctx.json(memberships.filter_map { |entry| team_by_id(ctx, entry["teamId"]) }.map { |team| team_wire(ctx, team) })
  end
end

.organization_members_response_schemaObject



1059
1060
1061
1062
1063
1064
1065
1066
1067
# File 'lib/better_auth/plugins/organization.rb', line 1059

def organization_members_response_schema
  OpenAPI.object_schema(
    {
      members: organization_array_schema("Member"),
      total: {type: "number"}
    },
    required: ["members", "total"]
  )
end

.organization_membership_capacity_limit(ctx, config, user, organization) ⇒ Object



1285
1286
1287
1288
1289
# File 'lib/better_auth/plugins/organization.rb', line 1285

def organization_membership_capacity_limit(ctx, config, user, organization)
  configured = config[:membership_limit] || 100
  value = configured.respond_to?(:call) ? configured.call(user, organization_wire(ctx, organization)) : configured
  numeric_member_limit(value)
end

.organization_nullable_schema(name) ⇒ Object



1025
1026
1027
1028
1029
1030
# File 'lib/better_auth/plugins/organization.rb', line 1025

def organization_nullable_schema(name)
  {
    type: ["object", "null"],
    "$ref": "#/components/schemas/#{name}"
  }
end

.organization_openapi(operation_id, description, response:, response_description: "Success", request: nil, required: [], parameters: nil) ⇒ Object



949
950
951
952
953
954
955
956
957
958
959
960
961
962
# File 'lib/better_auth/plugins/organization.rb', line 949

def organization_openapi(operation_id, description, response:, response_description: "Success", request: nil, required: [], parameters: nil)
  request ||= organization_request_schema(operation_id)
  openapi = {
    operationId: operation_id,
    description: description,
    responses: {
      "200" => OpenAPI.json_response(response_description, response)
    }
  }
  openapi[:requestBody] = OpenAPI.json_request_body(OpenAPI.object_schema(request, required: required)) if request
  openapi[:parameters] = parameters if parameters

  {openapi: openapi}
end

.organization_permission?(ctx, config, role_string, permissions, organization_id) ⇒ Boolean

Returns:

  • (Boolean)


1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
# File 'lib/better_auth/plugins/organization.rb', line 1139

def organization_permission?(ctx, config, role_string, permissions, organization_id)
  roles = organization_roles(config)
  if org_truthy?(config.dig(:dynamic_access_control, :enabled))
    ctx.context.adapter.find_many(model: "organizationRole", where: [{field: "organizationId", value: organization_id}]).each do |entry|
      permission = parse_permission(entry["permission"])
      if roles.key?(entry["role"])
        permission = merge_permissions(roles[entry["role"]].statements, permission)
      end
      roles[entry["role"]] = (config[:ac] || create_access_control(ORGANIZATION_DEFAULT_STATEMENTS)).new_role(permission)
    end
  end
  role_string.to_s.split(",").any? do |role|
    roles[role]&.authorize(permissions || {})&.fetch(:success, false)
  end
end

.organization_permission_response_schemaObject



1069
1070
1071
1072
1073
1074
1075
1076
1077
# File 'lib/better_auth/plugins/organization.rb', line 1069

def organization_permission_response_schema
  OpenAPI.object_schema(
    {
      error: {type: ["string", "null"]},
      success: {type: "boolean"}
    },
    required: ["success"]
  )
end

.organization_ref_schema(name) ⇒ Object



1018
1019
1020
1021
1022
1023
# File 'lib/better_auth/plugins/organization.rb', line 1018

def organization_ref_schema(name)
  {
    type: "object",
    "$ref": "#/components/schemas/#{name}"
  }
end

.organization_reject_invitation_endpoint(config) ⇒ Object



428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/better_auth/plugins/organization.rb', line 428

def organization_reject_invitation_endpoint(config)
  Endpoint.new(path: "/organization/reject-invitation", method: "POST", metadata: organization_openapi("rejectOrganizationInvitation", "Reject an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx|
    session = Routes.current_session(ctx)
    invitation = invitation_by_id(ctx, normalize_hash(ctx.body)[:invitation_id])
    require_pending_invitation!(invitation, check_expiration: false)
    require_invitation_recipient!(invitation, session)
    require_verified_invitation_recipient!(ctx, config, session, "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION")
    organization = organization_by_id(ctx, invitation["organizationId"])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
    run_org_hook(config, :before_reject_invitation, {invitation: invitation_wire(ctx, invitation), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "rejected"})
    run_org_hook(config, :after_reject_invitation, {invitation: invitation_wire(ctx, updated), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    ctx.json(invitation_wire(ctx, updated))
  end
end

.organization_remove_member_endpoint(config) ⇒ Object



547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
# File 'lib/better_auth/plugins/organization.rb', line 547

def organization_remove_member_endpoint(config)
  Endpoint.new(path: "/organization/remove-member", method: "POST", metadata: organization_openapi("removeOrganizationMember", "Remove an organization member", response: OpenAPI.status_response_schema)) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    member = member_by_id(ctx, body[:member_id]) || require_member(ctx, body[:user_id], body[:organization_id])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless member
    require_org_permission!(ctx, config, session, member["organizationId"], {member: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER"))
    organization = organization_by_id(ctx, member["organizationId"])
    user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
    run_org_hook(config, :before_remove_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx)
    organization_transaction(ctx) do |adapter|
      lock_organization_capacity!(adapter, organization)
      ensure_not_last_owner!(ctx, member, adapter: adapter)
      adapter.delete(model: "member", where: [{field: "id", value: member["id"]}])
      if org_truthy?(config.dig(:teams, :enabled))
        team_ids = adapter.find_many(model: "team", where: [{field: "organizationId", value: member["organizationId"]}]).map { |team| team["id"] }
        adapter.delete_many(model: "teamMember", where: [{field: "userId", value: member["userId"]}, {field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any?
      end
    end
    run_org_hook(config, :after_remove_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx)
    ctx.json({status: true})
  end
end

.organization_remove_team_endpoint(config) ⇒ Object



745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
# File 'lib/better_auth/plugins/organization.rb', line 745

def organization_remove_team_endpoint(config)
  Endpoint.new(path: "/organization/remove-team", method: "POST", metadata: organization_openapi("removeOrganizationTeam", "Remove an organization team", response: OpenAPI.status_response_schema)) do |ctx|
    session = Routes.current_session(ctx)
    team = team_by_id(ctx, normalize_hash(ctx.body)[:team_id])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
    require_org_permission!(ctx, config, session, team["organizationId"], {team: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM"))
    organization = organization_by_id(ctx, team["organizationId"])
    run_org_hook(config, :before_delete_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    organization_transaction(ctx) do |adapter|
      lock_organization_capacity!(adapter, organization)
      teams = adapter.find_many(model: "team", where: [{field: "organizationId", value: team["organizationId"]}])
      if teams.length <= 1 && config.dig(:teams, :allow_removing_all_teams) != true
        raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("UNABLE_TO_REMOVE_LAST_TEAM"))
      end
      adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}])
      adapter.delete(model: "team", where: [{field: "id", value: team["id"]}])
      pending = adapter.find_many(model: "invitation", where: [{field: "organizationId", value: team["organizationId"]}, {field: "status", value: "pending"}])
      pending.each do |invitation|
        team_ids = organization_team_ids(invitation["teamId"])
        next unless team_ids.include?(team["id"])

        remaining = team_ids.reject { |team_id| team_id == team["id"] }
        adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {teamId: remaining.any? ? remaining.join(",") : nil})
      end
    end
    run_org_hook(config, :after_delete_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    ctx.json({status: true})
  end
end

.organization_remove_team_member_endpoint(config) ⇒ Object



843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
# File 'lib/better_auth/plugins/organization.rb', line 843

def organization_remove_team_member_endpoint(config)
  Endpoint.new(path: "/organization/remove-team-member", method: "POST", metadata: organization_openapi("removeTeamMember", "Remove a team member", response: OpenAPI.status_response_schema)) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    team = team_by_id(ctx, body[:team_id])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
    require_org_permission!(ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER"))
    member = ctx.context.adapter.find_one(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: body[:user_id]}])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_NOT_A_MEMBER_OF_THE_TEAM")) unless member
    user = ctx.context.internal_adapter.find_user_by_id(body[:user_id])
    organization = organization_by_id(ctx, team["organizationId"])
    run_org_hook(config, :before_remove_team_member, {team_member: team_member_wire(ctx, member), team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx)
    ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: body[:user_id]}])
    run_org_hook(config, :after_remove_team_member, {team_member: team_member_wire(ctx, member), team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx)
    ctx.json({status: true})
  end
end

.organization_request_schema(operation_id) ⇒ Object



964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
# File 'lib/better_auth/plugins/organization.rb', line 964

def organization_request_schema(operation_id)
  string = {type: "string"}
  boolean = {type: "boolean"}
  array = ->(items = string) { {type: "array", items: items} }
  object = {type: "object", additionalProperties: true}
  {
    "createOrganization" => {
      name: string,
      slug: string,
      logo: string,
      metadata: object,
      userId: string,
      keepCurrentActiveOrganization: boolean
    },
    "checkOrganizationSlug" => {slug: string},
    "updateOrganization" => {
      organizationId: string,
      organizationSlug: string,
      data: object,
      name: string,
      slug: string,
      logo: string,
      metadata: object
    },
    "deleteOrganization" => {organizationId: string, organizationSlug: string},
    "setActiveOrganization" => {organizationId: string, organizationSlug: string},
    "createOrganizationInvitation" => {
      organizationId: string,
      organizationSlug: string,
      email: {type: "string", format: "email"},
      role: {oneOf: [string, array.call]},
      teamId: string,
      teamIds: array.call
    },
    "acceptOrganizationInvitation" => {invitationId: string, id: string},
    "rejectOrganizationInvitation" => {invitationId: string},
    "cancelOrganizationInvitation" => {invitationId: string},
    "addOrganizationMember" => {organizationId: string, userId: string, role: {oneOf: [string, array.call]}},
    "removeOrganizationMember" => {memberId: string, userId: string, organizationId: string},
    "updateOrganizationMemberRole" => {memberId: string, userId: string, organizationId: string, role: {oneOf: [string, array.call]}},
    "leaveOrganization" => {organizationId: string},
    "hasOrganizationPermission" => {organizationId: string, permission: object, permissions: object},
    "createOrganizationTeam" => {organizationId: string, name: string},
    "updateOrganizationTeam" => {teamId: string, name: string},
    "removeOrganizationTeam" => {teamId: string},
    "setActiveOrganizationTeam" => {teamId: string},
    "addTeamMember" => {teamId: string, userId: string},
    "removeTeamMember" => {teamId: string, userId: string},
    "createOrganizationRole" => {organizationId: string, role: string, roleName: string, permission: object, permissions: object},
    "updateOrganizationRole" => {organizationId: string, roleId: string, role: string, roleName: string, permission: object, permissions: object, data: object},
    "deleteOrganizationRole" => {organizationId: string, roleId: string, role: string, roleName: string}
  }[operation_id]
end

.organization_role_action_schemaObject



1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
# File 'lib/better_auth/plugins/organization.rb', line 1091

def organization_role_action_schema
  OpenAPI.object_schema(
    {
      success: {type: "boolean"},
      roleData: organization_role_schema,
      statements: {type: "object"}
    },
    required: ["success", "roleData"]
  )
end

.organization_role_by_id(ctx, id) ⇒ Object



1231
1232
1233
1234
1235
# File 'lib/better_auth/plugins/organization.rb', line 1231

def organization_role_by_id(ctx, id)
  return nil if id.to_s.empty?

  ctx.context.adapter.find_one(model: "organizationRole", where: [{field: "id", value: id}])
end

.organization_role_by_name(ctx, organization_id, role) ⇒ Object



1237
1238
1239
1240
1241
# File 'lib/better_auth/plugins/organization.rb', line 1237

def organization_role_by_name(ctx, organization_id, role)
  return nil if role.to_s.empty?

  ctx.context.adapter.find_one(model: "organizationRole", where: [{field: "organizationId", value: organization_id}, {field: "role", value: role}])
end

.organization_role_schemaObject



1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
# File 'lib/better_auth/plugins/organization.rb', line 1079

def organization_role_schema
  OpenAPI.object_schema(
    {
      id: {type: "string"},
      organizationId: {type: "string"},
      role: {type: "string"},
      permission: {type: "object"}
    },
    required: ["role", "permission"]
  )
end

.organization_role_wire(role) ⇒ Object



1346
1347
1348
# File 'lib/better_auth/plugins/organization.rb', line 1346

def organization_role_wire(role)
  role.merge("permission" => parse_permission(role["permission"]))
end

.organization_roles(config) ⇒ Object



1135
1136
1137
# File 'lib/better_auth/plugins/organization.rb', line 1135

def organization_roles(config)
  (config[:roles] || organization_default_roles(config)).transform_keys(&:to_s)
end

.organization_set_active_endpointObject



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/better_auth/plugins/organization.rb', line 283

def organization_set_active_endpoint
  Endpoint.new(path: "/organization/set-active", method: "POST", metadata: organization_openapi("setActiveOrganization", "Set the active organization", response: organization_nullable_schema("Organization"))) do |ctx|
    session = Routes.current_session(ctx, sensitive: true)
    body = normalize_hash(ctx.body)
    if body.key?(:organization_id) && body[:organization_id].nil?
      updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeOrganizationId: nil, activeTeamId: nil})
      Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => nil, "activeTeamId" => nil), user: session[:user]})
      next ctx.json(nil)
    end

    organization = organization_by_id(ctx, body[:organization_id]) || organization_by_slug(ctx, body[:organization_slug])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
    require_member!(ctx, session[:user]["id"], organization["id"])
    updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeOrganizationId: organization["id"], activeTeamId: nil})
    Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => organization["id"], "activeTeamId" => nil), user: session[:user]})
    ctx.json(organization_wire(ctx, organization))
  end
end

.organization_set_active_team_endpoint(_config) ⇒ Object



775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
# File 'lib/better_auth/plugins/organization.rb', line 775

def organization_set_active_team_endpoint(_config)
  Endpoint.new(path: "/organization/set-active-team", method: "POST", metadata: organization_openapi("setActiveOrganizationTeam", "Set the active organization team", response: organization_nullable_schema("Team"))) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    if body.key?(:team_id) && body[:team_id].nil?
      updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeTeamId: nil})
      Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeTeamId" => nil), user: session[:user]})
      next ctx.json({status: true})
    end
    team = team_by_id(ctx, body[:team_id])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
    active_organization_id = session[:session]["activeOrganizationId"]
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless active_organization_id
    unless team["organizationId"] == active_organization_id
      raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND"))
    end
    require_team_member!(ctx, session[:user]["id"], team["id"])
    updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeTeamId: team["id"]})
    Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeTeamId" => team["id"]), user: session[:user]})
    ctx.json(team_wire(ctx, team))
  end
end

.organization_team_ids(value) ⇒ Object



1477
1478
1479
# File 'lib/better_auth/plugins/organization.rb', line 1477

def organization_team_ids(value)
  Array(value).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
end

.organization_time(value) ⇒ Object



1527
1528
1529
# File 'lib/better_auth/plugins/organization.rb', line 1527

def organization_time(value)
  value.is_a?(Time) ? value : Time.parse(value.to_s)
end

.organization_transaction(ctx) ⇒ Object



1531
1532
1533
1534
1535
1536
1537
1538
# File 'lib/better_auth/plugins/organization.rb', line 1531

def organization_transaction(ctx)
  adapter = ctx.context.adapter
  if adapter.method(:transaction).owner == BetterAuth::Adapters::Base
    raise BetterAuth::Error, "Organization mutations require an adapter with real transaction support"
  end

  adapter.transaction { |transaction_adapter| yield(transaction_adapter || adapter) }
end

.organization_update_endpoint(config) ⇒ Object



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/better_auth/plugins/organization.rb', line 229

def organization_update_endpoint(config)
  Endpoint.new(path: "/organization/update", method: "POST", metadata: organization_openapi("updateOrganization", "Update an organization", response: organization_ref_schema("Organization"))) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    id = body[:organization_id] || body[:organizationId]
    data = normalize_hash(body[:data] || body)
    organization = organization_by_id(ctx, id) || organization_by_slug(ctx, body[:organization_slug])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
    member = require_org_permission!(ctx, config, session, organization["id"], {organization: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION"))
    if data[:slug] && data[:slug].to_s.empty?
      raise APIError.new("BAD_REQUEST", message: "slug is required")
    end
    if data[:name] && data[:name].to_s.empty?
      raise APIError.new("BAD_REQUEST", message: "name is required")
    end
    existing = data[:slug] ? organization_by_slug(ctx, data[:slug]) : nil
    raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_SLUG_ALREADY_TAKEN")) if existing && existing["id"] != organization["id"]
    update = additional_input(data, :organization_id, :organizationId, :organization_slug, :data)
    update[:metadata] = (update[:metadata]) if update.key?(:metadata)
    merge_hook_data!(update, run_org_hook(config, :before_update_organization, {organization: update, user: session[:user], member: member_wire(ctx, member)}, ctx))
    updated = ctx.context.adapter.update(model: "organization", where: [{field: "id", value: organization["id"]}], update: update)
    run_org_hook(config, :after_update_organization, {organization: organization_wire(ctx, updated), user: session[:user], member: member_wire(ctx, member)}, ctx)
    ctx.json(organization_wire(ctx, updated))
  end
end

.organization_update_member_role_endpoint(config) ⇒ Object



571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
# File 'lib/better_auth/plugins/organization.rb', line 571

def organization_update_member_role_endpoint(config)
  Endpoint.new(path: "/organization/update-member-role", method: "POST", metadata: organization_openapi("updateOrganizationMemberRole", "Update an organization member role", response: organization_ref_schema("Member"))) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id

    role_names = normalized_role_names(body[:role])
    raise APIError.new("BAD_REQUEST") if role_names.empty?

    validate_organization_roles!(ctx, config, organization_id, role_names)

    updating_member = require_member(ctx, session[:user]["id"], organization_id)
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless updating_member

    member = if body[:member_id]
      (body[:member_id].to_s == updating_member["id"]) ? updating_member : member_by_id(ctx, body[:member_id])
    else
      require_member(ctx, body[:user_id], organization_id)
    end
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless member
    unless member["organizationId"] == organization_id
      raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER"))
    end

    creator_role = config[:creator_role]
    updating_member_roles = normalized_role_names(updating_member["role"])
    member_roles = normalized_role_names(member["role"])
    updater_is_creator = updating_member_roles.include?(creator_role)
    updating_creator = member_roles.include?(creator_role)
    setting_creator_role = role_names.include?(creator_role)

    if (updating_creator && !updater_is_creator) || (setting_creator_role && !updater_is_creator)
      raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER"))
    end

    if updater_is_creator && updating_member["id"] == member["id"] && !setting_creator_role
      creator_count = count_members_with_role(ctx, organization_id, creator_role)
      if creator_count <= 1
        raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER"))
      end
    end

    unless updater_is_creator
      require_org_permission!(ctx, config, session, organization_id, {member: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER"))
    end
    organization = organization_by_id(ctx, organization_id)
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
    user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user
    previous_role = member["role"]
    update = {role: parse_roles(role_names)}
    merge_hook_data!(update, run_org_hook(config, :before_update_member_role, {member: member_wire(ctx, member), new_role: update[:role], user: user, organization: organization_wire(ctx, organization)}, ctx))
    updated = ctx.context.adapter.update(model: "member", where: [{field: "id", value: member["id"]}], update: update)
    run_org_hook(config, :after_update_member_role, {member: member_wire(ctx, updated), previous_role: previous_role, user: user, organization: organization_wire(ctx, organization)}, ctx)
    ctx.json(member_wire(ctx, updated))
  end
end

.organization_update_role_endpoint(config) ⇒ Object



903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
# File 'lib/better_auth/plugins/organization.rb', line 903

def organization_update_role_endpoint(config)
  Endpoint.new(path: "/organization/update-role", method: "POST", metadata: organization_openapi("updateOrganizationRole", "Update an organization role", response: organization_role_action_schema)) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization_id = body[:organization_id] || session[:session]["activeOrganizationId"]
    require_org_permission!(ctx, config, session, organization_id, {ac: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE"))
    role = organization_role_by_id(ctx, body[:role_id]) || organization_role_by_name(ctx, organization_id, body[:role] || body[:role_name])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND")) unless role
    update = {}
    update[:role] = body[:data][:role] || body[:data][:role_name] if body[:data].is_a?(Hash) && (body[:data][:role] || body[:data][:role_name])
    permission = body[:permission] || body[:permissions] || body.dig(:data, :permission) || body.dig(:data, :permissions)
    if permission
      permission = stringify_permission(permission)
      validate_permission_resources!(config, permission)
      unless organization_can_delegate_permissions?(ctx, config, require_member!(ctx, session[:user]["id"], organization_id)["role"], permission, organization_id)
        raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE"))
      end
      update[:permission] = JSON.generate(permission)
    end
    update.merge!(additional_input(body[:data], :role, :role_name, :permission, :permissions)) if body[:data].is_a?(Hash)
    updated = ctx.context.adapter.update(model: "organizationRole", where: [{field: "id", value: role["id"]}], update: update)
    ctx.json({success: true, roleData: organization_role_wire(updated)})
  end
end

.organization_update_team_endpoint(config) ⇒ Object



729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
# File 'lib/better_auth/plugins/organization.rb', line 729

def organization_update_team_endpoint(config)
  Endpoint.new(path: "/organization/update-team", method: "POST", metadata: organization_openapi("updateOrganizationTeam", "Update an organization team", response: organization_ref_schema("Team"))) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    team = team_by_id(ctx, body[:team_id])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
    require_org_permission!(ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM"))
    organization = organization_by_id(ctx, team["organizationId"])
    update = additional_input(body[:data].is_a?(Hash) ? body[:data] : body, :team_id, :organization_id)
    merge_hook_data!(update, run_org_hook(config, :before_update_team, {team: team_wire(ctx, team), updates: update, user: session[:user], organization: organization_wire(ctx, organization)}, ctx))
    updated = ctx.context.adapter.update(model: "team", where: [{field: "id", value: team["id"]}], update: update)
    run_org_hook(config, :after_update_team, {team: team_wire(ctx, updated), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
    ctx.json(team_wire(ctx, updated))
  end
end

.organization_wire(ctx, organization) ⇒ Object



1328
1329
1330
1331
1332
# File 'lib/better_auth/plugins/organization.rb', line 1328

def organization_wire(ctx, organization)
  data = Schema.parse_output(ctx.context.options, "organization", organization)
  data["metadata"] = (data["metadata"]) if data&.key?("metadata")
  data
end

.parse_accept_language(header) ⇒ Object



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/better_auth/plugins/i18n.rb', line 90

def parse_accept_language(header)
  return [] if header.nil? || header.to_s.empty?

  header.to_s
    .split(",")
    .map do |part|
      locale_str, quality = part.strip.split(";", 2)
      q_string = quality ? quality.strip.sub(/\Aq=/i, "") : "1"
      q = Float(q_string)
      locale = locale_str.to_s.strip.split("-").first.to_s
      {locale: locale, q: q}
    end
    .select { |item| !item[:locale].empty? }
    .sort_by { |item| -item[:q] }
    .map { |item| item[:locale] }
end

.parse_duration(value) ⇒ Object

Raises:

  • (TypeError)


492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/better_auth/plugins/jwt.rb', line 492

def parse_duration(value)
  match = value.strip.match(/\A(-?\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|y|yr|yrs|year|years)(?:\s+from now|\s+ago)?\z/i)
  raise TypeError, "Invalid time string" unless match

  amount = match[1].to_i
  amount = -amount if value.include?("ago")
  unit = match[2].downcase
  multiplier = case unit
  when "s", "sec", "secs", "second", "seconds" then 1
  when "m", "min", "mins", "minute", "minutes" then 60
  when "h", "hr", "hrs", "hour", "hours" then 3600
  when "d", "day", "days" then 86_400
  when "w", "week", "weeks" then 604_800
  else 31_557_600
  end
  amount * multiplier
end

.parse_metadata(value) ⇒ Object



1444
1445
1446
1447
1448
1449
1450
# File 'lib/better_auth/plugins/organization.rb', line 1444

def (value)
  return value if value.nil? || value.is_a?(Hash)

  JSON.parse(value)
rescue JSON::ParserError
  value
end

.parse_permission(value) ⇒ Object



1456
1457
1458
1459
1460
1461
1462
1463
# File 'lib/better_auth/plugins/organization.rb', line 1456

def parse_permission(value)
  return value if value.is_a?(Hash)
  return {} if value.nil? || value.to_s.empty?

  JSON.parse(value)
rescue JSON::ParserError
  {}
end

.parse_roles(roles) ⇒ Object



1102
1103
1104
# File 'lib/better_auth/plugins/organization.rb', line 1102

def parse_roles(roles)
  Array(roles).join(",")
end

.parsed_session(ctx, entry) ⇒ Object



215
216
217
218
219
220
# File 'lib/better_auth/plugins/multi_session.rb', line 215

def parsed_session(ctx, entry)
  {
    session: Schema.parse_output(ctx.context.options, "session", entry[:session]),
    user: Schema.parse_output(ctx.context.options, "user", entry[:user])
  }
end

.passkey(*args, &block) ⇒ Object



9
10
11
# File 'lib/better_auth/plugins/passkey.rb', line 9

def passkey(*args, &block)
  call_external_plugin!(:passkey, *args, implementation_constant: :PASSKEY_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-passkey", entry: "lib/better_auth/passkey.rb", &block)
end

.patreon(options = {}) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/better_auth/plugins/generic_oauth.rb', line 166

def patreon(options = {})
  data = normalize_hash(options)
  generic_oauth_provider_config(
    data,
    provider_id: "patreon",
    authorization_url: "https://www.patreon.com/oauth2/authorize",
    token_url: "https://www.patreon.com/api/oauth2/token",
    scopes: ["identity[email]"],
    get_user_info: ->(tokens) {
      profile = generic_oauth_fetch_json("https://www.patreon.com/api/oauth2/v2/identity?fields[user]=email,full_name,image_url,is_email_verified", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
      data = fetch_value(profile, "data")
      attributes = fetch_value(data, "attributes")
      return nil unless data && attributes

      {
        id: fetch_value(data, "id"),
        name: fetch_value(attributes, "full_name"),
        email: fetch_value(attributes, "email"),
        image: fetch_value(attributes, "image_url"),
        emailVerified: fetch_value(attributes, "is_email_verified")
      }
    }
  )
end

.phone_number(options = {}) ⇒ Object



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/better_auth/plugins/phone_number.rb', line 24

def phone_number(options = {})
  config = {
    expires_in: 300,
    otp_length: 6,
    allowed_attempts: 3,
    phone_number: "phoneNumber",
    phone_number_verified: "phoneNumberVerified"
  }.merge(normalize_hash(options))

  Plugin.new(
    id: "phone-number",
    hooks: {
      before: [
        {
          matcher: ->(ctx) { ctx.path == "/sign-up/email" && normalize_hash(ctx.body).key?(:phone_number) },
          handler: ->(ctx) { validate_unique_phone_number!(ctx, normalize_hash(ctx.body)[:phone_number]) }
        },
        {
          matcher: ->(ctx) { ctx.path == "/update-user" && normalize_hash(ctx.body).key?(:phone_number) },
          handler: ->(_ctx) { raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_CANNOT_BE_UPDATED"]) }
        }
      ]
    },
    endpoints: {
      sign_in_phone_number: (config),
      send_phone_number_otp: send_phone_number_otp_endpoint(config),
      verify_phone_number: verify_phone_number_endpoint(config),
      request_password_reset_phone_number: request_password_reset_phone_number_endpoint(config),
      reset_password_phone_number: reset_password_phone_number_endpoint(config)
    },
    schema: phone_number_schema(config[:schema]),
    rate_limit: [
      {
        path_matcher: ->(path) { path.start_with?("/phone-number") },
        window: 60,
        max: 10
      }
    ],
    error_codes: PHONE_NUMBER_ERROR_CODES,
    options: config
  )
end

.phone_number_create_user(ctx, config, body, phone_number) ⇒ Object



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/better_auth/plugins/phone_number.rb', line 345

def phone_number_create_user(ctx, config, body, phone_number)
   = config[:sign_up_on_verification]
  email_callback = [:get_temp_email]
  name_callback = [:get_temp_name]
  email = email_callback.respond_to?(:call) ? email_callback.call(phone_number) : "temp-#{phone_number}"
  name = name_callback.respond_to?(:call) ? name_callback.call(phone_number) : phone_number
  reserved = %i[phone_number code disable_session update_phone_number]
  additional = body.reject { |key, _value| reserved.include?(key.to_sym) }

  ctx.context.internal_adapter.create_user(
    additional.merge(
      "email" => email,
      "name" => name,
      "phoneNumber" => phone_number,
      "phoneNumberVerified" => true,
      "emailVerified" => false
    ),
    context: ctx
  )
end

.phone_number_deliver_otp(config, data, ctx) ⇒ Object



418
419
420
421
# File 'lib/better_auth/plugins/phone_number.rb', line 418

def phone_number_deliver_otp(config, data, ctx)
  sender = config[:send_otp]
  sender.call(data, ctx) if sender.respond_to?(:call)
end

.phone_number_generate_code(config) ⇒ Object



438
439
440
# File 'lib/better_auth/plugins/phone_number.rb', line 438

def phone_number_generate_code(config)
  Array.new(config[:otp_length].to_i) { SecureRandom.random_number(10).to_s }.join
end

.phone_number_schema(custom_schema) ⇒ Object



333
334
335
336
337
338
339
340
341
342
343
# File 'lib/better_auth/plugins/phone_number.rb', line 333

def phone_number_schema(custom_schema)
  base = {
    user: {
      fields: {
        phoneNumber: {type: "string", required: false, unique: true, sortable: true, returned: true},
        phoneNumberVerified: {type: "boolean", required: false, returned: true, input: false}
      }
    }
  }
  deep_merge_hashes(base, normalize_hash(custom_schema || {}))
end

.phone_number_split_code(value) ⇒ Object



442
443
444
445
446
447
448
# File 'lib/better_auth/plugins/phone_number.rb', line 442

def phone_number_split_code(value)
  string = value.to_s
  index = string.rindex(":")
  return [string, ""] unless index

  [string[0...index], string[(index + 1)..]]
end

.phone_number_store_code(ctx, config, identifier, code) ⇒ Object



408
409
410
411
412
413
414
415
416
# File 'lib/better_auth/plugins/phone_number.rb', line 408

def phone_number_store_code(ctx, config, identifier, code)
  ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
  expires_at = Time.now + config[:expires_in].to_i
  ctx.context.internal_adapter.create_verification_value(
    identifier: identifier,
    value: "#{code}:0",
    expiresAt: expires_at
  )
end

.phone_number_verify_code!(ctx, config, identifier, code, consume: true) ⇒ Object

Raises:



366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/better_auth/plugins/phone_number.rb', line 366

def phone_number_verify_code!(ctx, config, identifier, code, consume: true)
  verifier = config[:verify_otp]
  if verifier.respond_to?(:call)
    # Custom verify_otp owns single-use and expiry state completely.
    valid = verifier.call({phone_number: identifier.delete_suffix("-request-password-reset"), code: code}, ctx)
    raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"]) unless valid

    return true
  end

  existing = ctx.context.internal_adapter.find_verification_value(identifier)
  raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_NOT_FOUND"]) unless existing

  if Routes.expired_time?(existing["expiresAt"])
    ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
    raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_EXPIRED"])
  end

  _, existing_attempts = phone_number_split_code(existing["value"])
  if existing_attempts.to_i >= config[:allowed_attempts].to_i
    ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
    raise APIError.new("FORBIDDEN", message: PHONE_NUMBER_ERROR_CODES["TOO_MANY_ATTEMPTS"])
  end

  verification = ctx.context.internal_adapter.consume_verification_value(identifier)
  raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"]) unless verification

  stored_code, attempts = phone_number_split_code(verification["value"])
  attempts_count = attempts.to_i
  if attempts_count >= config[:allowed_attempts].to_i
    raise APIError.new("FORBIDDEN", message: PHONE_NUMBER_ERROR_CODES["TOO_MANY_ATTEMPTS"])
  end

  unless stored_code == code
    ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: "#{stored_code}:#{attempts_count + 1}", expiresAt: verification["expiresAt"])
    raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"])
  end

  ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: verification["value"], expiresAt: verification["expiresAt"]) unless consume
  verification
end

.plugin_loaded?(name) ⇒ Boolean

Returns:

  • (Boolean)


172
173
174
# File 'lib/better_auth/plugin_loader.rb', line 172

def plugin_loaded?(name)
  @loaded_plugins.key?(name.to_sym)
end

.plugin_loader_for_method(name) ⇒ Object



187
188
189
190
191
192
193
194
195
196
# File 'lib/better_auth/plugin_loader.rb', line 187

def plugin_loader_for_method(name)
  symbol = name.to_sym
  return symbol if LAZY_PLUGIN_METHODS.include?(symbol)

  PLUGIN_FILES.each_key do |plugin|
    return plugin if name.to_s.start_with?("#{plugin}_")
  end

  nil
end

.plugin_options(plugin) ⇒ Object



1427
1428
1429
# File 'lib/better_auth/plugins/organization.rb', line 1427

def plugin_options(plugin)
  plugin.respond_to?(:dig) ? (plugin.dig(:options) || plugin.dig("options") || {}) : {}
end

.positive_integer?(value) ⇒ Boolean

Returns:

  • (Boolean)


427
428
429
# File 'lib/better_auth/plugins/device_authorization.rb', line 427

def positive_integer?(value)
  value.is_a?(Integer) && value.positive?
end

.present?(value) ⇒ Boolean

Returns:

  • (Boolean)


372
373
374
# File 'lib/better_auth/plugins/username.rb', line 372

def present?(value)
  !value.nil? && value != false && !value.to_s.empty?
end

.present_string?(value) ⇒ Boolean

Returns:

  • (Boolean)


230
231
232
# File 'lib/better_auth/plugins/anonymous.rb', line 230

def present_string?(value)
  value.is_a?(String) && !value.empty?
end

.private_key_pem(pair) ⇒ Object



404
405
406
# File 'lib/better_auth/plugins/jwt.rb', line 404

def private_key_pem(pair)
  pair.respond_to?(:private_to_pem) ? pair.private_to_pem : pair.to_pem
end

.process_device_decision(ctx, session, status) ⇒ Object



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
# File 'lib/better_auth/plugins/device_authorization.rb', line 297

def process_device_decision(ctx, session, status)
  body = OAuthProtocol.stringify_keys(ctx.body)
  code = normalize_user_code(body["userCode"] || body["user_code"])
  record = find_device_user_code(ctx, code)
  action = (status == "approved") ? "approve" : "deny"
  raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_USER_CODE"]) unless record
  record = OAuthProtocol.stringify_keys(record)
  raise device_authorization_error("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_USER_CODE"]) if device_authorization_time(record["expiresAt"]) <= Time.now
  raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["DEVICE_CODE_ALREADY_PROCESSED"]) unless record["status"] == "pending"
  unless record["userId"]
    raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["DEVICE_CODE_NOT_CLAIMED"])
  end
  if record["userId"] != session[:user]["id"]
    raise device_authorization_error("FORBIDDEN", "access_denied", "You are not authorized to #{action} this device authorization")
  end

  updated = ctx.context.adapter.increment_one(
    model: "deviceCode",
    where: [
      {field: "id", value: record["id"]},
      {field: "status", value: "pending"},
      {field: "userId", value: session[:user]["id"]}
    ],
    increment: {},
    set: {"status" => status, "userId" => session[:user]["id"]}
  )
  raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["DEVICE_CODE_ALREADY_PROCESSED"]) unless updated
  ctx.json({success: true})
end

.public_jwk(key, _config) ⇒ Object



348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/better_auth/plugins/jwt.rb', line 348

def public_jwk(key, _config)
  data = {
    kid: key["id"],
    kty: key["kty"] || key_type_for_alg(key["alg"] || "RS256"),
    alg: key["alg"] || "EdDSA",
    use: "sig",
    pem: key["pem"] || key["publicKey"]
  }
  data[:n] = key["n"] if key["n"]
  data[:e] = key["e"] if key["e"]
  data[:crv] = key["crv"] if key["crv"]
  data[:x] = key["x"] if key["x"]
  data[:y] = key["y"] if key["y"]
  data
end

.public_jwks(ctx, config) ⇒ Object



258
259
260
261
262
263
264
265
# File 'lib/better_auth/plugins/jwt.rb', line 258

def public_jwks(ctx, config)
  now = Time.now
  grace_period = config.dig(:jwks, :grace_period) || 60 * 60 * 24 * 30
  all_jwks(ctx, config).select do |key|
    expires_at = normalize_time(key["expiresAt"])
    !expires_at || expires_at + grace_period.to_i > now
  end
end

.public_key_for(pair) ⇒ Object



400
401
402
# File 'lib/better_auth/plugins/jwt.rb', line 400

def public_key_for(pair)
  OpenSSL::PKey.read(pair.public_to_pem)
end

.public_key_jwk_fields(public_key, alg) ⇒ Object



412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/better_auth/plugins/jwt.rb', line 412

def public_key_jwk_fields(public_key, alg)
  if public_key.is_a?(OpenSSL::PKey::RSA)
    {
      "kty" => "RSA",
      "n" => base64url_bn(public_key.n),
      "e" => base64url_bn(public_key.e)
    }
  elsif alg == "EdDSA"
    {
      "kty" => "OKP",
      "crv" => "Ed25519",
      "x" => Crypto.base64url_encode(public_key.raw_public_key)
    }
  else
    point = public_key.public_key.to_octet_string(:uncompressed).bytes
    length = (point.length - 1) / 2
    {
      "kty" => "EC",
      "crv" => ec_curve_for_alg(alg),
      "x" => Crypto.base64url_encode(point[1, length].pack("C*")),
      "y" => Crypto.base64url_encode(point[(1 + length), length].pack("C*"))
    }
  end
end

.public_key_pem(pair) ⇒ Object



408
409
410
# File 'lib/better_auth/plugins/jwt.rb', line 408

def public_key_pem(pair)
  pair.respond_to?(:public_to_pem) ? pair.public_to_pem : pair.to_pem
end

.remote_jwks(ctx, config) ⇒ Object



291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/better_auth/plugins/jwt.rb', line 291

def remote_jwks(ctx, config)
  url = config.dig(:jwks, :remote_url)
  fetcher = config.dig(:jwks, :fetch) || config.dig(:jwks, :fetcher)
  payload = if fetcher.respond_to?(:call)
    fetcher.call(url)
  else
    cached = config[:remote_jwks_cache] ||= JWT::RemoteJwksCache.new({})
    entry = cached.entries[url.to_s]
    if entry && entry[:expires_at] > Time.now
      entry[:payload]
    else
      fetched = HTTPClient.get_json(url)
      cached.entries[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
      fetched
    end
  end
  keys = fetch_value(payload, "keys")
  Array(keys).map { |entry| normalize_remote_jwk(entry) }
rescue JSON::ParserError, URI::InvalidURIError, SocketError, SystemCallError
  []
end

.request_email_change_email_otp_endpoint(config) ⇒ Object



343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
# File 'lib/better_auth/plugins/email_otp.rb', line 343

def request_email_change_email_otp_endpoint(config)
  Endpoint.new(
    path: "/email-otp/request-email-change",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "requestEmailChangeOTP",
        description: "Request an OTP to change the current user's email",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              newEmail: {type: "string"},
              otp: {type: ["string", "null"]}
            },
            required: ["newEmail"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Change email OTP sent", OpenAPI.success_response_schema)
        }
      }
    }
  ) do |ctx|
    email_otp_change_email_enabled!(config)
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    current_email = session[:user]["email"].to_s.downcase
    new_email = body[:new_email].to_s.downcase
    validate_email_otp_email!(new_email)
    raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == current_email

    if config.dig(:change_email, :verify_current_email)
      raise APIError.new("BAD_REQUEST", message: "OTP is required to verify current email") if body[:otp].to_s.empty?
      email_otp_verify!(ctx, config, email: current_email, type: "email-verification", otp: body[:otp])
    end

    otp = email_otp_resolve(ctx, config, email: new_email, type: "change-email", identifier_email: "#{current_email}-#{new_email}")
    if ctx.context.internal_adapter.find_user_by_email(new_email)
      ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier("#{current_email}-#{new_email}", "change-email"))
      next ctx.json({success: true})
    end

    email_otp_deliver(config, {email: new_email, otp: otp, type: "change-email"}, ctx)
    ctx.json({success: true})
  end
end

.request_password_reset_email_otp_endpoint(config) ⇒ Object



435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
# File 'lib/better_auth/plugins/email_otp.rb', line 435

def request_password_reset_email_otp_endpoint(config)
  Endpoint.new(
    path: "/email-otp/request-password-reset",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "requestPasswordResetEmailOTP",
        description: "Request a password reset OTP by email",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              email: {type: "string"}
            },
            required: ["email"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Password reset OTP requested", OpenAPI.status_response_schema)
        }
      }
    }
  ) do |ctx|
    email_otp_password_reset_request(ctx, config)
  end
end

.request_password_reset_phone_number_endpoint(config) ⇒ Object



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/better_auth/plugins/phone_number.rb', line 257

def request_password_reset_phone_number_endpoint(config)
  Endpoint.new(
    path: "/phone-number/request-password-reset",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "requestPasswordResetPhoneNumber",
        description: "Request a phone number password reset OTP",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              phoneNumber: {type: "string"}
            },
            required: ["phoneNumber"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Password reset OTP requested", OpenAPI.status_response_schema)
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    phone_number = body[:phone_number].to_s
    user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
    code = phone_number_generate_code(config)
    phone_number_store_code(ctx, config, "#{phone_number}-request-password-reset", code) unless config[:verify_otp].respond_to?(:call)

    if user && config[:send_password_reset_otp].respond_to?(:call)
      config[:send_password_reset_otp].call({phone_number: phone_number, code: code}, ctx)
    end

    ctx.json({status: true})
  end
end

.require_invitation_recipient!(invitation, session) ⇒ Object

Raises:



1507
1508
1509
1510
1511
# File 'lib/better_auth/plugins/organization.rb', line 1507

def require_invitation_recipient!(invitation, session)
  return if invitation["email"].to_s.downcase == session[:user]["email"].to_s.downcase

  raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION"))
end

.require_member(ctx, user_id, organization_id) ⇒ Object



1183
1184
1185
1186
1187
# File 'lib/better_auth/plugins/organization.rb', line 1183

def require_member(ctx, user_id, organization_id)
  return nil if user_id.to_s.empty? || organization_id.to_s.empty?

  ctx.context.adapter.find_one(model: "member", where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}])
end

.require_member!(ctx, user_id, organization_id) ⇒ Object

Raises:



1176
1177
1178
1179
1180
1181
# File 'lib/better_auth/plugins/organization.rb', line 1176

def require_member!(ctx, user_id, organization_id)
  member = require_member(ctx, user_id, organization_id)
  raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION")) unless member

  member
end

.require_org_permission!(ctx, config, session, organization_id, permissions, message) ⇒ Object

Raises:



1163
1164
1165
1166
1167
1168
# File 'lib/better_auth/plugins/organization.rb', line 1163

def require_org_permission!(ctx, config, session, organization_id, permissions, message)
  member = require_member!(ctx, session[:user]["id"], organization_id)
  return member if organization_permission?(ctx, config, member["role"], permissions, organization_id)

  raise APIError.new("FORBIDDEN", message: message)
end

.require_pending_invitation!(invitation, check_expiration: true) ⇒ Object



1497
1498
1499
1500
1501
1502
1503
1504
1505
# File 'lib/better_auth/plugins/organization.rb', line 1497

def require_pending_invitation!(invitation, check_expiration: true)
  expired = invitation && invitation["expiresAt"] && organization_time(invitation["expiresAt"]) < Time.now
  valid = invitation && invitation["status"] == "pending"
  valid &&= !expired if check_expiration
  unless valid
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND"))
  end
  invitation
end

.require_team_member!(ctx, user_id, team_id) ⇒ Object

Raises:



1189
1190
1191
1192
1193
1194
# File 'lib/better_auth/plugins/organization.rb', line 1189

def require_team_member!(ctx, user_id, team_id)
  member = ctx.context.adapter.find_one(model: "teamMember", where: [{field: "userId", value: user_id}, {field: "teamId", value: team_id}])
  raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_NOT_A_MEMBER_OF_THE_TEAM")) unless member

  member
end

.require_verified_email_for_invitation_id_action?(ctx, config) ⇒ Boolean

Returns:

  • (Boolean)


1520
1521
1522
1523
1524
1525
# File 'lib/better_auth/plugins/organization.rb', line 1520

def require_verified_email_for_invitation_id_action?(ctx, config)
  return org_truthy?(config[:require_email_verification_on_invitation]) if config.key?(:require_email_verification_on_invitation)

  generator = ctx.context.options.advanced.dig(:database, :generate_id)
  !generator.nil? && generator != "uuid"
end

.require_verified_invitation_recipient!(ctx, config, session, error_code) ⇒ Object

Raises:



1513
1514
1515
1516
1517
1518
# File 'lib/better_auth/plugins/organization.rb', line 1513

def require_verified_invitation_recipient!(ctx, config, session, error_code)
  return unless require_verified_email_for_invitation_id_action?(ctx, config)
  return if session[:user]["emailVerified"]

  raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch(error_code))
end

.reset_password_email_otp_endpoint(config) ⇒ Object



461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'lib/better_auth/plugins/email_otp.rb', line 461

def reset_password_email_otp_endpoint(config)
  Endpoint.new(
    path: "/email-otp/reset-password",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "resetPasswordEmailOTP",
        description: "Reset a password with an email OTP",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              email: {type: "string"},
              otp: {type: "string"},
              password: {type: "string"}
            },
            required: ["email", "otp", "password"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Password reset", OpenAPI.status_response_schema)
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    email = body[:email].to_s.downcase
    otp = body[:otp].to_s
    password = body[:password].to_s

    email_otp_verify!(ctx, config, email: email, type: "forget-password", otp: otp)
    found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true)
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless found

    Routes.validate_password_length!(password, ctx.context.options.email_and_password)
    hashed = Routes.hash_password(ctx, password)
     = found[:accounts].find { |entry| entry["providerId"] == "credential" }
    if 
      ctx.context.internal_adapter.update_password(found[:user]["id"], hashed)
    else
      ctx.context.internal_adapter.(userId: found[:user]["id"], providerId: "credential", accountId: found[:user]["id"], password: hashed)
    end

    ctx.context.internal_adapter.update_user(found[:user]["id"], emailVerified: true) unless found[:user]["emailVerified"]
    callback = ctx.context.options.email_and_password[:on_password_reset]
    callback.call({user: found[:user]}, ctx.request) if callback.respond_to?(:call)
    ctx.context.internal_adapter.delete_user_sessions(found[:user]["id"]) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
    ctx.json({success: true})
  end
end

.reset_password_phone_number_endpoint(config) ⇒ Object



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/better_auth/plugins/phone_number.rb', line 293

def reset_password_phone_number_endpoint(config)
  Endpoint.new(
    path: "/phone-number/reset-password",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "resetPasswordPhoneNumber",
        description: "Reset a password with a phone number OTP",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              phoneNumber: {type: "string"},
              otp: {type: "string"},
              newPassword: {type: "string"}
            },
            required: ["phoneNumber", "otp", "newPassword"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Password reset", OpenAPI.status_response_schema)
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    phone_number = body[:phone_number].to_s
    otp = body[:otp].to_s
    new_password = body[:new_password]

    user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
    raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["UNEXPECTED_ERROR"]) unless user

    Routes.validate_password_length!(new_password, ctx.context.options.email_and_password)
    phone_number_verify_code!(ctx, config, "#{phone_number}-request-password-reset", otp)
    ctx.context.internal_adapter.update_password(user["id"], Routes.hash_password(ctx, new_password))
    ctx.context.internal_adapter.delete_user_sessions(user["id"]) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
    ctx.json({status: true})
  end
end

.resolve_i18n_default_locale(explicit, available_locales) ⇒ Object



82
83
84
85
86
87
88
# File 'lib/better_auth/plugins/i18n.rb', line 82

def resolve_i18n_default_locale(explicit, available_locales)
  explicit_locale = explicit.to_s
  return explicit_locale if explicit && available_locales.include?(explicit_locale)
  return "en" if available_locales.include?("en")

  nil
end

.resolve_i18n_error_code(error, ctx) ⇒ Object



184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/better_auth/plugins/i18n.rb', line 184

def resolve_i18n_error_code(error, ctx)
  body = error.body
  if body.is_a?(Hash)
    code = body[:code] || body["code"]
    return code.to_s.tr("-", "_").upcase if code
  end

  unless error.code.to_s.upcase == error.status.to_s.upcase
    return error.code.to_s.tr("-", "_").upcase
  end

  reverse_lookup_i18n_error_code(error.message, ctx)
end

.resolve_login_method(ctx, config) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/better_auth/plugins/last_login_method.rb', line 63

def (ctx, config)
  custom = config[:custom_resolve_method]
  resolve_context = ctx
  unless ctx.path
    resolve_context = ctx.dup
    resolve_context.path = ""
  end
  resolved = custom.call(resolve_context) if custom.respond_to?(:call)
  return resolved if resolved

  path = resolve_context.path.to_s
  case path
  when "/sign-in/email", "/sign-up/email"
    "email"
  when "/callback/:id"
    fetch_value(ctx.params, "id") || fetch_value(ctx.params, "providerId")
  when "/oauth2/callback/:providerId"
    fetch_value(ctx.params, "providerId")
  else
    return Regexp.last_match(1) if path =~ %r{\A/callback/([^/]+)\z}
    return Regexp.last_match(1) if path =~ %r{\A/oauth2/callback/([^/]+)\z}
    return "siwe" if path.include?("siwe")
    return "passkey" if path.include?("/passkey/verify-authentication")
    return "magic-link" if path.start_with?("/magic-link/verify")

    nil
  end
end

.respond_to_missing?(name, include_private = false) ⇒ Boolean

Returns:

  • (Boolean)


90
91
92
93
94
# File 'lib/better_auth/plugins.rb', line 90

def respond_to_missing?(name, include_private = false)
  return false if REMOVED_PLUGIN_FACTORIES.key?(name.to_sym)

  PLUGIN_FACTORY_LOADERS.key?(name.to_sym) || !plugin_loader_for_method(name).nil? || super
end

.reverse_lookup_i18n_error_code(message, ctx) ⇒ Object



198
199
200
# File 'lib/better_auth/plugins/i18n.rb', line 198

def reverse_lookup_i18n_error_code(message, ctx)
  merged_i18n_error_catalog(ctx).find { |_code, text| text == message }&.first
end

.revoke_device_session_endpointObject



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/better_auth/plugins/multi_session.rb', line 106

def revoke_device_session_endpoint
  Endpoint.new(
    path: "/multi-session/revoke",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "revokeDeviceSession",
        description: "Revoke a device session",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              sessionToken: {type: "string", description: "The session token"}
            },
            required: ["sessionToken"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Device session revoked", OpenAPI.status_response_schema)
        }
      }
    }
  ) do |ctx|
    current = Routes.current_session(ctx)
    token = fetch_value(ctx.body, "sessionToken").to_s
    cookie_name = multi_session_cookie_name(ctx, token)
    unless !token.empty? && ctx.get_signed_cookie(cookie_name, ctx.context.secret)
      raise APIError.new("UNAUTHORIZED", message: MULTI_SESSION_ERROR_CODES["INVALID_SESSION_TOKEN"])
    end

    ctx.context.internal_adapter.delete_session(token)
    expire_cookie(ctx, cookie_name)

    if current && current[:session]["token"] == token
      next_session = ctx.context.internal_adapter
        .find_sessions(verified_multi_session_tokens(ctx).reject { |entry| entry == token })
        .find { |entry| !entry[:session]["expiresAt"] || entry[:session]["expiresAt"] > Time.now }
      if next_session
        Cookies.set_session_cookie(ctx, next_session)
      else
        Cookies.delete_session_cookie(ctx)
      end
    end

    ctx.json({status: true})
  end
end

.run_org_hook(config, key, data, ctx) ⇒ Object



1403
1404
1405
1406
1407
# File 'lib/better_auth/plugins/organization.rb', line 1403

def run_org_hook(config, key, data, ctx)
  hooks = org_hook_candidates(config, key)
  hooks.concat(ctx.context.options.plugins.flat_map { |plugin| org_hook_candidates(plugin_options(plugin), key) }) if ctx&.context&.options
  hooks.compact.uniq.filter_map { |hook| hook.call(data, ctx) if hook.respond_to?(:call) }.find { |response| response.is_a?(Hash) && normalize_hash(response).key?(:data) }
end

.safe_decode_bearer_token(token) ⇒ Object



83
84
85
86
87
# File 'lib/better_auth/plugins/bearer.rb', line 83

def safe_decode_bearer_token(token)
  token.to_s.gsub(/%[0-9a-fA-F]{2}/) { |encoded| encoded[1, 2].to_i(16).chr }
rescue
  token.to_s
end

.safe_encode_bearer_token(token) ⇒ Object



77
78
79
80
81
# File 'lib/better_auth/plugins/bearer.rb', line 77

def safe_encode_bearer_token(token)
  URI.encode_www_form_component(token.to_s).gsub("+", "%20")
rescue
  token.to_s
end

.schema_for_table(table) ⇒ Object



388
389
390
391
392
393
394
395
396
397
398
# File 'lib/better_auth/plugins/open_api.rb', line 388

def schema_for_table(table)
  required = ["id"]
  properties = table[:fields].each_with_object({}) do |(field, attributes), result|
    next if field.to_s == "id"

    result[field.to_sym] = field_schema(attributes)
    required << field if attributes[:required] && attributes[:returned] != false
  end
  properties = {id: {type: "string", readOnly: true}}.merge(properties)
  {type: "object", properties: properties, required: required}
end

.scim(*args, &block) ⇒ Object



9
10
11
# File 'lib/better_auth/plugins/scim.rb', line 9

def scim(*args, &block)
  call_external_plugin!(:scim, *args, implementation_constant: :SCIM_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-scim", entry: "lib/better_auth/scim.rb", &block)
end

.send_phone_number_otp_endpoint(config) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/better_auth/plugins/phone_number.rb', line 134

def send_phone_number_otp_endpoint(config)
  Endpoint.new(
    path: "/phone-number/send-otp",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "sendPhoneNumberOTP",
        description: "Send a phone number OTP",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              phoneNumber: {type: "string"}
            },
            required: ["phoneNumber"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "OTP sent",
            OpenAPI.object_schema(
              {
                message: {type: "string"}
              },
              required: ["message"]
            )
          )
        }
      }
    }
  ) do |ctx|
    sender = config[:send_otp]
    unless sender.respond_to?(:call)
      raise APIError.new("NOT_IMPLEMENTED", message: PHONE_NUMBER_ERROR_CODES["SEND_OTP_NOT_IMPLEMENTED"])
    end

    body = normalize_hash(ctx.body)
    phone_number = body[:phone_number].to_s
    validate_phone_number!(config, phone_number)
    code = phone_number_generate_code(config)
    phone_number_store_code(ctx, config, phone_number, code) unless config[:verify_otp].respond_to?(:call)
    phone_number_deliver_otp(config, {phone_number: phone_number, code: code}, ctx)
    ctx.json({message: "code sent"})
  end
end

.send_verification_otp_endpoint(config) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/better_auth/plugins/email_otp.rb', line 78

def send_verification_otp_endpoint(config)
  Endpoint.new(
    path: "/email-otp/send-verification-otp",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "sendVerificationOTP",
        description: "Send an email verification OTP",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              email: {type: "string"},
              type: {type: "string", enum: ["email-verification", "sign-in", "forget-password"]}
            },
            required: ["email", "type"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("OTP sent", OpenAPI.success_response_schema)
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    email = body[:email].to_s.downcase
    type = body[:type].to_s
    validate_email_otp_type!(type)
    validate_email_otp_email!(email)
    if type == "change-email"
      raise APIError.new("BAD_REQUEST", message: "Invalid OTP type")
    end

    sender = config[:send_verification_otp]
    unless sender.respond_to?(:call)
      raise APIError.new("BAD_REQUEST", message: "send email verification is not implemented")
    end

    email_otp_send_verification(ctx, config, email: email, type: type)
    ctx.json({success: true})
  end
end

.serialize_metadata(value) ⇒ Object



1452
1453
1454
# File 'lib/better_auth/plugins/organization.rb', line 1452

def (value)
  value.is_a?(Hash) ? JSON.generate(value) : value
end

.set_active_session_endpointObject



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/better_auth/plugins/multi_session.rb', line 67

def set_active_session_endpoint
  Endpoint.new(
    path: "/multi-session/set-active",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "setActiveSession",
        description: "Set the active session",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              sessionToken: {type: "string", description: "The session token"}
            },
            required: ["sessionToken"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Active session", OpenAPI.session_response_schema_pair)
        }
      }
    }
  ) do |ctx|
    token = fetch_value(ctx.body, "sessionToken").to_s
    cookie_name = multi_session_cookie_name(ctx, token)
    unless !token.empty? && ctx.get_signed_cookie(cookie_name, ctx.context.secret)
      raise APIError.new("UNAUTHORIZED", message: MULTI_SESSION_ERROR_CODES["INVALID_SESSION_TOKEN"])
    end

    session = ctx.context.internal_adapter.find_session(token)
    unless session && session[:session]["expiresAt"] > Time.now
      expire_cookie(ctx, cookie_name)
      raise APIError.new("UNAUTHORIZED", message: MULTI_SESSION_ERROR_CODES["INVALID_SESSION_TOKEN"])
    end

    Cookies.set_session_cookie(ctx, session)
    ctx.json(parsed_session(ctx, session))
  end
end


197
198
199
200
201
202
203
204
205
# File 'lib/better_auth/plugins/anonymous.rb', line 197

def set_cookie_value(set_cookie, name)
  set_cookie.to_s.lines.each do |line|
    cookie_pair = line.split(";", 2).first.to_s.strip
    cookie_name, value = cookie_pair.split("=", 2)
    return value if cookie_name == name && !value.nil?
  end

  nil
end

.set_jwt_header(ctx, config) ⇒ Object



180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/better_auth/plugins/jwt.rb', line 180

def set_jwt_header(ctx, config)
  return if config[:disable_setting_jwt_header]

  session = ctx.context.current_session || ctx.context.new_session
  return unless session && session[:session]

  token = jwt_token(ctx, session, config)
  exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?)
  exposed << "set-auth-jwt"
  ctx.set_header("set-auth-jwt", token)
  ctx.set_header("access-control-expose-headers", exposed.uniq.join(", "))
  nil
end


153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/better_auth/plugins/multi_session.rb', line 153

def set_multi_session_cookie(ctx, config)
  new_session = ctx.context.new_session
  return unless new_session && new_session[:session]
  set_cookie = ctx.response_headers["set-cookie"].to_s

  token = new_session[:session]["token"]
  cookie_config = ctx.context.auth_cookies[:session_token]
  cookie_name = multi_session_cookie_name(ctx, token)
  cookies = ctx.cookies
  return unless set_cookie.include?(cookie_config.name)
  return if cookies.key?(cookie_name)

  deleted_count = 0
  existing_multi_cookie_names = multi_cookie_names(ctx)
  existing_multi_cookie_names.each do |name|
    existing_token = ctx.get_signed_cookie(name, ctx.context.secret)
    next unless existing_token

    existing_session = ctx.context.internal_adapter.find_session(existing_token)
    next unless existing_session && existing_session[:user]["id"] == new_session[:user]["id"]

    ctx.context.internal_adapter.delete_session(existing_token)
    expire_cookie(ctx, name)
    deleted_count += 1
  end

  current_count = existing_multi_cookie_names.length - deleted_count + 1
  return if current_count > config[:maximum_sessions].to_i

  ctx.set_signed_cookie(cookie_name, token, ctx.context.secret, cookie_config.attributes)
  nil
end

.sign_bearer_token(ctx, token, config) ⇒ Object



57
58
59
60
61
62
# File 'lib/better_auth/plugins/bearer.rb', line 57

def sign_bearer_token(ctx, token, config)
  return if config[:require_signature]

  signature = Crypto.hmac_signature(token, ctx.context.secret, encoding: :base64url)
  "#{token}.#{signature}"
end

.sign_in_anonymous_endpoint(config) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/better_auth/plugins/anonymous.rb', line 42

def (config)
  Endpoint.new(
    path: "/sign-in/anonymous",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "signInAnonymous",
        description: "Sign in anonymously",
        requestBody: OpenAPI.empty_request_body,
        responses: {
          "200" => OpenAPI.json_response(
            "Anonymous session created",
            OpenAPI.object_schema(
              {
                token: {type: "string"},
                user: {type: "object", "$ref": "#/components/schemas/User"}
              },
              required: ["token", "user"]
            )
          )
        }
      }
    }
  ) do |ctx|
    existing_session = Session.find_current(ctx, disable_refresh: true)
    if existing_session&.dig(:user, "isAnonymous")
      raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY"])
    end

    email = anonymous_email(config)
    name = anonymous_name(ctx, config)
    user = ctx.context.internal_adapter.create_user(
      email: email,
      emailVerified: false,
      isAnonymous: true,
      name: name,
      createdAt: Time.now,
      updatedAt: Time.now,
      context: ctx
    )
    raise APIError.new("INTERNAL_SERVER_ERROR", message: ANONYMOUS_ERROR_CODES["FAILED_TO_CREATE_USER"]) unless user

    session = ctx.context.internal_adapter.create_session(user["id"])
    raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["COULD_NOT_CREATE_SESSION"]) unless session

    Cookies.set_session_cookie(ctx, {session: session, user: user})
    ctx.json({token: session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
  end
end

.sign_in_email_otp_endpoint(config) ⇒ Object



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/better_auth/plugins/email_otp.rb', line 281

def (config)
  Endpoint.new(
    path: "/sign-in/email-otp",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "signInEmailOTP",
        description: "Sign in with an email OTP",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              email: {type: "string"},
              otp: {type: "string"}
            },
            required: ["email", "otp"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "Signed in",
            OpenAPI.object_schema(
              {
                token: {type: "string"},
                user: {type: "object", "$ref": "#/components/schemas/User"}
              },
              required: ["token", "user"]
            )
          )
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    email = body[:email].to_s.downcase
    otp = body[:otp].to_s

    email_otp_verify!(ctx, config, email: email, type: "sign-in", otp: otp)
    found = ctx.context.internal_adapter.find_user_by_email(email)
    user = if found
      found[:user]
    else
      raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) if config[:disable_sign_up]

      ctx.context.internal_adapter.create_user((ctx, body, email), context: ctx)
    end

    unless user["emailVerified"]
      user = ctx.context.adapter.transaction do
        ctx.context.internal_adapter.(user["id"])
        updated = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
        raise Error, "Failed to verify email-OTP user" unless updated

        updated
      end
    end

    session = ctx.context.internal_adapter.create_session(user["id"])
    Cookies.set_session_cookie(ctx, {session: session, user: user})
    ctx.json({token: session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
  end
end


33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/better_auth/plugins/magic_link.rb', line 33

def (config)
  Endpoint.new(
    path: "/sign-in/magic-link",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "signInMagicLink",
        description: "Send a magic sign-in link",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              email: {type: "string", description: "The email address to sign in"},
              name: {type: ["string", "null"], description: "The user name to use when creating a new user"},
              callbackURL: {type: ["string", "null"]},
              errorCallbackURL: {type: ["string", "null"]},
              newUserCallbackURL: {type: ["string", "null"]},
              metadata: {type: ["object", "null"]}
            },
            required: ["email"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Magic link sent", OpenAPI.status_response_schema)
        }
      }
    }
  ) do |ctx|
    sender = config[:send_magic_link]
    link_base_url = ctx.context.base_url
    link_base_url = ctx.context.token_link_base_url if sender.respond_to?(:call)
    body = normalize_hash(ctx.body)
    email = body[:email].to_s.downcase
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email)

    token = magic_link_token(email, config)
    stored_token = store_magic_link_token(token, config)
    ctx.context.internal_adapter.create_verification_value(
      identifier: stored_token,
      value: JSON.generate({"email" => email, "name" => body[:name], "attempt" => 0}),
      expiresAt: Time.now + (config[:expires_in] || 60 * 5).to_i
    )

    link = magic_link_url(link_base_url, token, body)
    data = {email: email, url: link, token: token}
    data[:metadata] = body[:metadata] if body.key?(:metadata)
    sender.call(data, ctx) if sender.respond_to?(:call)
    ctx.json({status: true})
  end
end

.sign_in_phone_number_endpoint(config) ⇒ Object



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/better_auth/plugins/phone_number.rb', line 67

def (config)
  Endpoint.new(
    path: "/sign-in/phone-number",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "signInPhoneNumber",
        description: "Sign in with phone number and password",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              phoneNumber: {type: "string"},
              password: {type: "string"},
              rememberMe: {type: ["boolean", "null"]}
            },
            required: ["phoneNumber", "password"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "Signed in",
            OpenAPI.object_schema(
              {
                token: {type: "string"},
                user: {type: "object", "$ref": "#/components/schemas/User"}
              },
              required: ["token", "user"]
            )
          )
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    phone_number = body[:phone_number].to_s
    password = body[:password].to_s
    validate_phone_number!(config, phone_number)

    found = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
    unless found
      Routes.hash_password(ctx, password)
      raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER_OR_PASSWORD"])
    end

    if config[:require_verification] && !found["phoneNumberVerified"]
      code = phone_number_generate_code(config)
      phone_number_store_code(ctx, config, phone_number, code) unless config[:verify_otp].respond_to?(:call)
      phone_number_deliver_otp(config, {phone_number: phone_number, code: code}, ctx)
      raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_NOT_VERIFIED"])
    end

    credential = ctx.context.internal_adapter.find_accounts(found["id"]).find { |entry| entry["providerId"] == "credential" }
    current_password = credential && credential["password"]
    unless current_password && Routes.verify_password_value(ctx, password, current_password)
      Routes.hash_password(ctx, password) unless current_password
      raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER_OR_PASSWORD"])
    end

    dont_remember_me = body.key?(:remember_me) && (body[:remember_me] == false || body[:remember_me].to_s == "false")
    session = ctx.context.internal_adapter.create_session(found["id"], dont_remember_me)
    raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session

    Cookies.set_session_cookie(ctx, {session: session, user: found}, dont_remember_me)
    ctx.json({token: session["token"], user: Schema.parse_output(ctx.context.options, "user", found)})
  end
end

.sign_in_username_endpoint(config) ⇒ Object



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/better_auth/plugins/username.rb', line 48

def (config)
  Endpoint.new(
    path: "/sign-in/username",
    method: "POST",
    metadata: {
      allowed_media_types: [
        "application/x-www-form-urlencoded",
        "application/json"
      ],
      openapi: {
        operationId: "signInUsername",
        description: "Sign in with username and password",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              username: {type: "string"},
              password: {type: "string"},
              callbackURL: {type: ["string", "null"]},
              rememberMe: {type: ["boolean", "null"]}
            },
            required: ["username", "password"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "Signed in",
            OpenAPI.object_schema(
              {
                redirect: {type: "boolean"},
                token: {type: "string"},
                url: {type: ["string", "null"]},
                user: {type: "object", "$ref": "#/components/schemas/User"}
              },
              required: ["redirect", "token", "user"]
            )
          )
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    raw_username = body[:username].to_s
    password = body[:password].to_s
    callback_url = body[:callback_url] || body[:callbackURL]
    remember_me = body.key?(:remember_me) ? body[:remember_me] : body[:rememberMe]
    Routes.(ctx)

    if raw_username.empty? || password.empty?
      raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
    end

    username = (raw_username, config)
    validate_username!(username, config, status: "UNPROCESSABLE_ENTITY")

    user = ctx.context.adapter.find_one(
      model: "user",
      where: [{field: "username", value: normalize_username(raw_username, config)}]
    )
    unless user
      Routes.hash_password(ctx, password)
      raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
    end

     = ctx.context.adapter.find_one(
      model: "account",
      where: [
        {field: "userId", value: user["id"]},
        {field: "providerId", value: "credential"}
      ]
    )
    current_password =  && ["password"]
    email_config = ctx.context.options.email_and_password
    unless current_password && Routes.verify_password_value(ctx, password, current_password)
      Routes.hash_password(ctx, password) unless current_password
      raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
    end

    if email_config[:require_email_verification] && !user["emailVerified"]
      Routes.(ctx, user, callback_url)
      raise APIError.new("FORBIDDEN", message: USERNAME_ERROR_CODES["EMAIL_NOT_VERIFIED"])
    end

    dont_remember_me = remember_me == false || remember_me.to_s == "false"
    session = ctx.context.internal_adapter.create_session(
      user["id"],
      dont_remember_me,
      Routes.session_overrides(ctx),
      true
    )
    raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session

    Cookies.set_session_cookie(ctx, {session: session, user: user}, dont_remember_me)
    ctx.set_header("location", callback_url) if callback_url
    ctx.json({
      redirect: !callback_url.nil?,
      token: session["token"],
      url: callback_url,
      user: Schema.parse_output(ctx.context.options, "user", user)
    })
  end
end

.sign_in_with_oauth2_endpoint(config) ⇒ Object



245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/better_auth/plugins/generic_oauth.rb', line 245

def (config)
  Endpoint.new(
    path: "/sign-in/oauth2",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "signInOAuth2",
        description: "Sign in with OAuth2",
        requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
        responses: {
          "200" => OpenAPI.json_response("Sign in with OAuth2", generic_oauth_url_response_schema)
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    provider_id = body[:provider_id].to_s
    provider = generic_oauth_provider!(config, provider_id)
    auth_url = generic_oauth_authorization_url(ctx, provider, body, link: nil)
    ctx.json({url: auth_url, redirect: !body[:disable_redirect]})
  end
end

.sign_jwt_endpoint(config) ⇒ Object



163
164
165
166
167
168
169
# File 'lib/better_auth/plugins/jwt.rb', line 163

def sign_jwt_endpoint(config)
  Endpoint.new(path: nil, method: "POST") do |ctx|
    payload = fetch_value(ctx.body, "payload") || {}
    override = normalize_hash(fetch_value(ctx.body, "overrideOptions") || {})
    ctx.json({token: sign_jwt_payload(ctx, stringify_payload(payload), deep_merge(config, override))})
  end
end

.sign_jwt_payload(ctx, payload, config) ⇒ Object



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/better_auth/plugins/jwt.rb', line 209

def sign_jwt_payload(ctx, payload, config)
  jwt_config = config[:jwt] || {}
  now = Time.now.to_i
  payload = stringify_payload(payload).dup
  payload["iat"] ||= now
  payload["exp"] ||= jwt_expiration(jwt_config[:expiration_time] || "15m", payload["iat"])
  payload["iss"] ||= jwt_config[:issuer] || ctx.context.canonical_base_url
  payload["aud"] ||= jwt_config[:audience] || ctx.context.canonical_base_url

  return jwt_config[:sign].call(payload) if jwt_config[:sign].respond_to?(:call)

  key = signing_jwk(ctx, config)
  private_key = OpenSSL::PKey.read(jwk_private_key_value(ctx, key, config))
  alg = key["alg"] || "RS256"
  return encode_eddsa_jwt(payload, private_key, key["id"]) if alg == "EdDSA"

  ::JWT.encode(payload, private_key, alg, kid: key["id"])
end

.signing_jwk(ctx, config) ⇒ Object



251
252
253
254
255
256
# File 'lib/better_auth/plugins/jwt.rb', line 251

def signing_jwk(ctx, config)
  key = latest_jwk(ctx, config)
  return key if key && !jwk_expired?(key)

  create_jwk(ctx, config)
end

.siwe(options = {}) ⇒ Object



15
16
17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/better_auth/plugins/siwe.rb', line 15

def siwe(options = {})
  config = normalize_hash(options)

  Plugin.new(
    id: "siwe",
    schema: siwe_schema(config[:schema]),
    endpoints: {
      get_siwe_nonce: get_siwe_nonce_endpoint(config, path: "/siwe/nonce", operation_id: "getSiweNonce"),
      get_nonce: get_siwe_nonce_endpoint(config, path: "/siwe/get-nonce", operation_id: "getNonce"),
      verify_siwe_message: verify_siwe_message_endpoint(config)
    },
    options: config
  )
end

.siwe_chain_id(value) ⇒ Object

Raises:



228
229
230
231
232
233
# File 'lib/better_auth/plugins/siwe.rb', line 228

def siwe_chain_id(value)
  chain_id = (value.nil? || value.to_s.empty?) ? 1 : value.to_i
  raise APIError.new("BAD_REQUEST", message: "Invalid chainId") unless chain_id.positive? && chain_id <= 2_147_483_647

  chain_id
end

.siwe_create_user(ctx, config, wallet_address, _chain_id, email, anonymous) ⇒ Object



369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/better_auth/plugins/siwe.rb', line 369

def siwe_create_user(ctx, config, wallet_address, _chain_id, email, anonymous)
  domain = config[:email_domain_name] || URI.parse(ctx.context.canonical_base_url).host || ctx.context.canonical_base_url
  lookup = config[:ens_lookup]
  ens = lookup.respond_to?(:call) ? normalize_hash(lookup.call(wallet_address: wallet_address) || {}) : {}
  normalized_email = email.to_s.downcase
  user_email = "#{wallet_address}@#{domain}".downcase
  if anonymous == false && !normalized_email.empty? && !ctx.context.internal_adapter.find_user_by_email(normalized_email)
    user_email = normalized_email
  end
  ctx.context.internal_adapter.create_user(
    name: ens[:name] || wallet_address,
    email: user_email,
    image: ens[:avatar] || "",
    context: ctx
  )
end

.siwe_ensure_wallet_and_account(ctx, user, wallet_address, chain_id) ⇒ Object



386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
# File 'lib/better_auth/plugins/siwe.rb', line 386

def (ctx, user, wallet_address, chain_id)
  exact = ctx.context.adapter.find_one(
    model: "walletAddress",
    where: [
      {field: "address", value: wallet_address},
      {field: "chainId", value: chain_id}
    ]
  )
  return if exact

  any_wallet = ctx.context.adapter.find_one(model: "walletAddress", where: [{field: "address", value: wallet_address}])
  ctx.context.adapter.create(
    model: "walletAddress",
    data: {
      userId: user["id"],
      address: wallet_address,
      chainId: chain_id,
      isPrimary: any_wallet.nil?,
      createdAt: Time.now
    }
  )
  ctx.context.internal_adapter.(
    userId: user["id"],
    providerId: "siwe",
    accountId: "#{wallet_address}:#{chain_id}"
  )
end

.siwe_expired_time?(value) ⇒ Boolean

Returns:

  • (Boolean)


414
415
416
# File 'lib/better_auth/plugins/siwe.rb', line 414

def siwe_expired_time?(value)
  value && value < Time.now
end

.siwe_find_user(ctx, wallet_address, chain_id) ⇒ Object



357
358
359
360
361
362
363
364
365
366
367
# File 'lib/better_auth/plugins/siwe.rb', line 357

def siwe_find_user(ctx, wallet_address, chain_id)
  existing = ctx.context.adapter.find_one(
    model: "walletAddress",
    where: [
      {field: "address", value: wallet_address},
      {field: "chainId", value: chain_id}
    ]
  )
  existing ||= ctx.context.adapter.find_one(model: "walletAddress", where: [{field: "address", value: wallet_address}])
  existing && ctx.context.internal_adapter.find_user_by_id(existing["userId"])
end

.siwe_identifier(wallet_address, chain_id) ⇒ Object



235
236
237
# File 'lib/better_auth/plugins/siwe.rb', line 235

def siwe_identifier(wallet_address, chain_id)
  "siwe:#{wallet_address}:#{chain_id}"
end

.siwe_merge_schema_fields(base_fields, custom_fields) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/better_auth/plugins/siwe.rb', line 179

def siwe_merge_schema_fields(base_fields, custom_fields)
  fields = base_fields.each_with_object({}) do |(raw_field, attributes), result|
    result[Schema.storage_key(raw_field)] = normalize_hash(attributes)
  end

  normalize_hash(custom_fields).each do |raw_field, value|
    field = Schema.storage_key(raw_field)
    custom_attributes = (value.is_a?(String) || value.is_a?(Symbol)) ? {field_name: value.to_s} : normalize_hash(value)
    fields[field] = (fields[field] || {}).merge(custom_attributes)
  end

  fields
end

.siwe_nonce_body(body) ⇒ Object



193
194
195
196
197
198
199
200
201
202
203
# File 'lib/better_auth/plugins/siwe.rb', line 193

def siwe_nonce_body(body)
  data = normalize_hash(body)
  wallet_address_keys = [:wallet_address, :address].select { |key| data.key?(key) }
  if wallet_address_keys.empty?
    raise APIError.new("BAD_REQUEST", message: "walletAddress or address is required")
  end

  wallet_address_keys.each { |key| siwe_normalize_wallet!(data[key]) }
  data[:chain_id] = siwe_chain_id(data[:chain_id])
  data
end

.siwe_normalize_domain(domain) ⇒ Object



290
291
292
293
294
295
296
# File 'lib/better_auth/plugins/siwe.rb', line 290

def siwe_normalize_domain(domain)
  normalized = domain.to_s.strip.downcase.sub(/\A[a-z][a-z0-9+.-]*:\/\//, "")
  path_start = normalized.index("/")
  path_start ? normalized[0...path_start] : normalized
rescue
  ""
end

.siwe_normalize_wallet!(value) ⇒ Object

Raises:



221
222
223
224
225
226
# File 'lib/better_auth/plugins/siwe.rb', line 221

def siwe_normalize_wallet!(value)
  wallet = value.to_s
  raise APIError.new("BAD_REQUEST", message: "Invalid walletAddress") unless SIWE_WALLET_PATTERN.match?(wallet)

  Crypto.to_checksum_address(wallet)
end

.siwe_parse_message(message) ⇒ Object



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/better_auth/plugins/siwe.rb', line 239

def siwe_parse_message(message)
  result = {}
  lines = message.to_s.split(/\r?\n/)

  if (header = SIWE_HEADER_PATTERN.match(lines[0].to_s))
    result[:scheme] = header[1] if header[1]
    result[:domain] = header[2]
  end

  address = lines[1].to_s.strip
  result[:address] = address if SIWE_WALLET_PATTERN.match?(address)

  lines.each do |line|
    field = SIWE_FIELD_PATTERN.match(line)
    next unless field

    key = field[1]
    value = field[2]
    case key
    when "URI"
      result[:uri] = value
    when "Version"
      result[:version] = value
    when "Chain ID"
      parsed_chain_id = siwe_parse_message_chain_id(value)
      result[:chain_id] = parsed_chain_id unless parsed_chain_id.nil?
    when "Nonce"
      result[:nonce] = value
    when "Issued At"
      result[:issued_at] = value
    when "Expiration Time"
      result[:expiration_time] = value
    when "Not Before"
      result[:not_before] = value
    when "Request ID"
      result[:request_id] = value
    end
  end

  result
rescue
  {}
end

.siwe_parse_message_chain_id(value) ⇒ Object



283
284
285
286
287
288
# File 'lib/better_auth/plugins/siwe.rb', line 283

def siwe_parse_message_chain_id(value)
  number = Float(value)
  number.to_i if number.finite? && number == number.to_i
rescue ArgumentError, TypeError, RangeError
  nil
end

.siwe_parse_message_time(value) ⇒ Object



324
325
326
327
328
# File 'lib/better_auth/plugins/siwe.rb', line 324

def siwe_parse_message_time(value)
  Time.parse(value.to_s)
rescue ArgumentError, TypeError, RangeError
  nil
end

.siwe_schema(custom_schema = nil) ⇒ Object



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/better_auth/plugins/siwe.rb', line 156

def siwe_schema(custom_schema = nil)
  base = {
    "walletAddress" => {
      fields: {
        userId: {type: "string", references: {model: "user", field: "id"}, required: true, index: true},
        address: {type: "string", required: true},
        chainId: {type: "number", required: true},
        isPrimary: {type: "boolean", default_value: false},
        createdAt: {type: "date", required: true}
      }
    }
  }
  return base unless custom_schema.is_a?(Hash)

  normalize_hash(custom_schema).each_with_object(base) do |(raw_model, table), result|
    model = Schema.storage_key(raw_model)
    current = result[model] || {}
    custom_table = normalize_hash(table)
    fields = siwe_merge_schema_fields(current[:fields] || current["fields"] || {}, custom_table.delete(:fields) || {})
    result[model] = current.merge(custom_table).merge(fields: fields)
  end
end

.siwe_unauthorized_error(status, message) ⇒ Object



330
331
332
# File 'lib/better_auth/plugins/siwe.rb', line 330

def siwe_unauthorized_error(status, message)
  APIError.new("UNAUTHORIZED", code: status, message: message)
end

.siwe_validate_message!(message, domain, wallet_address, chain_id, nonce) ⇒ Object



298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/better_auth/plugins/siwe.rb', line 298

def siwe_validate_message!(message, domain, wallet_address, chain_id, nonce)
  parsed = siwe_parse_message(message)
  matches = parsed[:nonce] == nonce &&
    parsed[:address]&.downcase == wallet_address.downcase &&
    parsed[:chain_id] == chain_id &&
    parsed[:domain] && siwe_normalize_domain(parsed[:domain]) == siwe_normalize_domain(domain)

  unless matches
    raise siwe_unauthorized_error(
      "UNAUTHORIZED_SIWE_MESSAGE_MISMATCH",
      "Unauthorized: SIWE message does not match the expected nonce, domain, address, or chain ID"
    )
  end

  now = Time.now
  expiration_time = siwe_parse_message_time(parsed[:expiration_time]) if parsed[:expiration_time]
  if expiration_time && now >= expiration_time
    raise siwe_unauthorized_error("UNAUTHORIZED_SIWE_MESSAGE_EXPIRED", "Unauthorized: SIWE message has expired")
  end

  not_before = siwe_parse_message_time(parsed[:not_before]) if parsed[:not_before]
  if not_before && now < not_before
    raise siwe_unauthorized_error("UNAUTHORIZED_SIWE_MESSAGE_NOT_YET_VALID", "Unauthorized: SIWE message is not yet valid")
  end
end

.siwe_verify_body(body, config) ⇒ Object

Raises:



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/better_auth/plugins/siwe.rb', line 205

def siwe_verify_body(body, config)
  data = normalize_hash(body)
  raise APIError.new("BAD_REQUEST", message: "message is required") if data[:message].to_s.empty?
  raise APIError.new("BAD_REQUEST", message: "signature is required") if data[:signature].to_s.empty?

  siwe_normalize_wallet!(data[:wallet_address])
  data[:chain_id] = siwe_chain_id(data[:chain_id])
  anonymous = config.key?(:anonymous) ? config[:anonymous] : true
  email = data[:email].to_s.downcase
  raise APIError.new("BAD_REQUEST", message: "Email is required when anonymous is disabled.") if anonymous == false && email.empty?
  raise APIError.new("BAD_REQUEST", message: "Invalid email address") if !email.empty? && !SIWE_EMAIL_PATTERN.match?(email)

  data[:email] = email unless email.empty?
  data
end

.siwe_verify_message(config, body, wallet_address, chain_id, nonce, ctx) ⇒ Object

Raises:



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# File 'lib/better_auth/plugins/siwe.rb', line 334

def siwe_verify_message(config, body, wallet_address, chain_id, nonce, ctx)
  verifier = config[:verify_message]
  raise APIError.new("INTERNAL_SERVER_ERROR", message: "SIWE verify_message callback is required") unless verifier.respond_to?(:call)

  verifier.call(
    message: body[:message].to_s,
    signature: body[:signature].to_s,
    address: wallet_address,
    chain_id: chain_id,
    cacao: {
      h: {t: "caip122"},
      p: {
        domain: config[:domain],
        aud: config[:domain],
        nonce: nonce,
        iss: config[:domain],
        version: "1"
      },
      s: {t: "eip191", s: body[:signature].to_s}
    }
  )
end

.slack(options = {}) ⇒ Object



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/better_auth/plugins/generic_oauth.rb', line 191

def slack(options = {})
  data = normalize_hash(options)
  generic_oauth_provider_config(
    data,
    provider_id: "slack",
    authorization_url: "https://slack.com/openid/connect/authorize",
    token_url: "https://slack.com/api/openid.connect.token",
    user_info_url: "https://slack.com/api/openid.connect.userInfo",
    scopes: ["openid", "profile", "email"],
    get_user_info: ->(tokens) {
      profile = generic_oauth_fetch_json("https://slack.com/api/openid.connect.userInfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
      return nil unless profile

      {
        id: fetch_value(profile, "https://slack.com/user_id") || fetch_value(profile, "sub"),
        name: fetch_value(profile, "name"),
        email: fetch_value(profile, "email"),
        image: fetch_value(profile, "picture") || fetch_value(profile, "https://slack.com/user_image_512"),
        emailVerified: fetch_value(profile, "email_verified") || false
      }
    }
  )
end

.sso(*args, &block) ⇒ Object



9
10
11
# File 'lib/better_auth/plugins/sso.rb', line 9

def sso(*args, &block)
  call_external_plugin!(:sso, *args, implementation_constant: :SSO_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-sso", entry: "lib/better_auth/sso.rb", &block)
end

.storage_fields(fields) ⇒ Object



25
26
27
28
29
# File 'lib/better_auth/plugins.rb', line 25

def storage_fields(fields)
  normalize_hash(fields).each_with_object({}) do |(key, value), result|
    result[Schema.storage_key(key)] = normalize_field(value)
  end
end


202
203
204
205
206
207
208
209
210
211
212
# File 'lib/better_auth/plugins/magic_link.rb', line 202

def store_magic_link_token(token, config)
  storage = config[:store_token]
  return Crypto.sha256(token, encoding: :base64url) if storage.to_s == "hashed"

  if storage.is_a?(Hash) && %w[custom-hasher custom_hasher].include?(storage[:type].to_s)
    hasher = storage[:hash]
    return hasher.call(token) if hasher.respond_to?(:call)
  end

  token
end

.stringify_keys(value) ⇒ Object



77
78
79
80
81
82
# File 'lib/better_auth/plugins/custom_session.rb', line 77

def stringify_keys(value)
  return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
  return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)

  value
end

.stringify_payload(value) ⇒ Object



526
527
528
529
530
531
# File 'lib/better_auth/plugins/jwt.rb', line 526

def stringify_payload(value)
  return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_payload(object_value) } if value.is_a?(Hash)
  return value.map { |entry| stringify_payload(entry) } if value.is_a?(Array)

  value
end

.stringify_permission(value) ⇒ Object



1465
1466
1467
1468
1469
# File 'lib/better_auth/plugins/organization.rb', line 1465

def stringify_permission(value)
  normalize_hash(value || {}).each_with_object({}) do |(resource, actions), result|
    result[resource.to_s] = Array(actions).map(&:to_s)
  end
end

.stripe(*args, &block) ⇒ Object



9
10
11
# File 'lib/better_auth/plugins/stripe.rb', line 9

def stripe(*args, &block)
  call_external_plugin!(:stripe, *args, implementation_constant: :STRIPE_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-stripe", entry: "lib/better_auth/stripe.rb", &block)
end

.symbolize_session(entry) ⇒ Object



69
70
71
72
73
74
75
# File 'lib/better_auth/plugins/custom_session.rb', line 69

def symbolize_session(entry)
  data = stringify_keys(entry)
  {
    session: data["session"],
    user: data["user"]
  }
end

.team_by_id(ctx, id) ⇒ Object



1225
1226
1227
1228
1229
# File 'lib/better_auth/plugins/organization.rb', line 1225

def team_by_id(ctx, id)
  return nil if id.to_s.empty?

  ctx.context.adapter.find_one(model: "team", where: [{field: "id", value: id}])
end

.team_member_wire(ctx, member) ⇒ Object



1342
1343
1344
# File 'lib/better_auth/plugins/organization.rb', line 1342

def team_member_wire(ctx, member)
  Schema.parse_output(ctx.context.options, "teamMember", member)
end

.team_wire(ctx, team) ⇒ Object



1338
1339
1340
# File 'lib/better_auth/plugins/organization.rb', line 1338

def team_wire(ctx, team)
  Schema.parse_output(ctx.context.options, "team", team)
end

.truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


450
451
452
# File 'lib/better_auth/plugins/phone_number.rb', line 450

def truthy?(value)
  value == true || value.to_s == "true"
end

.truthy_value?(value) ⇒ Boolean

Returns:

  • (Boolean)


65
66
67
# File 'lib/better_auth/plugins/custom_session.rb', line 65

def truthy_value?(value)
  value == true || value.to_s == "true"
end

.two_factor(options = {}) ⇒ Object



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/better_auth/plugins/two_factor.rb', line 36

def two_factor(options = {})
  config = {
    two_factor_table: "twoFactor",
    trust_device_max_age: TRUST_DEVICE_COOKIE_MAX_AGE,
    two_factor_cookie_max_age: TWO_FACTOR_COOKIE_MAX_AGE,
    account_lockout: {
      enabled: true,
      max_failed_attempts: DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS,
      duration_seconds: DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS
    },
    backup_code_options: {store_backup_codes: "encrypted"},
    otp_options: {},
    totp_options: {}
  }.merge(normalize_hash(options))
  config[:backup_code_options] = {store_backup_codes: "encrypted"}.merge(normalize_hash(config[:backup_code_options]))
  config[:otp_options] = normalize_hash(config[:otp_options])
  config[:totp_options] = normalize_hash(config[:totp_options])
  config[:account_lockout] = {
    enabled: true,
    max_failed_attempts: DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS,
    duration_seconds: DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS
  }.merge(normalize_hash(config[:account_lockout]))
  config[:backup_code_options][:allow_passwordless] = config[:allow_passwordless] unless config[:backup_code_options].key?(:allow_passwordless)
  config[:totp_options][:allow_passwordless] = config[:allow_passwordless] unless config[:totp_options].key?(:allow_passwordless)

  Plugin.new(
    id: "two-factor",
    endpoints: {
      enable_two_factor: two_factor_enable_endpoint(config),
      disable_two_factor: two_factor_disable_endpoint(config),
      generate_totp: two_factor_generate_totp_endpoint(config),
      get_totp_uri: two_factor_get_totp_uri_endpoint(config),
      verify_totp: two_factor_verify_totp_endpoint(config),
      send_two_factor_otp: two_factor_send_otp_endpoint(config),
      verify_two_factor_otp: two_factor_verify_otp_endpoint(config),
      verify_backup_code: two_factor_verify_backup_code_endpoint(config),
      generate_backup_codes: two_factor_generate_backup_codes_endpoint(config),
      view_backup_codes: two_factor_view_backup_codes_endpoint(config)
    },
    hooks: {
      after: [
        {
          matcher: ->(ctx) { ["/sign-in/email", "/sign-in/username", "/sign-in/phone-number"].include?(ctx.path) },
          handler: ->(ctx) { (ctx, config) }
        }
      ]
    },
    schema: two_factor_schema(config),
    rate_limit: [
      {
        path_matcher: ->(path) { path.start_with?("/two-factor/") },
        window: 10,
        max: 3
      }
    ],
    error_codes: TWO_FACTOR_ERROR_CODES,
    options: config
  )
end

.two_factor_after_sign_in(ctx, config) ⇒ Object



442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# File 'lib/better_auth/plugins/two_factor.rb', line 442

def (ctx, config)
  data = ctx.context.new_session
  return unless data && data[:user] && data[:session]
  return unless data[:user]["twoFactorEnabled"]
  return if two_factor_trusted_device_valid?(ctx, config, data[:user]["id"])

  Cookies.delete_session_cookie(ctx, skip_dont_remember_me: true)
  ctx.context.internal_adapter.delete_session(data[:session]["token"])
  cookie = ctx.context.create_auth_cookie(TWO_FACTOR_COOKIE_NAME, max_age: config[:two_factor_cookie_max_age])
  identifier = "2fa-#{Crypto.random_string(20)}"
  expires_at = Time.now + config[:two_factor_cookie_max_age].to_i
  ctx.context.internal_adapter.create_verification_value(
    identifier: identifier,
    value: data[:user]["id"],
    expiresAt: expires_at
  )
  ctx.context.internal_adapter.create_verification_value(
    identifier: "2fa-attempts-#{identifier}",
    value: "0",
    expiresAt: expires_at
  )
  ctx.set_signed_cookie(cookie.name, identifier, ctx.context.secret, cookie.attributes)
  ctx.json({twoFactorRedirect: true, twoFactorMethods: two_factor_methods(ctx, config, data[:user]["id"])})
end

.two_factor_assert_not_locked!(ctx, config, record) ⇒ Object

Raises:



578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
# File 'lib/better_auth/plugins/two_factor.rb', line 578

def two_factor_assert_not_locked!(ctx, config, record)
  lockout = config[:account_lockout]
  return unless lockout && lockout[:enabled] != false && record["lockedUntil"]

  locked_until = two_factor_lock_time(record["lockedUntil"])
  if locked_until > Time.now
    raise APIError.new("TOO_MANY_REQUESTS", message: TWO_FACTOR_ERROR_CODES["ACCOUNT_TEMPORARILY_LOCKED"])
  end

  cleared = ctx.context.adapter.increment_one(
    model: TWO_FACTOR_MODEL,
    where: [
      {field: "id", value: record["id"]},
      {field: "lockedUntil", operator: "lte", value: Time.now}
    ],
    increment: {},
    set: {failedVerificationCount: 0, lockedUntil: nil}
  )
  raise Error, "Failed to clear expired two-factor account lock" unless cleared
end

.two_factor_backup_codes_response_schemaObject



407
408
409
410
411
412
413
414
415
# File 'lib/better_auth/plugins/two_factor.rb', line 407

def two_factor_backup_codes_response_schema
  OpenAPI.object_schema(
    {
      status: {type: "boolean"},
      backupCodes: {type: "array", items: {type: "string"}}
    },
    required: ["status", "backupCodes"]
  )
end

.two_factor_check_password!(ctx, user_id, password, allow_passwordless: false) ⇒ Object



646
647
648
649
650
651
652
653
# File 'lib/better_auth/plugins/two_factor.rb', line 646

def two_factor_check_password!(ctx, user_id, password, allow_passwordless: false)
   = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
  return if allow_passwordless && !

  unless  && ["password"] && Routes.verify_password_value(ctx, password.to_s, ["password"])
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
  end
end

.two_factor_disable_endpoint(config) ⇒ Object



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/better_auth/plugins/two_factor.rb', line 132

def two_factor_disable_endpoint(config)
  Endpoint.new(path: "/two-factor/disable", method: "POST", metadata: two_factor_openapi("disableTwoFactor", "Disable two factor authentication", OpenAPI.status_response_schema)) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    two_factor_check_password!(ctx, session[:user]["id"], body[:password], allow_passwordless: config[:allow_passwordless])

    updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: false)
    ctx.context.adapter.delete(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: updated_user["id"]}])
    new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
    Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
    ctx.context.internal_adapter.delete_session(session[:session]["token"])

    trust_cookie = ctx.context.create_auth_cookie(TRUST_DEVICE_COOKIE_NAME, max_age: config[:trust_device_max_age])
    trust_value = ctx.get_signed_cookie(trust_cookie.name, ctx.context.secret)
    if trust_value
      _token, identifier = trust_value.split("!", 2)
      ctx.context.internal_adapter.delete_verification_by_identifier(identifier) if identifier
      Cookies.expire_cookie(ctx, trust_cookie)
    end
    ctx.json({status: true})
  end
end

.two_factor_enable_endpoint(config) ⇒ Object



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/better_auth/plugins/two_factor.rb', line 96

def two_factor_enable_endpoint(config)
  Endpoint.new(path: "/two-factor/enable", method: "POST", metadata: two_factor_openapi("enableTwoFactor", "Enable two factor authentication", two_factor_enable_response_schema)) do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    two_factor_check_password!(ctx, session[:user]["id"], body[:password], allow_passwordless: config[:allow_passwordless])

    secret = two_factor_generate_secret
    backup = two_factor_generate_backup_codes(ctx.context.secret_config, config[:backup_code_options])
    if config[:skip_verification_on_enable]
      updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: true)
      new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
      Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
      ctx.context.internal_adapter.delete_session(session[:session]["token"])
    end

    existing = two_factor_record(ctx, config, session[:user]["id"])
    verified = (!!existing && existing["verified"] != false) || !!config[:skip_verification_on_enable]
    ctx.context.adapter.delete_many(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: session[:user]["id"]}])
    ctx.context.adapter.create(
      model: TWO_FACTOR_MODEL,
      data: {
        secret: Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: secret),
        backupCodes: backup[:stored],
        userId: session[:user]["id"],
        verified: verified
      },
      force_allow_id: true
    )

    ctx.json({
      totpURI: two_factor_totp_uri(secret, issuer: body[:issuer] || config[:issuer] || ctx.context.app_name, account: session[:user]["email"], options: config[:totp_options]),
      backupCodes: backup[:codes]
    })
  end
end

.two_factor_enable_response_schemaObject



387
388
389
390
391
392
393
394
395
# File 'lib/better_auth/plugins/two_factor.rb', line 387

def two_factor_enable_response_schema
  OpenAPI.object_schema(
    {
      totpURI: {type: "string"},
      backupCodes: {type: "array", items: {type: "string"}}
    },
    required: ["totpURI", "backupCodes"]
  )
end

.two_factor_generate_backup_codes(secret, options) ⇒ Object



691
692
693
694
695
696
697
698
699
700
701
702
703
# File 'lib/better_auth/plugins/two_factor.rb', line 691

def two_factor_generate_backup_codes(secret, options)
  codes = if options[:custom_backup_codes_generate].respond_to?(:call)
    options[:custom_backup_codes_generate].call
  else
    amount = (options[:amount] || 10).to_i
    length = (options[:length] || 10).to_i
    Array.new(amount) do
      value = Crypto.random_string(length)
      "#{value[0, 5]}-#{value[5..]}"
    end
  end
  {codes: codes, stored: two_factor_store_backup_codes(secret, codes, options)}
end

.two_factor_generate_backup_codes_endpoint(config) ⇒ Object



329
330
331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/better_auth/plugins/two_factor.rb', line 329

def two_factor_generate_backup_codes_endpoint(config)
  Endpoint.new(path: "/two-factor/generate-backup-codes", method: "POST", metadata: two_factor_openapi("generateBackupCodes", "Generate two factor backup codes", two_factor_backup_codes_response_schema)) do |ctx|
    session = Routes.current_session(ctx)
    raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless session[:user]["twoFactorEnabled"]

    two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password], allow_passwordless: config[:backup_code_options][:allow_passwordless])
    record = two_factor_record(ctx, config, session[:user]["id"])
    raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless record

    backup = two_factor_generate_backup_codes(ctx.context.secret_config, config[:backup_code_options])
    ctx.context.adapter.update(model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {backupCodes: backup[:stored]})
    ctx.json({status: true, backupCodes: backup[:codes]})
  end
end

.two_factor_generate_secretObject



661
662
663
664
# File 'lib/better_auth/plugins/two_factor.rb', line 661

def two_factor_generate_secret
  raw = SecureRandom.random_bytes(20)
  base32_encode(raw)
end

.two_factor_generate_totp_endpoint(config) ⇒ Object



155
156
157
158
159
160
161
# File 'lib/better_auth/plugins/two_factor.rb', line 155

def two_factor_generate_totp_endpoint(config)
  Endpoint.new(path: "/totp/generate", method: "POST", metadata: two_factor_openapi("generateTOTP", "Generate a TOTP code", OpenAPI.object_schema({code: {type: "string"}}, required: ["code"]))) do |ctx|
    two_factor_totp_enabled!(config)
    body = normalize_hash(ctx.body)
    ctx.json({code: two_factor_totp(body[:secret], options: config[:totp_options])})
  end
end

.two_factor_get_totp_uri_endpoint(config) ⇒ Object



163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/better_auth/plugins/two_factor.rb', line 163

def two_factor_get_totp_uri_endpoint(config)
  Endpoint.new(path: "/two-factor/get-totp-uri", method: "POST", metadata: two_factor_openapi("getTOTPURI", "Get the TOTP URI", OpenAPI.object_schema({totpURI: {type: "string"}}, required: ["totpURI"]))) do |ctx|
    two_factor_totp_enabled!(config)
    session = Routes.current_session(ctx)
    two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password], allow_passwordless: config[:totp_options][:allow_passwordless])
    record = two_factor_record(ctx, config, session[:user]["id"])
    raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record

    secret = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: record["secret"])
    ctx.json({totpURI: two_factor_totp_uri(secret, issuer: config[:issuer] || ctx.context.app_name, account: session[:user]["email"], options: config[:totp_options])})
  end
end

.two_factor_lock_time(value) ⇒ Object



632
633
634
# File 'lib/better_auth/plugins/two_factor.rb', line 632

def two_factor_lock_time(value)
  value.is_a?(Time) ? value : Time.parse(value.to_s)
end

.two_factor_methods(ctx, config, user_id) ⇒ Object



636
637
638
639
640
641
642
643
644
# File 'lib/better_auth/plugins/two_factor.rb', line 636

def two_factor_methods(ctx, config, user_id)
  methods = []
  unless config[:totp_options][:disable]
    record = two_factor_record(ctx, config, user_id)
    methods << "totp" if record && record["verified"] != false
  end
  methods << "otp" if config[:otp_options][:send_otp].respond_to?(:call)
  methods
end

.two_factor_openapi(operation_id, description, response_schema) ⇒ Object



354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/better_auth/plugins/two_factor.rb', line 354

def two_factor_openapi(operation_id, description, response_schema)
  {
    openapi: {
      operationId: operation_id,
      description: description,
      requestBody: two_factor_request_body(operation_id),
      responses: {
        "200" => OpenAPI.json_response("Success", response_schema)
      }
    }
  }
end

.two_factor_otp_matches?(ctx, stored, input, options) ⇒ Boolean

Returns:

  • (Boolean)


748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
# File 'lib/better_auth/plugins/two_factor.rb', line 748

def two_factor_otp_matches?(ctx, stored, input, options)
  storage = options[:store_otp]
  expected, actual = if storage == "hashed"
    [stored, Crypto.sha256(input, encoding: :base64url)]
  elsif storage == "encrypted"
    [Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored), input]
  elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
    [stored, storage[:hash].call(input)]
  elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
    [storage[:decrypt].call(stored), input]
  else
    [stored, input]
  end
  expected && actual && Crypto.constant_time_compare(expected.to_s, actual.to_s)
end

.two_factor_random_digits(length) ⇒ Object



729
730
731
# File 'lib/better_auth/plugins/two_factor.rb', line 729

def two_factor_random_digits(length)
  Array.new(length) { SecureRandom.random_number(10) }.join
end

.two_factor_read_backup_codes(secret, stored, options) ⇒ Object



717
718
719
720
721
722
723
724
725
726
727
# File 'lib/better_auth/plugins/two_factor.rb', line 717

def two_factor_read_backup_codes(secret, stored, options)
  storage = options[:store_backup_codes]
  data = if storage == "encrypted"
    Crypto.symmetric_decrypt(key: secret, data: stored)
  elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
    storage[:decrypt].call(stored)
  else
    stored
  end
  JSON.parse(data.to_s)
end

.two_factor_record(ctx, config, user_id) ⇒ Object



574
575
576
# File 'lib/better_auth/plugins/two_factor.rb', line 574

def two_factor_record(ctx, config, user_id)
  ctx.context.adapter.find_one(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: user_id}])
end

.two_factor_record_failure!(ctx, config, record) ⇒ Object

Raises:



599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
# File 'lib/better_auth/plugins/two_factor.rb', line 599

def two_factor_record_failure!(ctx, config, record)
  lockout = config[:account_lockout]
  return unless lockout && lockout[:enabled] != false

  updated = ctx.context.adapter.increment_one(
    model: TWO_FACTOR_MODEL,
    where: [{field: "id", value: record["id"]}],
    increment: {failedVerificationCount: 1},
    allow_server_managed: true
  )
  raise Error, "Failed to record two-factor verification failure" unless updated
  return if updated["failedVerificationCount"].to_i < lockout[:max_failed_attempts].to_i

  locked = ctx.context.adapter.update(
    model: TWO_FACTOR_MODEL,
    where: [{field: "id", value: record["id"]}],
    update: {lockedUntil: Time.now + lockout[:duration_seconds].to_i}
  )
  raise Error, "Failed to lock two-factor account" unless locked
end

.two_factor_request_body(operation_id) ⇒ Object



367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/better_auth/plugins/two_factor.rb', line 367

def two_factor_request_body(operation_id)
  schema = case operation_id
  when "enableTwoFactor"
    OpenAPI.object_schema({password: {type: "string"}, issuer: {type: "string"}})
  when "disableTwoFactor", "getTOTPURI", "generateBackupCodes"
    OpenAPI.object_schema({password: {type: "string"}})
  when "generateTOTP"
    OpenAPI.object_schema({secret: {type: "string"}}, required: ["secret"])
  when "verifyTOTP", "verifyTwoFactorOTP"
    OpenAPI.object_schema({code: {type: "string"}, trustDevice: {type: "boolean"}}, required: ["code"])
  when "verifyBackupCode"
    OpenAPI.object_schema({code: {type: "string"}, disableSession: {type: "boolean"}, trustDevice: {type: "boolean"}}, required: ["code"])
  when "sendTwoFactorOTP"
    OpenAPI.empty_request_body.dig(:content, "application/json", :schema)
  else
    {type: "object", properties: {}}
  end
  OpenAPI.json_request_body(schema)
end

.two_factor_reset_failures!(ctx, config, record) ⇒ Object

Raises:



620
621
622
623
624
625
626
627
628
629
630
# File 'lib/better_auth/plugins/two_factor.rb', line 620

def two_factor_reset_failures!(ctx, config, record)
  lockout = config[:account_lockout]
  return unless lockout && lockout[:enabled] != false

  reset = ctx.context.adapter.update(
    model: TWO_FACTOR_MODEL,
    where: [{field: "id", value: record["id"]}],
    update: {failedVerificationCount: 0, lockedUntil: nil}
  )
  raise Error, "Failed to reset two-factor verification failures" unless reset
end

.two_factor_schema(config = {}) ⇒ Object



417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'lib/better_auth/plugins/two_factor.rb', line 417

def two_factor_schema(config = {})
  custom_schema = config[:schema]
  base = {
    user: {
      fields: {
        twoFactorEnabled: {type: "boolean", required: false, default_value: false, returned: true}
      }
    },
    twoFactor: {
      fields: {
        secret: {type: "string", required: true, returned: false, index: true},
        backupCodes: {type: "string", required: true, returned: false},
        userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}},
        verified: {type: "boolean", required: false, default_value: true, input: false},
        failedVerificationCount: {type: "number", required: false, default_value: 0, input: false, returned: false},
        lockedUntil: {type: "date", required: false, input: false, returned: false}
      }
    }
  }
  if config[:two_factor_table] && config[:two_factor_table] != TWO_FACTOR_MODEL
    base[:twoFactor][:model_name] = config[:two_factor_table].to_s
  end
  deep_merge_hashes(base, normalize_hash(custom_schema || {}))
end

.two_factor_send_otp_endpoint(config) ⇒ Object



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/better_auth/plugins/two_factor.rb', line 222

def two_factor_send_otp_endpoint(config)
  Endpoint.new(path: "/two-factor/send-otp", method: "POST", metadata: two_factor_openapi("sendTwoFactorOTP", "Send a two factor OTP", OpenAPI.status_response_schema)) do |ctx|
    otp_config = config[:otp_options]
    sender = otp_config[:send_otp]
    unless sender.respond_to?(:call)
      raise APIError.new("BAD_REQUEST", message: "otp isn't configured")
    end

    data = two_factor_verification_context(ctx, config)
    code = two_factor_random_digits((otp_config[:digits] || 6).to_i)
    stored = two_factor_store_otp_value(ctx, code, otp_config)
    ctx.context.internal_adapter.create_verification_value(
      identifier: "2fa-otp-#{data[:key]}",
      value: "#{stored}:0",
      expiresAt: Time.now + ((otp_config[:period] || 3).to_i * 60)
    )
    sender.call({user: data[:session][:user], otp: code}, ctx)
    ctx.json({status: true})
  end
end

.two_factor_set_trusted_device(ctx, config, user_id) ⇒ Object



546
547
548
549
550
551
552
553
# File 'lib/better_auth/plugins/two_factor.rb', line 546

def two_factor_set_trusted_device(ctx, config, user_id)
  max_age = config[:trust_device_max_age].to_i
  identifier = "trust-device-#{Crypto.random_string(32)}"
  token = Crypto.hmac_signature("#{user_id}!#{identifier}", ctx.context.secret, encoding: :base64url)
  ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: user_id, expiresAt: Time.now + max_age)
  cookie = ctx.context.create_auth_cookie(TRUST_DEVICE_COOKIE_NAME, max_age: max_age)
  ctx.set_signed_cookie(cookie.name, "#{token}!#{identifier}", ctx.context.secret, cookie.attributes)
end

.two_factor_store_backup_codes(secret, codes, options) ⇒ Object



705
706
707
708
709
710
711
712
713
714
715
# File 'lib/better_auth/plugins/two_factor.rb', line 705

def two_factor_store_backup_codes(secret, codes, options)
  data = JSON.generate(codes)
  storage = options[:store_backup_codes]
  if storage == "encrypted"
    Crypto.symmetric_encrypt(key: secret, data: data)
  elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call)
    storage[:encrypt].call(data)
  else
    data
  end
end

.two_factor_store_otp_value(ctx, code, options) ⇒ Object



733
734
735
736
737
738
739
740
741
742
743
744
745
746
# File 'lib/better_auth/plugins/two_factor.rb', line 733

def two_factor_store_otp_value(ctx, code, options)
  storage = options[:store_otp]
  if storage == "hashed"
    Crypto.sha256(code, encoding: :base64url)
  elsif storage == "encrypted"
    Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: code)
  elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
    storage[:hash].call(code)
  elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call)
    storage[:encrypt].call(code)
  else
    code
  end
end

.two_factor_totp(secret, options: {}) ⇒ Object



666
667
668
669
# File 'lib/better_auth/plugins/two_factor.rb', line 666

def two_factor_totp(secret, options: {})
  interval = Time.now.to_i / (options[:period] || 30).to_i
  two_factor_totp_at(secret, interval, digits: (options[:digits] || 6).to_i)
end

.two_factor_totp_at(secret, counter, digits:) ⇒ Object



677
678
679
680
681
682
683
# File 'lib/better_auth/plugins/two_factor.rb', line 677

def two_factor_totp_at(secret, counter, digits:)
  key = base32_decode(secret)
  digest = OpenSSL::HMAC.digest("SHA1", key, [counter].pack("Q>"))
  offset = digest.bytes.last & 0x0f
  binary = digest.byteslice(offset, 4).unpack1("N") & 0x7fffffff
  (binary % (10**digits)).to_s.rjust(digits, "0")
end

.two_factor_totp_enabled!(config) ⇒ Object



655
656
657
658
659
# File 'lib/better_auth/plugins/two_factor.rb', line 655

def two_factor_totp_enabled!(config)
  if config[:totp_options][:disable]
    raise APIError.new("BAD_REQUEST", message: "totp isn't configured")
  end
end

.two_factor_totp_uri(secret, issuer:, account:, options: {}) ⇒ Object



685
686
687
688
689
# File 'lib/better_auth/plugins/two_factor.rb', line 685

def two_factor_totp_uri(secret, issuer:, account:, options: {})
  label = "#{issuer}:#{}"
  params = {secret: secret, issuer: issuer, digits: options[:digits] || 6, period: options[:period] || 30}
  "otpauth://totp/#{URI.encode_www_form_component(label)}?#{URI.encode_www_form(params)}"
end

.two_factor_totp_valid?(secret, code, options: {}) ⇒ Boolean

Returns:

  • (Boolean)


671
672
673
674
675
# File 'lib/better_auth/plugins/two_factor.rb', line 671

def two_factor_totp_valid?(secret, code, options: {})
  period = (options[:period] || 30).to_i
  interval = Time.now.to_i / period
  (-1..1).any? { |offset| Crypto.constant_time_compare(two_factor_totp_at(secret, interval + offset, digits: (options[:digits] || 6).to_i), code.to_s) }
end

.two_factor_trusted_device_valid?(ctx, config, user_id) ⇒ Boolean

Returns:

  • (Boolean)


555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
# File 'lib/better_auth/plugins/two_factor.rb', line 555

def two_factor_trusted_device_valid?(ctx, config, user_id)
  cookie = ctx.context.create_auth_cookie(TRUST_DEVICE_COOKIE_NAME, max_age: config[:trust_device_max_age])
  value = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
  return false unless value

  token, identifier = value.split("!", 2)
  expected = Crypto.hmac_signature("#{user_id}!#{identifier}", ctx.context.secret, encoding: :base64url)
  verification = if token && identifier && Crypto.constant_time_compare(token, expected)
    ctx.context.internal_adapter.consume_verification_value(identifier)
  end
  if verification && verification["value"] == user_id
    two_factor_set_trusted_device(ctx, config, user_id)
    true
  else
    Cookies.expire_cookie(ctx, cookie)
    false
  end
end

.two_factor_verification_context(ctx, config) ⇒ Object

Raises:



467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# File 'lib/better_auth/plugins/two_factor.rb', line 467

def two_factor_verification_context(ctx, config)
  session = Routes.current_session(ctx, allow_nil: true)
  if session
    key = "#{session[:user]["id"]}!#{session[:session]["id"]}"
    no_op_attempt = ->(_allowed_attempts) { {record_failure: -> {}, restore: -> {}} }
    return {
      session: session,
      key: key,
      valid: -> { ctx.json({token: session[:session]["token"], user: Schema.parse_output(ctx.context.options, "user", session[:user])}) },
      begin_attempt: no_op_attempt
    }
  end

  cookie = ctx.context.create_auth_cookie(TWO_FACTOR_COOKIE_NAME)
  identifier = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
  raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless identifier

  verification = ctx.context.internal_adapter.find_verification_value(identifier)
  raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless verification && verification["expiresAt"] > Time.now

  user = ctx.context.internal_adapter.find_user_by_id(verification["value"])
  raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless user

  valid = lambda do
    consumed = ctx.context.internal_adapter.consume_verification_value(identifier)
    unless consumed && consumed["value"] == user["id"]
      raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"])
    end

    dont_remember_me = Cookies.dont_remember?(ctx)
    new_session = ctx.context.internal_adapter.create_session(user["id"], dont_remember_me)
    raise APIError.new("INTERNAL_SERVER_ERROR", message: "failed to create session") unless new_session

    Cookies.set_session_cookie(ctx, {session: new_session, user: user}, dont_remember_me)
    Cookies.expire_cookie(ctx, cookie)
    if normalize_hash(ctx.body)[:trust_device]
      two_factor_set_trusted_device(ctx, config, user["id"])
      Cookies.expire_cookie(ctx, ctx.context.auth_cookies[:dont_remember])
    end
    ctx.json({token: new_session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
  end

  begin_attempt = lambda do |allowed_attempts|
    attempts_identifier = "2fa-attempts-#{identifier}"
    consumed = begin
      ctx.context.internal_adapter.consume_verification_value(attempts_identifier)
    rescue
      nil
    end
    raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless consumed

    raw_count = consumed["value"].to_s
    attempts = raw_count.match?(/\A\d+\z/) ? raw_count.to_i : allowed_attempts
    if attempts >= allowed_attempts
      begin
        ctx.context.internal_adapter.consume_verification_value(identifier)
      rescue
        nil
      end
      Cookies.expire_cookie(ctx, cookie)
      raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE"])
    end

    rearm = lambda do |count|
      created = ctx.context.internal_adapter.create_verification_value(
        identifier: attempts_identifier,
        value: count.to_s,
        expiresAt: verification["expiresAt"]
      )
      raise Error, "Failed to re-arm two-factor attempt counter" unless created

      created
    end
    {record_failure: -> { rearm.call(attempts + 1) }, restore: -> { rearm.call(attempts) }}
  end

  {session: {session: nil, user: user}, key: identifier, valid: valid, begin_attempt: begin_attempt}
end

.two_factor_verification_response_schemaObject



397
398
399
400
401
402
403
404
405
# File 'lib/better_auth/plugins/two_factor.rb', line 397

def two_factor_verification_response_schema
  OpenAPI.object_schema(
    {
      token: {type: ["string", "null"]},
      user: {type: ["object", "null"], "$ref": "#/components/schemas/User"},
      status: {type: ["boolean", "null"]}
    }
  )
end

.two_factor_verify_backup_code_endpoint(config) ⇒ Object



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/better_auth/plugins/two_factor.rb', line 288

def two_factor_verify_backup_code_endpoint(config)
  Endpoint.new(path: "/two-factor/verify-backup-code", method: "POST", metadata: two_factor_openapi("verifyBackupCode", "Verify a two factor backup code", two_factor_verification_response_schema)) do |ctx|
    body = normalize_hash(ctx.body)
    data = two_factor_verification_context(ctx, config)
    record = two_factor_record(ctx, config, data[:session][:user]["id"])
    raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record

     = data[:session][:session].nil?
    two_factor_assert_not_locked!(ctx, config, record) if 
    attempt =  ? data[:begin_attempt].call(DEFAULT_TWO_FACTOR_ALLOWED_ATTEMPTS) : nil
    codes = begin
      two_factor_read_backup_codes(ctx.context.secret_config, record["backupCodes"], config[:backup_code_options])
    rescue
      attempt&.fetch(:restore)&.call
      raise
    end
    unless codes.include?(body[:code].to_s)
      attempt&.fetch(:record_failure)&.call
      two_factor_record_failure!(ctx, config, record) if 
      raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_BACKUP_CODE"])
    end

    remaining = codes.reject { |code| code == body[:code].to_s }
    stored = begin
      two_factor_store_backup_codes(ctx.context.secret_config, remaining, config[:backup_code_options])
    rescue
      attempt&.fetch(:restore)&.call
      raise
    end
    updated = ctx.context.adapter.update(
      model: TWO_FACTOR_MODEL,
      where: [{field: "id", value: record["id"]}, {field: "backupCodes", value: record["backupCodes"]}],
      update: {backupCodes: stored}
    )
    raise APIError.new("CONFLICT", message: "Failed to verify backup code. Please try again.") unless updated

    two_factor_reset_failures!(ctx, config, record) if 
    body[:disable_session] ? ctx.json({token: data[:session][:session]&.fetch("token", nil), user: Schema.parse_output(ctx.context.options, "user", data[:session][:user])}) : data[:valid].call
  end
end

.two_factor_verify_otp_endpoint(config) ⇒ Object



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/better_auth/plugins/two_factor.rb', line 243

def two_factor_verify_otp_endpoint(config)
  Endpoint.new(path: "/two-factor/verify-otp", method: "POST", metadata: two_factor_openapi("verifyTwoFactorOTP", "Verify a two factor OTP", two_factor_verification_response_schema)) do |ctx|
    body = normalize_hash(ctx.body)
    data = two_factor_verification_context(ctx, config)
     = data[:session][:session].nil?
    record = nil
    if 
      record = two_factor_record(ctx, config, data[:session][:user]["id"])
      raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless record

      two_factor_assert_not_locked!(ctx, config, record)
    end
    identifier = "2fa-otp-#{data[:key]}"
    verification = ctx.context.internal_adapter.consume_verification_value(identifier)
    stored, counter = verification&.fetch("value", nil).to_s.split(":", 2)
    unless verification
      raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["OTP_HAS_EXPIRED"])
    end

    allowed = (config[:otp_options][:allowed_attempts] || 5).to_i
    if counter.to_i >= allowed
      raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE"])
    end

    unless two_factor_otp_matches?(ctx, stored, body[:code].to_s, config[:otp_options])
      rearmed = ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: "#{stored}:#{counter.to_i + 1}", expiresAt: verification["expiresAt"])
      raise Error, "Failed to re-arm two-factor OTP attempt counter" unless rearmed

      two_factor_record_failure!(ctx, config, record) if 
      raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"])
    end
    two_factor_reset_failures!(ctx, config, record) if 

    if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
      updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
      new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
      ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
      Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
      next ctx.json({token: new_session["token"], user: Schema.parse_output(ctx.context.options, "user", updated_user)})
    end

    data[:valid].call
  end
end

.two_factor_verify_totp_endpoint(config) ⇒ Object



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/better_auth/plugins/two_factor.rb', line 176

def two_factor_verify_totp_endpoint(config)
  Endpoint.new(path: "/two-factor/verify-totp", method: "POST", metadata: two_factor_openapi("verifyTOTP", "Verify a TOTP code", two_factor_verification_response_schema)) do |ctx|
    two_factor_totp_enabled!(config)
    body = normalize_hash(ctx.body)
    data = two_factor_verification_context(ctx, config)
    record = two_factor_record(ctx, config, data[:session][:user]["id"])
    raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record
    if !data[:session][:session] && record["verified"] == false
      raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"])
    end

     = data[:session][:session].nil?
    two_factor_assert_not_locked!(ctx, config, record) if 
    attempt =  ? data[:begin_attempt].call(DEFAULT_TWO_FACTOR_ALLOWED_ATTEMPTS) : nil
    valid_code = begin
      secret = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: record["secret"])
      two_factor_totp_valid?(secret, body[:code], options: config[:totp_options])
    rescue
      attempt&.fetch(:restore)&.call
      raise
    end
    unless valid_code
      attempt&.fetch(:record_failure)&.call
      two_factor_record_failure!(ctx, config, record) if 
      raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"])
    end
    two_factor_reset_failures!(ctx, config, record) if 

    if record["verified"] != true
      if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
        updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
        new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
        ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
        Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
      end
      ctx.context.adapter.update(model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {verified: true})
    elsif !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
      updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
      new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
      ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
      Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
    end
    data[:valid].call
  end
end

.two_factor_view_backup_codes_endpoint(config) ⇒ Object



344
345
346
347
348
349
350
351
352
# File 'lib/better_auth/plugins/two_factor.rb', line 344

def two_factor_view_backup_codes_endpoint(config)
  Endpoint.new(method: "POST") do |ctx|
    body = normalize_hash(ctx.body)
    record = two_factor_record(ctx, config, body[:user_id])
    raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record

    ctx.json({status: true, backupCodes: two_factor_read_backup_codes(ctx.context.secret_config, record["backupCodes"], config[:backup_code_options])})
  end
end

.username(options = {}) ⇒ Object



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/better_auth/plugins/username.rb', line 20

def username(options = {})
  config = normalize_hash(options)

  Plugin.new(
    id: "username",
    init: ->(_context) { {options: {database_hooks: username_database_hooks(config)}} },
    endpoints: {
      sign_in_username: (config),
      is_username_available: is_username_available_endpoint(config)
    },
    schema: username_schema(config),
    hooks: {
      before: [
        {
          matcher: ->(ctx) { username_mutation_path?(ctx.path) },
          handler: ->(ctx) { validate_username_mutation!(ctx, config) }
        },
        {
          matcher: ->(ctx) { username_mutation_path?(ctx.path) },
          handler: ->(ctx) { mirror_username_fields!(ctx) }
        }
      ]
    },
    error_codes: USERNAME_ERROR_CODES,
    options: config
  )
end

.username_database_hooks(config) ⇒ Object



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/better_auth/plugins/username.rb', line 215

def username_database_hooks(config)
  before_hook = lambda do |user, endpoint_context|
    data = user.dup
    raw_username = data["username"]
    raw_display_username = data["displayUsername"]
    if raw_username.is_a?(String) && !raw_username.empty?
      unless endpoint_context && username_mutation_path?(endpoint_context.path)
        username = username_for_validation(raw_username, config)
        validate_username!(username, config, status: "BAD_REQUEST")
        adapter = endpoint_context&.context&.adapter
        if adapter
          existing = adapter.find_one(model: "user", where: [{field: "username", value: normalize_username(username, config)}])
          current_user_id = endpoint_context&.context&.current_session&.dig(:user, "id")
          if existing && existing["id"] != current_user_id
            raise APIError.new("BAD_REQUEST", code: "USERNAME_IS_ALREADY_TAKEN", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
          end
        end
      end
      data["username"] = normalize_username(raw_username, config)
      data["displayUsername"] = raw_username unless raw_display_username.is_a?(String) && !raw_display_username.empty?
    end
    if data["displayUsername"].is_a?(String) && !data["displayUsername"].empty?
      display_username = if validation_order(config, :display_username) == "post-normalization"
        normalize_display_username(data["displayUsername"], config)
      else
        data["displayUsername"]
      end
      validator = config[:display_username_validator]
      unless !validator.respond_to?(:call) || validator.call(display_username)
        raise APIError.new("BAD_REQUEST", code: "INVALID_DISPLAY_USERNAME", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"])
      end
      data["displayUsername"] = normalize_display_username(data["displayUsername"], config)
    end
    {data: data}
  end

  {
    user: {
      create: {before: before_hook},
      update: {before: before_hook}
    }
  }
end

.username_for_sign_in_validation(username, config) ⇒ Object



334
335
336
# File 'lib/better_auth/plugins/username.rb', line 334

def (username, config)
  (validation_order(config, :username) == "pre-normalization") ? normalize_username(username, config) : username
end

.username_for_validation(username, config) ⇒ Object



330
331
332
# File 'lib/better_auth/plugins/username.rb', line 330

def username_for_validation(username, config)
  (validation_order(config, :username) == "post-normalization") ? normalize_username(username, config) : username
end

.username_mutation_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


356
357
358
# File 'lib/better_auth/plugins/username.rb', line 356

def username_mutation_path?(path)
  path == "/sign-up/email" || path == "/update-user"
end

.username_schema(config) ⇒ Object



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/better_auth/plugins/username.rb', line 193

def username_schema(config)
  {
    user: {
      fields: {
        username: {
          type: "string",
          required: false,
          sortable: true,
          unique: true,
          returned: true,
          field_name: "username"
        },
        displayUsername: {
          type: "string",
          required: false,
          field_name: "display_username"
        }
      }
    }
  }
end

.valid_signed_token?(ctx, signed_token) ⇒ Boolean

Returns:

  • (Boolean)


64
65
66
67
68
69
70
71
# File 'lib/better_auth/plugins/bearer.rb', line 64

def valid_signed_token?(ctx, signed_token)
  payload, signature = signed_token.rpartition(".").values_at(0, 2)
  return false if payload.empty? || signature.empty?

  Crypto.verify_hmac_signature(payload, signature, ctx.context.secret, encoding: :base64url)
rescue
  false
end

.validate_device_authorization_options!(config) ⇒ Object

Raises:



415
416
417
418
419
420
421
422
423
424
425
# File 'lib/better_auth/plugins/device_authorization.rb', line 415

def validate_device_authorization_options!(config)
  duration_seconds(config[:expires_in])
  duration_seconds(config[:interval])
  raise Error, "device_code_length must be a positive integer" unless positive_integer?(config[:device_code_length])
  raise Error, "user_code_length must be a positive integer" unless positive_integer?(config[:user_code_length])
  raise Error, "generate_device_code must be callable" if config.key?(:generate_device_code) && !config[:generate_device_code].respond_to?(:call)
  raise Error, "generate_user_code must be callable" if config.key?(:generate_user_code) && !config[:generate_user_code].respond_to?(:call)
  raise Error, "validate_client must be callable" if config.key?(:validate_client) && !config[:validate_client].respond_to?(:call)
  raise Error, "on_device_auth_request must be callable" if config.key?(:on_device_auth_request) && !config[:on_device_auth_request].respond_to?(:call)
  raise Error, "verification_uri must be a string" if config.key?(:verification_uri) && !config[:verification_uri].is_a?(String)
end

.validate_email_otp_email!(email) ⇒ Object

Raises:



718
719
720
# File 'lib/better_auth/plugins/email_otp.rb', line 718

def validate_email_otp_email!(email)
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email)
end

.validate_email_otp_type!(type) ⇒ Object

Raises:



722
723
724
725
726
# File 'lib/better_auth/plugins/email_otp.rb', line 722

def validate_email_otp_type!(type)
  return if %w[email-verification sign-in forget-password change-email].include?(type)

  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"])
end

.validate_i18n_translations!(config) ⇒ Object



75
76
77
78
79
80
# File 'lib/better_auth/plugins/i18n.rb', line 75

def validate_i18n_translations!(config)
  translations = config[:translations]
  if translations.nil? || translations.empty?
    raise BetterAuth::Error, "i18n plugin: translations object is empty"
  end
end

.validate_invitation_team_ids!(ctx, config, organization_id, raw_value, team_ids) ⇒ Object



1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
# File 'lib/better_auth/plugins/organization.rb', line 1481

def validate_invitation_team_ids!(ctx, config, organization_id, raw_value, team_ids)
  return if raw_value.nil?
  unless org_truthy?(config.dig(:teams, :enabled))
    raise APIError.new("BAD_REQUEST", message: "Teams are not enabled")
  end
  if Array(raw_value).any? { |team_id| team_id.to_s.include?(",") }
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVALID_TEAM_ID"))
  end
  team_ids.each do |team_id|
    team = team_by_id(ctx, team_id)
    unless team && team["organizationId"] == organization_id
      raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND"))
    end
  end
end

.validate_jwt_options!(config) ⇒ Object



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/better_auth/plugins/jwt.rb', line 92

def validate_jwt_options!(config)
  alg = config.dig(:jwks, :key_pair_config, :alg)
  if alg && !JWT::SUPPORTED_ALGORITHMS.include?(alg.to_s)
    raise Error, "JWT/JWKS algorithm #{alg} is not supported by the Ruby server. Supported algorithms: #{JWT::SUPPORTED_ALGORITHMS.join(", ")}"
  end

  if config.dig(:jwt, :sign) && !config.dig(:jwks, :remote_url)
    raise Error, "options.jwks.remoteUrl must be set when using options.jwt.sign"
  end

  if config.dig(:jwks, :remote_url) && !config.dig(:jwks, :key_pair_config, :alg)
    raise Error, "options.jwks.keyPairConfig.alg must be specified when using the oidc plugin with options.jwks.remoteUrl"
  end

  path = config.dig(:jwks, :jwks_path)
  if path && (!path.is_a?(String) || path.empty? || !path.start_with?("/") || path.include?(".."))
    raise Error, "options.jwks.jwksPath must be a non-empty string starting with '/' and not contain '.."
  end
end

Raises:



224
225
226
227
228
229
# File 'lib/better_auth/plugins/magic_link.rb', line 224

def validate_magic_link_callback!(ctx, value, label)
  return if value.nil? || value.to_s.empty?
  return if ctx.context.trusted_origin?(value.to_s, allow_relative_paths: true)

  raise APIError.new("FORBIDDEN", message: "Invalid #{label}")
end

.validate_organization_roles!(ctx, config, organization_id, role_names, adapter: ctx.context.adapter) ⇒ Object

Raises:



1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
# File 'lib/better_auth/plugins/organization.rb', line 1110

def validate_organization_roles!(ctx, config, organization_id, role_names, adapter: ctx.context.adapter)
  valid_static_roles = (organization_default_roles(config).keys + organization_roles(config).keys).uniq
  unknown_roles = role_names.reject { |role| valid_static_roles.include?(role) }.uniq
  return if unknown_roles.empty?

  if org_truthy?(config.dig(:dynamic_access_control, :enabled))
    found_roles = adapter.find_many(
      model: "organizationRole",
      where: [{field: "organizationId", value: organization_id}, {field: "role", value: unknown_roles, operator: "in"}]
    ).map { |role| role["role"] }
    unknown_roles -= found_roles
  end
  return if unknown_roles.empty?

  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND"))
end

.validate_permission_resources!(config, permission) ⇒ Object

Raises:



1471
1472
1473
1474
1475
# File 'lib/better_auth/plugins/organization.rb', line 1471

def validate_permission_resources!(config, permission)
  valid = (config[:ac] || create_access_control(ORGANIZATION_DEFAULT_STATEMENTS)).statements.keys
  invalid = permission.keys - valid
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVALID_RESOURCE")) if invalid.any?
end

.validate_phone_number!(config, phone_number) ⇒ Object

Raises:



430
431
432
433
434
435
436
# File 'lib/better_auth/plugins/phone_number.rb', line 430

def validate_phone_number!(config, phone_number)
  validator = config[:phone_number_validator]
  return unless validator.respond_to?(:call)
  return if validator.call(phone_number)

  raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER"])
end

.validate_stored_invitation_teams!(adapter, organization_id, team_ids) ⇒ Object



1548
1549
1550
1551
1552
1553
# File 'lib/better_auth/plugins/organization.rb', line 1548

def validate_stored_invitation_teams!(adapter, organization_id, team_ids)
  team_ids.each do |team_id|
    team = adapter.find_one(model: "team", where: [{field: "id", value: team_id}, {field: "organizationId", value: organization_id}])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team
  end
end

.validate_unique_phone_number!(ctx, phone_number) ⇒ Object

Raises:



423
424
425
426
427
428
# File 'lib/better_auth/plugins/phone_number.rb', line 423

def validate_unique_phone_number!(ctx, phone_number)
  return if phone_number.to_s.empty?

  existing = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number.to_s}])
  raise APIError.new("UNPROCESSABLE_ENTITY", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_EXIST"]) if existing
end

.validate_username!(username, config, status:) ⇒ Object

Raises:



316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/better_auth/plugins/username.rb', line 316

def validate_username!(username, config, status:)
  if username.length < min_username_length(config)
    raise APIError.new(status, code: "USERNAME_TOO_SHORT", message: USERNAME_ERROR_CODES["USERNAME_TOO_SHORT"])
  end

  if username.length > max_username_length(config)
    raise APIError.new(status, code: "USERNAME_TOO_LONG", message: USERNAME_ERROR_CODES["USERNAME_TOO_LONG"])
  end

  validator = config[:username_validator]
  valid = validator.respond_to?(:call) ? validator.call(username) : default_username_valid?(username)
  raise APIError.new(status, code: "INVALID_USERNAME", message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) unless valid
end

.validate_username_mutation!(ctx, config) ⇒ Object



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/better_auth/plugins/username.rb', line 259

def validate_username_mutation!(ctx, config)
  body = normalize_hash(ctx.body)
  raw_username = body.key?(:username) ? body[:username] : nil
  if raw_username.nil? && present?(body[:display_username])
    begin
      validate_username!(username_for_validation(body[:display_username], config), config, status: "BAD_REQUEST")
      body[:username] = body[:display_username]
      raw_username = body[:username]
    rescue APIError
      # A display-only value that cannot be a username remains display-only.
    end
  end
  username = if raw_username.is_a?(String) && validation_order(config, :username) == "post-normalization"
    normalize_username(raw_username, config)
  else
    raw_username
  end

  if username.is_a?(String)
    validate_username!(username, config, status: "BAD_REQUEST")
    existing = ctx.context.adapter.find_one(model: "user", where: [{field: "username", value: normalize_username(username, config)}])
    current = (ctx.path == "/update-user") ? Routes.current_session(ctx, allow_nil: true) : nil
    same_user = existing && current && existing["id"] == current[:session]["userId"]

    if existing && ctx.path == "/sign-up/email"
      raise APIError.new("UNPROCESSABLE_ENTITY", code: "USERNAME_IS_ALREADY_TAKEN", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
    end

    if existing && ctx.path == "/update-user" && !same_user
      raise APIError.new("BAD_REQUEST", code: "USERNAME_IS_ALREADY_TAKEN", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
    end
  end

  raw_display_username = body.key?(:display_username) ? body[:display_username] : nil
  display_username = if raw_display_username.is_a?(String) && validation_order(config, :display_username) == "post-normalization"
    normalize_display_username(raw_display_username, config)
  else
    raw_display_username
  end

  if display_username.is_a?(String)
    validator = config[:display_username_validator]
    unless !validator.respond_to?(:call) || validator.call(display_username)
      raise APIError.new("BAD_REQUEST", code: "INVALID_DISPLAY_USERNAME", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"])
    end
  end
  ctx.body = body
  nil
end

.validation_order(config, field) ⇒ Object



351
352
353
354
# File 'lib/better_auth/plugins/username.rb', line 351

def validation_order(config, field)
  order = config[:validation_order] || {}
  order[field] || "pre-normalization"
end

.verification_jwks(ctx, config) ⇒ Object



284
285
286
287
288
289
# File 'lib/better_auth/plugins/jwt.rb', line 284

def verification_jwks(ctx, config)
  local = all_jwks(ctx, config)
  return local unless config.dig(:jwks, :remote_url)

  local + remote_jwks(ctx, config)
end

.verification_uri(ctx, config) ⇒ Object



392
393
394
395
396
397
# File 'lib/better_auth/plugins/device_authorization.rb', line 392

def verification_uri(ctx, config)
  uri = config[:verification_uri] || "/device"
  return uri if uri.to_s.start_with?("http://", "https://")

  "#{OAuthProtocol.endpoint_base(ctx)}#{uri.to_s.start_with?("/") ? uri : "/#{uri}"}"
end

.verified_multi_session_tokens(ctx) ⇒ Object



199
200
201
# File 'lib/better_auth/plugins/multi_session.rb', line 199

def verified_multi_session_tokens(ctx)
  multi_cookie_names(ctx).filter_map { |name| ctx.get_signed_cookie(name, ctx.context.secret) }
end

.verify_eddsa_jwt(ctx, token, key, config) ⇒ Object



457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# File 'lib/better_auth/plugins/jwt.rb', line 457

def verify_eddsa_jwt(ctx, token, key, config)
  header_segment, payload_segment, signature_segment = token.split(".", 3)
  return nil unless header_segment && payload_segment && signature_segment

  public_key = JWT.public_key(key)
  signing_input = "#{header_segment}.#{payload_segment}"
  signature = Crypto.base64url_decode(signature_segment)
  return nil unless public_key.verify(nil, signature, signing_input)

  payload = JSON.parse(Crypto.base64url_decode(payload_segment))
  now = Time.now.to_i
  return nil if payload["exp"] && payload["exp"].to_i <= now
  issuer = config.dig(:jwt, :issuer) || ctx.context.canonical_base_url
  audience = config.dig(:jwt, :audience) || ctx.context.canonical_base_url
  return nil if issuer && payload["iss"] != issuer
  return nil if audience && Array(payload["aud"]).map(&:to_s).none?(audience.to_s)
  return nil unless jwt_payload_valid?(payload)

  payload
rescue JSON::ParserError, OpenSSL::PKey::PKeyError, ArgumentError
  nil
end

.verify_email_otp_endpoint(config) ⇒ Object



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/better_auth/plugins/email_otp.rb', line 221

def verify_email_otp_endpoint(config)
  Endpoint.new(
    path: "/email-otp/verify-email",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "verifyEmailOTP",
        description: "Verify an email address with an OTP",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              email: {type: "string"},
              otp: {type: "string"}
            },
            required: ["email", "otp"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "Email verified",
            OpenAPI.object_schema(
              {
                status: {type: "boolean"},
                token: {type: ["string", "null"]},
                user: {type: "object", "$ref": "#/components/schemas/User"}
              },
              required: ["status", "user"]
            )
          )
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    email = body[:email].to_s.downcase
    otp = body[:otp].to_s
    validate_email_otp_email!(email)

    email_otp_verify!(ctx, config, email: email, type: "email-verification", otp: otp)
    found = ctx.context.internal_adapter.find_user_by_email(email)
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless found

    user = found[:user]
    call_email_verification_option(ctx, :before_email_verification, user)
    updated = ctx.context.internal_adapter.update_user(user["id"], email: email, emailVerified: true)
    call_email_verification_option(ctx, :on_email_verification, updated)
    call_email_verification_option(ctx, :after_email_verification, updated)

    if ctx.context.options.email_verification[:auto_sign_in_after_verification]
      session = ctx.context.internal_adapter.create_session(updated["id"])
      Cookies.set_session_cookie(ctx, {session: session, user: updated})
      next ctx.json({status: true, token: session["token"], user: Schema.parse_output(ctx.context.options, "user", updated)})
    end

    current = Routes.current_session(ctx, allow_nil: true)
    Cookies.set_session_cookie(ctx, {session: current[:session], user: updated}) if current
    ctx.json({status: true, token: nil, user: Schema.parse_output(ctx.context.options, "user", updated)})
  end
end

.verify_jwt_endpoint(config) ⇒ Object



171
172
173
174
175
176
177
178
# File 'lib/better_auth/plugins/jwt.rb', line 171

def verify_jwt_endpoint(config)
  Endpoint.new(path: nil, method: "POST") do |ctx|
    token = fetch_value(ctx.body, "token")
    issuer = fetch_value(ctx.body, "issuer")
    verify_options = issuer ? deep_merge(config, jwt: {issuer: issuer}) : config
    ctx.json({payload: verify_jwt_token(ctx, token, verify_options)})
  end
end

.verify_jwt_token(ctx, token, config) ⇒ Object



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/better_auth/plugins/jwt.rb', line 228

def verify_jwt_token(ctx, token, config)
  header = ::JWT.decode(token.to_s, nil, false).last
  key = verification_jwks(ctx, config).find { |entry| entry["id"] == header["kid"] || entry["kid"] == header["kid"] }
  return nil unless key
  return verify_eddsa_jwt(ctx, token.to_s, key, config) if (key["alg"] || header["alg"]) == "EdDSA"

  options = {
    algorithm: key["alg"] || "RS256",
    iss: config.dig(:jwt, :issuer) || ctx.context.canonical_base_url,
    verify_iss: true,
    aud: config.dig(:jwt, :audience) || ctx.context.canonical_base_url,
    verify_aud: true
  }
  decoded, = ::JWT.decode(token.to_s, JWT.public_key(key), true, options)
  jwt_payload_valid?(decoded) ? decoded : nil
rescue ::JWT::DecodeError, OpenSSL::PKey::PKeyError
  nil
end

.verify_one_time_token_endpoint(config) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/better_auth/plugins/one_time_token.rb', line 63

def verify_one_time_token_endpoint(config)
  Endpoint.new(
    path: "/one-time-token/verify",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "verifyOneTimeToken",
        description: "Verify a one-time token and restore its session",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              token: {type: "string"}
            },
            required: ["token"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response("Session restored", OpenAPI.session_response_schema_pair)
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    token = body[:token].to_s
    stored_token = one_time_token_stored_value(config, token)
    verification = ctx.context.internal_adapter.consume_verification_value("one-time-token:#{stored_token}")
    raise APIError.new("BAD_REQUEST", message: "Invalid token") unless verification

    session = ctx.context.internal_adapter.find_session(verification["value"])
    raise APIError.new("BAD_REQUEST", message: "Session not found") unless session
    raise APIError.new("BAD_REQUEST", message: "Session expired") if Routes.expired_time?(session[:session]["expiresAt"])

    Cookies.set_session_cookie(ctx, session) unless config[:disable_set_session_cookie]
    ctx.json(session)
  end
end

.verify_phone_number_endpoint(config) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/better_auth/plugins/phone_number.rb', line 179

def verify_phone_number_endpoint(config)
  Endpoint.new(
    path: "/phone-number/verify",
    method: "POST",
    metadata: {
      openapi: {
        operationId: "verifyPhoneNumber",
        description: "Verify a phone number OTP",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              phoneNumber: {type: "string"},
              code: {type: "string"},
              updatePhoneNumber: {type: ["boolean", "null"]},
              disableSession: {type: ["boolean", "null"]}
            },
            required: ["phoneNumber", "code"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "Phone number verified",
            OpenAPI.object_schema(
              {
                status: {type: "boolean"},
                token: {type: ["string", "null"]},
                user: {type: "object", "$ref": "#/components/schemas/User"}
              },
              required: ["status", "user"]
            )
          )
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    phone_number = body[:phone_number].to_s
    code = body[:code].to_s
    phone_number_verify_code!(ctx, config, phone_number, code)

    if truthy?(body[:update_phone_number])
      session = Routes.current_session(ctx)
      existing = ctx.context.adapter.find_many(model: "user", where: [{field: "phoneNumber", value: phone_number}])
      unless existing.empty?
        raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_EXIST"])
      end

      updated = ctx.context.internal_adapter.update_user(
        session[:user]["id"],
        phoneNumber: phone_number,
        phoneNumberVerified: true
      )
      next ctx.json({status: true, token: session[:session]["token"], user: Schema.parse_output(ctx.context.options, "user", updated)})
    end

    user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
    user = if user
      ctx.context.internal_adapter.update_user(user["id"], phoneNumberVerified: true)
    elsif config[:sign_up_on_verification]
      phone_number_create_user(ctx, config, body, phone_number)
    end
    raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_UPDATE_USER"]) unless user

    callback = config[:callback_on_verification]
    callback.call({phone_number: phone_number, user: user}, ctx) if callback.respond_to?(:call)

    if truthy?(body[:disable_session])
      next ctx.json({status: true, token: nil, user: Schema.parse_output(ctx.context.options, "user", user)})
    end

    session = ctx.context.internal_adapter.create_session(user["id"])
    raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session

    Cookies.set_session_cookie(ctx, {session: session, user: user})
    ctx.json({status: true, token: session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
  end
end

.verify_siwe_message_endpoint(config) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/better_auth/plugins/siwe.rb', line 78

def verify_siwe_message_endpoint(config)
  Endpoint.new(
    path: "/siwe/verify",
    method: "POST",
    body_schema: ->(body) { siwe_verify_body(body, config) },
    metadata: {
      openapi: {
        operationId: "verifySiweMessage",
        description: "Verify a Sign-In with Ethereum message",
        requestBody: OpenAPI.json_request_body(
          OpenAPI.object_schema(
            {
              walletAddress: {type: "string"},
              chainId: {type: ["number", "string", "null"]},
              message: {type: "string"},
              signature: {type: "string"},
              email: {type: ["string", "null"]}
            },
            required: ["walletAddress", "message", "signature"]
          )
        ),
        responses: {
          "200" => OpenAPI.json_response(
            "SIWE message verified",
            OpenAPI.object_schema(
              {
                token: {type: "string"},
                success: {type: "boolean"},
                user: {type: "object"}
              },
              required: ["token", "success", "user"]
            )
          )
        }
      }
    }
  ) do |ctx|
    body = normalize_hash(ctx.body)
    wallet_address = siwe_normalize_wallet!(body[:wallet_address])
    chain_id = siwe_chain_id(body[:chain_id])
    email = body[:email].to_s.downcase
    anonymous = config.key?(:anonymous) ? config[:anonymous] : true
    raise APIError.new("BAD_REQUEST", message: "Email is required when anonymous is disabled.") if anonymous == false && email.empty?
    raise APIError.new("BAD_REQUEST", message: "Invalid email address") if !email.empty? && !SIWE_EMAIL_PATTERN.match?(email)

    verification = ctx.context.internal_adapter.consume_verification_value(siwe_identifier(wallet_address, chain_id))
    unless verification
      raise APIError.new("UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE", message: "Unauthorized: Invalid or expired nonce")
    end

    siwe_validate_message!(body[:message], config[:domain], wallet_address, chain_id, verification["value"])

    verified = siwe_verify_message(config, body, wallet_address, chain_id, verification["value"], ctx)
    raise APIError.new("UNAUTHORIZED", message: "Unauthorized: Invalid SIWE signature") unless verified

    user = siwe_find_user(ctx, wallet_address, chain_id)
    user ||= siwe_create_user(ctx, config, wallet_address, chain_id, email, anonymous)
    (ctx, user, wallet_address, chain_id)
    session = ctx.context.internal_adapter.create_session(user["id"])
    session_data = {session: session, user: user}
    Cookies.set_session_cookie(ctx, session_data)

    ctx.json({
      token: session["token"],
      success: true,
      user: {
        id: user["id"],
        walletAddress: wallet_address,
        chainId: chain_id
      }
    })
  rescue APIError
    raise
  rescue
    raise APIError.new("UNAUTHORIZED", message: "Something went wrong. Please try again later.")
  end
end

.yandex(options = {}) ⇒ Object



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/better_auth/plugins/generic_oauth.rb', line 215

def yandex(options = {})
  data = normalize_hash(options)
  generic_oauth_provider_config(
    data,
    provider_id: "yandex",
    authorization_url: "https://oauth.yandex.com/authorize",
    token_url: "https://oauth.yandex.com/token",
    scopes: ["login:info", "login:email", "login:avatar"],
    get_user_info: ->(tokens) {
      profile = generic_oauth_fetch_json(
        "https://login.yandex.ru/info?format=json",
        authorization: "OAuth #{fetch_value(tokens, "accessToken")}"
      )
      return nil unless profile

      avatar_id = fetch_value(profile, "default_avatar_id")
      image = if !fetch_value(profile, "is_avatar_empty") && !avatar_id.to_s.empty?
        "https://avatars.yandex.net/get-yapic/#{avatar_id}/islands-200"
      end
      {
        id: fetch_value(profile, "id"),
        name: fetch_value(profile, "display_name") || fetch_value(profile, "real_name") || fetch_value(profile, "first_name") || fetch_value(profile, "login"),
        email: fetch_value(profile, "default_email") || Array(fetch_value(profile, "emails")).first,
        emailVerified: false,
        image: image
      }.compact
    }
  )
end