Module: BetterAuth::Plugins

Defined in:
lib/better_auth/plugins.rb,
lib/better_auth/plugins/jwt.rb,
lib/better_auth/plugins/mcp.rb,
lib/better_auth/plugins/sso.rb,
lib/better_auth/plugins/expo.rb,
lib/better_auth/plugins/scim.rb,
lib/better_auth/plugins/siwe.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_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/oidc_provider.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, MCP, OAuthProtocol, OIDCProvider, OrganizationSchema Classes: AccessControl, Role

Constant Summary collapse

SIWE_WALLET_PATTERN =
/\A0[xX][a-fA-F0-9]{40}\z/
SIWE_EMAIL_PATTERN =
/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
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"
}.freeze
ADMIN_DEFAULT_STATEMENTS =
{
  user: ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "get", "update"],
  session: ["list", "revoke", "delete"]
}.freeze
ADMIN_DEFAULT_ROLE_STATEMENTS =
{
  user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "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_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.",
  "INVALID_TWO_FACTOR_COOKIE" => "Invalid two factor cookie"
}.freeze
"two_factor"
"trust_device"
30 * 24 * 60 * 60
10 * 60
BASE32_ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
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",
  "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",
  "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",
  "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",
  "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

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



976
977
978
979
980
# File 'lib/better_auth/plugins/organization.rb', line 976

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



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/admin.rb', line 42

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: ->(ctx) { ctx.json(Array(ctx.returned).reject { |session| session["impersonatedBy"] || session[:impersonatedBy] }) }
        }
      ]
    },
    error_codes: ADMIN_ERROR_CODES,
    options: config
  )
end

.admin_ban_user_endpoint(config) ⇒ Object



238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/better_auth/plugins/admin.rb', line 238

def admin_ban_user_endpoint(config)
  Endpoint.new(path: "/admin/ban-user", method: "POST") 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", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless found
    raise APIError.new("BAD_REQUEST", 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_sessions(body[:user_id])
    ctx.json({user: Schema.parse_output(ctx.context.options, "user", user)})
  end
end

.admin_config(options) ⇒ Object

Raises:



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

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



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
# File 'lib/better_auth/plugins/admin.rb', line 163

def admin_create_user_endpoint(config)
  Endpoint.new(path: "/admin/create-user", method: "POST") do |ctx|
    session = Routes.current_session(ctx, allow_nil: true)
    if session
      unless admin_permission?(session[:user], session[:user]["role"], {user: ["create"]}, config)
        raise APIError.new("FORBIDDEN", 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", 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", 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 }
    user = ctx.context.internal_adapter.create_user(data.merge(
      name: body[:name].to_s,
      email: email,
      role: admin_validate_roles!(body[:role] || config[:default_role], config)
    ).merge(body.key?(:image) ? {image: body[:image]} : {}))
    raise APIError.new("INTERNAL_SERVER_ERROR", 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



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
# File 'lib/better_auth/plugins/admin.rb', line 102

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



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

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



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
# File 'lib/better_auth/plugins/admin.rb', line 473

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



149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/better_auth/plugins/admin.rb', line 149

def admin_get_user_endpoint(config)
  Endpoint.new(path: "/admin/get-user", method: "GET") 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", 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



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
# File 'lib/better_auth/plugins/admin.rb', line 357

def admin_has_permission_endpoint(config)
  Endpoint.new(path: "/admin/has-permission", method: "POST") do |ctx|
    session = Routes.current_session(ctx, allow_nil: 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



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
# File 'lib/better_auth/plugins/admin.rb', line 260

def admin_impersonate_user_endpoint(config)
  Endpoint.new(path: "/admin/impersonate-user", method: "POST") 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", message: "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", 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



230
231
232
233
234
235
236
# File 'lib/better_auth/plugins/admin.rb', line 230

def admin_list_user_sessions_endpoint(config)
  Endpoint.new(path: "/admin/list-user-sessions", method: "POST") 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



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/better_auth/plugins/admin.rb', line 211

def admin_list_users_endpoint(config)
  Endpoint.new(path: "/admin/list-users", method: "GET") 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_paginate_users(users, query) ⇒ Object



511
512
513
514
515
516
# File 'lib/better_auth/plugins/admin.rb', line 511

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



414
415
416
# File 'lib/better_auth/plugins/admin.rb', line 414

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

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

Returns:

  • (Boolean)


396
397
398
399
400
401
402
403
404
405
# File 'lib/better_auth/plugins/admin.rb', line 396

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



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

def admin_remove_user_endpoint(config)
  Endpoint.new(path: "/admin/remove-user", method: "POST") 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", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_REMOVE_YOURSELF")) if user_id == session[:user]["id"]
    raise APIError.new("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:



389
390
391
392
393
394
# File 'lib/better_auth/plugins/admin.rb', line 389

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)

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

.admin_revoke_user_session_endpoint(config) ⇒ Object



314
315
316
317
318
319
320
# File 'lib/better_auth/plugins/admin.rb', line 314

def admin_revoke_user_session_endpoint(config)
  Endpoint.new(path: "/admin/revoke-user-session", method: "POST") 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



322
323
324
325
326
327
328
# File 'lib/better_auth/plugins/admin.rb', line 322

def admin_revoke_user_sessions_endpoint(config)
  Endpoint.new(path: "/admin/revoke-user-sessions", method: "POST") 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_sessions(normalize_hash(ctx.body)[:user_id])
    ctx.json({success: true})
  end
end

.admin_role_for(roles, role) ⇒ Object



435
436
437
# File 'lib/better_auth/plugins/admin.rb', line 435

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_set_role_endpoint(config) ⇒ Object



137
138
139
140
141
142
143
144
145
146
147
# File 'lib/better_auth/plugins/admin.rb', line 137

def admin_set_role_endpoint(config)
  Endpoint.new(path: "/admin/set-role", method: "POST") 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)}
    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



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# File 'lib/better_auth/plugins/admin.rb', line 341

def admin_set_user_password_endpoint(config)
  Endpoint.new(path: "/admin/set-user-password", method: "POST") 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", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_SHORT")) if password.length < min
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_LONG")) if password.length > max
    ctx.context.internal_adapter.update_password(user_id, Routes.hash_password(ctx, password))
    ctx.json({status: true})
  end
end

.admin_sort_users(users, query) ⇒ Object



501
502
503
504
505
506
507
508
509
# File 'lib/better_auth/plugins/admin.rb', line 501

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



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
# File 'lib/better_auth/plugins/admin.rb', line 286

def admin_stop_impersonating_endpoint
  Endpoint.new(path: "/admin/stop-impersonating", method: "POST") 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



252
253
254
255
256
257
258
# File 'lib/better_auth/plugins/admin.rb', line 252

def admin_unban_user_endpoint(config)
  Endpoint.new(path: "/admin/unban-user", method: "POST") do |ctx|
    admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
    user = ctx.context.internal_adapter.update_user(normalize_hash(ctx.body)[: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



196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/better_auth/plugins/admin.rb', line 196

def admin_update_user_endpoint(config)
  Endpoint.new(path: "/admin/update-user", method: "POST") 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", message: ADMIN_ERROR_CODES.fetch("NO_DATA_TO_UPDATE")) if data.empty?
    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
    user = ctx.context.internal_adapter.update_user(body[:user_id], data)
    ctx.json(Schema.parse_output(ctx.context.options, "user", user))
  end
end

.admin_user?(user, config) ⇒ Boolean

Returns:

  • (Boolean)


407
408
409
410
411
412
# File 'lib/better_auth/plugins/admin.rb', line 407

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_sort(query) ⇒ Object



463
464
465
466
467
468
469
470
471
# File 'lib/better_auth/plugins/admin.rb', line 463

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



439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
# File 'lib/better_auth/plugins/admin.rb', line 439

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



418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# File 'lib/better_auth/plugins/admin.rb', line 418

def admin_validate_roles!(roles, config)
  unless Array(roles).all? { |role| role.is_a?(String) || role.is_a?(Symbol) }
    raise APIError.new("BAD_REQUEST", 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", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE"))
    end
  end

  parsed
end

.all_jwks(ctx, config) ⇒ Object



227
228
229
230
231
232
233
234
# File 'lib/better_auth/plugins/jwt.rb', line 227

def all_jwks(ctx, config)
  adapter = config[:adapter]
  if adapter && adapter[:get_jwks].respond_to?(:call)
    return Array(adapter[:get_jwks].call(ctx)).map { |entry| stringify_payload(entry) }
  end

  ctx.context.adapter.find_many(model: "jwks")
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



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

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)


171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/better_auth/plugins/anonymous.rb', line 171

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



124
125
126
127
128
129
130
# File 'lib/better_auth/plugins/anonymous.rb', line 124

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



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/better_auth/plugins/anonymous.rb', line 92

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



185
186
187
188
189
190
191
192
# File 'lib/better_auth/plugins/anonymous.rb', line 185

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) ⇒ Object



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

def api_key(*args)
  Kernel.require "better_auth/api_key"
  BetterAuth::Plugins.api_key(*args)
rescue LoadError => error
  raise if error.path && error.path != "better_auth/api_key"

  raise LoadError, "BetterAuth::Plugins.api_key requires the better_auth-api-key gem. Add `gem \"better_auth-api-key\"` and `require \"better_auth/api_key\"`."
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 = [ctx.headers["cookie"], "#{cookie_name}=#{signed_token}"].compact.reject(&:empty?).join("; ")
  {context: {headers: ctx.headers.merge("cookie" => cookie)}}
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
58
59
60
61
62
63
64
65
66
67
68
69
# 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"],
    get_user_info: ->(tokens) {
      profile = generic_oauth_fetch_json("https://#{domain}/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
      return nil unless profile

      {
        id: fetch_value(profile, "sub"),
        name: fetch_value(profile, "name") || fetch_value(profile, "nickname"),
        email: fetch_value(profile, "email"),
        image: fetch_value(profile, "picture"),
        emailVerified: fetch_value(profile, "email_verified") || false
      }
    }
  )
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



508
509
510
511
512
# File 'lib/better_auth/plugins/two_factor.rb', line 508

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



503
504
505
506
# File 'lib/better_auth/plugins/two_factor.rb', line 503

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



452
453
454
455
456
# File 'lib/better_auth/plugins/jwt.rb', line 452

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


89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/better_auth/plugins/bearer.rb', line 89

def bearer_session_cookie(line)
  first, *attributes = line.to_s.split(";").map(&:strip)
  name, value = first.split("=", 2)
  return unless name && value

  {
    name: name,
    value: value,
    attributes: attributes.each_with_object({}) do |attribute, result|
      key, attribute_value = attribute.split("=", 2)
      result[key.to_s.downcase] = attribute_value || true unless key.to_s.empty?
    end
  }
end

.call_email_verification_option(ctx, key, user) ⇒ Object



508
509
510
511
# File 'lib/better_auth/plugins/email_otp.rb', line 508

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


168
169
170
171
# File 'lib/better_auth/plugins/multi_session.rb', line 168

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

.captcha(options = {}) ⇒ Object



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

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



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

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 = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
  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



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

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



136
137
138
139
140
# File 'lib/better_auth/plugins/captcha.rb', line 136

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



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/better_auth/plugins/captcha.rb', line 47

def captcha_on_request(request, context, config)
  endpoints = Array(config[:endpoints]).empty? ? CAPTCHA_DEFAULT_ENDPOINTS : Array(config[:endpoints])
  return nil unless endpoints.any? { |endpoint| request.path_info.include?(endpoint.to_s) || request.url.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



114
115
116
117
118
119
120
121
122
123
124
# File 'lib/better_auth/plugins/captcha.rb', line 114

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



146
147
148
# File 'lib/better_auth/plugins/captcha.rb', line 146

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

.captcha_response(status, code, message) ⇒ Object



142
143
144
# File 'lib/better_auth/plugins/captcha.rb', line 142

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)


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

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

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

  true
end

.captcha_verifier_params(params) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
# File 'lib/better_auth/plugins/captcha.rb', line 84

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



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/better_auth/plugins/captcha.rb', line 67

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
  }
  return captcha_normalize_verifier_response(config[:verifier].call(captcha_verifier_params(params))) if config[:verifier].respond_to?(:call)

  captcha_http_verify(params)
end

.change_email_email_otp_endpoint(config) ⇒ Object



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/better_auth/plugins/email_otp.rb', line 237

def change_email_email_otp_endpoint(config)
  Endpoint.new(path: "/email-otp/change-email", method: "POST") 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



141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/better_auth/plugins/email_otp.rb', line 141

def check_verification_otp_endpoint(config)
  Endpoint.new(path: "/email-otp/check-verification-otp", method: "POST") 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_multi_session_cookies(ctx) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/better_auth/plugins/multi_session.rb', line 128

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


42
43
44
# File 'lib/better_auth/plugins.rb', line 42

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

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



81
82
83
# File 'lib/better_auth/plugins/access.rb', line 81

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

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



906
907
908
909
910
911
912
913
914
915
916
917
918
# File 'lib/better_auth/plugins/organization.rb', line 906

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)
  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



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

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)

  if adapter && adapter[:create_jwk].respond_to?(:call)
    return stringify_payload(adapter[:create_jwk].call(data, ctx))
  end

  ctx.context.adapter.create(model: "jwks", data: data, force_allow_id: true)
end

.create_verification_otp_endpoint(config) ⇒ Object



100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/better_auth/plugins/email_otp.rb', line 100

def create_verification_otp_endpoint(config)
  Endpoint.new(method: "POST") 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



458
459
460
461
462
463
464
465
466
# File 'lib/better_auth/plugins/jwt.rb', line 458

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



311
312
313
314
315
316
317
318
319
# File 'lib/better_auth/plugins/phone_number.rb', line 311

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_error_responsesObject



409
410
411
412
413
414
415
416
417
418
# File 'lib/better_auth/plugins/open_api.rb', line 409

def default_error_responses
  {
    "400" => error_response("Bad Request. Usually due to missing parameters, or invalid parameters.", required: true),
    "401" => error_response("Unauthorized. Due to missing or invalid authentication.", required: true),
    "403" => error_response("Forbidden. You do not have permission to access this resource or to perform this action."),
    "404" => error_response("Not Found. The requested resource was not found."),
    "429" => error_response("Too Many Requests. You have exceeded the rate limit. Try again later."),
    "500" => error_response("Internal Server Error. This is a problem with the server that you cannot fix.")
  }
end

.default_username_valid?(username) ⇒ Boolean

Returns:

  • (Boolean)


270
271
272
# File 'lib/better_auth/plugins/username.rb', line 270

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

.delete_anonymous_user_endpoint(config) ⇒ Object



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 69

def delete_anonymous_user_endpoint(config)
  Endpoint.new(path: "/delete-anonymous-user", method: "POST") 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

.device_approve_endpointObject



155
156
157
158
159
160
161
162
# File 'lib/better_auth/plugins/device_authorization.rb', line 155

def device_approve_endpoint
  Endpoint.new(path: "/device/approve", method: "POST") 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



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

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



271
272
273
# File 'lib/better_auth/plugins/device_authorization.rb', line 271

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



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/better_auth/plugins/device_authorization.rb', line 281

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



275
276
277
278
279
# File 'lib/better_auth/plugins/device_authorization.rb', line 275

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



262
263
264
265
266
267
268
269
# File 'lib/better_auth/plugins/device_authorization.rb', line 262

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



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/device_authorization.rb', line 49

def device_code_endpoint(config)
  Endpoint.new(path: "/device/code", method: "POST") 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,
        "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_deny_endpointObject



164
165
166
167
168
169
170
171
# File 'lib/better_auth/plugins/device_authorization.rb', line 164

def device_deny_endpoint
  Endpoint.new(path: "/device/deny", method: "POST") 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



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
# File 'lib/better_auth/plugins/device_authorization.rb', line 88

def device_token_endpoint(config)
  Endpoint.new(path: "/device/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) 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"
      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.context.adapter.delete(model: "deviceCode", where: [{field: "id", value: record["id"]}])
      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_verify_endpointObject



143
144
145
146
147
148
149
150
151
152
153
# File 'lib/better_auth/plugins/device_authorization.rb', line 143

def device_verify_endpoint
  Endpoint.new(path: "/device", method: "GET") 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

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

.duration_seconds(value) ⇒ Object

Raises:



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/better_auth/plugins/device_authorization.rb', line 230

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



385
386
387
# File 'lib/better_auth/plugins/jwt.rb', line 385

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
49
# 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),
      forget_password_email_otp: forget_password_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



301
302
303
304
305
306
307
308
309
310
311
# File 'lib/better_auth/plugins/email_otp.rb', line 301

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:



502
503
504
505
506
# File 'lib/better_auth/plugins/email_otp.rb', line 502

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



475
476
477
478
# File 'lib/better_auth/plugins/email_otp.rb', line 475

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



414
415
416
417
418
419
420
# File 'lib/better_auth/plugins/email_otp.rb', line 414

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



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

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

.email_otp_init(config) ⇒ Object



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
# File 'lib/better_auth/plugins/email_otp.rb', line 51

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)


447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
# File 'lib/better_auth/plugins/email_otp.rb', line 447

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, 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



313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/better_auth/plugins/email_otp.rb', line 313

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



466
467
468
469
470
471
472
473
# File 'lib/better_auth/plugins/email_otp.rb', line 466

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, 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



513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
# File 'lib/better_auth/plugins/email_otp.rb', line 513

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
    /forget-password/email-otp
    /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



355
356
357
358
359
360
361
362
363
364
# File 'lib/better_auth/plugins/email_otp.rb', line 355

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



366
367
368
369
370
371
372
373
374
375
376
377
378
379
# File 'lib/better_auth/plugins/email_otp.rb', line 366

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



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/better_auth/plugins/email_otp.rb', line 328

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(body, email) ⇒ Object



422
423
424
425
426
427
428
429
430
431
432
# File 'lib/better_auth/plugins/email_otp.rb', line 422

def (body, email)
  reserved = %i[email otp name image callback_url callbackURL callbackUrl]
  additional = body.reject { |key, _value| reserved.include?(key.to_sym) }
  additional = additional.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value }
  additional.merge(
    "email" => email,
    "emailVerified" => true,
    "name" => body[:name].to_s,
    "image" => body[:image]
  )
end

.email_otp_split(value) ⇒ Object



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

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



344
345
346
347
348
349
350
351
352
353
# File 'lib/better_auth/plugins/email_otp.rb', line 344

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



434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/better_auth/plugins/email_otp.rb', line 434

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, 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:



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
407
408
409
410
411
412
# File 'lib/better_auth/plugins/email_otp.rb', line 381

def email_otp_verify!(ctx, config, email:, type:, otp:, consume: true)
  verification = ctx.context.internal_adapter.find_verification_value(email_otp_identifier(email, type))
  raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) unless verification

  if Routes.expired_time?(verification["expiresAt"])
    ctx.context.internal_adapter.delete_verification_value(verification["id"])
    raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["OTP_EXPIRED"])
  end

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

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

  true
end

.empty_request_bodyObject



378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/better_auth/plugins/open_api.rb', line 378

def empty_request_body
  {
    content: {
      "application/json" => {
        schema: {
          type: "object",
          properties: {}
        }
      }
    }
  }
end

.encode_eddsa_jwt(payload, private_key, kid) ⇒ Object



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

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_not_last_owner!(ctx, member) ⇒ Object

Raises:



899
900
901
902
903
904
# File 'lib/better_auth/plugins/organization.rb', line 899

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

  owners = ctx.context.adapter.find_many(model: "member", where: [{field: "organizationId", value: member["organizationId"]}]).select { |entry| entry["role"].to_s.split(",").include?("owner") }
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER")) if owners.length <= 1
end

.error_response(description, required: false) ⇒ Object



420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
# File 'lib/better_auth/plugins/open_api.rb', line 420

def error_response(description, required: false)
  schema = {
    type: "object",
    properties: {
      message: {
        type: "string"
      }
    }
  }
  schema[:required] = ["message"] if required
  {
    description: description,
    content: {
      "application/json" => {
        schema: schema
      }
    }
  }
end


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

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)


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

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
# File 'lib/better_auth/plugins/expo.rb', line 31

def expo_authorization_proxy_endpoint
  Endpoint.new(path: "/expo-authorization-proxy", method: "GET") 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)


84
85
86
# File 'lib/better_auth/plugins/expo.rb', line 84

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


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

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



53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/better_auth/plugins/expo.rb', line 53

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



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

def expose_auth_token(ctx)
  set_cookie = ctx.response_headers["set-cookie"].to_s
  token_name = ctx.context.auth_cookies[:session_token].name
  token = set_cookie.lines.filter_map do |line|
    cookie = bearer_session_cookie(line)
    next unless cookie && cookie[:name] == token_name
    next if cookie[:value].empty? || expired_bearer_cookie?(cookie)

    cookie[:value]
  end.first
  return unless token

  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



982
983
984
# File 'lib/better_auth/plugins/organization.rb', line 982

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

.fetch_value(data, key) ⇒ Object



36
37
38
39
40
# File 'lib/better_auth/plugins.rb', line 36

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



456
457
458
459
460
461
462
463
464
465
466
467
468
# File 'lib/better_auth/plugins/open_api.rb', line 456

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



194
195
196
# File 'lib/better_auth/plugins/device_authorization.rb', line 194

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



198
199
200
201
202
203
204
# File 'lib/better_auth/plugins/device_authorization.rb', line 198

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



808
809
810
811
# File 'lib/better_auth/plugins/organization.rb', line 808

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

.forget_password_email_otp_endpoint(config) ⇒ Object



267
268
269
270
271
# File 'lib/better_auth/plugins/email_otp.rb', line 267

def forget_password_email_otp_endpoint(config)
  Endpoint.new(path: "/forget-password/email-otp", method: "POST") do |ctx|
    email_otp_password_reset_request(ctx, config)
  end
end

.generate_device_authorization_device_code(config) ⇒ Object



210
211
212
213
214
# File 'lib/better_auth/plugins/device_authorization.rb', line 210

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



216
217
218
219
220
221
# File 'lib/better_auth/plugins/device_authorization.rb', line 216

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



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

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
# 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") 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: generic_oauth_social_providers(config, context).merge(context.social_providers)
        }
      }
    },
    endpoints: {
      sign_in_with_oauth2: (config),
      o_auth2_callback: o_auth2_callback_endpoint(config),
      o_auth2_link_account: (config)
    },
    error_codes: GENERIC_OAUTH_ERROR_CODES,
    options: config
  )
end

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



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

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:) ⇒ Object

Raises:



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
376
# File 'lib/better_auth/plugins/generic_oauth.rb', line 321

def generic_oauth_authorization_url(ctx, provider, body, link:)
  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)
  state_data = normalize_hash(body[:additional_data] || body[:additionalData]).transform_keys(&:to_s).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)
  legacy_state = Crypto.sign_jwt(
    {
      "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" => code_verifier,
      "link" => link
    },
    ctx.context.secret,
    expires_in: 600
  )
  state ||= legacy_state

  uri = URI.parse(authorization_url.to_s)
  params = URI.decode_www_form(uri.query.to_s)
  params.concat([
    ["client_id", provider[:client_id].to_s],
    ["response_type", provider[:response_type] || "code"],
    ["redirect_uri", generic_oauth_redirect_uri(ctx, provider)],
    ["state", state]
  ])
  scopes = Array(body[:scopes]) + Array(provider[:scopes])
  params << ["scope", scopes.join(" ")] unless scopes.empty?
  if provider[:pkce]
    params << ["code_challenge", generic_oauth_pkce_challenge(code_verifier)]
    params << ["code_challenge_method", "S256"]
  end
  params << ["prompt", provider[:prompt]] if provider[:prompt]
  params << ["access_type", provider[:access_type]] if provider[:access_type]
  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| params << [key.to_s, value.to_s] }
  uri.query = URI.encode_www_form(params)
  uri.to_s
end

.generic_oauth_discovery(provider) ⇒ Object



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

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 = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
  provider[:_discovery] = response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : {}
rescue
  {}
end

.generic_oauth_error_url(base_url, error) ⇒ Object



767
768
769
770
771
772
773
# File 'lib/better_auth/plugins/generic_oauth.rb', line 767

def generic_oauth_error_url(base_url, error)
  uri = URI.parse(base_url.to_s)
  query = URI.decode_www_form(uri.query.to_s)
  query << ["error", error.to_s]
  uri.query = URI.encode_www_form(query)
  uri.to_s
end

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

Raises:



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

def generic_oauth_exchange_token(ctx, provider, code, state_data)
  token_callback = provider[:get_token]
  if token_callback.respond_to?(:call)
    return normalize_hash(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
    ))
  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



652
653
654
655
656
# File 'lib/better_auth/plugins/generic_oauth.rb', line 652

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



679
680
681
682
683
684
685
686
687
688
689
# File 'lib/better_auth/plugins/generic_oauth.rb', line 679

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 = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
  return nil unless response.is_a?(Net::HTTPSuccess)

  JSON.parse(response.body)
rescue
  nil
end

.generic_oauth_generate_state(ctx, state_data) ⇒ Object



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

def generic_oauth_generate_state(ctx, state_data)
  strategy = ctx.context.options.[:store_state_strategy]
  state = Crypto.random_string(32)
  if strategy.to_s == "cookie"
    cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
    encrypted = Crypto.symmetric_encrypt(key: ctx.context.secret, data: JSON.generate(state_data.merge("state" => state)))
    ctx.set_cookie(cookie.name, encrypted, cookie.attributes)
    return state
  end

  cookie = ctx.context.create_auth_cookie("state", max_age: 300)
  ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
  ctx.context.internal_adapter.create_verification_value(
    identifier: state,
    value: JSON.generate(state_data),
    expiresAt: Time.now + 600
  )
  state
rescue
  nil
end


504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
# File 'lib/better_auth/plugins/generic_oauth.rb', line 504

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



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

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) ⇒ Object



639
640
641
642
643
644
645
646
647
648
649
650
# File 'lib/better_auth/plugins/generic_oauth.rb', line 639

def generic_oauth_normalize_tokens(data)
  token_data = normalize_hash(data)
  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: generic_oauth_expiry_time(token_data[:expires_in]),
    refresh_token_expires_at: generic_oauth_expiry_time(token_data[:refresh_token_expires_in]),
    scopes: generic_oauth_token_scopes(token_data[:scope]),
    raw: token_data
  ).compact
end

.generic_oauth_normalize_user_info(data) ⇒ Object



669
670
671
672
673
674
675
676
677
# File 'lib/better_auth/plugins/generic_oauth.rb', line 669

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



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
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
# File 'lib/better_auth/plugins/generic_oauth.rb', line 418

def generic_oauth_parse_state(ctx, state)
  if state.to_s.empty?
    raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
  end

  if ctx.context.options.[:store_state_strategy].to_s == "cookie"
    cookie = ctx.context.create_auth_cookie("oauth_state")
    encrypted = ctx.get_cookie(cookie.name)
    unless encrypted
      raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
    end

    begin
      decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, data: encrypted)
      unless decrypted
        Cookies.expire_cookie(ctx, cookie)
        raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
      end

      parsed = JSON.parse(decrypted)
    rescue JSON::ParserError
      Cookies.expire_cookie(ctx, cookie)
      raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
    end

    Cookies.expire_cookie(ctx, cookie)
    if parsed["state"] != state
      raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
    end
    if parsed["expiresAt"].to_i.positive? && parsed["expiresAt"].to_i < Time.now.to_i
      raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
    end
    return parsed
  else
    verification = ctx.context.internal_adapter.find_verification_value(state)
    if verification
      cookie = ctx.context.create_auth_cookie("state")
      cookie_state = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
      if cookie_state && cookie_state != state
        Cookies.expire_cookie(ctx, cookie)
        raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
      end

      parsed = JSON.parse(verification.fetch("value"))
      ctx.context.internal_adapter.delete_verification_value(verification.fetch("id"))
      Cookies.expire_cookie(ctx, cookie) if cookie_state
      return parsed
    end
  end

  Crypto.verify_jwt(state.to_s, ctx.context.secret) || {}
rescue JSON::ParserError
  {}
end

.generic_oauth_pkce_challenge(code_verifier) ⇒ Object



665
666
667
# File 'lib/better_auth/plugins/generic_oauth.rb', line 665

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:



746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
# File 'lib/better_auth/plugins/generic_oauth.rb', line 746

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 = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(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))
end

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



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
# File 'lib/better_auth/plugins/generic_oauth.rb', line 590

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 = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
  return nil unless response.is_a?(Net::HTTPSuccess)

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

.generic_oauth_provider(config, provider_id) ⇒ Object



557
558
559
# File 'lib/better_auth/plugins/generic_oauth.rb', line 557

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:



550
551
552
553
554
555
# File 'lib/better_auth/plugins/generic_oauth.rb', line 550

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



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

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]
  )
  config[:scopes] = data[:scopes] if data[:scopes]
  config.compact
end

.generic_oauth_redirect_uri(ctx, provider) ⇒ Object



561
562
563
# File 'lib/better_auth/plugins/generic_oauth.rb', line 561

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

.generic_oauth_refresh_access_token(ctx, provider, refresh_token) ⇒ Object

Raises:



739
740
741
742
743
744
# File 'lib/better_auth/plugins/generic_oauth.rb', line 739

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


543
544
545
546
547
548
# File 'lib/better_auth/plugins/generic_oauth.rb', line 543

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_social_providers(config, context) ⇒ Object



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

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) }
    }
  end
end

.generic_oauth_state_error_url(ctx) ⇒ Object



473
474
475
# File 'lib/better_auth/plugins/generic_oauth.rb', line 473

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



536
537
538
539
540
541
# File 'lib/better_auth/plugins/generic_oauth.rb', line 536

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, data: token)
end

.generic_oauth_token_scopes(scope) ⇒ Object



658
659
660
661
662
663
# File 'lib/better_auth/plugins/generic_oauth.rb', line 658

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

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

.generic_oauth_user_from_id_token(id_token) ⇒ Object



626
627
628
629
630
631
632
633
634
635
636
637
# File 'lib/better_auth/plugins/generic_oauth.rb', line 626

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



477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
# File 'lib/better_auth/plugins/generic_oauth.rb', line 477

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

  id_token = tokens[:id_token] || tokens[:idToken]
  return generic_oauth_user_from_id_token(id_token) if id_token

   = 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 = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(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



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

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



775
776
777
778
# File 'lib/better_auth/plugins/generic_oauth.rb', line 775

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



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/generic_oauth.rb', line 691

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"],
    get_user_info: ->(tokens) {
      profile = generic_oauth_fetch_json(, authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
      return nil unless profile

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

.get_jwks_endpoint(config, path) ⇒ Object



105
106
107
108
109
110
111
112
# File 'lib/better_auth/plugins/jwt.rb', line 105

def get_jwks_endpoint(config, path)
  Endpoint.new(path: path, method: "GET") 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) ⇒ Object



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

def get_siwe_nonce_endpoint(config)
  Endpoint.new(path: "/siwe/nonce", method: "POST", body_schema: ->(body) { siwe_nonce_body(body) }) do |ctx|
    body = normalize_hash(ctx.body)
    wallet_address = siwe_normalize_wallet!(body[:wallet_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



114
115
116
117
118
119
120
121
# File 'lib/better_auth/plugins/jwt.rb', line 114

def get_token_endpoint(config)
  Endpoint.new(path: "/token", method: "GET") 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



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
# File 'lib/better_auth/plugins/email_otp.rb', line 113

def get_verification_otp_endpoint(config)
  Endpoint.new(path: "/email-otp/get-verification-otp", method: "GET") 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, 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



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

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 = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(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



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

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

.invitation_by_id(ctx, id) ⇒ Object



825
826
827
828
829
# File 'lib/better_auth/plugins/organization.rb', line 825

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



883
884
885
# File 'lib/better_auth/plugins/organization.rb', line 883

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

.is_username_available_endpoint(config) ⇒ Object



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

def is_username_available_endpoint(config)
  Endpoint.new(path: "/is-username-available", method: "POST") 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)


422
423
424
425
# File 'lib/better_auth/plugins/jwt.rb', line 422

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



306
307
308
309
310
# File 'lib/better_auth/plugins/jwt.rb', line 306

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, data: private_key)
end

.jwk_private_key_value(ctx, key, _config) ⇒ Object



312
313
314
315
# File 'lib/better_auth/plugins/jwt.rb', line 312

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

.jwt(options = {}) ⇒ 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
# File 'lib/better_auth/plugins/jwt.rb', line 42

def jwt(options = {})
  config = normalize_hash(options)
  validate_jwt_options!(config)
  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: {
          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



427
428
429
430
431
432
# File 'lib/better_auth/plugins/jwt.rb', line 427

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)


317
318
319
320
321
322
323
324
325
# File 'lib/better_auth/plugins/jwt.rb', line 317

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



154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/better_auth/plugins/jwt.rb', line 154

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



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

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

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

.keycloak(options = {}) ⇒ Object



116
117
118
119
120
# File 'lib/better_auth/plugins/generic_oauth.rb', line 116

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



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

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

.line(options = {}) ⇒ Object



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/better_auth/plugins/generic_oauth.rb', line 122

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


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
# File 'lib/better_auth/plugins/anonymous.rb', line 132

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
# 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") 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 = {}) ⇒ Object



849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
# File 'lib/better_auth/plugins/organization.rb', line 849

def list_members_for(ctx, organization_id, query = {})
  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
  members = ctx.context.adapter.find_many(
    model: "member",
    where: where,
    limit: query[:limit],
    offset: query[:offset],
    sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_order] || "asc"} : nil
  )
  {
    members: members.map { |entry| member_wire(ctx, entry) },
    total: ctx.context.adapter.count(model: "member", where: where)
  }
end


10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 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))

  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)


135
136
137
138
139
140
# File 'lib/better_auth/plugins/magic_link.rb', line 135

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


171
172
173
174
175
176
177
178
179
# File 'lib/better_auth/plugins/magic_link.rb', line 171

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


128
129
130
131
132
133
# File 'lib/better_auth/plugins/magic_link.rb', line 128

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

  Array.new(32) { [*"a".."z", *"A".."Z"].sample }.join
end


154
155
156
157
158
159
160
161
162
# File 'lib/better_auth/plugins/magic_link.rb', line 154

def magic_link_url(ctx, 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]
  "#{ctx.context.base_url}/magic-link/verify?#{URI.encode_www_form(params)}"
end


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
# File 'lib/better_auth/plugins/magic_link.rb', line 53

def magic_link_verify_endpoint(config)
  Endpoint.new(path: "/magic-link/verify", method: "GET") 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.find_verification_value(stored_token)
    redirect_with_error.call("INVALID_TOKEN") unless verification

    if Routes.expired_time?(verification["expiresAt"])
      ctx.context.internal_adapter.delete_verification_value(verification["id"])
      redirect_with_error.call("EXPIRED_TOKEN")
    end

    payload = JSON.parse(verification["value"])
    email = payload.fetch("email").to_s.downcase
    name = payload["name"]
    attempt = payload["attempt"].to_i
    if magic_link_attempts_exceeded?(attempt, config)
      ctx.context.internal_adapter.delete_verification_value(verification["id"])
      redirect_with_error.call("ATTEMPTS_EXCEEDED")
    end
    ctx.context.internal_adapter.update_verification_value(
      verification["id"],
      value: JSON.generate(payload.merge("attempt" => attempt + 1))
    )
    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 || ""
      )
      new_user = true
      redirect_with_error.call("failed_to_create_user") unless user
    end

    unless user["emailVerified"]
      user = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
    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



266
267
268
# File 'lib/better_auth/plugins/username.rb', line 266

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

.mcp(options = {}) ⇒ 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
# File 'lib/better_auth/plugins/mcp.rb', line 42

def mcp(options = {})
  config = {
    login_page: "/login",
    consent_page: "/oauth/consent",
    resource: nil,
    oidc_config: {},
    code_expires_in: 600,
    default_scope: "openid",
    access_token_expires_in: 3600,
    refresh_token_expires_in: 604_800,
    allow_plain_code_challenge_method: true,
    scopes: %w[openid profile email offline_access],
    store: OAuthProtocol.stores
  }.merge(normalize_hash(options))
  config = mcp_normalize_config(config)

  Plugin.new(
    id: "mcp",
    endpoints: mcp_endpoints(config),
    hooks: {
      after: [
        {
          matcher: ->(_ctx) { true },
          handler: ->(ctx) { (ctx, config) }
        }
      ]
    },
    schema: oidc_provider_schema,
    options: config
  )
end

.mcp_authenticate_token_client!(ctx, body, config) ⇒ Object

Raises:



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

def mcp_authenticate_token_client!(ctx, body, config)
  authorization = ctx.headers["authorization"].to_s
  if authorization.start_with?("Basic ") && body["client_id"].to_s.empty?
    return OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret] || "plain")
  end

  client = OAuthProtocol.find_client(ctx, "oauthApplication", body["client_id"])
  raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client

  data = OAuthProtocol.stringify_keys(client)
  method = data["tokenEndpointAuthMethod"] || "client_secret_basic"
  if method != "none" && !OAuthProtocol.verify_client_secret(ctx, data["clientSecret"], body["client_secret"], config[:store_client_secret] || "plain")
    raise APIError.new("UNAUTHORIZED", message: "invalid_client")
  end
  client
end

.mcp_authorization_redirect(ctx, config, query, session) ⇒ Object



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
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
# File 'lib/better_auth/plugins/mcp.rb', line 171

def mcp_authorization_redirect(ctx, config, query, session)
  query = OAuthProtocol.stringify_keys(query)
  query["prompt"] = (query["prompt"]) if query.key?("prompt")
  prompts = OIDCProvider.parse_prompt(query["prompt"])
  unless query["client_id"]
    raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client")
  end
  unless query["response_type"]
    raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(ctx.context.base_url + "/error", error: "invalid_request", error_description: "response_type is required"))
  end
  client = OAuthProtocol.find_client(ctx, "oauthApplication", query["client_id"])
  raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") unless client
  OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])
  client_data = OAuthProtocol.stringify_keys(client)
  raise ctx.redirect("#{ctx.context.base_url}/error?error=client_disabled") if client_data["disabled"]
  unless query["response_type"] == "code"
    raise ctx.redirect("#{ctx.context.base_url}/error?error=unsupported_response_type")
  end

  scopes = OAuthProtocol.parse_scopes(query["scope"] || config[:default_scope])
  invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) }
  unless invalid_scopes.empty?
    redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_scope", error_description: "The following scopes are invalid: #{invalid_scopes.join(", ")}", state: query["state"])
    raise ctx.redirect(redirect)
  end
  if config[:require_pkce] && (query["code_challenge"].to_s.empty? || query["code_challenge_method"].to_s.empty?)
    redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "pkce is required", state: query["state"])
    raise ctx.redirect(redirect)
  end
  challenge_method = query["code_challenge_method"].to_s
  if challenge_method.empty?
    query["code_challenge_method"] = "plain" if query["code_challenge"]
  elsif !valid_code_challenge_method?(challenge_method, config)
    redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "invalid code_challenge method", state: query["state"])
    raise ctx.redirect(redirect)
  end

  if prompts.include?("consent")
    consent_code = Crypto.random_string(32)
    config[:store][:consents][consent_code] = {
      query: query,
      session: session,
      client: client,
      scopes: scopes,
      expires_at: Time.now + config[:code_expires_in].to_i
    }
    raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:consent_page], consent_code: consent_code, client_id: client_data["clientId"], scope: OAuthProtocol.scope_string(scopes)))
  end

  code = Crypto.random_string(32)
  OAuthProtocol.store_code(
    config[:store],
    code: code,
    client_id: query["client_id"],
    redirect_uri: query["redirect_uri"],
    session: session,
    scopes: scopes,
    code_challenge: query["code_challenge"],
    code_challenge_method: query["code_challenge_method"]
  )
  OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"])
end

.mcp_authorize_endpoint(config) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/better_auth/plugins/mcp.rb', line 143

def mcp_authorize_endpoint(config)
  Endpoint.new(path: "/mcp/authorize", method: "GET") do |ctx|
    query = OAuthProtocol.stringify_keys(ctx.query)
    session = Routes.current_session(ctx, allow_nil: true)
    unless session
      ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
      raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query))
    end

    raise ctx.redirect(mcp_authorization_redirect(ctx, config, query, session))
  end
end

.mcp_endpoints(config) ⇒ Object



74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/better_auth/plugins/mcp.rb', line 74

def mcp_endpoints(config)
  {
    get_mcp_o_auth_config: mcp_oauth_config_endpoint(config),
    get_mcp_protected_resource: mcp_protected_resource_endpoint(config),
    mcp_o_auth_authorize: mcp_authorize_endpoint(config),
    mcp_o_auth_token: mcp_token_endpoint(config),
    mcp_o_auth_user_info: mcp_userinfo_endpoint(config),
    mcp_register: mcp_register_endpoint(config),
    get_mcp_session: mcp_get_session_endpoint(config),
    o_auth_consent: oidc_consent_endpoint(config),
    mcp_jwks: mcp_jwks_endpoint(config)
  }
end

.mcp_get_session_endpoint(config) ⇒ Object



293
294
295
296
297
298
299
300
301
# File 'lib/better_auth/plugins/mcp.rb', line 293

def mcp_get_session_endpoint(config)
  Endpoint.new(path: "/mcp/get-session", method: "GET") do |ctx|
    authorization = ctx.headers["authorization"].to_s
    token = authorization.start_with?("Bearer ") ? authorization.delete_prefix("Bearer ").strip : ""
    next ctx.json(nil) if token.empty?

    ctx.json(OAuthProtocol.token_record(config[:store], token))
  end
end

.mcp_jwks_endpoint(config) ⇒ Object



303
304
305
306
307
308
309
# File 'lib/better_auth/plugins/mcp.rb', line 303

def mcp_jwks_endpoint(config)
  Endpoint.new(path: "/mcp/jwks", method: "GET") do |ctx|
    jwt_config = config[:jwt] || {}
    create_jwk(ctx, jwt_config) if all_jwks(ctx, jwt_config).empty?
    ctx.json({keys: public_jwks(ctx, jwt_config).map { |key| public_jwk(key, jwt_config) }})
  end
end

.mcp_normalize_config(config) ⇒ Object



311
312
313
314
315
316
# File 'lib/better_auth/plugins/mcp.rb', line 311

def mcp_normalize_config(config)
  oidc = normalize_hash(config[:oidc_config] || {})
  merged = config.merge(oidc.except(:metadata))
  merged[:scopes] = (Array(config[:scopes]) + Array(oidc[:scopes])).compact.map(&:to_s).uniq
  merged
end

.mcp_oauth_config_endpoint(config) ⇒ Object



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/better_auth/plugins/mcp.rb', line 88

def mcp_oauth_config_endpoint(config)
  Endpoint.new(path: "/.well-known/oauth-authorization-server", method: "GET", metadata: {hide: true}) do |ctx|
    base = OAuthProtocol.endpoint_base(ctx)
    ctx.json({
      issuer: OAuthProtocol.issuer(ctx),
      authorization_endpoint: "#{base}/mcp/authorize",
      token_endpoint: "#{base}/mcp/token",
      userinfo_endpoint: "#{base}/mcp/userinfo",
      jwks_uri: "#{base}/mcp/jwks",
      registration_endpoint: "#{base}/mcp/register",
      scopes_supported: config[:scopes],
      response_types_supported: ["code"],
      response_modes_supported: ["query"],
      grant_types_supported: ["authorization_code", "refresh_token"],
      acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"],
      subject_types_supported: ["public"],
      id_token_signing_alg_values_supported: ["RS256", "none"],
      token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
      code_challenge_methods_supported: ["S256"],
      claims_supported: %w[sub iss aud exp nbf iat jti email email_verified name]
    }.merge(config[:oidc_config][:metadata] || {}))
  end
end

.mcp_parse_login_prompt(value) ⇒ Object



240
241
242
243
244
245
# File 'lib/better_auth/plugins/mcp.rb', line 240

def (value)
  parsed = JSON.parse(value.to_s)
  parsed.is_a?(Hash) ? parsed : nil
rescue JSON::ParserError
  nil
end

.mcp_prompt_without_login(value) ⇒ Object



234
235
236
237
238
# File 'lib/better_auth/plugins/mcp.rb', line 234

def (value)
  prompts = value.to_s.split(/\s+/).reject(&:empty?)
  prompts.delete("login")
  prompts.join(" ")
end

.mcp_protected_resource_endpoint(config) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/better_auth/plugins/mcp.rb', line 112

def mcp_protected_resource_endpoint(config)
  Endpoint.new(path: "/.well-known/oauth-protected-resource", method: "GET", metadata: {hide: true}) do |ctx|
    origin = OAuthProtocol.origin_for(OAuthProtocol.endpoint_base(ctx))
    ctx.json({
      resource: config[:resource] || origin,
      authorization_servers: [origin],
      jwks_uri: config.dig(:oidc_config, :metadata, :jwks_uri) || "#{OAuthProtocol.endpoint_base(ctx)}/mcp/jwks",
      scopes_supported: config.dig(:oidc_config, :metadata, :scopes_supported) || config[:scopes],
      bearer_methods_supported: ["header"],
      resource_signing_alg_values_supported: ["RS256", "none"]
    })
  end
end

.mcp_register_endpoint(config) ⇒ Object



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

def mcp_register_endpoint(config)
  Endpoint.new(path: "/mcp/register", method: "POST") do |ctx|
    mcp_set_cors_headers(ctx)
    ctx.json(
      OAuthProtocol.create_client(
        ctx,
        model: "oauthApplication",
        body: ctx.body,
        default_auth_method: "none",
        store_client_secret: config[:store_client_secret] || "plain"
      ),
      status: 201,
      headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"}
    )
  end
end

.mcp_restore_login_prompt(ctx, config) ⇒ Object



156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/better_auth/plugins/mcp.rb', line 156

def (ctx, config)
  cookie = ctx.get_signed_cookie("oidc_login_prompt", ctx.context.secret)
  return unless cookie

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

  query = (cookie)
  return unless query

  ctx.set_cookie("oidc_login_prompt", "", path: "/", max_age: 0)
  ctx.context.set_current_session(session) if ctx.context.respond_to?(:set_current_session)
  [302, ctx.response_headers.merge("location" => mcp_authorization_redirect(ctx, config, query, session)), [""]]
end

.mcp_set_cors_headers(ctx) ⇒ Object



318
319
320
321
322
323
# File 'lib/better_auth/plugins/mcp.rb', line 318

def mcp_set_cors_headers(ctx)
  ctx.set_header("Access-Control-Allow-Origin", "*")
  ctx.set_header("Access-Control-Allow-Methods", "POST, OPTIONS")
  ctx.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
  ctx.set_header("Access-Control-Max-Age", "86400")
end

.mcp_token_endpoint(config) ⇒ Object



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
# File 'lib/better_auth/plugins/mcp.rb', line 247

def mcp_token_endpoint(config)
  Endpoint.new(path: "/mcp/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
    mcp_set_cors_headers(ctx)
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = mcp_authenticate_token_client!(ctx, body, config)
    raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client

    response = case body["grant_type"]
    when OAuthProtocol::AUTH_CODE_GRANT
      client_data = OAuthProtocol.stringify_keys(client)
      if client_data["type"] == "public" && body["code_verifier"].to_s.empty?
        raise APIError.new("BAD_REQUEST", message: "invalid_request")
      end
      code = OAuthProtocol.consume_code!(
        config[:store],
        body["code"],
        client_id: client_data["clientId"],
        redirect_uri: body["redirect_uri"],
        code_verifier: body["code_verifier"]
      )
      OAuthProtocol.issue_tokens(
        ctx,
        config[:store],
        model: "oauthAccessToken",
        client: client,
        session: code[:session],
        scopes: code[:scopes],
        include_refresh: code[:scopes].include?("offline_access"),
        issuer: OAuthProtocol.issuer(ctx),
        access_token_expires_in: config[:access_token_expires_in]
      )
    when OAuthProtocol::REFRESH_GRANT
      OAuthProtocol.refresh_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, refresh_token: body["refresh_token"], scopes: body["scope"], issuer: OAuthProtocol.issuer(ctx), access_token_expires_in: config[:access_token_expires_in])
    else
      raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
    end
    ctx.json(response, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
  end
end

.mcp_userinfo_endpoint(config) ⇒ Object



287
288
289
290
291
# File 'lib/better_auth/plugins/mcp.rb', line 287

def mcp_userinfo_endpoint(config)
  Endpoint.new(path: "/mcp/userinfo", method: "GET") do |ctx|
    ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"]))
  end
end

.member_by_id(ctx, id) ⇒ Object



802
803
804
805
806
# File 'lib/better_auth/plugins/organization.rb', line 802

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_wire(ctx, member) ⇒ Object



870
871
872
873
874
875
# File 'lib/better_auth/plugins/organization.rb', line 870

def member_wire(ctx, member)
  data = Schema.parse_output(ctx.context.options, "member", member)
  user = 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



931
932
933
934
935
936
937
# File 'lib/better_auth/plugins/organization.rb', line 931

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



776
777
778
779
780
# File 'lib/better_auth/plugins/organization.rb', line 776

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

.microsoft_entra_id(options = {}) ⇒ Object



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/plugins/generic_oauth.rb', line 147

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



262
263
264
# File 'lib/better_auth/plugins/username.rb', line 262

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

.mirror_username_fields!(ctx) ⇒ Object



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

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


145
146
147
# File 'lib/better_auth/plugins/multi_session.rb', line 145

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)


149
150
151
# File 'lib/better_auth/plugins/multi_session.rb', line 149

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


153
154
155
# File 'lib/better_auth/plugins/multi_session.rb', line 153

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



248
249
250
251
# File 'lib/better_auth/plugins/username.rb', line 248

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



29
30
31
32
33
34
# File 'lib/better_auth/plugins.rb', line 29

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



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

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_key(key) ⇒ Object



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

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



259
260
261
262
263
264
# File 'lib/better_auth/plugins/jwt.rb', line 259

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



475
476
477
478
479
480
# File 'lib/better_auth/plugins/jwt.rb', line 475

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



206
207
208
# File 'lib/better_auth/plugins/device_authorization.rb', line 206

def normalize_user_code(value)
  value.to_s
end

.normalize_username(username, config) ⇒ Object



240
241
242
243
244
245
246
# File 'lib/better_auth/plugins/username.rb', line 240

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

.o_auth2_callback_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
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
# File 'lib/better_auth/plugins/generic_oauth.rb', line 255

def o_auth2_callback_endpoint(config)
  Endpoint.new(
    path: "/oauth2/callback/:providerId",
    method: ["GET", "POST"],
    metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}
  ) 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) { raise ctx.redirect(generic_oauth_error_url(error_url, error)) }

    redirect_error.call(query[:error] || "oAuth_code_missing") 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?

    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
    if existing && provider[:override_user_info]
      ctx.context.internal_adapter.update_user(
        existing[:user]["id"],
        "name" => name,
        "image" => fetch_value(mapped_user, "image"),
        "emailVerified" => !!fetch_value(mapped_user, "emailVerified")
      )
    end

    session_data = Routes.persist_social_user(
      ctx,
      provider_id,
      mapped_user.merge("email" => email, "name" => name, "id" => ),
      (ctx, provider_id, , tokens)
    )
    (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


237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/better_auth/plugins/generic_oauth.rb', line 237

def (config)
  Endpoint.new(path: "/oauth2/link", method: "POST") 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_provider(*args) ⇒ Object



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

def oauth_provider(*args)
  Kernel.require "better_auth/oauth_provider"
  BetterAuth::Plugins.oauth_provider(*args)
rescue LoadError => error
  raise if error.path && error.path != "better_auth/oauth_provider"

  raise LoadError, "BetterAuth::Plugins.oauth_provider requires the better_auth-oauth-provider gem. Add `gem \"better_auth-oauth-provider\"` and `require \"better_auth/oauth_provider\"`."
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: {
      o_auth_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



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
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 146

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: ctx.context.secret,
    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



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
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 107

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: ctx.context.secret,
    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



75
76
77
78
79
80
81
82
83
84
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 75

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)


187
188
189
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 187

def oauth_proxy_callback_path?(path)
  path.to_s.start_with?("/callback", "/oauth2/callback")
end

.oauth_proxy_current_uri(ctx, config) ⇒ Object



199
200
201
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 199

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
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 45

def oauth_proxy_endpoint(config)
  Endpoint.new(path: "/oauth-proxy-callback", method: "GET") do |ctx|
    query = normalize_hash(ctx.query)
    callback_url = query[:callback_url] || "/"
    oauth_proxy_validate_callback!(ctx, callback_url)

    decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, 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



218
219
220
221
222
223
224
225
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 218

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


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/oauth_proxy.rb', line 235

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



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

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



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 86

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: ctx.context.secret, 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_set_location(ctx, location) ⇒ Object



227
228
229
230
231
232
233
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 227

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)


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

def (path)
  path.to_s.start_with?("/sign-in/social", "/sign-in/oauth2")
end

.oauth_proxy_skip?(ctx, config) ⇒ Boolean

Returns:

  • (Boolean)


191
192
193
194
195
196
197
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 191

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


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

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



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

def oauth_proxy_strip_trailing(value)
  value.to_s.sub(%r{/+\z}, "")
end

.oauth_proxy_validate_callback!(ctx, callback_url) ⇒ Object

Raises:



211
212
213
214
215
216
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 211

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

.object_schema(properties, required: []) ⇒ Object



343
344
345
346
347
348
349
# File 'lib/better_auth/plugins/open_api.rb', line 343

def object_schema(properties, required: [])
  {
    type: "object",
    properties: properties,
    required: required
  }
end

.oidc_authorize_endpoint(config) ⇒ Object



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
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
# File 'lib/better_auth/plugins/oidc_provider.rb', line 220

def oidc_authorize_endpoint(config)
  Endpoint.new(path: "/oauth2/authorize", method: "GET") do |ctx|
    query = OAuthProtocol.stringify_keys(ctx.query)
    prompts = OIDCProvider.parse_prompt(query["prompt"])
    session = Routes.current_session(ctx, allow_nil: true)
    if !session && prompts.include?("none")
      redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "login_required", error_description: "Authentication required but prompt is none", state: query["state"], iss: OAuthProtocol.issuer(ctx))
      raise ctx.redirect(redirect)
    end
    unless session
      ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
      raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query))
    end

    client = OAuthProtocol.find_client(ctx, "oauthApplication", query["client_id"])
    raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client
    OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])

    scopes = OAuthProtocol.parse_scopes(query["scope"] || config[:default_scope])
    invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) }
    unless invalid_scopes.empty?
      redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_scope", error_description: "The following scopes are invalid: #{invalid_scopes.join(", ")}", state: query["state"], iss: OAuthProtocol.issuer(ctx))
      raise ctx.redirect(redirect)
    end
    if config[:require_pkce] && (query["code_challenge"].to_s.empty? || query["code_challenge_method"].to_s.empty?)
      redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "pkce is required", state: query["state"], iss: OAuthProtocol.issuer(ctx))
      raise ctx.redirect(redirect)
    end
    challenge_method = query["code_challenge_method"].to_s
    if !challenge_method.empty? && !valid_code_challenge_method?(challenge_method, config)
      redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "invalid code_challenge method", state: query["state"], iss: OAuthProtocol.issuer(ctx))
      raise ctx.redirect(redirect)
    end

    client_data = OAuthProtocol.stringify_keys(client)
    requires_consent = !client_data["skipConsent"] && (prompts.include?("consent") || !oidc_consent_granted?(ctx, client_data["clientId"], session[:user]["id"], scopes))
    if oidc_requires_login?(session, prompts, query)
      ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
      raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], client_id: client_data["clientId"], state: query["state"]))
    end

    if requires_consent
      if prompts.include?("none")
        redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "consent_required", error_description: "Consent required but prompt is none", state: query["state"], iss: OAuthProtocol.issuer(ctx))
        raise ctx.redirect(redirect)
      end

      consent_code = Crypto.random_string(32)
      config[:store][:consents][consent_code] = {
        query: query,
        session: session,
        client: client,
        scopes: scopes,
        expires_at: Time.now + config[:code_expires_in].to_i
      }
      unless config[:consent_page]
        renderer = config[:get_consent_html]
        raise APIError.new("INTERNAL_SERVER_ERROR", message: "No consent page provided") unless renderer.respond_to?(:call)

        ctx.set_header("content-type", "text/html")
        next renderer.call(
          scopes: scopes,
          clientMetadata: client_data["metadata"] || {},
          clientIcon: client_data["icon"],
          clientId: client_data["clientId"],
          clientName: client_data["name"],
          code: consent_code
        )
      end

      raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:consent_page], consent_code: consent_code, client_id: client_data["clientId"], scope: OAuthProtocol.scope_string(scopes)))
    end

    code = Crypto.random_string(32)
    OAuthProtocol.store_code(
      config[:store],
      code: code,
      client_id: query["client_id"],
      redirect_uri: query["redirect_uri"],
      session: session,
      scopes: scopes,
      code_challenge: query["code_challenge"],
      code_challenge_method: query["code_challenge_method"]
    )

    redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProtocol.issuer(ctx))
    raise ctx.redirect(redirect)
  end
end


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
# File 'lib/better_auth/plugins/oidc_provider.rb', line 310

def oidc_consent_endpoint(config)
  Endpoint.new(path: "/oauth2/consent", method: "POST") do |ctx|
    Routes.current_session(ctx)
    body = OAuthProtocol.stringify_keys(ctx.body)
    consent = config[:store][:consents].delete(body["consent_code"].to_s)
    raise APIError.new("BAD_REQUEST", message: "invalid consent_code") unless consent
    raise APIError.new("BAD_REQUEST", message: "expired consent_code") if consent[:expires_at] <= Time.now

    query = consent[:query]
    if body["accept"] == false || body["accept"].to_s == "false"
      redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "access_denied", state: query["state"], iss: OAuthProtocol.issuer(ctx))
      next ctx.json({redirectURI: redirect})
    end

    oidc_store_consent(ctx, consent[:client], consent[:session], consent[:scopes])
    code = Crypto.random_string(32)
    OAuthProtocol.store_code(
      config[:store],
      code: code,
      client_id: query["client_id"],
      redirect_uri: query["redirect_uri"],
      session: consent[:session],
      scopes: consent[:scopes],
      code_challenge: query["code_challenge"],
      code_challenge_method: query["code_challenge_method"]
    )
    ctx.json({redirectURI: OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProtocol.issuer(ctx))})
  end
end

Returns:

  • (Boolean)


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

def oidc_consent_granted?(ctx, client_id, user_id, scopes)
  consent = ctx.context.adapter.find_one(
    model: "oauthConsent",
    where: [
      {field: "clientId", value: client_id},
      {field: "userId", value: user_id}
    ]
  )
  return false unless consent && consent["consentGiven"]

  granted = OAuthProtocol.parse_scopes(consent["scopes"])
  scopes.all? { |scope| granted.include?(scope) }
end

.oidc_delete_client_endpointObject



211
212
213
214
215
216
217
218
# File 'lib/better_auth/plugins/oidc_provider.rb', line 211

def oidc_delete_client_endpoint
  Endpoint.new(path: "/oauth2/client/:id", method: "DELETE") do |ctx|
    session = Routes.current_session(ctx)
    client = oidc_find_owned_client!(ctx, session)
    ctx.context.adapter.delete(model: "oauthApplication", where: [{field: "id", value: client.fetch("id")}])
    ctx.json({success: true})
  end
end

.oidc_end_session_endpointObject



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

def oidc_end_session_endpoint
  Endpoint.new(path: "/oauth2/endsession", method: ["GET", "POST"], metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
    input_source = (ctx.method == "GET") ? ctx.query : ctx.body
    input = OAuthProtocol.stringify_keys(input_source)
    if input["post_logout_redirect_uri"]
      client = OAuthProtocol.find_client(ctx, "oauthApplication", input["client_id"])
      raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client
      unless OAuthProtocol.client_logout_redirect_uris(client).include?(input["post_logout_redirect_uri"])
        raise APIError.new("BAD_REQUEST", message: "invalid_request")
      end
    end

    Cookies.delete_session_cookie(ctx)
    redirect = input["post_logout_redirect_uri"] || "/"
    redirect = OAuthProtocol.redirect_uri_with_params(redirect, state: input["state"]) if input["state"]
    raise ctx.redirect(redirect)
  end
end

.oidc_find_owned_client!(ctx, session) ⇒ Object

Raises:



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

def oidc_find_owned_client!(ctx, session)
  client = OAuthProtocol.find_client(ctx, "oauthApplication", ctx.params["id"] || ctx.params[:id])
  raise APIError.new("NOT_FOUND", message: "client not found") unless client
  raise APIError.new("FORBIDDEN", message: "Access denied") unless client["userId"] == session[:user]["id"]

  client
end

.oidc_get_client_endpointObject



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

def oidc_get_client_endpoint
  Endpoint.new(path: "/oauth2/client/:id", method: "GET") do |ctx|
    client = OAuthProtocol.find_client(ctx, "oauthApplication", ctx.params["id"] || ctx.params[:id])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client

    ctx.json(OAuthProtocol.client_response(client, include_secret: false))
  end
end

.oidc_id_token_signer(ctx, config) ⇒ Object



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

def oidc_id_token_signer(ctx, config)
  jwt_plugin = oidc_use_jwt_plugin?(ctx, config)
  return nil unless jwt_plugin

  lambda do |sign_ctx, payload|
    BetterAuth::Plugins.sign_jwt_payload(
      sign_ctx,
      OAuthProtocol.stringify_keys(payload),
      jwt_plugin[:options] || {}
    )
  end
end

.oidc_introspect_endpoint(config) ⇒ Object



382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# File 'lib/better_auth/plugins/oidc_provider.rb', line 382

def oidc_introspect_endpoint(config)
  Endpoint.new(path: "/oauth2/introspect", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
    OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret])
    body = OAuthProtocol.stringify_keys(ctx.body)
    token = config[:store][:tokens][body["token"].to_s] || config[:store][:refresh_tokens][body["token"].to_s]
    active = token && !token["revoked"] && (!token["expiresAt"] || token["expiresAt"] > Time.now)
    ctx.json(active ? {
      active: true,
      client_id: token["clientId"],
      scope: OAuthProtocol.scope_string(token["scope"] || token["scopes"]),
      sub: token.dig("user", "id"),
      exp: token["expiresAt"]&.to_i
    } : {active: false})
  end
end

.oidc_jwt_plugin(ctx) ⇒ Object



521
522
523
# File 'lib/better_auth/plugins/oidc_provider.rb', line 521

def oidc_jwt_plugin(ctx)
  ctx.context.options.plugins.find { |plugin| plugin[:id] == "jwt" }
end

.oidc_list_clients_endpointObject



157
158
159
160
161
162
163
# File 'lib/better_auth/plugins/oidc_provider.rb', line 157

def oidc_list_clients_endpoint
  Endpoint.new(path: "/oauth2/clients", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    clients = ctx.context.adapter.find_many(model: "oauthApplication", where: [{field: "userId", value: session[:user]["id"]}])
    ctx.json(clients.map { |client| OAuthProtocol.client_response(client, include_secret: false) })
  end
end

.oidc_metadata_endpoint(config) ⇒ Object



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
# File 'lib/better_auth/plugins/oidc_provider.rb', line 85

def (config)
  Endpoint.new(path: "/.well-known/openid-configuration", method: "GET", metadata: {hide: true}) do |ctx|
    base = OAuthProtocol.endpoint_base(ctx)
    supported_algs = oidc_use_jwt_plugin?(ctx, config) ? ["RS256", "EdDSA", "none"] : ["HS256", "none"]
    ctx.json({
      issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)),
      authorization_endpoint: "#{base}/oauth2/authorize",
      token_endpoint: "#{base}/oauth2/token",
      userinfo_endpoint: "#{base}/oauth2/userinfo",
      jwks_uri: "#{base}/jwks",
      registration_endpoint: "#{base}/oauth2/register",
      introspection_endpoint: "#{base}/oauth2/introspect",
      revocation_endpoint: "#{base}/oauth2/revoke",
      end_session_endpoint: "#{base}/oauth2/endsession",
      scopes_supported: config[:scopes],
      response_types_supported: ["code"],
      response_modes_supported: ["query"],
      grant_types_supported: ["authorization_code", "refresh_token"],
      acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"],
      subject_types_supported: ["public"],
      id_token_signing_alg_values_supported: supported_algs,
      token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
      introspection_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
      revocation_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
      code_challenge_methods_supported: ["S256"],
      claims_supported: %w[sub iss aud exp nbf iat jti email email_verified name]
    }.merge(config[:metadata] || {}))
  end
end

.oidc_provider(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
# File 'lib/better_auth/plugins/oidc_provider.rb', line 36

def oidc_provider(options = {})
  config = {
    code_expires_in: 600,
    consent_page: "/oauth2/authorize",
    login_page: "/login",
    default_scope: "openid",
    access_token_expires_in: 3600,
    refresh_token_expires_in: 604_800,
    allow_plain_code_challenge_method: true,
    store_client_secret: "plain",
    scopes: %w[openid profile email offline_access],
    store: OAuthProtocol.stores
  }.merge(normalize_hash(options))

  Plugin.new(
    id: "oidc-provider",
    endpoints: oidc_provider_endpoints(config),
    hooks: {
      after: [
        {
          matcher: ->(ctx) { ctx.path.start_with?("/sign-in/", "/sign-up/") },
          handler: ->(ctx) { (ctx, config) }
        }
      ]
    },
    schema: oidc_provider_schema,
    options: config
  )
end

.oidc_provider_endpoints(config) ⇒ Object



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/better_auth/plugins/oidc_provider.rb', line 66

def oidc_provider_endpoints(config)
  {
    get_open_id_config: (config),
    o_auth2_authorize: oidc_authorize_endpoint(config),
    o_auth_consent: oidc_consent_endpoint(config),
    o_auth2_token: oidc_token_endpoint(config),
    o_auth2_introspect: oidc_introspect_endpoint(config),
    o_auth2_revoke: oidc_revoke_endpoint(config),
    o_auth2_user_info: oidc_userinfo_endpoint(config),
    register_o_auth_application: oidc_register_endpoint(config),
    get_o_auth_client: oidc_get_client_endpoint,
    list_o_auth_applications: oidc_list_clients_endpoint,
    update_o_auth_application: oidc_update_client_endpoint,
    rotate_o_auth_application_secret: oidc_rotate_client_secret_endpoint(config),
    delete_o_auth_application: oidc_delete_client_endpoint,
    end_session: oidc_end_session_endpoint
  }
end

.oidc_provider_schemaObject



428
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
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/better_auth/plugins/oidc_provider.rb', line 428

def oidc_provider_schema
  {
    oauthApplication: {
      modelName: "oauthApplication",
      fields: {
        name: {type: "string"},
        icon: {type: "string", required: false},
        uri: {type: "string", required: false},
        metadata: {type: "json", required: false},
        clientId: {type: "string", unique: true},
        clientSecret: {type: "string", required: false},
        redirectUrls: {type: "string"},
        redirectUris: {type: "string[]", required: false},
        postLogoutRedirectUris: {type: "string[]", required: false},
        tokenEndpointAuthMethod: {type: "string", required: false},
        skipConsent: {type: "boolean", required: false},
        grantTypes: {type: "string[]", required: false},
        responseTypes: {type: "string[]", required: false},
        scopes: {type: "string[]", required: false},
        type: {type: "string"},
        disabled: {type: "boolean", required: false, default_value: false},
        userId: {type: "string", required: false, references: {model: "users", field: "id", on_delete: "cascade"}, index: true},
        createdAt: {type: "date", required: true, default_value: -> { Time.now }},
        updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
      }
    },
    oauthAccessToken: {
      modelName: "oauthAccessToken",
      fields: {
        accessToken: {type: "string", unique: true, required: false},
        token: {type: "string", unique: true, required: false},
        refreshToken: {type: "string", unique: true, required: false},
        accessTokenExpiresAt: {type: "date", required: false},
        expiresAt: {type: "date", required: false},
        clientId: {type: "string", required: true},
        userId: {type: "string", required: false},
        sessionId: {type: "string", required: false},
        scope: {type: "string", required: false},
        scopes: {type: "string[]", required: false},
        revoked: {type: "date", required: false},
        createdAt: {type: "date", required: true, default_value: -> { Time.now }},
        updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
      }
    },
    oauthConsent: {
      modelName: "oauthConsent",
      fields: {
        clientId: {type: "string", required: true},
        userId: {type: "string", required: true},
        scopes: {type: "string[]", required: false},
        consentGiven: {type: "boolean", required: false},
        createdAt: {type: "date", required: true, default_value: -> { Time.now }},
        updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
      }
    }
  }
end

.oidc_register_endpoint(config) ⇒ Object



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
# File 'lib/better_auth/plugins/oidc_provider.rb', line 115

def oidc_register_endpoint(config)
  Endpoint.new(path: "/oauth2/register", method: "POST") do |ctx|
    session = Routes.current_session(ctx, allow_nil: true)
    unless session || config[:allow_dynamic_client_registration]
      raise APIError.new("UNAUTHORIZED", message: "invalid_token")
    end

    body = OAuthProtocol.stringify_keys(ctx.body)
    grant_types = Array(body["grant_types"] || [OAuthProtocol::AUTH_CODE_GRANT])
    response_types = Array(body["response_types"] || ["code"])
    redirects = Array(body["redirect_uris"]).map(&:to_s)
    if (grant_types.empty? || grant_types.include?(OAuthProtocol::AUTH_CODE_GRANT) || grant_types.include?("implicit")) && redirects.empty?
      raise APIError.new("BAD_REQUEST", message: "invalid_redirect_uri")
    end
    if grant_types.include?(OAuthProtocol::AUTH_CODE_GRANT) && !response_types.include?("code")
      raise APIError.new("BAD_REQUEST", message: "invalid_client_metadata")
    end
    if grant_types.include?("implicit") && !response_types.include?("token")
      raise APIError.new("BAD_REQUEST", message: "invalid_client_metadata")
    end

    client = OAuthProtocol.create_client(
      ctx,
      model: "oauthApplication",
      body: body,
      owner_session: session,
      default_auth_method: "client_secret_basic",
      store_client_secret: config[:store_client_secret]
    )
    ctx.json(client, status: 201, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
  end
end

.oidc_requires_login?(session, prompts, query) ⇒ Boolean

Returns:

  • (Boolean)


501
502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/better_auth/plugins/oidc_provider.rb', line 501

def oidc_requires_login?(session, prompts, query)
  return true if prompts.include?("login")
  return false unless query.key?("max_age")

  max_age = Integer(query["max_age"])
  return false if max_age.negative?

  created_at = session.dig(:session, "createdAt") || session.dig(:session, :createdAt)
  created_at = Time.parse(created_at.to_s) unless created_at.is_a?(Time)
  (Time.now - created_at) > max_age
rescue ArgumentError, TypeError
  false
end

.oidc_resume_login_prompt(ctx, config) ⇒ Object



570
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
# File 'lib/better_auth/plugins/oidc_provider.rb', line 570

def (ctx, config)
  prompt = ctx.get_signed_cookie("oidc_login_prompt", ctx.context.secret)
  return unless prompt
  return unless ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)

  ctx.set_cookie("oidc_login_prompt", "", path: "/", max_age: 0)
  query = JSON.parse(prompt)
  prompts = OIDCProvider.parse_prompt(query["prompt"])
  if prompts.include?("login")
    prompts.delete("login")
    query["prompt"] = prompts.to_a.join(" ")
  end
  ctx.query = query
  ctx.context.set_current_session(ctx.context.new_session) if ctx.context.respond_to?(:set_current_session) && ctx.context.new_session
  oidc_authorize_endpoint(config).call(ctx)
rescue APIError => error
  raise APIError.new(
    error.status,
    message: error.message,
    headers: Endpoint::Result.merge_headers(ctx.response_headers, error.headers),
    code: error.code,
    body: error.body
  )
rescue JSON::ParserError
  nil
end

.oidc_revoke_endpoint(config) ⇒ Object



398
399
400
401
402
403
404
405
406
407
# File 'lib/better_auth/plugins/oidc_provider.rb', line 398

def oidc_revoke_endpoint(config)
  Endpoint.new(path: "/oauth2/revoke", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
    OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret])
    body = OAuthProtocol.stringify_keys(ctx.body)
    if (token = config[:store][:tokens][body["token"].to_s] || config[:store][:refresh_tokens][body["token"].to_s])
      token["revoked"] = Time.now
    end
    ctx.json({revoked: true})
  end
end

.oidc_rotate_client_secret_endpoint(config) ⇒ Object



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

def oidc_rotate_client_secret_endpoint(config)
  Endpoint.new(path: "/oauth2/client/:id/rotate-secret", method: "POST") do |ctx|
    session = Routes.current_session(ctx)
    client = oidc_find_owned_client!(ctx, session)
    if OAuthProtocol.stringify_keys(client)["tokenEndpointAuthMethod"] == "none"
      raise APIError.new("BAD_REQUEST", message: "invalid_client")
    end

    client_secret = Crypto.random_string(32)
    updated = ctx.context.adapter.update(
      model: "oauthApplication",
      where: [{field: "id", value: client.fetch("id")}],
      update: {clientSecret: OAuthProtocol.store_client_secret_value(ctx, client_secret, config[:store_client_secret]), updatedAt: Time.now}
    )
    ctx.json(OAuthProtocol.client_response(updated, include_secret: false).merge(client_secret: client_secret))
  end
end


552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
# File 'lib/better_auth/plugins/oidc_provider.rb', line 552

def oidc_store_consent(ctx, client, session, scopes)
  client_id = OAuthProtocol.stringify_keys(client)["clientId"]
  user_id = session[:user]["id"]
  existing = ctx.context.adapter.find_one(
    model: "oauthConsent",
    where: [
      {field: "clientId", value: client_id},
      {field: "userId", value: user_id}
    ]
  )
  data = {clientId: client_id, userId: user_id, scopes: scopes, consentGiven: true}
  if existing
    ctx.context.adapter.update(model: "oauthConsent", where: [{field: "id", value: existing.fetch("id")}], update: data)
  else
    ctx.context.adapter.create(model: "oauthConsent", data: data)
  end
end

.oidc_token_endpoint(config) ⇒ Object



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
# File 'lib/better_auth/plugins/oidc_provider.rb', line 340

def oidc_token_endpoint(config)
  Endpoint.new(path: "/oauth2/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret])
    raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client

    response = case body["grant_type"]
    when OAuthProtocol::AUTH_CODE_GRANT
      code = OAuthProtocol.consume_code!(
        config[:store],
        body["code"],
        client_id: body["client_id"],
        redirect_uri: body["redirect_uri"],
        code_verifier: body["code_verifier"]
      )
      OAuthProtocol.issue_tokens(
        ctx,
        config[:store],
        model: "oauthAccessToken",
        client: client,
        session: code[:session],
        scopes: code[:scopes],
        include_refresh: code[:scopes].include?("offline_access"),
        issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)),
        access_token_expires_in: config[:access_token_expires_in],
        id_token_signer: oidc_id_token_signer(ctx, config)
      )
    when OAuthProtocol::REFRESH_GRANT
      OAuthProtocol.refresh_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, refresh_token: body["refresh_token"], scopes: body["scope"], issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)), access_token_expires_in: config[:access_token_expires_in], id_token_signer: oidc_id_token_signer(ctx, config))
    else
      raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
    end
    ctx.json(response)
  end
end

.oidc_update_client_endpointObject



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/oidc_provider.rb', line 165

def oidc_update_client_endpoint
  Endpoint.new(path: "/oauth2/client/:id", method: "PATCH") do |ctx|
    session = Routes.current_session(ctx)
    client = oidc_find_owned_client!(ctx, session)
    body = OAuthProtocol.stringify_keys(ctx.body)
    update_source = OAuthProtocol.stringify_keys(body["update"] || body)
    update = {}
    if update_source.key?("client_name") || update_source.key?("name")
      update["name"] = update_source["client_name"] || update_source["name"]
    end
    update["uri"] = update_source["client_uri"] if update_source.key?("client_uri")
    update["icon"] = update_source["logo_uri"] if update_source.key?("logo_uri")
    if update_source.key?("redirect_uris")
      redirects = Array(update_source["redirect_uris"]).map(&:to_s)
      update["redirectUris"] = redirects
      update["redirectUrls"] = redirects.join(",")
    end
    update["postLogoutRedirectUris"] = Array(update_source["post_logout_redirect_uris"]).map(&:to_s) if update_source.key?("post_logout_redirect_uris")
    update["grantTypes"] = Array(update_source["grant_types"]).map(&:to_s) if update_source.key?("grant_types")
    update["responseTypes"] = Array(update_source["response_types"]).map(&:to_s) if update_source.key?("response_types")
    update["scopes"] = OAuthProtocol.parse_scopes(update_source["scope"] || update_source["scopes"]) if update_source.key?("scope") || update_source.key?("scopes")
    update["metadata"] = update_source["metadata"] if update_source.key?("metadata")
    update["updatedAt"] = Time.now
    updated = update.empty? ? client : ctx.context.adapter.update(model: "oauthApplication", where: [{field: "id", value: client.fetch("id")}], update: update)
    ctx.json(OAuthProtocol.client_response(updated, include_secret: false))
  end
end

.oidc_use_jwt_plugin?(ctx, config) ⇒ Boolean

Returns:

  • (Boolean)


515
516
517
518
519
# File 'lib/better_auth/plugins/oidc_provider.rb', line 515

def oidc_use_jwt_plugin?(ctx, config)
  return false unless config[:use_jwt_plugin]

  oidc_jwt_plugin(ctx)
end

.oidc_userinfo_endpoint(config) ⇒ Object



376
377
378
379
380
# File 'lib/better_auth/plugins/oidc_provider.rb', line 376

def oidc_userinfo_endpoint(config)
  Endpoint.new(path: "/oauth2/userinfo", method: "GET") do |ctx|
    ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"], additional_claim: config[:get_additional_user_info_claim]))
  end
end

.okta(options = {}) ⇒ Object



172
173
174
175
176
# File 'lib/better_auth/plugins/generic_oauth.rb', line 172

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_boolean_value(value) ⇒ Object



150
151
152
# File 'lib/better_auth/plugins/one_tap.rb', line 150

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
# 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: {
        summary: "One tap callback",
        description: "Use this endpoint to authenticate with Google One Tap"
      }
    }
  ) 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

    if email.empty?
      next ctx.json({error: "Email not available in token"})
    end

    user = ctx.context.internal_adapter.find_user_by_email(email)
    if user
      (ctx, config, user, payload, id_token)
      session_data = one_tap_create_session(ctx, user[:user])
    else
      raise APIError.new("BAD_GATEWAY", message: "User not found") if config[:disable_signup]

      created = ctx.context.internal_adapter.create_oauth_user(
        {
          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: fetch_value(payload, "sub").to_s,
          idToken: id_token
        }
      )
      raise APIError.new("INTERNAL_SERVER_ERROR", message: "Could not create user") unless created

      session_data = one_tap_create_session(ctx, created[:user])
    end

    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_create_session(ctx, user) ⇒ Object



137
138
139
140
# File 'lib/better_auth/plugins/one_tap.rb', line 137

def one_tap_create_session(ctx, user)
  session = ctx.context.internal_adapter.create_session(user["id"])
  {session: session, user: user}
end

.one_tap_google_jwksObject



107
108
109
110
111
112
113
# File 'lib/better_auth/plugins/one_tap.rb', line 107

def one_tap_google_jwks
  uri = URI("https://www.googleapis.com/oauth2/v3/certs")
  response = Net::HTTP.get_response(uri)
  raise "Unable to fetch Google JWKS" unless response.is_a?(Net::HTTPSuccess)

  JWT::JWK::Set.new(JSON.parse(response.body))
end


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

def (ctx, _config, user, payload, id_token)
  sub = fetch_value(payload, "sub").to_s
   = ctx.context.internal_adapter.(sub)
  return if 

   = ctx.context.options.[:account_linking] || {}
  trusted = Array([:trusted_providers]).map(&:to_s).include?("google")
  enabled = .fetch(:enabled, true)
   = enabled != false && (trusted || one_tap_boolean_value(fetch_value(payload, "email_verified")))
  unless 
    raise APIError.new("UNAUTHORIZED", message: "Google sub doesn't match")
  end

  ctx.context.internal_adapter.(
    userId: user[:user]["id"],
    providerId: "google",
    accountId: sub,
    scope: "openid,profile,email",
    idToken: id_token
  )
end

.one_tap_stringify_payload(payload) ⇒ Object



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

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



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

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
  }
  if audience
    options[:aud] = audience
    options[:verify_aud] = true
  end
  payload, = JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
  payload
end

.one_tap_verify_id_token(ctx, config, id_token) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/better_auth/plugins/one_tap.rb', line 79

def one_tap_verify_id_token(ctx, config, id_token)
  verifier = config[:verify_id_token]
  audience = config[:client_id] || ctx.context.options.social_providers.dig(:google, :client_id)
  payload = if verifier.respond_to?(:call)
    verifier.call(id_token, ctx, audience: audience)
  else
    one_tap_verify_google_id_token(id_token, audience)
  end
  one_tap_stringify_payload(payload)
rescue
  raise APIError.new("BAD_REQUEST", message: "invalid id token")
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



63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/better_auth/plugins/one_time_token.rb', line 63

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



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

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



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

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



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

def open_api(options = {})
  config = {path: "/reference", theme: "default"}.merge(normalize_hash(options))

  Plugin.new(
    id: "open-api",
    endpoints: {
      generate_open_api_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



440
441
442
443
444
445
# File 'lib/better_auth/plugins/open_api.rb', line 440

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_endpoints(options) ⇒ Object



59
60
61
62
63
64
65
66
67
# File 'lib/better_auth/plugins/open_api.rb', line 59

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



470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
# File 'lib/better_auth/plugins/open_api.rb', line 470

def open_api_html(schema, config)
  nonce = config[:nonce] ? " nonce=\"#{config[:nonce]}\"" : ""
  <<~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}>window.scalarTheme = "#{config[:theme]}";</script>
        <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"#{nonce}></script>
      </body>
    </html>
  HTML
end

.open_api_operation(endpoint, method, tag) ⇒ Object



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

def open_api_operation(endpoint, method, tag)
   = endpoint.[:openapi] || {}
  operation = {
    tags: Array([:tags] || [tag]),
    description: [:description] || route_description(endpoint.path, method),
    operationId: .key?(:operationId) ? [:operationId] : route_operation_id(endpoint.path, method),
    security: [
      {
        bearerAuth: []
      }
    ],
    parameters: [:parameters] || [],
    responses: open_api_responses([:responses] || route_responses(endpoint.path, method))
  }

  if %w[POST PATCH PUT].include?(method)
    operation[:requestBody] = [:requestBody] || route_request_body(endpoint.path, method) || empty_request_body
  end

  operation
end

.open_api_path(path) ⇒ Object



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

def open_api_path(path)
  path.split("/").map { |part| part.start_with?(":") ? "{#{part.delete_prefix(":")}}" : part }.join("/")
end

.open_api_paths(endpoints, options) ⇒ Object



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

def open_api_paths(endpoints, options)
  disabled_paths = Array(options.disabled_paths).map(&:to_s)
  endpoints.each_with_object({}) do |(_key, endpoint, tag), paths|
    next unless endpoint.path
    next if endpoint.[:hide] || endpoint.[:SERVER_ONLY] || endpoint.[:server_only]
    next if disabled_paths.include?(endpoint.path)

    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)
    end
  end
end

.open_api_responses(responses = nil) ⇒ Object



391
392
393
# File 'lib/better_auth/plugins/open_api.rb', line 391

def open_api_responses(responses = nil)
  {"200" => success_response}.merge(default_error_responses).merge(responses || {})
end

.open_api_schema(context) ⇒ Object



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
# File 'lib/better_auth/plugins/open_api.rb', line 26

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.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



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

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

.open_api_user_response_schemaObject



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

def open_api_user_response_schema
  object_schema(
    {
      id: {type: "string", description: "The unique identifier of the user"},
      email: {type: "string", format: "email", description: "The email address of the user"},
      name: {type: "string", description: "The name of the user"},
      image: {type: "string", format: "uri", nullable: true, description: "The profile image URL of the user"},
      emailVerified: {type: "boolean", description: "Whether the email has been verified"},
      createdAt: {type: "string", format: "date-time", description: "When the user was created"},
      updatedAt: {type: "string", format: "date-time", description: "When the user was last updated"}
    },
    required: ["id", "email", "name", "emailVerified", "createdAt", "updatedAt"]
  )
end

.org_truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


986
987
988
# File 'lib/better_auth/plugins/organization.rb', line 986

def org_truthy?(value)
  value == true || value.to_s == "true"
end

.organization(options = {}) ⇒ Object



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
# File 'lib/better_auth/plugins/organization.rb', line 77

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,
    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



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/organization.rb', line 356

def organization_accept_invitation_endpoint(config)
  Endpoint.new(path: "/organization/accept-invitation", method: "POST") do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    invitation = invitation_by_id(ctx, body[:invitation_id] || body[:id])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation
    raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION")) unless invitation["email"].to_s.downcase == session[:user]["email"].to_s.downcase
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation["status"] == "pending"
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) if invitation["expiresAt"] && Time.parse(invitation["expiresAt"].to_s) < Time.now
    if config[:require_email_verification_on_invitation] && !session[:user]["emailVerified"]
      raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION"))
    end
    member = ctx.context.adapter.create(model: "member", data: {organizationId: invitation["organizationId"], userId: session[:user]["id"], role: invitation["role"], createdAt: Time.now})
    organization_team_ids(invitation["teamId"]).each do |team_id|
      ctx.context.adapter.create(model: "teamMember", data: {teamId: team_id, userId: session[:user]["id"], createdAt: Time.now})
    end
    updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "accepted"})
    ctx.json({invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member)})
  end
end

.organization_add_member_endpoint(config) ⇒ Object



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/better_auth/plugins/organization.rb', line 426

def organization_add_member_endpoint(config)
  Endpoint.new(path: "/organization/add-member", method: "POST") do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization_id = body[:organization_id]
    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?
    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
    organization = organization_by_id(ctx, organization_id)
    user = ctx.context.internal_adapter.find_user_by_id(user_id)
    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))
    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)
    ctx.json(member_wire(ctx, member))
  end
end

.organization_add_team_member_endpoint(config) ⇒ Object



624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
# File 'lib/better_auth/plugins/organization.rb', line 624

def organization_add_team_member_endpoint(config)
  Endpoint.new(path: "/organization/add-team-member", method: "POST") 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"])
    max_members = config.dig(:teams, :maximum_members_per_team)
    if max_members && ctx.context.adapter.count(model: "teamMember", where: [{field: "teamId", value: team["id"]}]) >= max_members.to_i
      raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_MEMBER_LIMIT_REACHED"))
    end
    existing = ctx.context.adapter.find_one(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: user_id}])
    next ctx.json(team_member_wire(ctx, existing)) if existing

    member = ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: user_id, createdAt: Time.now})
    ctx.json(team_member_wire(ctx, member))
  end
end

.organization_by_id(ctx, id) ⇒ Object



813
814
815
816
817
# File 'lib/better_auth/plugins/organization.rb', line 813

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



819
820
821
822
823
# File 'lib/better_auth/plugins/organization.rb', line 819

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_cancel_invitation_endpoint(config) ⇒ Object



388
389
390
391
392
393
394
395
396
397
# File 'lib/better_auth/plugins/organization.rb', line 388

def organization_cancel_invitation_endpoint(config)
  Endpoint.new(path: "/organization/cancel-invitation", method: "POST") 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"))
    updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "canceled"})
    ctx.json(invitation_wire(ctx, updated))
  end
end

.organization_check_slug_endpointObject



206
207
208
209
210
211
212
213
214
# File 'lib/better_auth/plugins/organization.rb', line 206

def organization_check_slug_endpoint
  Endpoint.new(path: "/organization/check-slug", method: "POST") do |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



137
138
139
140
141
142
143
144
145
146
147
# File 'lib/better_auth/plugins/organization.rb', line 137

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



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
199
200
201
202
203
204
# File 'lib/better_auth/plugins/organization.rb', line 158

def organization_create_endpoint(config)
  Endpoint.new(path: "/organization/create", method: "POST") 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



657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
# File 'lib/better_auth/plugins/organization.rb', line 657

def organization_create_role_endpoint(config)
  Endpoint.new(path: "/organization/create-role", method: "POST") 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_permission?(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



528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
# File 'lib/better_auth/plugins/organization.rb', line 528

def organization_create_team_endpoint(config)
  Endpoint.new(path: "/organization/create-team", method: "POST") 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)
    max_teams = config.dig(:teams, :maximum_teams)
    if max_teams && ctx.context.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
    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 = ctx.context.adapter.create(model: "team", data: team_data)
    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)
    ctx.json(team_wire(ctx, team))
  end
end

.organization_created_count(ctx, user_id) ⇒ Object



920
921
922
923
# File 'lib/better_auth/plugins/organization.rb', line 920

def organization_created_count(ctx, user_id)
  members = ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: user_id}])
  members.count { |member| member["role"].to_s.split(",").include?("owner") }
end

.organization_default_roles(config = {}) ⇒ Object



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

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



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/better_auth/plugins/organization.rb', line 250

def organization_delete_endpoint(config)
  Endpoint.new(path: "/organization/delete", method: "POST") do |ctx|
    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)
    if org_truthy?(config.dig(:teams, :enabled))
      team_ids = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: organization["id"]}]).map { |team| team["id"] }
      ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any?
      ctx.context.adapter.delete_many(model: "team", where: [{field: "organizationId", value: organization["id"]}])
    end
    ctx.context.adapter.delete_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}])
    ctx.context.adapter.delete_many(model: "member", where: [{field: "organizationId", value: organization["id"]}])
    ctx.context.adapter.delete_many(model: "organizationRole", where: [{field: "organizationId", value: organization["id"]}]) if org_truthy?(config.dig(:dynamic_access_control, :enabled))
    ctx.context.adapter.delete(model: "organization", where: [{field: "id", value: organization["id"]}])
    ctx.json({status: true})
  end
end

.organization_delete_role_endpoint(config) ⇒ Object



724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
# File 'lib/better_auth/plugins/organization.rb', line 724

def organization_delete_role_endpoint(config)
  Endpoint.new(path: "/organization/delete-role", method: "POST") 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_get_active_member_endpoint(_config) ⇒ Object



473
474
475
476
477
478
479
480
481
# File 'lib/better_auth/plugins/organization.rb', line 473

def organization_get_active_member_endpoint(_config)
  Endpoint.new(path: "/organization/get-active-member", method: "GET") 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



483
484
485
486
487
488
489
490
491
# File 'lib/better_auth/plugins/organization.rb', line 483

def organization_get_active_member_role_endpoint(_config)
  Endpoint.new(path: "/organization/get-active-member-role", method: "GET") 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({role: member["role"]})
  end
end

.organization_get_full_endpoint(config) ⇒ Object



284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/better_auth/plugins/organization.rb', line 284

def organization_get_full_endpoint(config)
  Endpoint.new(path: "/organization/get-full-organization", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    query = normalize_hash(ctx.query)
    organization = organization_by_slug(ctx, query[:organization_slug]) || organization_by_id(ctx, query[:organization_id] || session[:session]["activeOrganizationId"])
    next ctx.json(nil) unless organization

    require_member!(ctx, session[:user]["id"], organization["id"])
    members = list_members_for(ctx, organization["id"])
    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_endpointObject



399
400
401
402
403
404
405
# File 'lib/better_auth/plugins/organization.rb', line 399

def organization_get_invitation_endpoint
  Endpoint.new(path: "/organization/get-invitation", method: "GET") do |ctx|
    invitation = invitation_by_id(ctx, normalize_hash(ctx.query)[:id] || normalize_hash(ctx.query)[:invitation_id])
    raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation
    ctx.json(invitation_wire(ctx, invitation))
  end
end

.organization_get_role_endpoint(config) ⇒ Object



687
688
689
690
691
692
693
694
695
696
697
# File 'lib/better_auth/plugins/organization.rb', line 687

def organization_get_role_endpoint(config)
  Endpoint.new(path: "/organization/get-role", method: "GET") 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



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

def organization_has_permission_endpoint(config)
  Endpoint.new(path: "/organization/has-permission", method: "POST") 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



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
# File 'lib/better_auth/plugins/organization.rb', line 306

def organization_invite_endpoint(config)
  Endpoint.new(path: "/organization/invite-member", method: "POST") do |ctx|
    session = Routes.current_session(ctx)
    body = normalize_hash(ctx.body)
    organization = organization_by_id(ctx, body[: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"], {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
    pending = ctx.context.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| ctx.context.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 = ctx.context.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
    team_ids = organization_team_ids(body[:team_id] || body[:team_ids])
    invitation = ctx.context.adapter.create(
      model: "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
      }
    )
    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)
    ctx.json(invitation_wire(ctx, invitation))
  end
end

.organization_leave_endpoint(config) ⇒ Object



493
494
495
496
497
498
499
500
501
502
503
# File 'lib/better_auth/plugins/organization.rb', line 493

def organization_leave_endpoint(config)
  Endpoint.new(path: "/organization/leave", method: "POST") do |ctx|
    session = Routes.current_session(ctx)
    organization_id = normalize_hash(ctx.body)[:organization_id]
    member = require_member!(ctx, session[:user]["id"], organization_id)
    ensure_not_last_owner!(ctx, member)
    ctx.context.adapter.delete(model: "member", where: [{field: "id", value: member["id"]}])
    ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "userId", value: session[:user]["id"]}]) if org_truthy?(config.dig(:teams, :enabled))
    ctx.json({status: true})
  end
end

.organization_list_endpointObject



216
217
218
219
220
221
222
223
# File 'lib/better_auth/plugins/organization.rb', line 216

def organization_list_endpoint
  Endpoint.new(path: "/organization/list", method: "GET") 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



407
408
409
410
411
412
413
414
415
416
# File 'lib/better_auth/plugins/organization.rb', line 407

def organization_list_invitations_endpoint(config)
  Endpoint.new(path: "/organization/list-invitations", method: "GET") 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



505
506
507
508
509
510
511
512
513
514
# File 'lib/better_auth/plugins/organization.rb', line 505

def organization_list_members_endpoint(_config)
  Endpoint.new(path: "/organization/list-members", method: "GET") 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")
    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))
  end
end

.organization_list_roles_endpoint(config) ⇒ Object



676
677
678
679
680
681
682
683
684
685
# File 'lib/better_auth/plugins/organization.rb', line 676

def organization_list_roles_endpoint(config)
  Endpoint.new(path: "/organization/list-roles", method: "GET") 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



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

def organization_list_team_members_endpoint(_config)
  Endpoint.new(path: "/organization/list-team-members", method: "GET") 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



548
549
550
551
552
553
554
555
556
# File 'lib/better_auth/plugins/organization.rb', line 548

def organization_list_teams_endpoint(_config)
  Endpoint.new(path: "/organization/list-teams", method: "GET") 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



418
419
420
421
422
423
424
# File 'lib/better_auth/plugins/organization.rb', line 418

def organization_list_user_invitations_endpoint
  Endpoint.new(path: "/organization/list-user-invitations", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    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



604
605
606
607
608
609
610
# File 'lib/better_auth/plugins/organization.rb', line 604

def organization_list_user_teams_endpoint
  Endpoint.new(path: "/organization/list-user-teams", method: "GET") 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_permission?(ctx, config, role_string, permissions, organization_id) ⇒ Boolean

Returns:

  • (Boolean)


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

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_reject_invitation_endpoint(_config) ⇒ Object



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

def organization_reject_invitation_endpoint(_config)
  Endpoint.new(path: "/organization/reject-invitation", method: "POST") 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
    raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION")) unless invitation["email"].to_s.downcase == session[:user]["email"].to_s.downcase
    updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "rejected"})
    ctx.json(invitation_wire(ctx, updated))
  end
end

.organization_remove_member_endpoint(config) ⇒ Object



447
448
449
450
451
452
453
454
455
456
457
458
459
# File 'lib/better_auth/plugins/organization.rb', line 447

def organization_remove_member_endpoint(config)
  Endpoint.new(path: "/organization/remove-member", method: "POST") 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"))
    ensure_not_last_owner!(ctx, member)
    ctx.context.adapter.delete(model: "member", where: [{field: "id", value: member["id"]}])
    ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "userId", value: member["userId"]}]) if org_truthy?(config.dig(:teams, :enabled))
    ctx.json({status: true})
  end
end

.organization_remove_team_endpoint(config) ⇒ Object



570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
# File 'lib/better_auth/plugins/organization.rb', line 570

def organization_remove_team_endpoint(config)
  Endpoint.new(path: "/organization/remove-team", method: "POST") 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"))
    teams = ctx.context.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
    ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}])
    ctx.context.adapter.delete(model: "team", where: [{field: "id", value: team["id"]}])
    ctx.json({status: true})
  end
end

.organization_remove_team_member_endpoint(config) ⇒ Object



645
646
647
648
649
650
651
652
653
654
655
# File 'lib/better_auth/plugins/organization.rb', line 645

def organization_remove_team_member_endpoint(config)
  Endpoint.new(path: "/organization/remove-team-member", method: "POST") 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"))
    ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: body[:user_id]}])
    ctx.json({status: true})
  end
end

.organization_role_by_id(ctx, id) ⇒ Object



837
838
839
840
841
# File 'lib/better_auth/plugins/organization.rb', line 837

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



843
844
845
846
847
# File 'lib/better_auth/plugins/organization.rb', line 843

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_wire(role) ⇒ Object



895
896
897
# File 'lib/better_auth/plugins/organization.rb', line 895

def organization_role_wire(role)
  role.merge("permission" => parse_permission(role["permission"]))
end

.organization_roles(config) ⇒ Object



749
750
751
# File 'lib/better_auth/plugins/organization.rb', line 749

def organization_roles(config)
  (config[:roles] || organization_default_roles(config)).transform_keys(&:to_s)
end

.organization_set_active_endpointObject



271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/better_auth/plugins/organization.rb', line 271

def organization_set_active_endpoint
  Endpoint.new(path: "/organization/set-active", method: "POST") do |ctx|
    session = Routes.current_session(ctx, sensitive: true)
    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_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



586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
# File 'lib/better_auth/plugins/organization.rb', line 586

def organization_set_active_team_endpoint(_config)
  Endpoint.new(path: "/organization/set-active-team", method: "POST") 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
    require_team_member!(ctx, session[:user]["id"], team["id"])
    updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeOrganizationId: team["organizationId"], activeTeamId: team["id"]})
    Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => team["organizationId"], "activeTeamId" => team["id"]), user: session[:user]})
    ctx.json(team_wire(ctx, team))
  end
end

.organization_team_ids(value) ⇒ Object



972
973
974
# File 'lib/better_auth/plugins/organization.rb', line 972

def organization_team_ids(value)
  Array(value).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
end

.organization_update_endpoint(config) ⇒ 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
# File 'lib/better_auth/plugins/organization.rb', line 225

def organization_update_endpoint(config)
  Endpoint.new(path: "/organization/update", method: "POST") 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
    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)
    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]}, ctx)
    ctx.json(organization_wire(ctx, updated))
  end
end

.organization_update_member_role_endpoint(config) ⇒ Object



461
462
463
464
465
466
467
468
469
470
471
# File 'lib/better_auth/plugins/organization.rb', line 461

def organization_update_member_role_endpoint(config)
  Endpoint.new(path: "/organization/update-member-role", method: "POST") 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: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER"))
    updated = ctx.context.adapter.update(model: "member", where: [{field: "id", value: member["id"]}], update: {role: parse_roles(body[:role])})
    ctx.json(member_wire(ctx, updated))
  end
end

.organization_update_role_endpoint(config) ⇒ Object



699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
# File 'lib/better_auth/plugins/organization.rb', line 699

def organization_update_role_endpoint(config)
  Endpoint.new(path: "/organization/update-role", method: "POST") 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_permission?(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



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

def organization_update_team_endpoint(config)
  Endpoint.new(path: "/organization/update-team", method: "POST") 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"))
    updated = ctx.context.adapter.update(model: "team", where: [{field: "id", value: team["id"]}], update: additional_input(body, :team_id, :organization_id))
    ctx.json(team_wire(ctx, updated))
  end
end

.organization_wire(ctx, organization) ⇒ Object



877
878
879
880
881
# File 'lib/better_auth/plugins/organization.rb', line 877

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_duration(value) ⇒ Object

Raises:

  • (TypeError)


434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'lib/better_auth/plugins/jwt.rb', line 434

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



939
940
941
942
943
944
945
# File 'lib/better_auth/plugins/organization.rb', line 939

def (value)
  return value if value.nil? || value.is_a?(Hash)

  JSON.parse(value)
rescue JSON::ParserError
  value
end

.parse_permission(value) ⇒ Object



951
952
953
954
955
956
957
958
# File 'lib/better_auth/plugins/organization.rb', line 951

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



745
746
747
# File 'lib/better_auth/plugins/organization.rb', line 745

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

.parsed_session(ctx, entry) ⇒ Object



157
158
159
160
161
162
# File 'lib/better_auth/plugins/multi_session.rb', line 157

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) ⇒ Object



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

def passkey(*args)
  Kernel.require "better_auth/passkey"
  BetterAuth::Plugins.passkey(*args)
rescue LoadError => error
  raise if error.path && error.path != "better_auth/passkey"

  raise LoadError, "BetterAuth::Plugins.passkey requires the better_auth-passkey gem. Add `gem \"better_auth-passkey\"` and `require \"better_auth/passkey\"`."
end

.patreon(options = {}) ⇒ Object



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/better_auth/plugins/generic_oauth.rb', line 178

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_000,
        max: 10
      }
    ],
    error_codes: PHONE_NUMBER_ERROR_CODES,
    options: config
  )
end

.phone_number_create_user(ctx, config, body, phone_number) ⇒ Object



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/better_auth/plugins/phone_number.rb', line 212

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
    )
  )
end

.phone_number_deliver_otp(config, data, ctx) ⇒ Object



275
276
277
278
# File 'lib/better_auth/plugins/phone_number.rb', line 275

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



295
296
297
# File 'lib/better_auth/plugins/phone_number.rb', line 295

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



200
201
202
203
204
205
206
207
208
209
210
# File 'lib/better_auth/plugins/phone_number.rb', line 200

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



299
300
301
302
303
304
305
# File 'lib/better_auth/plugins/phone_number.rb', line 299

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



266
267
268
269
270
271
272
273
# File 'lib/better_auth/plugins/phone_number.rb', line 266

def phone_number_store_code(ctx, config, identifier, code)
  ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
  ctx.context.internal_adapter.create_verification_value(
    identifier: identifier,
    value: "#{code}:0",
    expiresAt: Time.now + config[:expires_in].to_i
  )
end

.phone_number_verify_code!(ctx, config, identifier, code, consume: true) ⇒ Object

Raises:



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
# File 'lib/better_auth/plugins/phone_number.rb', line 232

def phone_number_verify_code!(ctx, config, identifier, code, consume: true)
  verifier = config[:verify_otp]
  if verifier.respond_to?(:call)
    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

    verification = ctx.context.internal_adapter.find_verification_value(identifier)
    ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume && verification
    return verification || true
  end

  verification = ctx.context.internal_adapter.find_verification_value(identifier)
  raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_NOT_FOUND"]) unless verification

  if Routes.expired_time?(verification["expiresAt"])
    raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_EXPIRED"])
  end

  stored_code, attempts = phone_number_split_code(verification["value"])
  attempts_count = attempts.to_i
  if attempts_count >= config[:allowed_attempts].to_i
    ctx.context.internal_adapter.delete_verification_value(verification["id"])
    raise APIError.new("FORBIDDEN", message: PHONE_NUMBER_ERROR_CODES["TOO_MANY_ATTEMPTS"])
  end

  unless stored_code == code
    ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{stored_code}:#{attempts_count + 1}")
    raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"])
  end

  ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume
  verification
end

.positive_integer?(value) ⇒ Boolean

Returns:

  • (Boolean)


258
259
260
# File 'lib/better_auth/plugins/device_authorization.rb', line 258

def positive_integer?(value)
  value.is_a?(Integer) && value.positive?
end

.present?(value) ⇒ Boolean

Returns:

  • (Boolean)


274
275
276
# File 'lib/better_auth/plugins/username.rb', line 274

def present?(value)
  !value.nil? && value != false && !value.to_s.empty?
end

.present_string?(value) ⇒ Boolean

Returns:

  • (Boolean)


194
195
196
# File 'lib/better_auth/plugins/anonymous.rb', line 194

def present_string?(value)
  value.is_a?(String) && !value.empty?
end

.private_key_pem(pair) ⇒ Object



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

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



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/better_auth/plugins/device_authorization.rb', line 173

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"
  if record["userId"] && record["userId"] != session[:user]["id"]
    raise device_authorization_error("FORBIDDEN", "access_denied", "You are not authorized to #{action} this device authorization")
  end

  ctx.context.adapter.update(
    model: "deviceCode",
    where: [{field: "id", value: record["id"]}],
    update: {"status" => status, "userId" => record["userId"] || session[:user]["id"]}
  )
  ctx.json({success: true})
end

.public_jwk(key, _config) ⇒ Object



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/better_auth/plugins/jwt.rb', line 290

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



218
219
220
221
222
223
224
225
# File 'lib/better_auth/plugins/jwt.rb', line 218

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



342
343
344
# File 'lib/better_auth/plugins/jwt.rb', line 342

def public_key_for(pair)
  OpenSSL::PKey.read(pair.public_to_pem)
end

.public_key_jwk_fields(public_key, alg) ⇒ Object



354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
# File 'lib/better_auth/plugins/jwt.rb', line 354

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



350
351
352
# File 'lib/better_auth/plugins/jwt.rb', line 350

def public_key_pem(pair)
  pair.respond_to?(:public_to_pem) ? pair.public_to_pem : pair.to_pem
end

.remote_jwks(ctx, config) ⇒ Object



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/better_auth/plugins/jwt.rb', line 243

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
    uri = URI.parse(url.to_s)
    response = Net::HTTP.get_response(uri)
    response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil
  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



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
# File 'lib/better_auth/plugins/email_otp.rb', line 211

def request_email_change_email_otp_endpoint(config)
  Endpoint.new(path: "/email-otp/request-email-change", method: "POST") 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



261
262
263
264
265
# File 'lib/better_auth/plugins/email_otp.rb', line 261

def request_password_reset_email_otp_endpoint(config)
  Endpoint.new(path: "/email-otp/request-password-reset", method: "POST") do |ctx|
    email_otp_password_reset_request(ctx, config)
  end
end

.request_password_reset_phone_number_endpoint(config) ⇒ Object



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/better_auth/plugins/phone_number.rb', line 165

def request_password_reset_phone_number_endpoint(config)
  Endpoint.new(path: "/phone-number/request-password-reset", method: "POST") 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)

    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_member(ctx, user_id, organization_id) ⇒ Object



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

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:



782
783
784
785
786
787
# File 'lib/better_auth/plugins/organization.rb', line 782

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:



769
770
771
772
773
774
# File 'lib/better_auth/plugins/organization.rb', line 769

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_team_member!(ctx, user_id, team_id) ⇒ Object

Raises:



795
796
797
798
799
800
# File 'lib/better_auth/plugins/organization.rb', line 795

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

.reset_password_email_otp_endpoint(config) ⇒ Object



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
# File 'lib/better_auth/plugins/email_otp.rb', line 273

def reset_password_email_otp_endpoint(config)
  Endpoint.new(path: "/email-otp/reset-password", method: "POST") 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_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



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/better_auth/plugins/phone_number.rb', line 181

def reset_password_phone_number_endpoint(config)
  Endpoint.new(path: "/phone-number/reset-password", method: "POST") do |ctx|
    body = normalize_hash(ctx.body)
    phone_number = body[:phone_number].to_s
    otp = body[:otp].to_s
    new_password = body[:new_password]

    verification = phone_number_verify_code!(ctx, config, "#{phone_number}-request-password-reset", otp, consume: false)
    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)
    ctx.context.internal_adapter.update_password(user["id"], Routes.hash_password(ctx, new_password))
    ctx.context.internal_adapter.delete_verification_value(verification["id"])
    ctx.context.internal_adapter.delete_sessions(user["id"]) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
    ctx.json({status: true})
  end
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/:providerId"
    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

.revoke_device_session_endpointObject



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
# File 'lib/better_auth/plugins/multi_session.rb', line 68

def revoke_device_session_endpoint
  Endpoint.new(path: "/multi-session/revoke", method: "POST") 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

.route_description(path, method) ⇒ Object



126
127
128
# File 'lib/better_auth/plugins/open_api.rb', line 126

def route_description(path, method)
  (path, method)[:description]
end

.route_open_api_metadata(path, method) ⇒ Object



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
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
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
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/open_api.rb', line 142

def (path, method)
  case [path, method.to_s.upcase]
  when ["/change-email", "POST"]
    {
      operationId: "changeEmail",
      requestBody: {
        required: true,
        content: {
          "application/json" => {
            schema: object_schema(
              {
                callbackURL: {type: ["string", "null"], description: "The URL to redirect to after email verification"},
                newEmail: {type: "string", description: "The new email address to set must be a valid email address"}
              },
              required: ["newEmail"]
            )
          }
        }
      },
      responses: {
        "200" => {
          description: "Email change request processed successfully",
          content: {
            "application/json" => {
              schema: object_schema(
                {
                  message: {
                    type: "string",
                    nullable: true,
                    enum: ["Email updated", "Verification email sent"],
                    description: "Status message of the email change process"
                  },
                  status: {type: "boolean", description: "Indicates if the request was successful"},
                  user: {type: "object", "$ref": "#/components/schemas/User"}
                },
                required: ["status"]
              )
            }
          }
        },
        "422" => error_response("Unprocessable Entity. Email already exists")
      }
    }
  when ["/change-password", "POST"]
    {
      description: "Change the password of the user",
      operationId: "changePassword",
      requestBody: {
        required: true,
        content: {
          "application/json" => {
            schema: object_schema(
              {
                newPassword: {type: "string", description: "The new password to set"},
                currentPassword: {type: "string", description: "The current password is required"},
                revokeOtherSessions: {type: ["boolean", "null"], description: "Must be a boolean value"}
              },
              required: ["newPassword", "currentPassword"]
            )
          }
        }
      },
      responses: {
        "200" => {
          description: "Password successfully changed",
          content: {
            "application/json" => {
              schema: object_schema(
                {
                  token: {type: "string", nullable: true, description: "New session token if other sessions were revoked"},
                  user: open_api_user_response_schema
                },
                required: ["user"]
              )
            }
          }
        }
      }
    }
  when ["/sign-in/email", "POST"]
    {
      description: "Sign in with email and password",
      operationId: "signInEmail",
      requestBody: {
        required: true,
        content: {
          "application/json" => {
            schema: object_schema(
              {
                email: {type: "string", description: "Email of the user"},
                password: {type: "string", description: "Password of the user"},
                callbackURL: {type: ["string", "null"], description: "Callback URL to use as a redirect for email verification"},
                rememberMe: {type: ["boolean", "null"], default: true, description: "If this is false, the session will not be remembered. Default is `true`."}
              },
              required: ["email", "password"]
            )
          }
        }
      },
      responses: {
        "200" => {
          description: "Success - Returns either session details or redirect URL",
          content: {
            "application/json" => {
              schema: session_response_schema(description: "Session response when idToken is provided", nullable_url: true)
            }
          }
        }
      }
    }
  when ["/sign-in/social", "POST"]
    {
      description: "Sign in with a social provider",
      operationId: "socialSignIn",
      requestBody: {
        required: true,
        content: {
          "application/json" => {
            schema: object_schema(
              {
                provider: {type: "string"},
                callbackURL: {type: ["string", "null"], description: "Callback URL to redirect to after the user has signed in"},
                errorCallbackURL: {type: ["string", "null"], description: "Callback URL to redirect to if an error happens"},
                newUserCallbackURL: {type: ["string", "null"]},
                disableRedirect: {type: ["boolean", "null"], description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself"},
                requestSignUp: {type: ["boolean", "null"], description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"},
                loginHint: {type: ["string", "null"], description: "The login hint to use for the authorization code request"},
                additionalData: {type: ["string", "null"]},
                scopes: {type: ["array", "null"], description: "Array of scopes to request from the provider. This will override the default scopes passed."},
                idToken: {
                  type: ["object", "null"],
                  properties: {
                    token: {type: "string", description: "ID token from the provider"},
                    accessToken: {type: ["string", "null"], description: "Access token from the provider"},
                    refreshToken: {type: ["string", "null"], description: "Refresh token from the provider"},
                    expiresAt: {type: ["number", "null"], description: "Expiry date of the token"},
                    nonce: {type: ["string", "null"], description: "Nonce used to generate the token"}
                  },
                  required: ["token"]
                }
              },
              required: ["provider"]
            )
          }
        }
      },
      responses: {
        "200" => {
          description: "Success - Returns either session details or redirect URL",
          content: {
            "application/json" => {
              schema: session_response_schema(description: "Session response when idToken is provided")
            }
          }
        }
      }
    }
  when ["/sign-up/email", "POST"]
    {
      description: "Sign up a user using email and password",
      operationId: "signUpWithEmailAndPassword",
      requestBody: {
        content: {
          "application/json" => {
            schema: object_schema(
              {
                name: {type: "string", description: "The name of the user"},
                email: {type: "string", description: "The email of the user"},
                password: {type: "string", description: "The password of the user"},
                image: {type: "string", description: "The profile image URL of the user"},
                callbackURL: {type: "string", description: "The URL to use for email verification callback"},
                rememberMe: {type: "boolean", description: "If this is false, the session will not be remembered. Default is `true`."}
              },
              required: ["name", "email", "password"]
            )
          }
        }
      },
      responses: {
        "200" => {
          description: "Successfully created user",
          content: {
            "application/json" => {
              schema: object_schema(
                {
                  token: {type: "string", nullable: true, description: "Authentication token for the session"},
                  user: {type: "object", "$ref": "#/components/schemas/User"}
                },
                required: ["user"]
              )
            }
          }
        },
        "422" => error_response("Unprocessable Entity. User already exists or failed to create user.")
      }
    }
  else
    {}
  end
end

.route_operation_id(path, method) ⇒ Object



130
131
132
# File 'lib/better_auth/plugins/open_api.rb', line 130

def route_operation_id(path, method)
  (path, method)[:operationId]
end

.route_request_body(path, method) ⇒ Object



134
135
136
# File 'lib/better_auth/plugins/open_api.rb', line 134

def route_request_body(path, method)
  (path, method)[:requestBody]
end

.route_responses(path, method) ⇒ Object



138
139
140
# File 'lib/better_auth/plugins/open_api.rb', line 138

def route_responses(path, method)
  (path, method)[:responses]
end

.run_org_hook(config, key, data, ctx) ⇒ Object



925
926
927
928
929
# File 'lib/better_auth/plugins/organization.rb', line 925

def run_org_hook(config, key, data, ctx)
  hooks = [config.dig(:organization_hooks, key), config.dig(:hooks, key)]
  hooks.concat(ctx.context.options.plugins.filter_map { |plugin| plugin.dig(:options, :organization_hooks, key) || plugin.dig("options", "organizationHooks", key.to_s) }) 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



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

def schema_for_table(table)
  required = []
  properties = table[:fields].each_with_object({}) do |(field, attributes), result|
    result[field.to_sym] = field_schema(attributes)
    required << field if attributes[:required] && attributes[:input] != false && field != "id"
  end
  {type: "object", properties: properties, required: required}
end

.scim(*args) ⇒ Object



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

def scim(*args)
  Kernel.require "better_auth/scim"
  BetterAuth::Plugins.scim(*args)
rescue LoadError => error
  raise if error.path && error.path != "better_auth/scim"

  raise LoadError, "BetterAuth::Plugins.scim requires the better_auth-scim gem. Add `gem \"better_auth-scim\"` and `require \"better_auth/scim\"`."
end

.send_phone_number_otp_endpoint(config) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/better_auth/plugins/phone_number.rb', line 103

def send_phone_number_otp_endpoint(config)
  Endpoint.new(path: "/phone-number/send-otp", method: "POST") 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)
    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



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/email_otp.rb', line 79

def send_verification_otp_endpoint(config)
  Endpoint.new(path: "/email-otp/send-verification-otp", method: "POST") 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



947
948
949
# File 'lib/better_auth/plugins/organization.rb', line 947

def (value)
  value.is_a?(Hash) ? JSON.generate(value) : value
end

.session_response_schema(description:, nullable_url: false) ⇒ Object



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

def session_response_schema(description:, nullable_url: false)
  object_schema(
    {
      redirect: {type: "boolean", enum: [false]},
      token: {type: "string", description: "Session token"},
      url: nullable_url ? {type: "string", nullable: true} : {type: "string"},
      user: {type: "object", "$ref": "#/components/schemas/User"}
    },
    required: ["redirect", "token", "user"]
  ).merge(description: description)
end

.set_active_session_endpointObject



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

def set_active_session_endpoint
  Endpoint.new(path: "/multi-session/set-active", method: "POST") 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


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

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



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

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


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/multi_session.rb', line 95

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
# File 'lib/better_auth/plugins/anonymous.rb', line 42

def (config)
  Endpoint.new(path: "/sign-in/anonymous", method: "POST") 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
    )
    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



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
# File 'lib/better_auth/plugins/email_otp.rb', line 185

def (config)
  Endpoint.new(path: "/sign-in/email-otp", method: "POST") 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: BASE_ERROR_CODES["USER_NOT_FOUND"]) if config[:disable_sign_up]

      ctx.context.internal_adapter.create_user((body, email))
    end

    unless user["emailVerified"]
      user = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
    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


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

def (config)
  Endpoint.new(path: "/sign-in/magic-link", method: "POST") do |ctx|
    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(ctx, token, body)
    sender = config[:send_magic_link]
    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
# File 'lib/better_auth/plugins/phone_number.rb', line 67

def (config)
  Endpoint.new(path: "/sign-in/phone-number", method: "POST") 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)
      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
# 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"
      ]
    }
  ) 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]

    if raw_username.empty? || password.empty?
      raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
    end

    username = username_for_validation(raw_username, config)
    validate_username!(username, config, status: "UNPROCESSABLE_ENTITY")

    user = ctx.context.adapter.find_one(
      model: "user",
      where: [{field: "username", value: normalize_username(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.json({
      token: session["token"],
      user: Schema.parse_output(ctx.context.options, "user", user)
    })
  end
end

.sign_in_with_oauth2_endpoint(config) ⇒ Object



227
228
229
230
231
232
233
234
235
# File 'lib/better_auth/plugins/generic_oauth.rb', line 227

def (config)
  Endpoint.new(path: "/sign-in/oauth2", method: "POST") 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



123
124
125
126
127
128
129
# File 'lib/better_auth/plugins/jwt.rb', line 123

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



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/better_auth/plugins/jwt.rb', line 169

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.base_url
  payload["aud"] ||= jwt_config[:audience] || ctx.context.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



211
212
213
214
215
216
# File 'lib/better_auth/plugins/jwt.rb', line 211

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



12
13
14
15
16
17
18
19
20
21
22
23
24
# File 'lib/better_auth/plugins/siwe.rb', line 12

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),
      verify_siwe_message: verify_siwe_message_endpoint(config)
    },
    options: config
  )
end

.siwe_chain_id(value) ⇒ Object

Raises:



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

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



199
200
201
202
203
204
205
206
207
208
# File 'lib/better_auth/plugins/siwe.rb', line 199

def siwe_create_user(ctx, config, wallet_address, _chain_id, email, anonymous)
  domain = config[:email_domain_name] || URI.parse(ctx.context.base_url).host || ctx.context.base_url
  lookup = config[:ens_lookup]
  ens = lookup.respond_to?(:call) ? normalize_hash(lookup.call(wallet_address: wallet_address) || {}) : {}
  ctx.context.internal_adapter.create_user(
    name: ens[:name] || wallet_address,
    email: (anonymous == false && !email.empty?) ? email : "#{wallet_address}@#{domain}",
    image: ens[:avatar] || ""
  )
end

.siwe_ensure_wallet_and_account(ctx, user, wallet_address, chain_id) ⇒ Object



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
# File 'lib/better_auth/plugins/siwe.rb', line 210

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)


238
239
240
# File 'lib/better_auth/plugins/siwe.rb', line 238

def siwe_expired_time?(value)
  value && value < Time.now
end

.siwe_find_user(ctx, wallet_address, chain_id) ⇒ Object



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

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



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

def siwe_identifier(wallet_address, chain_id)
  "siwe:#{wallet_address}:#{chain_id}"
end

.siwe_merge_schema_fields(base_fields, custom_fields) ⇒ Object



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

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



124
125
126
127
128
129
# File 'lib/better_auth/plugins/siwe.rb', line 124

def siwe_nonce_body(body)
  data = normalize_hash(body)
  siwe_normalize_wallet!(data[:wallet_address])
  data[:chain_id] = siwe_chain_id(data[:chain_id])
  data
end

.siwe_normalize_wallet!(value) ⇒ Object

Raises:



146
147
148
149
150
151
# File 'lib/better_auth/plugins/siwe.rb', line 146

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_schema(custom_schema = nil) ⇒ Object



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/better_auth/plugins/siwe.rb', line 87

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_verify_body(body, config) ⇒ Object

Raises:



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

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
  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
end

.siwe_verify_message(config, body, wallet_address, chain_id, nonce, ctx) ⇒ Object

Raises:



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/better_auth/plugins/siwe.rb', line 164

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



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/better_auth/plugins/generic_oauth.rb', line 203

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) ⇒ Object



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

def sso(*args)
  Kernel.require "better_auth/sso"
  BetterAuth::Plugins.sso(*args)
rescue LoadError => error
  raise if error.path && error.path != "better_auth/sso"

  raise LoadError, "BetterAuth::Plugins.sso requires the better_auth-sso gem. Add `gem \"better_auth-sso\"` and `require \"better_auth/sso\"`."
end

.storage_fields(fields) ⇒ Object



23
24
25
26
27
# File 'lib/better_auth/plugins.rb', line 23

def storage_fields(fields)
  normalize_hash(fields).each_with_object({}) do |(key, value), result|
    result[Schema.storage_key(key)] = normalize_field(value)
  end
end


142
143
144
145
146
147
148
149
150
151
152
# File 'lib/better_auth/plugins/magic_link.rb', line 142

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



468
469
470
471
472
473
# File 'lib/better_auth/plugins/jwt.rb', line 468

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



960
961
962
963
964
# File 'lib/better_auth/plugins/organization.rb', line 960

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) ⇒ Object



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

def stripe(*args)
  Kernel.require "better_auth/stripe"
  BetterAuth::Plugins.stripe(*args)
rescue LoadError => error
  raise if error.path && error.path != "better_auth/stripe"

  raise LoadError, "BetterAuth::Plugins.stripe requires the better_auth-stripe gem. Add `gem \"better_auth-stripe\"` and `require \"better_auth/stripe\"`."
end

.success_responseObject



395
396
397
398
399
400
401
402
403
404
405
406
407
# File 'lib/better_auth/plugins/open_api.rb', line 395

def success_response
  {
    description: "Success",
    content: {
      "application/json" => {
        schema: {
          type: "object",
          properties: {}
        }
      }
    }
  }
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



831
832
833
834
835
# File 'lib/better_auth/plugins/organization.rb', line 831

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



891
892
893
# File 'lib/better_auth/plugins/organization.rb', line 891

def team_member_wire(ctx, member)
  Schema.parse_output(ctx.context.options, "teamMember", member)
end

.team_wire(ctx, team) ⇒ Object



887
888
889
# File 'lib/better_auth/plugins/organization.rb', line 887

def team_wire(ctx, team)
  Schema.parse_output(ctx.context.options, "team", team)
end

.truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


307
308
309
# File 'lib/better_auth/plugins/phone_number.rb', line 307

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



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/two_factor.rb', line 30

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,
    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])

  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[:schema]),
    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



298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/better_auth/plugins/two_factor.rb', line 298

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)}"
  ctx.context.internal_adapter.create_verification_value(
    identifier: identifier,
    value: data[:user]["id"],
    expiresAt: Time.now + config[:two_factor_cookie_max_age].to_i
  )
  ctx.set_signed_cookie(cookie.name, identifier, ctx.context.secret, cookie.attributes)
  ctx.json({twoFactorRedirect: true})
end

.two_factor_check_password!(ctx, user_id, password) ⇒ Object



383
384
385
386
387
388
# File 'lib/better_auth/plugins/two_factor.rb', line 383

def two_factor_check_password!(ctx, user_id, password)
   = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
  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



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

def two_factor_disable_endpoint(config)
  Endpoint.new(path: "/two-factor/disable", method: "POST") do |ctx|
    session = Routes.current_session(ctx, sensitive: true)
    body = normalize_hash(ctx.body)
    two_factor_check_password!(ctx, session[:user]["id"], body[:password])

    updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: false)
    ctx.context.adapter.delete(model: config[:two_factor_table], 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



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
# File 'lib/better_auth/plugins/two_factor.rb', line 78

def two_factor_enable_endpoint(config)
  Endpoint.new(path: "/two-factor/enable", method: "POST") do |ctx|
    session = Routes.current_session(ctx, sensitive: true)
    body = normalize_hash(ctx.body)
    two_factor_check_password!(ctx, session[:user]["id"], body[:password])

    secret = two_factor_generate_secret
    backup = two_factor_generate_backup_codes(ctx.context.secret, 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

    ctx.context.adapter.delete_many(model: config[:two_factor_table], where: [{field: "userId", value: session[:user]["id"]}])
    ctx.context.adapter.create(
      model: config[:two_factor_table],
      data: {
        secret: Crypto.symmetric_encrypt(key: ctx.context.secret, data: secret),
        backupCodes: backup[:stored],
        userId: session[:user]["id"]
      }
    )

    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_generate_backup_codes(secret, options) ⇒ Object



426
427
428
429
430
431
432
433
434
435
436
437
438
# File 'lib/better_auth/plugins/two_factor.rb', line 426

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



255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/better_auth/plugins/two_factor.rb', line 255

def two_factor_generate_backup_codes_endpoint(config)
  Endpoint.new(path: "/two-factor/generate-backup-codes", method: "POST") do |ctx|
    session = Routes.current_session(ctx, sensitive: true)
    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])
    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[:backup_code_options])
    ctx.context.adapter.update(model: config[:two_factor_table], where: [{field: "id", value: record["id"]}], update: {backupCodes: backup[:stored]})
    ctx.json({status: true, backupCodes: backup[:codes]})
  end
end

.two_factor_generate_secretObject



396
397
398
399
# File 'lib/better_auth/plugins/two_factor.rb', line 396

def two_factor_generate_secret
  raw = SecureRandom.random_bytes(20)
  base32_encode(raw)
end

.two_factor_generate_totp_endpoint(config) ⇒ Object



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

def two_factor_generate_totp_endpoint(config)
  Endpoint.new(path: "/totp/generate", method: "POST") 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



141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/better_auth/plugins/two_factor.rb', line 141

def two_factor_get_totp_uri_endpoint(config)
  Endpoint.new(path: "/two-factor/get-totp-uri", method: "POST") do |ctx|
    two_factor_totp_enabled!(config)
    session = Routes.current_session(ctx, sensitive: true)
    two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password])
    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, 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_otp_matches?(ctx, stored, input, options) ⇒ Boolean

Returns:

  • (Boolean)


485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
# File 'lib/better_auth/plugins/two_factor.rb', line 485

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, 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



466
467
468
# File 'lib/better_auth/plugins/two_factor.rb', line 466

def two_factor_random_digits(length)
  Array.new(length) { SecureRandom.random_number(10) }.join
end

.two_factor_read_backup_codes(secret, stored, options) ⇒ Object



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

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)
rescue JSON::ParserError
  []
end

.two_factor_record(ctx, config, user_id) ⇒ Object



379
380
381
# File 'lib/better_auth/plugins/two_factor.rb', line 379

def two_factor_record(ctx, config, user_id)
  ctx.context.adapter.find_one(model: config[:two_factor_table], where: [{field: "userId", value: user_id}])
end

.two_factor_schema(custom_schema = nil) ⇒ Object



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/better_auth/plugins/two_factor.rb', line 280

def two_factor_schema(custom_schema = nil)
  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"}}
      }
    }
  }
  deep_merge_hashes(base, normalize_hash(custom_schema || {}))
end

.two_factor_send_otp_endpoint(config) ⇒ Object



175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/better_auth/plugins/two_factor.rb', line 175

def two_factor_send_otp_endpoint(config)
  Endpoint.new(path: "/two-factor/send-otp", method: "POST") 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



352
353
354
355
356
357
358
359
# File 'lib/better_auth/plugins/two_factor.rb', line 352

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



440
441
442
443
444
445
446
447
448
449
450
# File 'lib/better_auth/plugins/two_factor.rb', line 440

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



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

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, 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



401
402
403
404
# File 'lib/better_auth/plugins/two_factor.rb', line 401

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



412
413
414
415
416
417
418
# File 'lib/better_auth/plugins/two_factor.rb', line 412

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



390
391
392
393
394
# File 'lib/better_auth/plugins/two_factor.rb', line 390

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



420
421
422
423
424
# File 'lib/better_auth/plugins/two_factor.rb', line 420

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)


406
407
408
409
410
# File 'lib/better_auth/plugins/two_factor.rb', line 406

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)


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

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 = identifier && ctx.context.internal_adapter.find_verification_value(identifier)
  if token && identifier && Crypto.constant_time_compare(token, expected) && verification && verification["value"] == user_id && verification["expiresAt"] > Time.now
    ctx.context.internal_adapter.delete_verification_value(verification["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:



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
# File 'lib/better_auth/plugins/two_factor.rb', line 317

def two_factor_verification_context(ctx, config)
  session = Routes.current_session(ctx, allow_nil: true)
  if session
    key = "#{session[:user]["id"]}!#{session[:session]["id"]}"
    return {session: session, key: key, valid: -> { ctx.json({token: session[:session]["token"], user: Schema.parse_output(ctx.context.options, "user", session[:user])}) }}
  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
    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

    ctx.context.internal_adapter.delete_verification_value(verification["id"])
    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

  {session: {session: nil, user: user}, key: identifier, valid: valid}
end

.two_factor_verify_backup_code_endpoint(config) ⇒ Object



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/two_factor.rb', line 230

def two_factor_verify_backup_code_endpoint(config)
  Endpoint.new(path: "/two-factor/verify-backup-code", method: "POST") 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

    codes = two_factor_read_backup_codes(ctx.context.secret, record["backupCodes"], config[:backup_code_options])
    unless codes.include?(body[:code].to_s)
      raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_BACKUP_CODE"])
    end

    remaining = codes.reject { |code| code == body[:code].to_s }
    stored = two_factor_store_backup_codes(ctx.context.secret, remaining, config[:backup_code_options])
    updated = ctx.context.adapter.update(
      model: config[:two_factor_table],
      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

    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



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
# File 'lib/better_auth/plugins/two_factor.rb', line 196

def two_factor_verify_otp_endpoint(config)
  Endpoint.new(path: "/two-factor/verify-otp", method: "POST") do |ctx|
    body = normalize_hash(ctx.body)
    data = two_factor_verification_context(ctx, config)
    verification = ctx.context.internal_adapter.find_verification_value("2fa-otp-#{data[:key]}")
    stored, counter = verification&.fetch("value", nil).to_s.split(":", 2)
    if !verification || verification["expiresAt"] < Time.now
      ctx.context.internal_adapter.delete_verification_value(verification["id"]) if 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
      ctx.context.internal_adapter.delete_verification_value(verification["id"])
      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])
      ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{stored}:#{counter.to_i + 1}")
      raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"])
    end

    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



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/better_auth/plugins/two_factor.rb', line 154

def two_factor_verify_totp_endpoint(config)
  Endpoint.new(path: "/two-factor/verify-totp", method: "POST") 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

    secret = Crypto.symmetric_decrypt(key: ctx.context.secret, data: record["secret"])
    raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"]) unless two_factor_totp_valid?(secret, body[:code], options: config[:totp_options])

    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
    data[:valid].call
  end
end

.two_factor_view_backup_codes_endpoint(config) ⇒ Object



270
271
272
273
274
275
276
277
278
# File 'lib/better_auth/plugins/two_factor.rb', line 270

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, 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



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/better_auth/plugins/username.rb', line 154

def username_database_hooks(config)
  before_hook = lambda do |user, _context|
    data = user.dup
    if data["username"].is_a?(String) && !data["username"].empty?
      data["username"] = normalize_username(data["username"], config)
    end
    if data["displayUsername"].is_a?(String) && !data["displayUsername"].empty?
      data["displayUsername"] = normalize_display_username(data["displayUsername"], config)
    end
    {data: data}
  end

  {
    user: {
      create: {before: before_hook},
      update: {before: before_hook}
    }
  }
end

.username_for_validation(username, config) ⇒ Object



236
237
238
# File 'lib/better_auth/plugins/username.rb', line 236

def username_for_validation(username, config)
  (validation_order(config, :username) == "pre-normalization") ? normalize_username(username, config) : username
end

.username_mutation_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


258
259
260
# File 'lib/better_auth/plugins/username.rb', line 258

def username_mutation_path?(path)
  path == "/sign-up/email" || path == "/update-user"
end

.username_schema(config) ⇒ Object



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

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_code_challenge_method?(method, config) ⇒ Boolean

Returns:

  • (Boolean)


494
495
496
497
498
499
# File 'lib/better_auth/plugins/oidc_provider.rb', line 494

def valid_code_challenge_method?(method, config)
  normalized = method.to_s.downcase
  return true if normalized == "s256"

  normalized == "plain" && config[:allow_plain_code_challenge_method]
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:



246
247
248
249
250
251
252
253
254
255
256
# File 'lib/better_auth/plugins/device_authorization.rb', line 246

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:



492
493
494
# File 'lib/better_auth/plugins/email_otp.rb', line 492

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:



496
497
498
499
500
# File 'lib/better_auth/plugins/email_otp.rb', line 496

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_jwt_options!(config) ⇒ Object



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

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:



164
165
166
167
168
169
# File 'lib/better_auth/plugins/magic_link.rb', line 164

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_permission_resources!(config, permission) ⇒ Object

Raises:



966
967
968
969
970
# File 'lib/better_auth/plugins/organization.rb', line 966

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:



287
288
289
290
291
292
293
# File 'lib/better_auth/plugins/phone_number.rb', line 287

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_unique_phone_number!(ctx, phone_number) ⇒ Object

Raises:



280
281
282
283
284
285
# File 'lib/better_auth/plugins/phone_number.rb', line 280

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:



222
223
224
225
226
227
228
229
230
231
232
233
234
# File 'lib/better_auth/plugins/username.rb', line 222

def validate_username!(username, config, status:)
  if username.length < min_username_length(config)
    raise APIError.new(status, message: USERNAME_ERROR_CODES["USERNAME_TOO_SHORT"])
  end

  if username.length > max_username_length(config)
    raise APIError.new(status, 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, message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) unless valid
end

.validate_username_mutation!(ctx, config) ⇒ Object



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
208
209
210
211
212
# File 'lib/better_auth/plugins/username.rb', line 174

def validate_username_mutation!(ctx, config)
  body = normalize_hash(ctx.body)
  raw_username = body.key?(:username) ? body[:username] : nil
  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", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
    end

    if existing && ctx.path == "/update-user" && !same_user
      raise APIError.new("BAD_REQUEST", 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", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"])
    end
  end
  nil
end

.validation_order(config, field) ⇒ Object



253
254
255
256
# File 'lib/better_auth/plugins/username.rb', line 253

def validation_order(config, field)
  order = config[:validation_order] || {}
  order[field] || "pre-normalization"
end

.verification_jwks(ctx, config) ⇒ Object



236
237
238
239
240
241
# File 'lib/better_auth/plugins/jwt.rb', line 236

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



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

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



141
142
143
# File 'lib/better_auth/plugins/multi_session.rb', line 141

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



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
# File 'lib/better_auth/plugins/jwt.rb', line 399

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.base_url
  audience = config.dig(:jwt, :audience) || ctx.context.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



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
# File 'lib/better_auth/plugins/email_otp.rb', line 156

def verify_email_otp_endpoint(config)
  Endpoint.new(path: "/email-otp/verify-email", method: "POST") 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



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

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



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/better_auth/plugins/jwt.rb', line 188

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.base_url,
    verify_iss: true,
    aud: config.dig(:jwt, :audience) || ctx.context.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



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 43

def verify_one_time_token_endpoint(config)
  Endpoint.new(path: "/one-time-token/verify", method: "POST") 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.find_verification_value("one-time-token:#{stored_token}")
    raise APIError.new("BAD_REQUEST", message: "Invalid token") unless verification

    ctx.context.internal_adapter.delete_verification_value(verification["id"])
    raise APIError.new("BAD_REQUEST", message: "Token expired") if Routes.expired_time?(verification["expiresAt"])

    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



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
# File 'lib/better_auth/plugins/phone_number.rb', line 120

def verify_phone_number_endpoint(config)
  Endpoint.new(path: "/phone-number/verify", method: "POST") 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



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
# File 'lib/better_auth/plugins/siwe.rb', line 44

def verify_siwe_message_endpoint(config)
  Endpoint.new(path: "/siwe/verify", method: "POST", body_schema: ->(body) { siwe_verify_body(body, config) }) 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
    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.find_verification_value(siwe_identifier(wallet_address, chain_id))
    if !verification || siwe_expired_time?(verification["expiresAt"])
      raise APIError.new("UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE", message: "Unauthorized: Invalid or expired nonce")
    end

    verified = siwe_verify_message(config, body, wallet_address, chain_id, verification["value"], ctx)
    raise APIError.new("UNAUTHORIZED", message: "Unauthorized: Invalid SIWE signature") unless verified

    ctx.context.internal_adapter.delete_verification_value(verification["id"])

    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