Module: BetterAuth::Plugins
- Defined in:
- lib/better_auth/plugins.rb,
lib/better_auth/plugins/dub.rb,
lib/better_auth/plugins/jwt.rb,
lib/better_auth/plugins/sso.rb,
lib/better_auth/plugins/expo.rb,
lib/better_auth/plugins/i18n.rb,
lib/better_auth/plugins/scim.rb,
lib/better_auth/plugins/siwe.rb,
lib/better_auth/plugin_loader.rb,
lib/better_auth/plugins/admin.rb,
lib/better_auth/plugins/access.rb,
lib/better_auth/plugins/bearer.rb,
lib/better_auth/plugins/stripe.rb,
lib/better_auth/plugins/api_key.rb,
lib/better_auth/plugins/captcha.rb,
lib/better_auth/plugins/one_tap.rb,
lib/better_auth/plugins/passkey.rb,
lib/better_auth/plugins/open_api.rb,
lib/better_auth/plugins/username.rb,
lib/better_auth/plugins/anonymous.rb,
lib/better_auth/plugins/email_otp.rb,
lib/better_auth/plugins/magic_link.rb,
lib/better_auth/plugins/two_factor.rb,
lib/better_auth/plugins/oauth_popup.rb,
lib/better_auth/plugins/oauth_proxy.rb,
lib/better_auth/plugins/admin/schema.rb,
lib/better_auth/plugins/organization.rb,
lib/better_auth/plugins/phone_number.rb,
lib/better_auth/plugins/generic_oauth.rb,
lib/better_auth/plugins/multi_session.rb,
lib/better_auth/plugins/custom_session.rb,
lib/better_auth/plugins/oauth_protocol.rb,
lib/better_auth/plugins/oauth_provider.rb,
lib/better_auth/plugins/one_time_token.rb,
lib/better_auth/plugins/additional_fields.rb,
lib/better_auth/plugins/have_i_been_pwned.rb,
lib/better_auth/plugins/last_login_method.rb,
lib/better_auth/plugins/organization/schema.rb,
lib/better_auth/plugins/device_authorization.rb
Defined Under Namespace
Modules: AdminSchema, JWT, OAuthProtocol, OrganizationSchema Classes: AccessControl, Role
Constant Summary collapse
- PLUGIN_FACTORY_LOADERS =
{ create_access_control: :access, createAccessControl: :access }.freeze
- REMOVED_PLUGIN_FACTORIES =
{ oidc_provider: "BetterAuth::Plugins.oauth_provider (require \"better_auth/oauth_provider\")", mcp: "BetterAuth::Plugins.oauth_provider (require \"better_auth/oauth_provider\")" }.freeze
- SIWE_WALLET_PATTERN =
/\A0[xX][a-fA-F0-9]{40}\z/- SIWE_EMAIL_PATTERN =
/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/- SIWE_HEADER_PATTERN =
/\A(?:([a-zA-Z][a-zA-Z0-9+.-]*):\/\/)?(\S+) wants you to sign in with your Ethereum account:\z/- SIWE_FIELD_PATTERN =
/\A([A-Za-z ]+): (.*)\z/- PLUGIN_FILES =
Maps
BetterAuth::Pluginsfactory methods and nested modules to source files. { additional_fields: "plugins/additional_fields", custom_session: "plugins/custom_session", multi_session: "plugins/multi_session", last_login_method: "plugins/last_login_method", bearer: "plugins/bearer", jwt: "plugins/jwt", open_api: "plugins/open_api", access: "plugins/access", username: "plugins/username", anonymous: "plugins/anonymous", magic_link: "plugins/magic_link", email_otp: "plugins/email_otp", phone_number: "plugins/phone_number", one_time_token: "plugins/one_time_token", one_tap: "plugins/one_tap", siwe: "plugins/siwe", generic_oauth: "plugins/generic_oauth", oauth_popup: "plugins/oauth_popup", dub: "plugins/dub", oauth_proxy: "plugins/oauth_proxy", passkey: "plugins/passkey", organization_schema: "plugins/organization/schema", organization: "plugins/organization", admin_schema: "plugins/admin/schema", admin: "plugins/admin", oauth_protocol: "plugins/oauth_protocol", oauth_provider: "plugins/oauth_provider", device_authorization: "plugins/device_authorization", two_factor: "plugins/two_factor", captcha: "plugins/captcha", have_i_been_pwned: "plugins/have_i_been_pwned", api_key: "plugins/api_key", sso: "plugins/sso", scim: "plugins/scim", stripe: "plugins/stripe", expo: "plugins/expo", i18n: "plugins/i18n" }.freeze
- PLUGIN_DEPENDENCIES =
{ organization: %i[organization_schema access], admin_schema: %i[organization_schema], admin: %i[admin_schema access], device_authorization: %i[oauth_protocol] }.freeze
- BOOT_PLUGINS =
Core route metadata helpers; loaded at boot because base endpoints reference OpenAPI.
%i[open_api].freeze
- PLUGIN_ID_TO_LOADER =
{ "additional-fields" => :additional_fields, "custom-session" => :custom_session, "multi-session" => :multi_session, "last-login-method" => :last_login_method, "bearer" => :bearer, "jwt" => :jwt, "open-api" => :open_api, "username" => :username, "anonymous" => :anonymous, "magic-link" => :magic_link, "email-otp" => :email_otp, "phone-number" => :phone_number, "one-time-token" => :one_time_token, "one-tap" => :one_tap, "siwe" => :siwe, "generic-oauth" => :generic_oauth, "oauth-popup" => :oauth_popup, "dub" => :dub, "oauth-proxy" => :oauth_proxy, "passkey" => :passkey, "organization" => :organization, "admin" => :admin, "oauth-provider" => :oauth_provider, "device-authorization" => :device_authorization, "two-factor" => :two_factor, "captcha" => :captcha, "have-i-been-pwned" => :have_i_been_pwned, "api-key" => :api_key, "sso" => :sso, "scim" => :scim, "stripe" => :stripe, "expo" => :expo, "i18n" => :i18n }.freeze
- NESTED_MODULE_LOADERS =
{ OAuthProtocol: :oauth_protocol, JWT: :jwt, OrganizationSchema: :organization_schema, AdminSchema: :admin_schema }.freeze
- LAZY_PLUGIN_METHODS =
PLUGIN_FILES.keys.freeze
- EXTERNAL_PLUGIN_IMPLEMENTATIONS =
{ sso: :SSO_PLUGIN_IMPLEMENTATION, scim: :SCIM_PLUGIN_IMPLEMENTATION, api_key: :API_KEY_PLUGIN_IMPLEMENTATION, passkey: :PASSKEY_PLUGIN_IMPLEMENTATION, stripe: :STRIPE_PLUGIN_IMPLEMENTATION, oauth_provider: :OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION }.freeze
- ADMIN_ERROR_CODES =
{ "FAILED_TO_CREATE_USER" => "Failed to create user", "USER_ALREADY_EXISTS" => "User already exists.", "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL" => "User already exists. Use another email.", "YOU_CANNOT_BAN_YOURSELF" => "You cannot ban yourself", "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE" => "You are not allowed to change users role", "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS" => "You are not allowed to create users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS" => "You are not allowed to list users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS" => "You are not allowed to list users sessions", "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS" => "You are not allowed to ban users", "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS" => "You are not allowed to impersonate users", "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS" => "You are not allowed to revoke users sessions", "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS" => "You are not allowed to delete users", "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD" => "You are not allowed to set users password", "BANNED_USER" => "You have been banned from this application", "YOU_ARE_NOT_ALLOWED_TO_GET_USER" => "You are not allowed to get user", "NO_DATA_TO_UPDATE" => "No data to update", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS" => "You are not allowed to update users", "YOU_CANNOT_REMOVE_YOURSELF" => "You cannot remove yourself", "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE" => "You are not allowed to set a non-existent role value", "YOU_CANNOT_IMPERSONATE_ADMINS" => "You cannot impersonate admins", "INVALID_ROLE_TYPE" => "Invalid role type", "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL" => "You are not allowed to update users email", "PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER" => "Password cannot be updated through update-user. Use the set-user-password endpoint instead" }.freeze
- ADMIN_DEFAULT_STATEMENTS =
{ user: ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"], session: ["list", "revoke", "delete"] }.freeze
- ADMIN_DEFAULT_ROLE_STATEMENTS =
{ user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "set-email", "get", "update"], session: ["list", "revoke", "delete"] }.freeze
- BEARER_SCHEME =
"bearer "- CAPTCHA_EXTERNAL_ERROR_CODES =
{ "VERIFICATION_FAILED" => "Captcha verification failed", "MISSING_RESPONSE" => "Missing CAPTCHA response", "UNKNOWN_ERROR" => "Something went wrong" }.freeze
- CAPTCHA_INTERNAL_ERROR_CODES =
{ "MISSING_SECRET_KEY" => "Missing secret key", "SERVICE_UNAVAILABLE" => "CAPTCHA service unavailable" }.freeze
- CAPTCHA_DEFAULT_ENDPOINTS =
[ "/sign-up/email", "/sign-in/email", "/request-password-reset" ].freeze
- CAPTCHA_VERIFY_TIMEOUT_SECONDS =
10- CAPTCHA_SITE_VERIFY_URLS =
{ "cloudflare-turnstile" => "https://challenges.cloudflare.com/turnstile/v0/siteverify", "google-recaptcha" => "https://www.google.com/recaptcha/api/siteverify", "hcaptcha" => "https://api.hcaptcha.com/siteverify", "captchafox" => "https://api.captchafox.com/siteverify" }.freeze
- USERNAME_ERROR_CODES =
{ "INVALID_USERNAME_OR_PASSWORD" => "Invalid username or password", "EMAIL_NOT_VERIFIED" => "Email not verified", "UNEXPECTED_ERROR" => "Unexpected error", "USERNAME_IS_ALREADY_TAKEN" => "Username is already taken. Please try another.", "USERNAME_TOO_SHORT" => "Username is too short", "USERNAME_TOO_LONG" => "Username is too long", "INVALID_USERNAME" => "Username is invalid", "INVALID_DISPLAY_USERNAME" => "Display username is invalid" }.freeze
- ANONYMOUS_ERROR_CODES =
{ "INVALID_EMAIL_FORMAT" => "Email was not generated in a valid format", "FAILED_TO_CREATE_USER" => "Failed to create user", "COULD_NOT_CREATE_SESSION" => "Could not create session", "ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY" => "Anonymous users cannot sign in again anonymously", "FAILED_TO_DELETE_ANONYMOUS_USER" => "Failed to delete anonymous user", "USER_IS_NOT_ANONYMOUS" => "User is not anonymous", "DELETE_ANONYMOUS_USER_DISABLED" => "Deleting anonymous users is disabled" }.freeze
- EMAIL_OTP_ERROR_CODES =
{ "OTP_EXPIRED" => "OTP expired", "INVALID_OTP" => "Invalid OTP", "TOO_MANY_ATTEMPTS" => "Too many attempts" }.freeze
- TWO_FACTOR_ERROR_CODES =
{ "OTP_NOT_ENABLED" => "OTP not enabled", "OTP_HAS_EXPIRED" => "OTP has expired", "TOTP_NOT_ENABLED" => "TOTP not enabled", "TWO_FACTOR_NOT_ENABLED" => "Two factor isn't enabled", "BACKUP_CODES_NOT_ENABLED" => "Backup codes aren't enabled", "INVALID_BACKUP_CODE" => "Invalid backup code", "INVALID_CODE" => "Invalid code", "TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE" => "Too many attempts. Please request a new code.", "ACCOUNT_TEMPORARILY_LOCKED" => "Too many failed verification attempts. Your account is temporarily locked. Please try again later.", "INVALID_TWO_FACTOR_COOKIE" => "Invalid two factor cookie" }.freeze
- TWO_FACTOR_COOKIE_NAME =
"two_factor"- TRUST_DEVICE_COOKIE_NAME =
"trust_device"- TRUST_DEVICE_COOKIE_MAX_AGE =
30 * 24 * 60 * 60
- TWO_FACTOR_COOKIE_MAX_AGE =
10 * 60
- TWO_FACTOR_MODEL =
"twoFactor"- DEFAULT_TWO_FACTOR_ALLOWED_ATTEMPTS =
5- DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS =
10- DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS =
15 * 60
- BASE32_ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"- OAUTH_POPUP_MESSAGE_TYPE =
"better-auth:oauth-popup"- OAUTH_POPUP_DATA_ELEMENT_ID =
"better-auth-oauth-popup"- OAUTH_POPUP_MARKER_COOKIE =
"oauth_popup"- OAUTH_POPUP_ERROR_CODES =
{ "POPUP_SIGN_IN_FAILED" => "Popup sign-in failed", "POPUP_BLOCKED" => "Sign-in popup was blocked by the browser", "POPUP_CLOSED" => "Sign-in popup was closed before completing", "POPUP_TIMEOUT" => "Sign-in popup timed out" }.freeze
- OAUTH_POPUP_INTERNAL_STATE_KEYS =
OAuthState::INTERNAL_KEYS
- OAUTH_POPUP_COMPLETE_SCRIPT =
[ "(function () {", "\tvar el = document.getElementById(\"better-auth-oauth-popup\");", "\tif (!el) return;", "\tvar payload;", "\ttry {", "\t\tpayload = JSON.parse(el.textContent || \"\");", "\t} catch (e) {", "\t\treturn;", "\t}", "\tvar target = window.opener || window.parent;", "\tif (target && target !== window) {", "\t\ttry {", "\t\t\ttarget.postMessage(", "\t\t\t\t{", "\t\t\t\t\ttype: payload.type,", "\t\t\t\t\tnonce: payload.nonce,", "\t\t\t\t\ttoken: payload.token,", "\t\t\t\t\tredirectTo: payload.redirectTo,", "\t\t\t\t\terror: payload.error,", "\t\t\t\t},", "\t\t\t\tpayload.targetOrigin,", "\t\t\t);", "\t\t} catch (e) {}", "\t}", "\twindow.close();", "})();", "" ].join("\n")
- OAUTH_POPUP_SCRIPT_CSP_HASH =
"sha256-tIo2K8VBC9SnhvdZ+9GsGkQoZm+jm/JcxL+d+i8b8KQ="- ORGANIZATION_ERROR_CODES =
{ "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION" => "You are not allowed to create a new organization", "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS" => "You have reached the maximum number of organizations", "ORGANIZATION_ALREADY_EXISTS" => "Organization already exists", "ORGANIZATION_SLUG_ALREADY_TAKEN" => "Organization slug already taken", "ORGANIZATION_NOT_FOUND" => "Organization not found", "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION" => "User is not a member of the organization", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION" => "You are not allowed to update this organization", "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION" => "You are not allowed to delete this organization", "ORGANIZATION_DELETION_DISABLED" => "Organization deletion is disabled", "NO_ACTIVE_ORGANIZATION" => "No active organization", "USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION" => "User is already a member of this organization", "MEMBER_NOT_FOUND" => "Member not found", "ROLE_NOT_FOUND" => "Role not found", "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM" => "You are not allowed to create a new team", "TEAM_ALREADY_EXISTS" => "Team already exists", "TEAM_NOT_FOUND" => "Team not found", "INVALID_TEAM_ID" => "Team IDs cannot contain commas", "YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER" => "You cannot leave the organization as the only owner", "YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER" => "You cannot leave the organization without an owner", "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER" => "You are not allowed to delete this member", "YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION" => "You are not allowed to invite users to this organization", "USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION" => "User is already invited to this organization", "INVITATION_NOT_FOUND" => "Invitation not found", "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION" => "You are not the recipient of the invitation", "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION" => "Email verification required before accepting or rejecting invitation", "EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION" => "Email verification required to view or list invitations for the session email", "YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION" => "You are not allowed to cancel this invitation", "INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION" => "Inviter is no longer a member of the organization", "YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE" => "You are not allowed to invite a user with this role", "FAILED_TO_RETRIEVE_INVITATION" => "Failed to retrieve invitation", "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS" => "You have reached the maximum number of teams", "UNABLE_TO_REMOVE_LAST_TEAM" => "Unable to remove last team", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER" => "You are not allowed to update this member", "ORGANIZATION_MEMBERSHIP_LIMIT_REACHED" => "Organization membership limit reached", "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION" => "You are not allowed to create teams in this organization", "YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION" => "You are not allowed to delete teams in this organization", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM" => "You are not allowed to update this team", "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM" => "You are not allowed to delete this team", "INVITATION_LIMIT_REACHED" => "Invitation limit reached", "TEAM_MEMBER_LIMIT_REACHED" => "Team member limit reached", "USER_IS_NOT_A_MEMBER_OF_THE_TEAM" => "User is not a member of the team", "YOU_CAN_NOT_ACCESS_THE_MEMBERS_OF_THIS_TEAM" => "You are not allowed to list the members of this team", "YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM" => "You do not have an active team", "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER" => "You are not allowed to create a new member", "YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER" => "You are not allowed to remove a team member", "YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION" => "You are not allowed to access this organization as an owner", "YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION" => "You are not a member of this organization", "MISSING_AC_INSTANCE" => "Dynamic Access Control requires a pre-defined ac instance on the server auth plugin. Read server logs for more information", "YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE" => "You must be in an organization to create a role", "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE" => "You are not allowed to create a role", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE" => "You are not allowed to update a role", "YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE" => "You are not allowed to delete a role", "YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE" => "You are not allowed to read a role", "YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE" => "You are not allowed to list a role", "YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE" => "You are not allowed to get a role", "TOO_MANY_ROLES" => "This organization has too many roles", "INVALID_RESOURCE" => "The provided permission includes an invalid resource", "ROLE_NAME_IS_ALREADY_TAKEN" => "That role name is already taken", "CANNOT_DELETE_A_PRE_DEFINED_ROLE" => "Cannot delete a pre-defined role", "ROLE_IS_ASSIGNED_TO_MEMBERS" => "Cannot delete a role that is assigned to members. Please reassign the members to a different role first" }.freeze
- ORGANIZATION_DEFAULT_STATEMENTS =
{ organization: ["update", "delete"], member: ["create", "update", "delete"], invitation: ["create", "cancel"], team: ["create", "update", "delete"], ac: ["create", "read", "update", "delete"] }.freeze
- PHONE_NUMBER_ERROR_CODES =
{ "INVALID_PHONE_NUMBER" => "Invalid phone number", "PHONE_NUMBER_EXIST" => "Phone number already exists", "PHONE_NUMBER_NOT_EXIST" => "phone number isn't registered", "INVALID_PHONE_NUMBER_OR_PASSWORD" => "Invalid phone number or password", "UNEXPECTED_ERROR" => "Unexpected error", "OTP_NOT_FOUND" => "OTP not found", "OTP_EXPIRED" => "OTP expired", "INVALID_OTP" => "Invalid OTP", "PHONE_NUMBER_NOT_VERIFIED" => "Phone number not verified", "PHONE_NUMBER_CANNOT_BE_UPDATED" => "Phone number cannot be updated", "SEND_OTP_NOT_IMPLEMENTED" => "sendOTP not implemented", "TOO_MANY_ATTEMPTS" => "Too many attempts" }.freeze
- GENERIC_OAUTH_ERROR_CODES =
{ "INVALID_OAUTH_CONFIGURATION" => "Invalid OAuth configuration", "TOKEN_URL_NOT_FOUND" => "Invalid OAuth configuration. Token URL not found.", "PROVIDER_CONFIG_NOT_FOUND" => "No config found for provider", "PROVIDER_ID_REQUIRED" => "Provider ID is required", "INVALID_OAUTH_CONFIG" => "Invalid OAuth configuration.", "SESSION_REQUIRED" => "Session is required", "ISSUER_MISMATCH" => "OAuth issuer mismatch. The authorization server issuer does not match the expected value (RFC 9207).", "ISSUER_MISSING" => "OAuth issuer parameter missing. The authorization server did not include the required iss parameter (RFC 9207)." }.freeze
- MULTI_SESSION_ERROR_CODES =
{ "INVALID_SESSION_TOKEN" => "Invalid session token" }.freeze
- HAVE_I_BEEN_PWNED_ERROR_CODES =
{ "PASSWORD_COMPROMISED" => "The password you entered has been compromised. Please choose a different password." }.freeze
- HAVE_I_BEEN_PWNED_DEFAULT_PATHS =
[ "/sign-up/email", "/change-password", "/reset-password" ].freeze
- DEVICE_AUTHORIZATION_ERROR_CODES =
{ "INVALID_DEVICE_CODE" => "Invalid device code", "EXPIRED_DEVICE_CODE" => "Device code has expired", "EXPIRED_USER_CODE" => "User code has expired", "AUTHORIZATION_PENDING" => "Authorization pending", "ACCESS_DENIED" => "Access denied", "INVALID_USER_CODE" => "Invalid user code", "DEVICE_CODE_ALREADY_PROCESSED" => "Device code already processed", "DEVICE_CODE_NOT_CLAIMED" => "Device code has not been claimed by a verifying session; call `GET /device` with the `user_code` while signed in before approving or denying", "POLLING_TOO_FREQUENTLY" => "Polling too frequently", "USER_NOT_FOUND" => "User not found", "FAILED_TO_CREATE_SESSION" => "Failed to create session", "INVALID_DEVICE_CODE_STATUS" => "Invalid device code status", "AUTHENTICATION_REQUIRED" => "Authentication required" }.freeze
Class Method Summary collapse
- .additional_fields(schema = {}) ⇒ Object
- .additional_input(hash, *exclude) ⇒ Object
- .admin(options = {}) ⇒ Object
- .admin_ban_user_endpoint(config) ⇒ Object
- .admin_config(options) ⇒ Object
- .admin_create_user_endpoint(config) ⇒ Object
- .admin_database_hooks(config) ⇒ Object
- .admin_default_roles(config = {}) ⇒ Object
- .admin_filter_users(users, query) ⇒ Object
- .admin_get_user_endpoint(config) ⇒ Object
- .admin_has_permission_endpoint(config) ⇒ Object
- .admin_impersonate_user_endpoint(config) ⇒ Object
- .admin_list_user_sessions_endpoint(config) ⇒ Object
- .admin_list_users_endpoint(config) ⇒ Object
- .admin_list_users_parameters ⇒ Object
- .admin_paginate_users(users, query) ⇒ Object
- .admin_parse_roles(roles) ⇒ Object
- .admin_permission?(user, role_string, permissions, config) ⇒ Boolean
- .admin_remove_user_endpoint(config) ⇒ Object
- .admin_require_permission!(ctx, config, permissions, message) ⇒ Object
- .admin_revoke_user_session_endpoint(config) ⇒ Object
- .admin_revoke_user_sessions_endpoint(config) ⇒ Object
- .admin_role_for(roles, role) ⇒ Object
- .admin_sessions_openapi(operation_id:, description:, response_description:) ⇒ Object
- .admin_set_role_endpoint(config) ⇒ Object
- .admin_set_user_password_endpoint(config) ⇒ Object
- .admin_sort_users(users, query) ⇒ Object
- .admin_stop_impersonating_endpoint ⇒ Object
- .admin_unban_user_endpoint(config) ⇒ Object
- .admin_update_user_endpoint(config) ⇒ Object
- .admin_user?(user, config) ⇒ Boolean
- .admin_user_mutation_openapi(operation_id:, description:, response_description:, properties:, required:) ⇒ Object
- .admin_user_sort(query) ⇒ Object
- .admin_user_where(query) ⇒ Object
- .admin_validate_roles!(roles, config) ⇒ Object
- .all_jwks(ctx, config, force_refresh: false) ⇒ Object
- .anonymous(options = {}) ⇒ Object
- .anonymous_email(config) ⇒ Object
- .anonymous_link_path?(path) ⇒ Boolean
- .anonymous_name(ctx, config) ⇒ Object
- .anonymous_schema(config) ⇒ Object
- .anonymous_schema_field_name(config) ⇒ Object
- .api_key(*args, &block) ⇒ Object
- .apply_bearer_token(ctx, config) ⇒ Object
- .apply_i18n_translation(ctx, config) ⇒ Object
- .apply_last_login_method(ctx, config) ⇒ Object
- .auth0(options = {}) ⇒ Object
- .authorization_header(ctx) ⇒ Object
- .base32_decode(value) ⇒ Object
- .base32_encode(bytes) ⇒ Object
- .base64url_bn(number) ⇒ Object
- .bearer(options = {}) ⇒ Object
- .call_email_verification_option(ctx, key, user) ⇒ Object
- .call_external_plugin!(method_name, *args, implementation_constant:, gem_name:, entry:, &block) ⇒ Object
- .camelize_lower(key) ⇒ Object
- .canonical_multi_session_cookie_name(name) ⇒ Object
- .captcha(options = {}) ⇒ Object
- .captcha_http_verify(params) ⇒ Object
- .captcha_log(context, message) ⇒ Object
- .captcha_normalize_verifier_response(value) ⇒ Object
- .captcha_on_request(request, context, config) ⇒ Object
- .captcha_payload(provider, params) ⇒ Object
- .captcha_remote_ip(request, context) ⇒ Object
- .captcha_response(status, code, message) ⇒ Object
- .captcha_success?(config, result) ⇒ Boolean
- .captcha_verifier_params(params) ⇒ Object
- .captcha_verify(config, response_token, remote_ip) ⇒ Object
- .change_email_email_otp_endpoint(config) ⇒ Object
- .check_verification_otp_endpoint(config) ⇒ Object
- .clear_active_organization_session(ctx, session, organization_id) ⇒ Object
- .clear_multi_session_cookies(ctx) ⇒ Object
- .const_missing(name) ⇒ Object
- .cookie_header_from_set_cookie(set_cookie) ⇒ Object
- .count_members_with_role(ctx, organization_id, role) ⇒ Object
- .create_access_control(statements) ⇒ Object (also: createAccessControl)
- .create_default_team(ctx, config, organization, session) ⇒ Object
- .create_jwk(ctx, config) ⇒ Object
- .create_verification_otp_endpoint(config) ⇒ Object
- .custom_session(resolver, options = nil, plugin_options = nil, **keywords) ⇒ Object
- .deep_merge(base, override) ⇒ Object
- .deep_merge_hashes(base, override) ⇒ Object
- .default_username_valid?(username) ⇒ Boolean
- .delete_anonymous_user_endpoint(config) ⇒ Object
- .detect_i18n_locale(ctx, config) ⇒ Object
- .detect_locale_from_callback(ctx, config, available_locales) ⇒ Object
- .detect_locale_from_cookie(ctx, config, available_locales) ⇒ Object
- .detect_locale_from_header(ctx, available_locales) ⇒ Object
- .detect_locale_from_session(ctx, config, available_locales) ⇒ Object
- .device_approve_endpoint ⇒ Object
- .device_authorization(options = {}) ⇒ Object
- .device_authorization_error(status, error, description) ⇒ Object
- .device_authorization_schema(custom_schema = nil) ⇒ Object
- .device_authorization_time(value) ⇒ Object
- .device_authorization_user_code_candidates(value) ⇒ Object
- .device_code_endpoint(config) ⇒ Object
- .device_code_response_schema ⇒ Object
- .device_deny_endpoint ⇒ Object
- .device_token_endpoint(config) ⇒ Object
- .device_token_response_schema ⇒ Object
- .device_verification_response_schema ⇒ Object
- .device_verify_endpoint ⇒ Object
- .dub(options = {}) ⇒ Object
- .dub_default_lead_track(config, user, dub_id, ctx) ⇒ Object
- .dub_invoke_lead(track, payload) ⇒ Object
- .dub_lead_request_body(payload) ⇒ Object
- .dub_link_endpoint(oauth_plugin) ⇒ Object
- .dub_log_error(ctx, error) ⇒ Object
- .dub_oauth_plugin(oauth_options) ⇒ Object
- .dub_track_lead(config, user, ctx) ⇒ Object
- .duration_seconds(value) ⇒ Object
- .ec_curve_for_alg(alg) ⇒ Object
- .email_otp(options = {}) ⇒ Object
- .email_otp_after_sign_up(ctx, config) ⇒ Object
- .email_otp_change_email_enabled!(config) ⇒ Object
- .email_otp_deliver(config, data, ctx) ⇒ Object
- .email_otp_generate(config, email:, type:, ctx:) ⇒ Object
- .email_otp_identifier(email, type) ⇒ Object
- .email_otp_init(config) ⇒ Object
- .email_otp_matches?(ctx, config, stored_otp, otp) ⇒ Boolean
- .email_otp_password_reset_request(ctx, config) ⇒ Object
- .email_otp_plain_value(ctx, config, stored_otp) ⇒ Object
- .email_otp_rate_limits(config) ⇒ Object
- .email_otp_resolve(ctx, config, email:, type:, identifier_email: email) ⇒ Object
- .email_otp_reuse(ctx, config, email:, type:) ⇒ Object
- .email_otp_send_verification(ctx, config, email:, type:) ⇒ Object
- .email_otp_sign_up_user_data(ctx, body, email) ⇒ Object
- .email_otp_split(value) ⇒ Object
- .email_otp_store(ctx, config, email:, type:, otp:) ⇒ Object
- .email_otp_stored_value(ctx, config, otp) ⇒ Object
- .email_otp_verify!(ctx, config, email:, type:, otp:, consume: true) ⇒ Object
- .encode_eddsa_jwt(payload, private_key, kid) ⇒ Object
- .ensure_external_plugin_loaded!(gem_name:, entry:, implementation_constant:) ⇒ Object
- .ensure_not_last_owner!(ctx, member, adapter: ctx.context.adapter) ⇒ Object
- .ensure_organization_member_capacity!(ctx, config, user, organization, adapter: ctx.context.adapter) ⇒ Object
- .ensure_plugin_loaded_for!(plugin) ⇒ Object
- .ensure_team_member_capacity!(ctx, config, team_ids, adapter: ctx.context.adapter, lock: false) ⇒ Object
- .expire_cookie(ctx, name) ⇒ Object
- .expired_bearer_cookie?(cookie) ⇒ Boolean
- .expo(options = {}) ⇒ Object
- .expo_authorization_proxy_endpoint ⇒ Object
- .expo_development_environment? ⇒ Boolean
- .expo_inject_cookie_into_deep_link(ctx) ⇒ Object
- .expo_on_request(config) ⇒ Object
- .expose_auth_token(ctx) ⇒ Object
- .extra_input(hash, *exclude) ⇒ Object
- .fetch_i18n_option(options, name) ⇒ Object
- .fetch_value(data, key) ⇒ Object
- .field_schema(attributes) ⇒ Object
- .find_device_code(ctx, code) ⇒ Object
- .find_device_user_code(ctx, code) ⇒ Object
- .find_member_by_email(ctx, organization_id, email) ⇒ Object
- .generate_device_authorization_device_code(config) ⇒ Object
- .generate_device_authorization_user_code(config) ⇒ Object
- .generate_key_pair(alg) ⇒ Object
- .generate_one_time_token_endpoint(config) ⇒ Object
- .generic_oauth(options = {}) ⇒ Object
- .generic_oauth_account_info(ctx, provider_id, account_id, tokens) ⇒ Object
- .generic_oauth_authorization_url(ctx, provider, body, link:, code_verifier: nil, preserve_additional_data: false) ⇒ Object
- .generic_oauth_discovery(provider) ⇒ Object
- .generic_oauth_error_url(base_url, error, description = nil) ⇒ Object
- .generic_oauth_exchange_token(ctx, provider, code, state_data) ⇒ Object
- .generic_oauth_expiry_time(seconds) ⇒ Object
- .generic_oauth_fetch_json(url, headers = {}) ⇒ Object
- .generic_oauth_generate_state(ctx, state_data) ⇒ Object
- .generic_oauth_link_account(ctx, provider, tokens, user_info, link, redirect_error) ⇒ Object
- .generic_oauth_map_user(provider, user_info) ⇒ Object
- .generic_oauth_normalize_tokens(data, access_token_expires_in: nil) ⇒ Object
- .generic_oauth_normalize_user_info(data) ⇒ Object
- .generic_oauth_parse_state(ctx, state) ⇒ Object
- .generic_oauth_pkce_challenge(code_verifier) ⇒ Object
- .generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token) ⇒ Object
- .generic_oauth_post_token(ctx, token_url, provider, code, code_verifier, redirect_uri) ⇒ Object
- .generic_oauth_provider(config, provider_id) ⇒ Object
- .generic_oauth_provider!(config, provider_id) ⇒ Object
- .generic_oauth_provider_config(options, defaults) ⇒ Object
- .generic_oauth_provider_user_info(provider, tokens) ⇒ Object
- .generic_oauth_recovered_state_error_url(ctx, state_data) ⇒ Object
- .generic_oauth_redirect_uri(ctx, provider) ⇒ Object
- .generic_oauth_refresh_access_token(ctx, provider, refresh_token) ⇒ Object
- .generic_oauth_set_account_cookie(ctx, provider_id, account_id, user_id) ⇒ Object
- .generic_oauth_set_query_param(params, key, value) ⇒ Object
- .generic_oauth_social_providers(config, context) ⇒ Object
- .generic_oauth_start_body_schema ⇒ Object
- .generic_oauth_state_error_url(ctx) ⇒ Object
- .generic_oauth_token_for_storage(ctx, token) ⇒ Object
- .generic_oauth_token_scopes(scope) ⇒ Object
- .generic_oauth_url_response_schema ⇒ Object
- .generic_oauth_user_from_id_token(id_token) ⇒ Object
- .generic_oauth_user_info(provider, tokens) ⇒ Object
- .generic_oauth_validate_issuer!(ctx, provider, query, redirect_error) ⇒ Object
- .generic_oauth_warn_duplicate_providers(providers) ⇒ Object
- .generic_oidc_helper_provider(options, provider_id, issuer, discovery_url, _user_info_url) ⇒ Object
- .get_jwks_endpoint(config, path) ⇒ Object
- .get_siwe_nonce_endpoint(config, path:, operation_id:) ⇒ Object
- .get_token_endpoint(config) ⇒ Object
- .get_verification_otp_endpoint(config) ⇒ Object
- .gumroad(options = {}) ⇒ Object
- .have_i_been_pwned(options = {}) ⇒ Object
- .have_i_been_pwned_check_password!(password, config) ⇒ Object
- .have_i_been_pwned_range_lookup(prefix) ⇒ Object
- .have_i_been_pwned_wrap_password_hasher!(context, config) ⇒ Object
- .hubspot(options = {}) ⇒ Object
- .i18n(options = {}) ⇒ Object
- .invitation_by_id(ctx, id) ⇒ Object
- .invitation_wire(ctx, invitation) ⇒ Object
- .is_username_available_endpoint(config) ⇒ Object
- .jwk_expired?(key) ⇒ Boolean
- .jwk_private_key_for_storage(ctx, private_key, config) ⇒ Object
- .jwk_private_key_value(ctx, key, _config) ⇒ Object
- .jwt(options = {}) ⇒ Object
- .jwt_expiration(value, iat) ⇒ Object
- .jwt_payload_valid?(payload) ⇒ Boolean
- .jwt_token(ctx, session, config) ⇒ Object
- .key_type_for_alg(alg) ⇒ Object
- .keycloak(options = {}) ⇒ Object
- .last_login_method(options = {}) ⇒ Object
- .last_login_method_schema(config) ⇒ Object
- .latest_jwk(ctx, config) ⇒ Object
- .lazy_plugin_method?(name) ⇒ Boolean
- .line(options = {}) ⇒ Object
- .link_anonymous_user(ctx, config) ⇒ Object
- .list_device_sessions_endpoint ⇒ Object
- .list_members_for(ctx, organization_id, query = {}, config = nil, user = nil) ⇒ Object
- .load_boot_plugins! ⇒ Object
- .load_plugin!(name) ⇒ Object
- .load_plugin_for_constant!(name) ⇒ Object
- .lock_organization_capacity!(adapter, organization) ⇒ Object
- .lock_team_capacity!(adapter, team) ⇒ Object
- .magic_link(options = {}) ⇒ Object
- .magic_link_attempts_exceeded?(attempt, config) ⇒ Boolean
- .magic_link_error_url(url, error) ⇒ Object
- .magic_link_token(email, config) ⇒ Object
- .magic_link_url(base_url, token, body) ⇒ Object
- .magic_link_verify_endpoint(config) ⇒ Object
- .max_username_length(config) ⇒ Object
- .member_by_id(ctx, id) ⇒ Object
- .member_list_limit(ctx, organization_id, query, config, user) ⇒ Object
- .member_users_by_id(ctx, members) ⇒ Object
- .member_wire(ctx, member, users_by_id: nil) ⇒ Object
- .merge_hook_data!(target, response) ⇒ Object
- .merge_permissions(base, extra) ⇒ Object
- .merged_i18n_error_catalog(ctx) ⇒ Object
- .method_missing(name) ⇒ Object
- .microsoft_entra_id(options = {}) ⇒ Object
- .min_username_length(config) ⇒ Object
- .mirror_username_fields!(ctx) ⇒ Object
- .multi_cookie_names(ctx) ⇒ Object
- .multi_session(options = {}) ⇒ Object
- .multi_session_cookie?(name) ⇒ Boolean
- .multi_session_cookie_name(ctx, token) ⇒ Object
- .normalize_display_username(display_username, config) ⇒ Object
- .normalize_field(value) ⇒ Object
- .normalize_hash(value) ⇒ Object
- .normalize_i18n_detection(detection) ⇒ Object
- .normalize_i18n_options(options) ⇒ Object
- .normalize_i18n_translations(translations) ⇒ Object
- .normalize_key(key) ⇒ Object
- .normalize_remote_jwk(entry) ⇒ Object
- .normalize_signed_bearer_token(token) ⇒ Object
- .normalize_time(value) ⇒ Object
- .normalize_user_code(value) ⇒ Object
- .normalize_username(username, config) ⇒ Object
- .normalized_role_names(roles) ⇒ Object
- .numeric_member_limit(value) ⇒ Object
- .oauth2_callback_endpoint(config) ⇒ Object
- .oauth2_link_account_endpoint(config) ⇒ Object
- .oauth_popup ⇒ Object
- .oauth_popup_additional_data(value) ⇒ Object
- .oauth_popup_after_callback(ctx) ⇒ Object
- .oauth_popup_callback_path?(path) ⇒ Boolean
- .oauth_popup_completion(ctx, popup_origin, message) ⇒ Object
- .oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce) ⇒ Object
- .oauth_popup_log(context, level, message, *details) ⇒ Object
- .oauth_popup_redirect_error(location) ⇒ Object
- .oauth_popup_session_token(ctx) ⇒ Object
- .oauth_popup_set_marker(ctx, popup_origin, nonce) ⇒ Object
- .oauth_popup_start_endpoint ⇒ Object
- .oauth_popup_validate_origin!(ctx, popup_origin) ⇒ Object
- .oauth_popup_warn_missing_bearer(ctx) ⇒ Object
- .oauth_provider(*args, &block) ⇒ Object
- .oauth_proxy(options = {}) ⇒ Object
- .oauth_proxy_after_callback(ctx, config) ⇒ Object
- .oauth_proxy_after_sign_in(ctx, config) ⇒ Object
- .oauth_proxy_before_sign_in(ctx, config) ⇒ Object
- .oauth_proxy_callback_path?(path) ⇒ Boolean
- .oauth_proxy_current_uri(ctx, config) ⇒ Object
- .oauth_proxy_endpoint(config) ⇒ Object
- .oauth_proxy_error_url(ctx, message) ⇒ Object
- .oauth_proxy_parse_set_cookie(header) ⇒ Object
- .oauth_proxy_production_uri(ctx, config) ⇒ Object
- .oauth_proxy_restore_state_package(ctx, config) ⇒ Object
- .oauth_proxy_secret(ctx, config) ⇒ Object
- .oauth_proxy_set_location(ctx, location) ⇒ Object
- .oauth_proxy_sign_in_path?(path) ⇒ Boolean
- .oauth_proxy_skip?(ctx, config) ⇒ Boolean
- .oauth_proxy_state_cookie_value(ctx) ⇒ Object
- .oauth_proxy_strip_trailing(value) ⇒ Object
- .oauth_proxy_validate_callback!(ctx, callback_url) ⇒ Object
- .okta(options = {}) ⇒ Object
- .one_tap(options = {}) ⇒ Object
- .one_tap_blank_audience?(audience) ⇒ Boolean
- .one_tap_boolean_value(value) ⇒ Object
- .one_tap_callback_endpoint(config) ⇒ Object
- .one_tap_google_jwks ⇒ Object
- .one_tap_raise_persistence_error!(error) ⇒ Object
- .one_tap_stringify_payload(payload) ⇒ Object
- .one_tap_verify_google_id_token(id_token, audience) ⇒ Object
- .one_tap_verify_id_token(ctx, config, id_token) ⇒ Object
- .one_time_token(options = {}) ⇒ Object
- .one_time_token_after_response(ctx, config) ⇒ Object
- .one_time_token_create(ctx, config, session) ⇒ Object
- .one_time_token_stored_value(config, token) ⇒ Object
- .open_api(options = {}) ⇒ Object
- .open_api_components(options) ⇒ Object
- .open_api_documented_hidden_endpoint?(key, endpoint, tag) ⇒ Boolean
- .open_api_encode_uri_component(value) ⇒ Object
- .open_api_endpoints(options) ⇒ Object
- .open_api_html(schema, config) ⇒ Object
- .open_api_logo ⇒ Object
- .open_api_operation(endpoint, method, tag, used_operation_ids) ⇒ Object
- .open_api_operation_id(base_id, method, used_operation_ids) ⇒ Object
- .open_api_path(path) ⇒ Object
- .open_api_paths(endpoints, options) ⇒ Object
- .open_api_schema(context) ⇒ Object
- .open_api_security_schemes ⇒ Object
- .org_hook_candidates(options, key) ⇒ Object
- .org_truthy?(value) ⇒ Boolean
- .organization(options = {}) ⇒ Object
- .organization_accept_invitation_endpoint(config) ⇒ Object
- .organization_accept_invitation_schema ⇒ Object
- .organization_active_member_role_schema ⇒ Object
- .organization_add_member_endpoint(config) ⇒ Object
- .organization_add_team_member_endpoint(config) ⇒ Object
- .organization_array_schema(name) ⇒ Object
- .organization_by_id(ctx, id) ⇒ Object
- .organization_by_slug(ctx, slug) ⇒ Object
- .organization_can_delegate_permissions?(ctx, config, role_string, permissions, organization_id) ⇒ Boolean
- .organization_cancel_invitation_endpoint(config) ⇒ Object
- .organization_check_slug_endpoint ⇒ Object
- .organization_config(options) ⇒ Object
- .organization_create_endpoint(config) ⇒ Object
- .organization_create_role_endpoint(config) ⇒ Object
- .organization_create_team_endpoint(config) ⇒ Object
- .organization_created_count(ctx, user_id) ⇒ Object
- .organization_default_roles(config = {}) ⇒ Object
- .organization_delete_endpoint(config) ⇒ Object
- .organization_delete_role_endpoint(config) ⇒ Object
- .organization_each_adapter_record(adapter, model, where:, page_size: 100) ⇒ Object
- .organization_get_active_member_endpoint(_config) ⇒ Object
- .organization_get_active_member_role_endpoint(_config) ⇒ Object
- .organization_get_full_endpoint(config) ⇒ Object
- .organization_get_invitation_endpoint(config) ⇒ Object
- .organization_get_role_endpoint(config) ⇒ Object
- .organization_has_permission_endpoint(config) ⇒ Object
- .organization_invite_endpoint(config) ⇒ Object
- .organization_leave_endpoint(config) ⇒ Object
- .organization_list_endpoint ⇒ Object
- .organization_list_invitations_endpoint(config) ⇒ Object
- .organization_list_members_endpoint(config) ⇒ Object
- .organization_list_roles_endpoint(config) ⇒ Object
- .organization_list_team_members_endpoint(_config) ⇒ Object
- .organization_list_teams_endpoint(_config) ⇒ Object
- .organization_list_user_invitations_endpoint ⇒ Object
- .organization_list_user_teams_endpoint ⇒ Object
- .organization_members_response_schema ⇒ Object
- .organization_membership_capacity_limit(ctx, config, user, organization) ⇒ Object
- .organization_nullable_schema(name) ⇒ Object
- .organization_openapi(operation_id, description, response:, response_description: "Success", request: nil, required: [], parameters: nil) ⇒ Object
- .organization_permission?(ctx, config, role_string, permissions, organization_id) ⇒ Boolean
- .organization_permission_response_schema ⇒ Object
- .organization_ref_schema(name) ⇒ Object
- .organization_reject_invitation_endpoint(config) ⇒ Object
- .organization_remove_member_endpoint(config) ⇒ Object
- .organization_remove_team_endpoint(config) ⇒ Object
- .organization_remove_team_member_endpoint(config) ⇒ Object
- .organization_request_schema(operation_id) ⇒ Object
- .organization_role_action_schema ⇒ Object
- .organization_role_by_id(ctx, id) ⇒ Object
- .organization_role_by_name(ctx, organization_id, role) ⇒ Object
- .organization_role_schema ⇒ Object
- .organization_role_wire(role) ⇒ Object
- .organization_roles(config) ⇒ Object
- .organization_set_active_endpoint ⇒ Object
- .organization_set_active_team_endpoint(_config) ⇒ Object
- .organization_team_ids(value) ⇒ Object
- .organization_time(value) ⇒ Object
- .organization_transaction(ctx) ⇒ Object
- .organization_update_endpoint(config) ⇒ Object
- .organization_update_member_role_endpoint(config) ⇒ Object
- .organization_update_role_endpoint(config) ⇒ Object
- .organization_update_team_endpoint(config) ⇒ Object
- .organization_wire(ctx, organization) ⇒ Object
- .parse_accept_language(header) ⇒ Object
- .parse_duration(value) ⇒ Object
- .parse_metadata(value) ⇒ Object
- .parse_permission(value) ⇒ Object
- .parse_roles(roles) ⇒ Object
- .parsed_session(ctx, entry) ⇒ Object
- .passkey(*args, &block) ⇒ Object
- .patreon(options = {}) ⇒ Object
- .phone_number(options = {}) ⇒ Object
- .phone_number_create_user(ctx, config, body, phone_number) ⇒ Object
- .phone_number_deliver_otp(config, data, ctx) ⇒ Object
- .phone_number_generate_code(config) ⇒ Object
- .phone_number_schema(custom_schema) ⇒ Object
- .phone_number_split_code(value) ⇒ Object
- .phone_number_store_code(ctx, config, identifier, code) ⇒ Object
- .phone_number_verify_code!(ctx, config, identifier, code, consume: true) ⇒ Object
- .plugin_loaded?(name) ⇒ Boolean
- .plugin_loader_for_method(name) ⇒ Object
- .plugin_options(plugin) ⇒ Object
- .positive_integer?(value) ⇒ Boolean
- .present?(value) ⇒ Boolean
- .present_string?(value) ⇒ Boolean
- .private_key_pem(pair) ⇒ Object
- .process_device_decision(ctx, session, status) ⇒ Object
- .public_jwk(key, _config) ⇒ Object
- .public_jwks(ctx, config) ⇒ Object
- .public_key_for(pair) ⇒ Object
- .public_key_jwk_fields(public_key, alg) ⇒ Object
- .public_key_pem(pair) ⇒ Object
- .remote_jwks(ctx, config) ⇒ Object
- .request_email_change_email_otp_endpoint(config) ⇒ Object
- .request_password_reset_email_otp_endpoint(config) ⇒ Object
- .request_password_reset_phone_number_endpoint(config) ⇒ Object
- .require_invitation_recipient!(invitation, session) ⇒ Object
- .require_member(ctx, user_id, organization_id) ⇒ Object
- .require_member!(ctx, user_id, organization_id) ⇒ Object
- .require_org_permission!(ctx, config, session, organization_id, permissions, message) ⇒ Object
- .require_pending_invitation!(invitation, check_expiration: true) ⇒ Object
- .require_team_member!(ctx, user_id, team_id) ⇒ Object
- .require_verified_email_for_invitation_id_action?(ctx, config) ⇒ Boolean
- .require_verified_invitation_recipient!(ctx, config, session, error_code) ⇒ Object
- .reset_password_email_otp_endpoint(config) ⇒ Object
- .reset_password_phone_number_endpoint(config) ⇒ Object
- .resolve_i18n_default_locale(explicit, available_locales) ⇒ Object
- .resolve_i18n_error_code(error, ctx) ⇒ Object
- .resolve_login_method(ctx, config) ⇒ Object
- .respond_to_missing?(name, include_private = false) ⇒ Boolean
- .reverse_lookup_i18n_error_code(message, ctx) ⇒ Object
- .revoke_device_session_endpoint ⇒ Object
- .run_org_hook(config, key, data, ctx) ⇒ Object
- .safe_decode_bearer_token(token) ⇒ Object
- .safe_encode_bearer_token(token) ⇒ Object
- .schema_for_table(table) ⇒ Object
- .scim(*args, &block) ⇒ Object
- .send_phone_number_otp_endpoint(config) ⇒ Object
- .send_verification_otp_endpoint(config) ⇒ Object
- .serialize_metadata(value) ⇒ Object
- .set_active_session_endpoint ⇒ Object
- .set_cookie_value(set_cookie, name) ⇒ Object
- .set_jwt_header(ctx, config) ⇒ Object
- .set_multi_session_cookie(ctx, config) ⇒ Object
- .sign_bearer_token(ctx, token, config) ⇒ Object
- .sign_in_anonymous_endpoint(config) ⇒ Object
- .sign_in_email_otp_endpoint(config) ⇒ Object
- .sign_in_magic_link_endpoint(config) ⇒ Object
- .sign_in_phone_number_endpoint(config) ⇒ Object
- .sign_in_username_endpoint(config) ⇒ Object
- .sign_in_with_oauth2_endpoint(config) ⇒ Object
- .sign_jwt_endpoint(config) ⇒ Object
- .sign_jwt_payload(ctx, payload, config) ⇒ Object
- .signing_jwk(ctx, config) ⇒ Object
- .siwe(options = {}) ⇒ Object
- .siwe_chain_id(value) ⇒ Object
- .siwe_create_user(ctx, config, wallet_address, _chain_id, email, anonymous) ⇒ Object
- .siwe_ensure_wallet_and_account(ctx, user, wallet_address, chain_id) ⇒ Object
- .siwe_expired_time?(value) ⇒ Boolean
- .siwe_find_user(ctx, wallet_address, chain_id) ⇒ Object
- .siwe_identifier(wallet_address, chain_id) ⇒ Object
- .siwe_merge_schema_fields(base_fields, custom_fields) ⇒ Object
- .siwe_nonce_body(body) ⇒ Object
- .siwe_normalize_domain(domain) ⇒ Object
- .siwe_normalize_wallet!(value) ⇒ Object
- .siwe_parse_message(message) ⇒ Object
- .siwe_parse_message_chain_id(value) ⇒ Object
- .siwe_parse_message_time(value) ⇒ Object
- .siwe_schema(custom_schema = nil) ⇒ Object
- .siwe_unauthorized_error(status, message) ⇒ Object
- .siwe_validate_message!(message, domain, wallet_address, chain_id, nonce) ⇒ Object
- .siwe_verify_body(body, config) ⇒ Object
- .siwe_verify_message(config, body, wallet_address, chain_id, nonce, ctx) ⇒ Object
- .slack(options = {}) ⇒ Object
- .sso(*args, &block) ⇒ Object
- .storage_fields(fields) ⇒ Object
- .store_magic_link_token(token, config) ⇒ Object
- .stringify_keys(value) ⇒ Object
- .stringify_payload(value) ⇒ Object
- .stringify_permission(value) ⇒ Object
- .stripe(*args, &block) ⇒ Object
- .symbolize_session(entry) ⇒ Object
- .team_by_id(ctx, id) ⇒ Object
- .team_member_wire(ctx, member) ⇒ Object
- .team_wire(ctx, team) ⇒ Object
- .truthy?(value) ⇒ Boolean
- .truthy_value?(value) ⇒ Boolean
- .two_factor(options = {}) ⇒ Object
- .two_factor_after_sign_in(ctx, config) ⇒ Object
- .two_factor_assert_not_locked!(ctx, config, record) ⇒ Object
- .two_factor_backup_codes_response_schema ⇒ Object
- .two_factor_check_password!(ctx, user_id, password, allow_passwordless: false) ⇒ Object
- .two_factor_disable_endpoint(config) ⇒ Object
- .two_factor_enable_endpoint(config) ⇒ Object
- .two_factor_enable_response_schema ⇒ Object
- .two_factor_generate_backup_codes(secret, options) ⇒ Object
- .two_factor_generate_backup_codes_endpoint(config) ⇒ Object
- .two_factor_generate_secret ⇒ Object
- .two_factor_generate_totp_endpoint(config) ⇒ Object
- .two_factor_get_totp_uri_endpoint(config) ⇒ Object
- .two_factor_lock_time(value) ⇒ Object
- .two_factor_methods(ctx, config, user_id) ⇒ Object
- .two_factor_openapi(operation_id, description, response_schema) ⇒ Object
- .two_factor_otp_matches?(ctx, stored, input, options) ⇒ Boolean
- .two_factor_random_digits(length) ⇒ Object
- .two_factor_read_backup_codes(secret, stored, options) ⇒ Object
- .two_factor_record(ctx, config, user_id) ⇒ Object
- .two_factor_record_failure!(ctx, config, record) ⇒ Object
- .two_factor_request_body(operation_id) ⇒ Object
- .two_factor_reset_failures!(ctx, config, record) ⇒ Object
- .two_factor_schema(config = {}) ⇒ Object
- .two_factor_send_otp_endpoint(config) ⇒ Object
- .two_factor_set_trusted_device(ctx, config, user_id) ⇒ Object
- .two_factor_store_backup_codes(secret, codes, options) ⇒ Object
- .two_factor_store_otp_value(ctx, code, options) ⇒ Object
- .two_factor_totp(secret, options: {}) ⇒ Object
- .two_factor_totp_at(secret, counter, digits:) ⇒ Object
- .two_factor_totp_enabled!(config) ⇒ Object
- .two_factor_totp_uri(secret, issuer:, account:, options: {}) ⇒ Object
- .two_factor_totp_valid?(secret, code, options: {}) ⇒ Boolean
- .two_factor_trusted_device_valid?(ctx, config, user_id) ⇒ Boolean
- .two_factor_verification_context(ctx, config) ⇒ Object
- .two_factor_verification_response_schema ⇒ Object
- .two_factor_verify_backup_code_endpoint(config) ⇒ Object
- .two_factor_verify_otp_endpoint(config) ⇒ Object
- .two_factor_verify_totp_endpoint(config) ⇒ Object
- .two_factor_view_backup_codes_endpoint(config) ⇒ Object
- .username(options = {}) ⇒ Object
- .username_database_hooks(config) ⇒ Object
- .username_for_sign_in_validation(username, config) ⇒ Object
- .username_for_validation(username, config) ⇒ Object
- .username_mutation_path?(path) ⇒ Boolean
- .username_schema(config) ⇒ Object
- .valid_signed_token?(ctx, signed_token) ⇒ Boolean
- .validate_device_authorization_options!(config) ⇒ Object
- .validate_email_otp_email!(email) ⇒ Object
- .validate_email_otp_type!(type) ⇒ Object
- .validate_i18n_translations!(config) ⇒ Object
- .validate_invitation_team_ids!(ctx, config, organization_id, raw_value, team_ids) ⇒ Object
- .validate_jwt_options!(config) ⇒ Object
- .validate_magic_link_callback!(ctx, value, label) ⇒ Object
- .validate_organization_roles!(ctx, config, organization_id, role_names, adapter: ctx.context.adapter) ⇒ Object
- .validate_permission_resources!(config, permission) ⇒ Object
- .validate_phone_number!(config, phone_number) ⇒ Object
- .validate_stored_invitation_teams!(adapter, organization_id, team_ids) ⇒ Object
- .validate_unique_phone_number!(ctx, phone_number) ⇒ Object
- .validate_username!(username, config, status:) ⇒ Object
- .validate_username_mutation!(ctx, config) ⇒ Object
- .validation_order(config, field) ⇒ Object
- .verification_jwks(ctx, config) ⇒ Object
- .verification_uri(ctx, config) ⇒ Object
- .verified_multi_session_tokens(ctx) ⇒ Object
- .verify_eddsa_jwt(ctx, token, key, config) ⇒ Object
- .verify_email_otp_endpoint(config) ⇒ Object
- .verify_jwt_endpoint(config) ⇒ Object
- .verify_jwt_token(ctx, token, config) ⇒ Object
- .verify_one_time_token_endpoint(config) ⇒ Object
- .verify_phone_number_endpoint(config) ⇒ Object
- .verify_siwe_message_endpoint(config) ⇒ Object
- .yandex(options = {}) ⇒ Object
Dynamic Method Handling
This class handles dynamic methods through the method_missing method
#method_missing(name) ⇒ Object (private)
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/better_auth/plugins.rb', line 68 def method_missing(name, ...) if (replacement = REMOVED_PLUGIN_FACTORIES[name.to_sym]) raise ArgumentError, "BetterAuth::Plugins.#{name} was removed. Use #{replacement} instead." end if (loader = plugin_loader_for_method(name)) load_plugin!(loader) return public_send(name, ...) if respond_to?(name, true) raise NoMethodError, "plugin file for #{loader} did not define BetterAuth::Plugins.#{name}" end if (loader = PLUGIN_FACTORY_LOADERS[name.to_sym]) load_plugin!(loader) return public_send(name, ...) if respond_to?(name, true) raise NoMethodError, "plugin file for #{loader} did not define BetterAuth::Plugins.#{name}" end super end |
Class Method Details
.additional_fields(schema = {}) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
# File 'lib/better_auth/plugins/additional_fields.rb', line 7 def additional_fields(schema = {}) config = normalize_hash(schema) user_fields = storage_fields(config[:user] || {}) session_fields = storage_fields(config[:session] || {}) Plugin.new( id: "additional-fields", schema: { user: {fields: user_fields}, session: {fields: session_fields} }, init: lambda do |_context| { options: { user: {additional_fields: user_fields}, session: {additional_fields: session_fields} } } end ) end |
.additional_input(hash, *exclude) ⇒ Object
1555 1556 1557 1558 1559 |
# File 'lib/better_auth/plugins/organization.rb', line 1555 def additional_input(hash, *exclude) data = normalize_hash(hash) additional = normalize_hash(data.delete(:additional_fields)) extra_input(data, *exclude, :additional_fields).merge(additional) end |
.admin(options = {}) ⇒ Object
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/better_auth/plugins/admin.rb', line 44 def admin( = {}) config = admin_config() 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: (config) }, hooks: { after: [ { matcher: ->(ctx) { ctx.path == "/list-sessions" }, handler: lambda do |ctx| next unless ctx.returned.is_a?(Array) ctx.json(ctx.returned.reject { |session| session["impersonatedBy"] || session[:impersonatedBy] }) end } ] }, error_codes: ADMIN_ERROR_CODES, options: config ) end |
.admin_ban_user_endpoint(config) ⇒ Object
378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 |
# File 'lib/better_auth/plugins/admin.rb', line 378 def admin_ban_user_endpoint(config) Endpoint.new( path: "/admin/ban-user", method: "POST", metadata: admin_user_mutation_openapi( operation_id: "banUser", description: "Ban a user", response_description: "User banned", properties: { userId: {type: "string", description: "The user id"}, banReason: {type: ["string", "null"], description: "The reason for the ban"}, banExpiresIn: {type: ["number", "null"], description: "The number of seconds until the ban expires"} }, required: ["userId"] ) ) do |ctx| session = (ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS")) body = normalize_hash(ctx.body) found = ctx.context.internal_adapter.find_user_by_id(body[:user_id]) raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless found raise APIError.new("BAD_REQUEST", code: "YOU_CANNOT_BAN_YOURSELF", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_BAN_YOURSELF")) if body[:user_id] == session[:user]["id"] expires_in = body[:ban_expires_in] || config[:default_ban_expires_in] user = ctx.context.internal_adapter.update_user(body[:user_id], banned: true, banReason: body[:ban_reason] || config[:default_ban_reason] || "No reason", banExpires: expires_in ? Time.now + expires_in.to_i : nil, updatedAt: Time.now) ctx.context.internal_adapter.delete_user_sessions(body[:user_id]) ctx.json({user: Schema.parse_output(ctx.context., "user", user)}) end end |
.admin_config(options) ⇒ Object
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/better_auth/plugins/admin.rb', line 84 def admin_config() config = normalize_hash() config[:roles_configured] = config.key?(:roles) config[:default_role] ||= "user" config[:admin_roles] = Array(config[:admin_roles] || ["admin"]).flat_map { |role| role.to_s.split(",") } config[:banned_user_message] ||= "You have been banned from this application. Please contact support if you believe this is an error." config[:impersonation_session_duration] ||= 60 * 60 config[:ac] ||= create_access_control(ADMIN_DEFAULT_STATEMENTS) config[:roles] ||= admin_default_roles(config) valid_roles = config[:roles].keys.map { |role| role.to_s.downcase } invalid = config[:admin_roles].reject { |role| valid_roles.include?(role.to_s.downcase) } raise Error, "Invalid admin roles: #{invalid.join(", ")}. Admin roles must be defined in the 'roles' configuration." if invalid.any? config end |
.admin_create_user_endpoint(config) ⇒ Object
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/better_auth/plugins/admin.rb', line 201 def admin_create_user_endpoint(config) Endpoint.new( path: "/admin/create-user", method: "POST", metadata: admin_user_mutation_openapi( operation_id: "createUser", description: "Create a new user", response_description: "User created", properties: { email: {type: "string", description: "The email of the user"}, password: {type: ["string", "null"], description: "The password of the user"}, name: {type: "string", description: "The name of the user"}, role: {type: ["string", "array", "null"], description: "The role or roles of the user"}, data: {type: ["object", "null"], description: "Additional user data"} }, required: ["email", "name"] ) ) do |ctx| session = Routes.current_session(ctx, allow_nil: true, sensitive: true) if session unless (session[:user], session[:user]["role"], {user: ["create"]}, config) raise APIError.new("FORBIDDEN", code: "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS")) end elsif !ctx.headers.empty? raise APIError.new("UNAUTHORIZED") end body = normalize_hash(ctx.body) email = body[:email].to_s.downcase raise APIError.new("BAD_REQUEST", code: "INVALID_EMAIL", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email) if ctx.context.internal_adapter.find_user_by_email(email) raise APIError.new("BAD_REQUEST", code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL", message: ADMIN_ERROR_CODES.fetch("USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL")) end data = normalize_hash(body[:data]).each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value } requested_role = body.key?(:role) ? body[:role] : data.delete("role") if !requested_role.nil? && session (ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE")) end if session && ["banned", "banReason", "banExpires"].any? { |field| data.key?(field) } (ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS")) end user = ctx.context.internal_adapter.create_user(data.merge( name: body[:name].to_s, email: email, role: requested_role.nil? ? config[:default_role] : admin_validate_roles!(requested_role, config) ).merge(body.key?(:image) ? {image: body[:image]} : {}), context: ctx) raise APIError.new("INTERNAL_SERVER_ERROR", code: "FAILED_TO_CREATE_USER", message: ADMIN_ERROR_CODES.fetch("FAILED_TO_CREATE_USER")) unless user if body[:password].to_s != "" ctx.context.internal_adapter.link_account(userId: user["id"], providerId: "credential", accountId: user["id"], password: Routes.hash_password(ctx, body[:password])) end ctx.json({user: Schema.parse_output(ctx.context., "user", user)}) end end |
.admin_database_hooks(config) ⇒ Object
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/better_auth/plugins/admin.rb', line 108 def admin_database_hooks(config) { user: { create: { before: lambda do |user, _ctx| {data: {"role" => config[:default_role]}.merge(user)} end } }, session: { create: { before: lambda do |session, ctx| next unless ctx user = ctx.context.internal_adapter.find_user_by_id(session["userId"] || session[:userId]) next unless user && user["banned"] if user["banExpires"] && Time.parse(user["banExpires"].to_s) < Time.now ctx.context.internal_adapter.update_user(user["id"], banned: false, banReason: nil, banExpires: nil, updatedAt: Time.now) next end if ctx.path.to_s.start_with?("/callback", "/oauth2/callback") error_url = ctx.context..on_api_error[:error_url] || "#{ctx.context.base_url}/error" url = "#{error_url}?error=banned&error_description=#{URI.encode_www_form_component(config[:banned_user_message])}" raise ctx.redirect(url) end raise APIError.new("FORBIDDEN", message: config[:banned_user_message], code: "BANNED_USER") end } } } end |
.admin_default_roles(config = {}) ⇒ Object
100 101 102 103 104 105 106 |
# File 'lib/better_auth/plugins/admin.rb', line 100 def admin_default_roles(config = {}) ac = config[:ac] || create_access_control(ADMIN_DEFAULT_STATEMENTS) { "admin" => ac.new_role(ADMIN_DEFAULT_ROLE_STATEMENTS), "user" => ac.new_role(user: [], session: []) } end |
.admin_filter_users(users, query) ⇒ Object
857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 |
# File 'lib/better_auth/plugins/admin.rb', line 857 def admin_filter_users(users, query) result = users search_value = query[:search_value].to_s if !search_value.empty? field = (query[:search_field] || "email").to_s result = result.select { |user| user[field].to_s.downcase.include?(search_value.downcase) } end filter_field = (query[:filter_field] || query.dig(:filter, :field)).to_s if !filter_field.empty? filter_value = if query.key?(:filter_value) query[:filter_value] else query.dig(:filter, :value) end operator = (query[:filter_operator] || query.dig(:filter, :operator) || "eq").to_s field = (filter_field == "_id") ? "id" : Schema.storage_key(filter_field) result = result.select do |user| current = user[field] case operator when "ne" then current != filter_value when "contains" then current.to_s.include?(filter_value.to_s) else current == filter_value end end end result end |
.admin_get_user_endpoint(config) ⇒ Object
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/better_auth/plugins/admin.rb', line 170 def admin_get_user_endpoint(config) Endpoint.new( path: "/admin/get-user", method: "GET", metadata: { openapi: { operationId: "getUser", description: "Get an existing user", parameters: [ {name: "id", in: "query", required: false, schema: {type: "string"}}, {name: "userId", in: "query", required: false, schema: {type: "string"}}, {name: "email", in: "query", required: false, schema: {type: "string"}} ], responses: { "200" => OpenAPI.json_response("User", {type: "object", "$ref": "#/components/schemas/User"}) } } } ) do |ctx| (ctx, config, {user: ["get"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_GET_USER")) query = normalize_hash(ctx.query) user = if query[:id] || query[:user_id] ctx.context.internal_adapter.find_user_by_id(query[:id] || query[:user_id]) elsif query[:email] ctx.context.internal_adapter.find_user_by_email(query[:email])&.fetch(:user) end raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user ctx.json(Schema.parse_output(ctx.context., "user", user)) end end |
.admin_has_permission_endpoint(config) ⇒ Object
648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 |
# File 'lib/better_auth/plugins/admin.rb', line 648 def (config) Endpoint.new( path: "/admin/has-permission", method: "POST", body_schema: ->(body) { body.is_a?(Hash) ? body : false }, metadata: { openapi: { operationId: "hasPermission", description: "Check if the user has permission", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { permissions: {type: "object", description: "The permissions to check"}, userId: {type: ["string", "null"], description: "The user id"}, role: {type: ["string", "null"], description: "The role to check"} }, required: ["permissions"] ) ), responses: { "200" => OpenAPI.json_response( "Success", OpenAPI.object_schema( { error: {type: ["string", "null"]}, success: {type: "boolean"} }, required: ["success"] ) ) } } } ) do |ctx| session = Routes.current_session(ctx, allow_nil: true, sensitive: true) body = normalize_hash(ctx.body) = body[:permissions] || body[:permission] unless 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: (user, role, , config)}) end end |
.admin_impersonate_user_endpoint(config) ⇒ Object
429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 |
# File 'lib/better_auth/plugins/admin.rb', line 429 def admin_impersonate_user_endpoint(config) Endpoint.new( path: "/admin/impersonate-user", method: "POST", metadata: { openapi: { operationId: "impersonateUser", description: "Impersonate a user", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { userId: {type: "string", description: "The user id"} }, required: ["userId"] ) ), responses: { "200" => OpenAPI.json_response("Impersonation session created", OpenAPI.session_response_schema_pair) } } } ) do |ctx| session = (ctx, config, {user: ["impersonate"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS")) body = normalize_hash(ctx.body) target = ctx.context.internal_adapter.find_user_by_id(body[:user_id]) raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless target can_impersonate_admins = config[:allow_impersonating_admins] || (session[:user], session[:user]["role"], {user: ["impersonate-admins"]}, config) if !can_impersonate_admins && admin_user?(target, config) raise APIError.new("FORBIDDEN", code: "YOU_CANNOT_IMPERSONATE_ADMINS", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_IMPERSONATE_ADMINS")) end impersonated = ctx.context.internal_adapter.create_session(target["id"], true, {impersonatedBy: session[:user]["id"], expiresAt: Time.now + config[:impersonation_session_duration].to_i}, true, ctx) raise APIError.new("INTERNAL_SERVER_ERROR", message: ADMIN_ERROR_CODES.fetch("FAILED_TO_CREATE_USER")) unless impersonated = ctx.(ctx.context.[:dont_remember].name, ctx.context.secret) Cookies.(ctx) = ctx.context.("admin_session") ctx.(.name, "#{session[:session]["token"]}:#{}", ctx.context.secret, ctx.context.[:session_token].attributes) Cookies.(ctx, {session: impersonated, user: target}, true) ctx.json({ session: Schema.parse_output(ctx.context., "session", impersonated), user: Schema.parse_output(ctx.context., "user", target) }) end end |
.admin_list_user_sessions_endpoint(config) ⇒ Object
362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 |
# File 'lib/better_auth/plugins/admin.rb', line 362 def admin_list_user_sessions_endpoint(config) Endpoint.new( path: "/admin/list-user-sessions", method: "POST", metadata: admin_sessions_openapi( operation_id: "adminListUserSessions", description: "List user sessions", response_description: "List of user sessions" ) ) do |ctx| (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., "session", session) }}) end end |
.admin_list_users_endpoint(config) ⇒ Object
319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 |
# File 'lib/better_auth/plugins/admin.rb', line 319 def admin_list_users_endpoint(config) Endpoint.new( path: "/admin/list-users", method: "GET", metadata: { openapi: { operationId: "listUsers", description: "List users", parameters: admin_list_users_parameters, responses: { "200" => OpenAPI.json_response( "List of users", OpenAPI.object_schema( { users: {type: "array", items: {type: "object", "$ref": "#/components/schemas/User"}}, total: {type: "number"}, limit: {type: ["number", "null"]}, offset: {type: ["number", "null"]} }, required: ["users", "total"] ) ) } } } ) do |ctx| (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., "user", user) }, total: total, limit: limit, offset: offset }) end end |
.admin_list_users_parameters ⇒ Object
755 756 757 758 759 760 761 762 763 764 765 766 767 768 |
# File 'lib/better_auth/plugins/admin.rb', line 755 def admin_list_users_parameters [ {name: "searchValue", in: "query", required: false, schema: {type: "string"}}, {name: "searchField", in: "query", required: false, schema: {type: "string"}}, {name: "searchOperator", in: "query", required: false, schema: {type: "string"}}, {name: "limit", in: "query", required: false, schema: {type: "number"}}, {name: "offset", in: "query", required: false, schema: {type: "number"}}, {name: "sortBy", in: "query", required: false, schema: {type: "string"}}, {name: "sortDirection", in: "query", required: false, schema: {type: "string"}}, {name: "filterField", in: "query", required: false, schema: {type: "string"}}, {name: "filterValue", in: "query", required: false, schema: {type: "string"}}, {name: "filterOperator", in: "query", required: false, schema: {type: "string"}} ] end |
.admin_paginate_users(users, query) ⇒ Object
895 896 897 898 899 900 |
# File 'lib/better_auth/plugins/admin.rb', line 895 def admin_paginate_users(users, query) offset = query[:offset].to_i limit = query[:limit] result = offset.positive? ? users.drop(offset) : users limit ? result.first(limit.to_i) : result end |
.admin_parse_roles(roles) ⇒ Object
798 799 800 |
# File 'lib/better_auth/plugins/admin.rb', line 798 def admin_parse_roles(roles) Array(roles).join(",") end |
.admin_permission?(user, role_string, permissions, config) ⇒ Boolean
780 781 782 783 784 785 786 787 788 789 |
# File 'lib/better_auth/plugins/admin.rb', line 780 def (user, role_string, , config) return true if user && Array(config[:admin_user_ids]).map(&:to_s).include?(user["id"].to_s) return false unless 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)&.( || {})&.fetch(:success, false) end end |
.admin_remove_user_endpoint(config) ⇒ Object
572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 |
# File 'lib/better_auth/plugins/admin.rb', line 572 def admin_remove_user_endpoint(config) Endpoint.new( path: "/admin/remove-user", method: "POST", metadata: { openapi: { operationId: "removeUser", description: "Remove a user", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { userId: {type: "string", description: "The user id"} }, required: ["userId"] ) ), responses: { "200" => OpenAPI.json_response("User removed", OpenAPI.success_response_schema) } } } ) do |ctx| session = (ctx, config, {user: ["delete"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS")) user_id = normalize_hash(ctx.body)[:user_id] raise APIError.new("BAD_REQUEST", code: "YOU_CANNOT_REMOVE_YOURSELF", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_REMOVE_YOURSELF")) if user_id == session[:user]["id"] raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id) ctx.context.internal_adapter.delete_user(user_id) ctx.json({success: true}) end end |
.admin_require_permission!(ctx, config, permissions, message) ⇒ Object
770 771 772 773 774 775 776 777 778 |
# File 'lib/better_auth/plugins/admin.rb', line 770 def (ctx, config, , ) session = Routes.current_session(ctx, sensitive: true) return session if (session[:user], session[:user]["role"], , config) code = ADMIN_ERROR_CODES.key() raise Error, "Unknown admin permission error: #{}" unless code raise APIError.new("FORBIDDEN", code: code, message: ) end |
.admin_revoke_user_session_endpoint(config) ⇒ Object
516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 |
# File 'lib/better_auth/plugins/admin.rb', line 516 def admin_revoke_user_session_endpoint(config) Endpoint.new( path: "/admin/revoke-user-session", method: "POST", metadata: { openapi: { operationId: "revokeUserSession", description: "Revoke a user session", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { sessionToken: {type: "string", description: "The session token"} }, required: ["sessionToken"] ) ), responses: { "200" => OpenAPI.json_response("Session revoked", OpenAPI.success_response_schema) } } } ) do |ctx| (ctx, config, {session: ["revoke"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS")) ctx.context.internal_adapter.delete_session(normalize_hash(ctx.body)[:session_token]) ctx.json({success: true}) end end |
.admin_revoke_user_sessions_endpoint(config) ⇒ Object
544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 |
# File 'lib/better_auth/plugins/admin.rb', line 544 def admin_revoke_user_sessions_endpoint(config) Endpoint.new( path: "/admin/revoke-user-sessions", method: "POST", metadata: { openapi: { operationId: "revokeUserSessions", description: "Revoke all user sessions", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { userId: {type: "string", description: "The user id"} }, required: ["userId"] ) ), responses: { "200" => OpenAPI.json_response("Sessions revoked", OpenAPI.success_response_schema) } } } ) do |ctx| (ctx, config, {session: ["revoke"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS")) ctx.context.internal_adapter.delete_user_sessions(normalize_hash(ctx.body)[:user_id]) ctx.json({success: true}) end end |
.admin_role_for(roles, role) ⇒ Object
819 820 821 |
# File 'lib/better_auth/plugins/admin.rb', line 819 def admin_role_for(roles, role) roles[role.to_s] || roles.find { |key, _value| key.to_s.downcase == role.to_s.downcase }&.last end |
.admin_sessions_openapi(operation_id:, description:, response_description:) ⇒ Object
731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 |
# File 'lib/better_auth/plugins/admin.rb', line 731 def admin_sessions_openapi(operation_id:, description:, response_description:) { openapi: { operationId: operation_id, description: description, requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( {userId: {type: "string", description: "The user id"}}, required: ["userId"] ) ), responses: { "200" => OpenAPI.json_response( response_description, OpenAPI.object_schema( {sessions: {type: "array", items: {type: "object", "$ref": "#/components/schemas/Session"}}}, required: ["sessions"] ) ) } } } end |
.admin_set_role_endpoint(config) ⇒ Object
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/better_auth/plugins/admin.rb', line 143 def admin_set_role_endpoint(config) Endpoint.new( path: "/admin/set-role", method: "POST", metadata: admin_user_mutation_openapi( operation_id: "setUserRole", description: "Set the role of a user", response_description: "User role updated", properties: { userId: {type: "string", description: "The user id"}, role: {type: ["string", "array"], description: "The role or roles to set"} }, required: ["userId", "role"] ) ) do |ctx| (ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE")) body = normalize_hash(ctx.body) user_id = body[:user_id].to_s raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty? update = {role: admin_validate_roles!(body[:role], config)} raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id) user = ctx.context.internal_adapter.update_user(user_id, update) ctx.json({user: Schema.parse_output(ctx.context., "user", user || {})}) end end |
.admin_set_user_password_endpoint(config) ⇒ Object
603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 |
# File 'lib/better_auth/plugins/admin.rb', line 603 def admin_set_user_password_endpoint(config) Endpoint.new( path: "/admin/set-user-password", method: "POST", metadata: { openapi: { operationId: "setUserPassword", description: "Set a user's password", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { userId: {type: "string", description: "The user id"}, newPassword: {type: "string", description: "The new password"} }, required: ["userId", "newPassword"] ) ), responses: { "200" => OpenAPI.json_response("Password set", OpenAPI.status_response_schema) } } } ) do |ctx| (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..email_and_password[:min_password_length] max = ctx.context..email_and_password[:max_password_length] raise APIError.new("BAD_REQUEST", code: "PASSWORD_TOO_SHORT", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_SHORT")) if password.length < min raise APIError.new("BAD_REQUEST", code: "PASSWORD_TOO_LONG", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_LONG")) if password.length > max raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id) hashed_password = Routes.hash_password(ctx, password) credential_account = ctx.context.internal_adapter.find_accounts(user_id).find { |account| account["providerId"] == "credential" } if credential_account ctx.context.internal_adapter.update_password(user_id, hashed_password) else ctx.context.internal_adapter.create_account(userId: user_id, providerId: "credential", accountId: user_id, password: hashed_password) end ctx.json({status: true}) end end |
.admin_sort_users(users, query) ⇒ Object
885 886 887 888 889 890 891 892 893 |
# File 'lib/better_auth/plugins/admin.rb', line 885 def admin_sort_users(users, query) sort_field = query[:sort_by] || query[:sort_field] return users unless sort_field field = Schema.storage_key(sort_field) sorted = users.sort_by { |user| user[field].to_s } direction = (query[:sort_direction] || query[:sort_order] || "asc").to_s (direction.downcase == "desc") ? sorted.reverse : sorted end |
.admin_stop_impersonating_endpoint ⇒ Object
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 |
# File 'lib/better_auth/plugins/admin.rb', line 475 def admin_stop_impersonating_endpoint Endpoint.new( path: "/admin/stop-impersonating", method: "POST", metadata: { openapi: { operationId: "stopImpersonating", description: "Stop impersonating a user", requestBody: OpenAPI.empty_request_body, responses: { "200" => OpenAPI.json_response("Impersonation stopped", OpenAPI.session_response_schema_pair) } } } ) do |ctx| session = Routes.current_session(ctx, sensitive: true) admin_id = session[:session]["impersonatedBy"] raise APIError.new("BAD_REQUEST", message: "You are not impersonating anyone") unless admin_id admin = ctx.context.internal_adapter.find_user_by_id(admin_id) raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to find user") unless admin = ctx.context.("admin_session") = ctx.(.name, ctx.context.secret) raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to find admin session") unless admin_session_token, = .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.(ctx, admin_session, !.to_s.empty?) Cookies.(ctx, ) ctx.json({ session: Schema.parse_output(ctx.context., "session", admin_session[:session]), user: Schema.parse_output(ctx.context., "user", admin_session[:user]) }) end end |
.admin_unban_user_endpoint(config) ⇒ Object
406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 |
# File 'lib/better_auth/plugins/admin.rb', line 406 def admin_unban_user_endpoint(config) Endpoint.new( path: "/admin/unban-user", method: "POST", metadata: admin_user_mutation_openapi( operation_id: "unbanUser", description: "Unban a user", response_description: "User unbanned", properties: { userId: {type: "string", description: "The user id"} }, required: ["userId"] ) ) do |ctx| (ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS")) user_id = normalize_hash(ctx.body)[:user_id] raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id) user = ctx.context.internal_adapter.update_user(user_id, banned: false, banReason: nil, banExpires: nil, updatedAt: Time.now) ctx.json({user: Schema.parse_output(ctx.context., "user", user)}) end end |
.admin_update_user_endpoint(config) ⇒ Object
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 |
# File 'lib/better_auth/plugins/admin.rb', line 257 def admin_update_user_endpoint(config) Endpoint.new( path: "/admin/update-user", method: "POST", metadata: { openapi: { operationId: "adminUpdateUser", description: "Update a user's details", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { userId: {type: "string", description: "The user id"}, data: {type: "object", description: "The user data to update"} }, required: ["userId", "data"] ) ), responses: { "200" => OpenAPI.json_response("User updated", {type: "object", "$ref": "#/components/schemas/User"}) } } } ) do |ctx| (ctx, config, {user: ["update"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS")) body = normalize_hash(ctx.body) data = normalize_hash(body[:data] || body).except(:user_id, :data) raise APIError.new("BAD_REQUEST", code: "NO_DATA_TO_UPDATE", message: ADMIN_ERROR_CODES.fetch("NO_DATA_TO_UPDATE")) if data.empty? if data.key?(:password) raise APIError.new("BAD_REQUEST", code: "PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER", message: ADMIN_ERROR_CODES.fetch("PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER")) end if data.key?(:role) (ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE")) data[:role] = admin_validate_roles!(data[:role], config) end if [:banned, :ban_reason, :ban_expires].any? { |field| data.key?(field) } session = (ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS")) if data[:banned] == true && body[:user_id] == session[:user]["id"] raise APIError.new("BAD_REQUEST", code: "YOU_CANNOT_BAN_YOURSELF", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_BAN_YOURSELF")) end end if data.key?(:email) || data.key?(:email_verified) (ctx, config, {user: ["set-email"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL")) if data.key?(:email) email = data[:email].to_s.downcase raise APIError.new("BAD_REQUEST", code: "INVALID_EMAIL", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email) existing = ctx.context.internal_adapter.find_user_by_email(email) if existing && existing[:user]["id"] != body[:user_id] raise APIError.new("BAD_REQUEST", code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL", message: ADMIN_ERROR_CODES.fetch("USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL")) end data[:email] = email end end raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(body[:user_id]) stored_data = data.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value } user = ctx.context.internal_adapter.update_user(body[:user_id], stored_data) ctx.context.internal_adapter.delete_user_sessions(body[:user_id]) if data[:banned] == true ctx.json(Schema.parse_output(ctx.context., "user", user)) end end |
.admin_user?(user, config) ⇒ Boolean
791 792 793 794 795 796 |
# File 'lib/better_auth/plugins/admin.rb', line 791 def admin_user?(user, config) return true if Array(config[:admin_user_ids]).map(&:to_s).include?(user["id"].to_s) admin_roles = config[:admin_roles].map { |role| role.to_s.downcase } user["role"].to_s.split(",").any? { |role| admin_roles.include?(role.to_s.downcase) } end |
.admin_user_mutation_openapi(operation_id:, description:, response_description:, properties:, required:) ⇒ Object
712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 |
# File 'lib/better_auth/plugins/admin.rb', line 712 def admin_user_mutation_openapi(operation_id:, description:, response_description:, properties:, required:) { openapi: { operationId: operation_id, description: description, requestBody: OpenAPI.json_request_body(OpenAPI.object_schema(properties, required: required)), responses: { "200" => OpenAPI.json_response( response_description, OpenAPI.object_schema( {user: {type: "object", "$ref": "#/components/schemas/User"}}, required: ["user"] ) ) } } } end |
.admin_user_sort(query) ⇒ Object
847 848 849 850 851 852 853 854 855 |
# File 'lib/better_auth/plugins/admin.rb', line 847 def admin_user_sort(query) sort_field = query[:sort_by] || query[:sort_field] return nil unless sort_field { field: sort_field, direction: query[:sort_direction] || query[:sort_order] || "asc" } end |
.admin_user_where(query) ⇒ Object
823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 |
# File 'lib/better_auth/plugins/admin.rb', line 823 def admin_user_where(query) where = [] search_value = query[:search_value] if search_value && !search_value.to_s.empty? where << { field: query[:search_field] || "email", operator: query[:search_operator] || "contains", value: search_value } end filter_value_defined = query.key?(:filter_value) || (query[:filter].is_a?(Hash) && query[:filter].key?(:value)) if filter_value_defined filter_field = query[:filter_field] || query.dig(:filter, :field) || "email" where << { field: (filter_field.to_s == "_id") ? "id" : filter_field, operator: query[:filter_operator] || query.dig(:filter, :operator) || "eq", value: query.key?(:filter_value) ? query[:filter_value] : query.dig(:filter, :value) } end where end |
.admin_validate_roles!(roles, config) ⇒ Object
802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 |
# File 'lib/better_auth/plugins/admin.rb', line 802 def admin_validate_roles!(roles, config) unless Array(roles).all? { |role| role.is_a?(String) || role.is_a?(Symbol) } raise APIError.new("BAD_REQUEST", code: "INVALID_ROLE_TYPE", message: ADMIN_ERROR_CODES.fetch("INVALID_ROLE_TYPE")) end parsed = admin_parse_roles(roles) if config[:roles_configured] defined_roles = (config[:roles] || {}).transform_keys(&:to_s) invalid = parsed.split(",", -1).reject { |role| admin_role_for(defined_roles, role) } if invalid.any? raise APIError.new("BAD_REQUEST", code: "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE")) end end parsed end |
.all_jwks(ctx, config, force_refresh: false) ⇒ Object
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/better_auth/plugins/jwt.rb', line 267 def all_jwks(ctx, config, force_refresh: false) cache = config[:local_jwks_cache] ||= JWT::LocalJwksCache.new if !force_refresh && cache.expires_at && cache.expires_at > Time.now return cache.keys end adapter = config[:adapter] keys = if adapter && adapter[:get_jwks].respond_to?(:call) Array(adapter[:get_jwks].call(ctx)).map { |entry| stringify_payload(entry) } else ctx.context.adapter.find_many(model: "jwks") end cache.keys = keys cache.expires_at = Time.now + 300 keys end |
.anonymous(options = {}) ⇒ Object
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
# File 'lib/better_auth/plugins/anonymous.rb', line 19 def anonymous( = {}) config = normalize_hash() Plugin.new( id: "anonymous", endpoints: { sign_in_anonymous: sign_in_anonymous_endpoint(config), delete_anonymous_user: delete_anonymous_user_endpoint(config) }, hooks: { after: [ { matcher: ->(ctx) { anonymous_link_path?(ctx.path) }, handler: ->(ctx) { link_anonymous_user(ctx, config) } } ] }, schema: anonymous_schema(config), error_codes: ANONYMOUS_ERROR_CODES, options: config ) end |
.anonymous_email(config) ⇒ Object
145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/better_auth/plugins/anonymous.rb', line 145 def anonymous_email(config) generator = config[:generate_random_email] email = generator.call if generator.respond_to?(:call) if email && email != "" unless email.is_a?(String) && !email.empty? && Routes::EMAIL_PATTERN.match?(email) raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["INVALID_EMAIL_FORMAT"]) end return email end id = SecureRandom.hex(16) domain = config[:email_domain_name] domain ? "temp-#{id}@#{domain}" : "temp@#{id}.com" end |
.anonymous_link_path?(path) ⇒ Boolean
207 208 209 210 211 212 213 214 215 216 217 218 219 |
# File 'lib/better_auth/plugins/anonymous.rb', line 207 def anonymous_link_path?(path) path.to_s.start_with?( "/sign-in", "/sign-up", "/callback", "/oauth2/callback", "/magic-link/verify", "/email-otp/verify-email", "/one-tap/callback", "/passkey/verify-authentication", "/phone-number/verify" ) end |
.anonymous_name(ctx, config) ⇒ Object
160 161 162 163 164 165 166 |
# File 'lib/better_auth/plugins/anonymous.rb', line 160 def anonymous_name(ctx, config) generator = config[:generate_name] name = generator.call(ctx) if generator.respond_to?(:call) return name if present_string?(name) "Anonymous" end |
.anonymous_schema(config) ⇒ Object
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/better_auth/plugins/anonymous.rb', line 128 def anonymous_schema(config) field_name = anonymous_schema_field_name(config) || "is_anonymous" { user: { fields: { isAnonymous: { type: "boolean", required: false, input: false, default_value: false, field_name: field_name } } } } end |
.anonymous_schema_field_name(config) ⇒ Object
221 222 223 224 225 226 227 228 |
# File 'lib/better_auth/plugins/anonymous.rb', line 221 def anonymous_schema_field_name(config) fields = config.dig(:schema, :user, :fields) || {} mapping = fields[:is_anonymous] || fields[:isAnonymous] || fields["isAnonymous"] return mapping if mapping.is_a?(String) return mapping[:field_name] || mapping[:fieldName] if mapping.is_a?(Hash) nil end |
.api_key(*args, &block) ⇒ Object
9 10 11 |
# File 'lib/better_auth/plugins/api_key.rb', line 9 def api_key(*args, &block) call_external_plugin!(:api_key, *args, implementation_constant: :API_KEY_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-api-key", entry: "lib/better_auth/api_key.rb", &block) end |
.apply_bearer_token(ctx, config) ⇒ Object
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/better_auth/plugins/bearer.rb', line 38 def apply_bearer_token(ctx, config) auth_header = (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) = ctx.context.[:session_token].name = Cookies.(ctx.headers["cookie"], , signed_token) {context: {headers: ctx.headers.merge("cookie" => )}} end |
.apply_i18n_translation(ctx, config) ⇒ Object
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/better_auth/plugins/i18n.rb', line 160 def apply_i18n_translation(ctx, config) error = ctx.returned return nil unless error.is_a?(BetterAuth::APIError) error_code = resolve_i18n_error_code(error, ctx) return nil unless error_code locale = detect_i18n_locale(ctx, config) translation = config.dig(:translations, locale, error_code) return nil unless translation raise BetterAuth::APIError.new( error.status, message: translation, headers: error.headers, code: error.code, body: { code: error_code, message: translation, originalMessage: error. } ) 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 apply_last_login_method(ctx, config) method = resolve_login_method(ctx, config) return unless method = ctx.response_headers["set-cookie"].to_s return unless .include?(ctx.context.[:session_token].name) attributes = ctx.context.[:session_token].attributes.merge(max_age: config[:max_age], http_only: false) ctx.(config[:cookie_name], method, attributes) if config[:store_in_database] && ctx.context.new_session&.dig(:user, "id") updated = ctx.context.internal_adapter.update_user(ctx.context.new_session[:user]["id"], lastLoginMethod: method) ctx.context.new_session[:user].merge!(updated) if updated end nil end |
.auth0(options = {}) ⇒ Object
48 49 50 51 52 53 54 55 56 57 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 48 def auth0( = {}) data = normalize_hash() domain = data.fetch(:domain).to_s.sub(%r{\Ahttps?://}, "") generic_oauth_provider_config( data, provider_id: "auth0", discovery_url: "https://#{domain}/.well-known/openid-configuration", scopes: ["openid", "profile", "email"] ) end |
.authorization_header(ctx) ⇒ Object
34 35 36 |
# File 'lib/better_auth/plugins/bearer.rb', line 34 def (ctx) ctx.headers["authorization"] || ctx.headers["Authorization"] end |
.base32_decode(value) ⇒ Object
771 772 773 774 775 |
# File 'lib/better_auth/plugins/two_factor.rb', line 771 def base32_decode(value) clean = value.to_s.upcase.gsub(/[^A-Z2-7]/, "") bits = clean.chars.map { |char| BASE32_ALPHABET.index(char).to_i.to_s(2).rjust(5, "0") }.join bits.scan(/.{8}/).map { |byte| byte.to_i(2).chr }.join end |
.base32_encode(bytes) ⇒ Object
766 767 768 769 |
# File 'lib/better_auth/plugins/two_factor.rb', line 766 def base32_encode(bytes) bits = bytes.bytes.map { |byte| byte.to_s(2).rjust(8, "0") }.join bits.scan(/.{1,5}/).map { |chunk| BASE32_ALPHABET[chunk.ljust(5, "0").to_i(2)] }.join end |
.base64url_bn(number) ⇒ Object
510 511 512 513 514 |
# File 'lib/better_auth/plugins/jwt.rb', line 510 def base64url_bn(number) hex = number.to_s(16) hex = "0#{hex}" if hex.length.odd? Crypto.base64url_encode([hex].pack("H*")) end |
.bearer(options = {}) ⇒ Object
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
# File 'lib/better_auth/plugins/bearer.rb', line 11 def bearer( = {}) config = normalize_hash() Plugin.new( id: "bearer", hooks: { before: [ { matcher: ->(ctx) { (ctx) }, handler: ->(ctx) { apply_bearer_token(ctx, config) } } ], after: [ { matcher: ->(_ctx) { true }, handler: ->(ctx) { expose_auth_token(ctx) } } ] }, options: config ) end |
.call_email_verification_option(ctx, key, user) ⇒ Object
734 735 736 737 |
# File 'lib/better_auth/plugins/email_otp.rb', line 734 def call_email_verification_option(ctx, key, user) callback = ctx.context..email_verification[key] callback.call(user, ctx.request) if callback.respond_to?(:call) end |
.call_external_plugin!(method_name, *args, implementation_constant:, gem_name:, entry:, &block) ⇒ Object
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/better_auth/plugin_loader.rb', line 130 def call_external_plugin!(method_name, *args, implementation_constant:, gem_name:, entry:, &block) loader = method(method_name) ensure_external_plugin_loaded!( gem_name: gem_name, entry: entry, implementation_constant: implementation_constant ) resolved = method(method_name) if resolved == loader raise LoadError, "BetterAuth::Plugins.#{method_name} requires the #{gem_name} gem. Add it to your Gemfile and require its entrypoint." end resolved.call(*args, &block) end |
.camelize_lower(key) ⇒ Object
1431 1432 1433 1434 |
# File 'lib/better_auth/plugins/organization.rb', line 1431 def camelize_lower(key) parts = key.to_s.split("_") ([parts.first] + parts.drop(1).map(&:capitalize)).join end |
.canonical_multi_session_cookie_name(name) ⇒ Object
226 227 228 229 |
# File 'lib/better_auth/plugins/multi_session.rb', line 226 def (name) prefix = "__Secure-" name.to_s.sub(/\A#{Regexp.escape(prefix)}/i, prefix) end |
.captcha(options = {}) ⇒ Object
37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/better_auth/plugins/captcha.rb', line 37 def captcha( = {}) config = normalize_hash() Plugin.new( id: "captcha", on_request: lambda do |request, context| captcha_on_request(request, context, config) end, error_codes: CAPTCHA_EXTERNAL_ERROR_CODES, options: config ) end |
.captcha_http_verify(params) ⇒ Object
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/better_auth/plugins/captcha.rb', line 106 def captcha_http_verify(params) verifier = captcha_verifier_params(params) uri = URI.parse(verifier[:url]) request = Net::HTTP::Post.new(uri) request["Content-Type"] = verifier[:content_type] request.body = if verifier[:content_type] == "application/json" JSON.generate(verifier[:payload]) else URI.encode_www_form(verifier[:payload]) end response = HTTPClient.request(uri, request, open_timeout: CAPTCHA_VERIFY_TIMEOUT_SECONDS, read_timeout: CAPTCHA_VERIFY_TIMEOUT_SECONDS) raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"] unless response.is_a?(Net::HTTPSuccess) JSON.parse(response.body.to_s) rescue JSON::ParserError raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"] end |
.captcha_log(context, message) ⇒ Object
168 169 170 171 172 173 174 175 |
# File 'lib/better_auth/plugins/captcha.rb', line 168 def captcha_log(context, ) logger = context.logger if logger.respond_to?(:call) logger.call(:error, ) elsif logger.respond_to?(:error) logger.error() end end |
.captcha_normalize_verifier_response(value) ⇒ Object
154 155 156 157 158 |
# File 'lib/better_auth/plugins/captcha.rb', line 154 def captcha_normalize_verifier_response(value) return value.transform_keys(&:to_s) if value.is_a?(Hash) {} end |
.captcha_on_request(request, context, config) ⇒ Object
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/better_auth/plugins/captcha.rb', line 49 def captcha_on_request(request, context, config) endpoints = Array(config[:endpoints]).empty? ? CAPTCHA_DEFAULT_ENDPOINTS : Array(config[:endpoints]) path = request.path_info.to_s email_otp_path = "/sign-in/email-otp" email_otp_is_explicit = Array(config[:endpoints]).map(&:to_s).include?(email_otp_path) return nil if path.include?(email_otp_path) && !email_otp_is_explicit return nil unless endpoints.any? { |endpoint| path.include?(endpoint.to_s) } raise CAPTCHA_INTERNAL_ERROR_CODES["MISSING_SECRET_KEY"] if config[:secret_key].to_s.empty? response_token = request.get_header("HTTP_X_CAPTCHA_RESPONSE") if response_token.to_s.empty? return {response: captcha_response(400, "MISSING_RESPONSE", CAPTCHA_EXTERNAL_ERROR_CODES["MISSING_RESPONSE"])} end result = captcha_verify(config, response_token, captcha_remote_ip(request, context)) return nil if captcha_success?(config, result) {response: captcha_response(403, "VERIFICATION_FAILED", CAPTCHA_EXTERNAL_ERROR_CODES["VERIFICATION_FAILED"])} rescue => error captcha_log(context, error.) {response: captcha_response(500, "UNKNOWN_ERROR", CAPTCHA_EXTERNAL_ERROR_CODES["UNKNOWN_ERROR"])} end |
.captcha_payload(provider, params) ⇒ Object
124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/better_auth/plugins/captcha.rb', line 124 def captcha_payload(provider, params) payload = { "secret" => params[:secret_key], "response" => params[:captcha_response] } payload["sitekey"] = params[:site_key] if params[:site_key] && ["hcaptcha", "captchafox"].include?(provider) if params[:remote_ip] payload[(provider == "captchafox") ? "remoteIp" : "remoteip"] = params[:remote_ip] end payload end |
.captcha_remote_ip(request, context) ⇒ Object
164 165 166 |
# File 'lib/better_auth/plugins/captcha.rb', line 164 def captcha_remote_ip(request, context) RequestIP.client_ip(request, context.) end |
.captcha_response(status, code, message) ⇒ Object
160 161 162 |
# File 'lib/better_auth/plugins/captcha.rb', line 160 def captcha_response(status, code, ) [status, {"content-type" => "application/json"}, [JSON.generate({code: code, message: })]] end |
.captcha_success?(config, result) ⇒ Boolean
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/better_auth/plugins/captcha.rb', line 136 def captcha_success?(config, result) return false unless result && result["success"] if config[:provider].to_s == "google-recaptcha" && result.key?("score") return false if result["score"].to_f < (config[:min_score] || 0.5).to_f end if ["google-recaptcha", "cloudflare-turnstile"].include?(config[:provider].to_s) expected_action = config[:expected_action].to_s return false if !expected_action.empty? && result["action"] != expected_action allowed_hostnames = Array(config[:allowed_hostnames]).map(&:to_s) return false if allowed_hostnames.any? && !allowed_hostnames.include?(result["hostname"].to_s) end true end |
.captcha_verifier_params(params) ⇒ Object
94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/better_auth/plugins/captcha.rb', line 94 def captcha_verifier_params(params) provider = params.fetch(:provider) payload = captcha_payload(provider, params) content_type = (provider == "cloudflare-turnstile") ? "application/json" : "application/x-www-form-urlencoded" { url: params.fetch(:site_verify_url), content_type: content_type, payload: payload, provider: provider } end |
.captcha_verify(config, response_token, remote_ip) ⇒ Object
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/better_auth/plugins/captcha.rb', line 73 def captcha_verify(config, response_token, remote_ip) provider = config[:provider].to_s url = config[:site_verify_url_override] || CAPTCHA_SITE_VERIFY_URLS.fetch(provider) params = { site_verify_url: url, secret_key: config[:secret_key], captcha_response: response_token, remote_ip: remote_ip, site_key: config[:site_key], min_score: config[:min_score], provider: provider } Timeout.timeout(CAPTCHA_VERIFY_TIMEOUT_SECONDS) do if config[:verifier].respond_to?(:call) captcha_normalize_verifier_response(config[:verifier].call(captcha_verifier_params(params))) else captcha_http_verify(params) end end end |
.change_email_email_otp_endpoint(config) ⇒ Object
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 |
# File 'lib/better_auth/plugins/email_otp.rb', line 390 def change_email_email_otp_endpoint(config) Endpoint.new( path: "/email-otp/change-email", method: "POST", metadata: { openapi: { operationId: "changeEmailWithEmailOTP", description: "Change the current user's email with an OTP", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { newEmail: {type: "string"}, otp: {type: "string"} }, required: ["newEmail", "otp"] ) ), responses: { "200" => OpenAPI.json_response("Email changed", OpenAPI.success_response_schema) } } } ) do |ctx| email_otp_change_email_enabled!(config) session = Routes.current_session(ctx) body = normalize_hash(ctx.body) current_email = session[:user]["email"].to_s.downcase new_email = body[:new_email].to_s.downcase validate_email_otp_email!(new_email) raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == current_email email_otp_verify!(ctx, config, email: "#{current_email}-#{new_email}", type: "change-email", otp: body[:otp].to_s) raise APIError.new("BAD_REQUEST", message: "Email already in use") if ctx.context.internal_adapter.find_user_by_email(new_email) current = ctx.context.internal_adapter.find_user_by_email(current_email) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless current call_email_verification_option(ctx, :before_email_verification, current[:user]) updated = ctx.context.internal_adapter.update_user(current[:user]["id"], email: new_email, emailVerified: true) call_email_verification_option(ctx, :after_email_verification, updated) Cookies.(ctx, {session: session[:session], user: updated}) ctx.json({success: true}) end end |
.check_verification_otp_endpoint(config) ⇒ Object
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 |
# File 'lib/better_auth/plugins/email_otp.rb', line 184 def check_verification_otp_endpoint(config) Endpoint.new( path: "/email-otp/check-verification-otp", method: "POST", metadata: { openapi: { operationId: "checkVerificationOTP", description: "Check an email verification OTP without consuming it", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { email: {type: "string"}, type: {type: "string"}, otp: {type: "string"} }, required: ["email", "type", "otp"] ) ), responses: { "200" => OpenAPI.json_response("OTP is valid", OpenAPI.success_response_schema) } } } ) do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase type = body[:type].to_s otp = body[:otp].to_s validate_email_otp_type!(type) validate_email_otp_email!(email) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless ctx.context.internal_adapter.find_user_by_email(email) email_otp_verify!(ctx, config, email: email, type: type, otp: otp, consume: false) ctx.json({success: true}) end end |
.clear_active_organization_session(ctx, session, organization_id) ⇒ Object
1360 1361 1362 1363 1364 1365 1366 |
# File 'lib/better_auth/plugins/organization.rb', line 1360 def clear_active_organization_session(ctx, session, organization_id) return unless session[:session]["activeOrganizationId"] == organization_id update = {activeOrganizationId: nil, activeTeamId: nil} updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], update) Cookies.(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => nil, "activeTeamId" => nil), user: session[:user]}) end |
.clear_multi_session_cookies(ctx) ⇒ Object
186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/better_auth/plugins/multi_session.rb', line 186 def (ctx) tokens = [] (ctx).each do |name| token = ctx.(name, ctx.context.secret) next unless token tokens << token if token (ctx, (name)) end ctx.context.internal_adapter.delete_sessions(tokens) unless tokens.empty? nil end |
.const_missing(name) ⇒ Object
96 97 98 99 100 101 |
# File 'lib/better_auth/plugins.rb', line 96 def const_missing(name) load_plugin_for_constant!(name) return const_get(name) if const_defined?(name, false) super end |
.cookie_header_from_set_cookie(set_cookie) ⇒ Object
54 55 56 |
# File 'lib/better_auth/plugins.rb', line 54 def () .to_s.lines.map { |line| line.split(";").first }.join("; ") end |
.count_members_with_role(ctx, organization_id, role) ⇒ Object
1127 1128 1129 1130 1131 1132 1133 |
# File 'lib/better_auth/plugins/organization.rb', line 1127 def count_members_with_role(ctx, organization_id, role) count = 0 organization_each_adapter_record(ctx.context.adapter, "member", where: [{field: "organizationId", value: organization_id}]) do |member| count += 1 if normalized_role_names(member["role"]).include?(role) end count end |
.create_access_control(statements) ⇒ Object Also known as: createAccessControl
78 79 80 |
# File 'lib/better_auth/plugins/access.rb', line 78 def create_access_control(statements) AccessControl.new(statements) end |
.create_default_team(ctx, config, organization, session) ⇒ Object
1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 |
# File 'lib/better_auth/plugins/organization.rb', line 1368 def create_default_team(ctx, config, organization, session) custom = config.dig(:teams, :default_team, :custom_create_default_team) team_data = {organizationId: organization["id"], name: organization["name"], createdAt: Time.now} merge_hook_data!(team_data, run_org_hook(config, :before_create_team, {team: team_data, user: session[:user], organization: organization_wire(ctx, organization)}, ctx)) team = if custom.respond_to?(:call) custom.call(organization_wire(ctx, organization), ctx) else ctx.context.adapter.create(model: "team", data: team_data, force_allow_id: true) end ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now}) run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) team end |
.create_jwk(ctx, config) ⇒ Object
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 |
# File 'lib/better_auth/plugins/jwt.rb', line 320 def create_jwk(ctx, config) adapter = config[:adapter] alg = (config.dig(:jwks, :key_pair_config, :alg) || "EdDSA").to_s pair = generate_key_pair(alg) public_key = public_key_for(pair) public_pem = public_key_pem(public_key) data = { "id" => Crypto.uuid, "publicKey" => public_pem, "privateKey" => jwk_private_key_for_storage(ctx, private_key_pem(pair), config), "createdAt" => Time.now, "alg" => alg, "pem" => public_pem } data.merge!(public_key_jwk_fields(public_key, alg)) data["expiresAt"] = Time.now + config.dig(:jwks, :rotation_interval).to_i if config.dig(:jwks, :rotation_interval) created = if adapter && adapter[:create_jwk].respond_to?(:call) stringify_payload(adapter[:create_jwk].call(data, ctx)) else ctx.context.adapter.create(model: "jwks", data: data, force_allow_id: true) end cache = config[:local_jwks_cache] cache.keys = nil if cache cache.expires_at = nil if cache created end |
.create_verification_otp_endpoint(config) ⇒ Object
120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/better_auth/plugins/email_otp.rb', line 120 def create_verification_otp_endpoint(config) Endpoint.new(method: "POST", metadata: {server_only: true}) do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase type = body[:type].to_s validate_email_otp_type!(type) otp = email_otp_generate(config, email: email, type: type, ctx: ctx) email_otp_store(ctx, config, email: email, type: type, otp: otp) otp end end |
.custom_session(resolver, options = nil, plugin_options = nil, **keywords) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/better_auth/plugins/custom_session.rb', line 7 def custom_session(resolver, = nil, = nil, **keywords) config = normalize_hash( || {}) config = config.merge(normalize_hash()) if && !.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.(ctx, session, false) if ctx.response_headers["set-cookie"].to_s.empty? ctx.json(resolver.call(Routes.parsed_session_response(ctx, session), ctx)) end }, hooks: { after: [ { matcher: ->(ctx) { ctx.path == "/multi-session/list-device-sessions" && config[:should_mutate_list_device_sessions_endpoint] }, handler: lambda do |ctx| list = Array(ctx.returned) ctx.json(list.map { |entry| resolver.call(symbolize_session(entry), ctx) }) end } ] }, options: config ) end |
.deep_merge(base, override) ⇒ Object
516 517 518 519 520 521 522 523 524 |
# File 'lib/better_auth/plugins/jwt.rb', line 516 def deep_merge(base, override) normalize_hash(base || {}).merge(normalize_hash(override || {})) do |_key, old_value, new_value| if old_value.is_a?(Hash) && new_value.is_a?(Hash) deep_merge(old_value, new_value) else new_value end end end |
.deep_merge_hashes(base, override) ⇒ Object
44 45 46 47 48 49 50 51 52 |
# File 'lib/better_auth/plugins.rb', line 44 def deep_merge_hashes(base, override) base.merge(override) do |_key, old_value, new_value| if old_value.is_a?(Hash) && new_value.is_a?(Hash) deep_merge_hashes(old_value, new_value) else new_value end end end |
.default_username_valid?(username) ⇒ Boolean
368 369 370 |
# File 'lib/better_auth/plugins/username.rb', line 368 def default_username_valid?(username) username.match?(/\A[a-zA-Z0-9_.]+\z/) end |
.delete_anonymous_user_endpoint(config) ⇒ Object
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/better_auth/plugins/anonymous.rb', line 92 def delete_anonymous_user_endpoint(config) Endpoint.new( path: "/delete-anonymous-user", method: "POST", metadata: { openapi: { operationId: "deleteAnonymousUser", description: "Delete the current anonymous user", requestBody: OpenAPI.empty_request_body, responses: { "200" => OpenAPI.json_response("Anonymous user deleted", OpenAPI.success_response_schema) } } } ) do |ctx| session = Routes.current_session(ctx, sensitive: true) if config[:disable_delete_anonymous_user] raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["DELETE_ANONYMOUS_USER_DISABLED"]) end unless session[:user]["isAnonymous"] raise APIError.new("FORBIDDEN", message: ANONYMOUS_ERROR_CODES["USER_IS_NOT_ANONYMOUS"]) end begin ctx.context.internal_adapter.delete_user(session[:user]["id"]) rescue raise APIError.new("INTERNAL_SERVER_ERROR", message: ANONYMOUS_ERROR_CODES["FAILED_TO_DELETE_ANONYMOUS_USER"]) end Cookies.(ctx) ctx.json({success: true}) end end |
.detect_i18n_locale(ctx, config) ⇒ Object
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/better_auth/plugins/i18n.rb', line 107 def detect_i18n_locale(ctx, config) available_locales = config[:translations].keys config[:detection].each do |strategy| locale = case strategy when "header" detect_locale_from_header(ctx, available_locales) when "cookie" (ctx, config, available_locales) when "session" detect_locale_from_session(ctx, config, available_locales) when "callback" detect_locale_from_callback(ctx, config, available_locales) end return locale if locale && available_locales.include?(locale) end config[:default_locale] end |
.detect_locale_from_callback(ctx, config, available_locales) ⇒ Object
151 152 153 154 155 156 157 158 |
# File 'lib/better_auth/plugins/i18n.rb', line 151 def detect_locale_from_callback(ctx, config, available_locales) callback = config[:get_locale] return nil unless callback.respond_to?(:call) locale = callback.call(ctx) locale = locale.to_s locale if locale && !locale.empty? && available_locales.include?(locale) end |
.detect_locale_from_cookie(ctx, config, available_locales) ⇒ Object
133 134 135 136 137 |
# File 'lib/better_auth/plugins/i18n.rb', line 133 def (ctx, config, available_locales) value = ctx.(config[:locale_cookie]) locale = value.to_s locale if value && available_locales.include?(locale) end |
.detect_locale_from_header(ctx, available_locales) ⇒ Object
127 128 129 130 131 |
# File 'lib/better_auth/plugins/i18n.rb', line 127 def detect_locale_from_header(ctx, available_locales) parse_accept_language(ctx.headers["accept-language"]).find do |locale| available_locales.include?(locale) end end |
.detect_locale_from_session(ctx, config, available_locales) ⇒ Object
139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/better_auth/plugins/i18n.rb', line 139 def detect_locale_from_session(ctx, config, available_locales) session = ctx.context.current_session || ctx.context.new_session return nil unless session user = session[:user] || session["user"] return nil unless user.is_a?(Hash) locale = fetch_value(user, config[:user_locale_field]) locale = locale.to_s locale if locale && !locale.empty? && available_locales.include?(locale) end |
.device_approve_endpoint ⇒ Object
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 239 def device_approve_endpoint Endpoint.new( path: "/device/approve", method: "POST", metadata: { openapi: { operationId: "approveDevice", description: "Approve a device authorization request", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { userCode: {type: "string", description: "User code shown on the device"} }, required: ["userCode"] ) ), responses: { "200" => OpenAPI.json_response("Success", OpenAPI.success_response_schema) } } } ) do |ctx| session = Routes.current_session(ctx, allow_nil: true) raise ("UNAUTHORIZED", "unauthorized", DEVICE_AUTHORIZATION_ERROR_CODES["AUTHENTICATION_REQUIRED"]) unless session process_device_decision(ctx, session, "approved") end end |
.device_authorization(options = {}) ⇒ Object
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 26 def ( = {}) config = { expires_in: "30m", interval: "5s", device_code_length: 40, user_code_length: 8 }.merge(normalize_hash()) (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: (config[:schema]), error_codes: DEVICE_AUTHORIZATION_ERROR_CODES, options: config ) end |
.device_authorization_error(status, error, description) ⇒ Object
440 441 442 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 440 def (status, error, description) APIError.new(status, code: error, message: description, body: {error: error, error_description: description}) end |
.device_authorization_schema(custom_schema = nil) ⇒ Object
450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 450 def (custom_schema = nil) base = { deviceCode: { fields: { deviceCode: {type: "string", required: true}, userCode: {type: "string", required: true}, userId: {type: "string", required: false}, expiresAt: {type: "date", required: true}, status: {type: "string", required: true}, lastPolledAt: {type: "date", required: false}, pollingInterval: {type: "number", required: false}, clientId: {type: "string", required: false}, scope: {type: "string", required: false} } } } return base unless custom_schema.is_a?(Hash) deep_merge_hashes(base, normalize_hash(custom_schema)) end |
.device_authorization_time(value) ⇒ Object
444 445 446 447 448 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 444 def (value) return value if value.is_a?(Time) Time.parse(value.to_s) end |
.device_authorization_user_code_candidates(value) ⇒ Object
431 432 433 434 435 436 437 438 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 431 def (value) original = value.to_s upper = original.upcase clean = original.delete("-") upper_clean = upper.delete("-") dashed = (upper_clean.length == 8) ? "#{upper_clean[0, 4]}-#{upper_clean[4, 4]}" : upper_clean [original, upper, clean, upper_clean, dashed].uniq end |
.device_code_endpoint(config) ⇒ Object
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 50 def device_code_endpoint(config) Endpoint.new( path: "/device/code", method: "POST", metadata: { openapi: { operationId: "requestDeviceCode", description: "Request a device and user code", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { client_id: {type: "string", description: "OAuth client ID"}, user_id: {type: "string", description: "Optional user ID to pre-bind to the device code"}, scope: {type: "string", description: "Requested scopes"} } ) ), responses: { "200" => OpenAPI.json_response("Success", device_code_response_schema) } } } ) do |ctx| body = OAuthProtocol.stringify_keys(ctx.body) client_id = body["client_id"] if config[:validate_client] && !config[:validate_client].call(client_id) raise ("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 = (config) user_code = (config) expires_in = duration_seconds(config[:expires_in]) interval = duration_seconds(config[:interval]) ctx.context.adapter.create( model: "deviceCode", data: { "deviceCode" => device_code, "userCode" => user_code, "userId" => body["user_id"].to_s.empty? ? nil : body["user_id"], "expiresAt" => Time.now + expires_in, "status" => "pending", "pollingInterval" => interval * 1000, "clientId" => client_id, "scope" => body["scope"] } ) verification_uri = verification_uri(ctx, config) complete = OAuthProtocol.redirect_uri_with_params(verification_uri, user_code: user_code) ctx.json({ device_code: device_code, user_code: user_code, verification_uri: verification_uri, verification_uri_complete: complete, expires_in: expires_in, interval: interval }, headers: {"Cache-Control" => "no-store"}) end end |
.device_code_response_schema ⇒ Object
327 328 329 330 331 332 333 334 335 336 337 338 339 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 327 def device_code_response_schema OpenAPI.object_schema( { device_code: {type: "string", description: "The device verification code"}, user_code: {type: "string", description: "The user code to display"}, verification_uri: {type: "string", format: "uri"}, verification_uri_complete: {type: "string", format: "uri"}, expires_in: {type: "number"}, interval: {type: "number"} }, required: ["device_code", "user_code", "verification_uri", "verification_uri_complete", "expires_in", "interval"] ) end |
.device_deny_endpoint ⇒ Object
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 268 def device_deny_endpoint Endpoint.new( path: "/device/deny", method: "POST", metadata: { openapi: { operationId: "denyDevice", description: "Deny a device authorization request", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { userCode: {type: "string", description: "User code shown on the device"} }, required: ["userCode"] ) ), responses: { "200" => OpenAPI.json_response("Success", OpenAPI.success_response_schema) } } } ) do |ctx| session = Routes.current_session(ctx, allow_nil: true) raise ("UNAUTHORIZED", "unauthorized", DEVICE_AUTHORIZATION_ERROR_CODES["AUTHENTICATION_REQUIRED"]) unless session process_device_decision(ctx, session, "denied") end end |
.device_token_endpoint(config) ⇒ Object
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 111 def device_token_endpoint(config) Endpoint.new( path: "/device/token", method: "POST", metadata: { allowed_media_types: ["application/x-www-form-urlencoded", "application/json"], openapi: { operationId: "exchangeDeviceToken", description: "Exchange device code for access token", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { grant_type: {type: "string", enum: [OAuthProtocol::DEVICE_CODE_GRANT]}, device_code: {type: "string"}, client_id: {type: "string"} }, required: ["grant_type", "device_code"] ) ), responses: { "200" => OpenAPI.json_response("Success", device_token_response_schema) } } } ) do |ctx| body = OAuthProtocol.stringify_keys(ctx.body) raise ("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 ("BAD_REQUEST", "invalid_grant", "Invalid client ID") end record = find_device_code(ctx, body["device_code"]) raise ("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 ("BAD_REQUEST", "invalid_grant", "Client ID mismatch") end if record["lastPolledAt"] && record["pollingInterval"].to_i.positive? elapsed = ((Time.now - (record["lastPolledAt"])) * 1000).to_i raise ("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 (record["expiresAt"]) <= Time.now ctx.context.adapter.delete(model: "deviceCode", where: [{field: "id", value: record["id"]}]) raise ("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_DEVICE_CODE"]) end case record["status"] when "pending" raise ("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 ("BAD_REQUEST", "access_denied", DEVICE_AUTHORIZATION_ERROR_CODES["ACCESS_DENIED"]) when "approved" claimed = ctx.context.adapter.consume_one( model: "deviceCode", where: [ {field: "deviceCode", value: body["device_code"].to_s}, {field: "status", value: "approved"} ] ) unless claimed && claimed["userId"] raise ("BAD_REQUEST", "invalid_grant", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_DEVICE_CODE"]) end record = OAuthProtocol.stringify_keys(claimed) user = ctx.context.internal_adapter.find_user_by_id(record["userId"]) raise ("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["USER_NOT_FOUND"]) unless user session = ctx.context.internal_adapter.create_session(user["id"]) raise ("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session session_data = {session: session, user: user} ctx.context.set_new_session(session_data) if ctx.context.respond_to?(:set_new_session) ctx.json({ access_token: session["token"], token_type: "Bearer", expires_in: [session["expiresAt"].to_i - Time.now.to_i, 0].max, scope: record["scope"].to_s }, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"}) else raise ("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_DEVICE_CODE_STATUS"]) end end end |
.device_token_response_schema ⇒ Object
341 342 343 344 345 346 347 348 349 350 351 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 341 def device_token_response_schema OpenAPI.object_schema( { access_token: {type: "string"}, token_type: {type: "string"}, expires_in: {type: "number"}, scope: {type: "string"} }, required: ["access_token", "token_type", "expires_in"] ) end |
.device_verification_response_schema ⇒ Object
353 354 355 356 357 358 359 360 361 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 353 def device_verification_response_schema OpenAPI.object_schema( { user_code: {type: "string"}, status: {type: "string"} }, required: ["user_code", "status"] ) end |
.device_verify_endpoint ⇒ Object
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 200 def device_verify_endpoint Endpoint.new( path: "/device", method: "GET", metadata: { openapi: { operationId: "getDeviceVerification", description: "Get device verification status", responses: { "200" => OpenAPI.json_response("Success", device_verification_response_schema) } } } ) do |ctx| code = normalize_user_code(OAuthProtocol.stringify_keys(ctx.query)["user_code"]) record = find_device_user_code(ctx, code) raise ("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_USER_CODE"]) unless record record = OAuthProtocol.stringify_keys(record) raise ("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_USER_CODE"]) if (record["expiresAt"]) <= Time.now session = Routes.current_session(ctx, allow_nil: true) if session && !record["userId"] && record["status"] == "pending" claimed = ctx.context.adapter.increment_one( model: "deviceCode", where: [ {field: "id", value: record["id"]}, {field: "status", value: "pending"}, {field: "userId", value: nil} ], increment: {}, set: {"userId" => session[:user]["id"]} ) record = OAuthProtocol.stringify_keys(claimed) if claimed end ctx.json({user_code: code, status: record["status"]}) end end |
.dub(options = {}) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
# File 'lib/better_auth/plugins/dub.rb', line 7 def dub( = {}) config = normalize_hash() oauth_plugin = dub_oauth_plugin(config[:oauth]) endpoints = {dub_link: dub_link_endpoint(oauth_plugin)} endpoints[:dub_oauth2_callback] = oauth_plugin.endpoints.fetch(:oauth2_callback) if oauth_plugin Plugin.new( id: "dub", endpoints: endpoints, init: ->(_context) { { options: { database_hooks: { user: { create: { after: ->(user, ctx) { dub_track_lead(config, user, ctx) } } } } } } }, options: config ) end |
.dub_default_lead_track(config, user, dub_id, ctx) ⇒ Object
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/better_auth/plugins/dub.rb', line 115 def dub_default_lead_track(config, user, dub_id, ctx) track = config[:dub_client]&.track return unless track&.respond_to?(:lead) dub_invoke_lead( track, click_id: dub_id, event_name: config[:lead_event_name] || "Sign Up", customer_external_id: fetch_value(user, "id"), customer_name: fetch_value(user, "name"), customer_email: fetch_value(user, "email"), customer_avatar: fetch_value(user, "image") ) rescue => error dub_log_error(ctx, error) end |
.dub_invoke_lead(track, payload) ⇒ Object
141 142 143 144 145 146 147 |
# File 'lib/better_auth/plugins/dub.rb', line 141 def dub_invoke_lead(track, payload) if track.method(:lead).parameters.any? { |type, name| [:key, :keyreq].include?(type) && name == :request } track.lead(request: dub_lead_request_body(payload)) else track.lead(payload) end end |
.dub_lead_request_body(payload) ⇒ Object
149 150 151 152 153 154 |
# File 'lib/better_auth/plugins/dub.rb', line 149 def dub_lead_request_body(payload) klass = defined?(::OpenApiSDK::Models::Operations::TrackLeadRequestBody) && ::OpenApiSDK::Models::Operations::TrackLeadRequestBody return payload unless klass klass.new(**payload) end |
.dub_link_endpoint(oauth_plugin) ⇒ Object
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
# File 'lib/better_auth/plugins/dub.rb', line 33 def dub_link_endpoint(oauth_plugin) Endpoint.new( path: "/dub/link", method: "POST", metadata: { openapi: { operationId: "dubLink", description: "Link a Dub OAuth account", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { callbackURL: {type: "string", description: "The URL to redirect to after linking"}, callback_url: {type: "string", description: "The URL to redirect to after linking"} } ) ), responses: { "200" => OpenAPI.json_response( "Authorization URL generated successfully for linking a Dub account", OpenAPI.object_schema( { url: {type: "string"}, redirect: {type: "boolean"} }, required: ["url", "redirect"] ) ) } } } ) do |ctx| unless oauth_plugin raise APIError.new("NOT_FOUND", message: "Dub OAuth is not configured") end body = normalize_hash(ctx.body) callback_url = body[:callback_url] || body[:callbackURL] if callback_url.to_s.empty? raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) end Routes.validate_auth_callback_url!(ctx.context, callback_url, "callbackURL") ctx.body = body.merge(provider_id: "dub", callback_url: callback_url) oauth_plugin.endpoints.fetch(:oauth2_link_account).call(ctx) end end |
.dub_log_error(ctx, error) ⇒ Object
132 133 134 135 136 137 138 139 |
# File 'lib/better_auth/plugins/dub.rb', line 132 def dub_log_error(ctx, error) logger = ctx.context.logger if logger.respond_to?(:error) logger.error(error) elsif logger.respond_to?(:call) logger.call(:error, error) end end |
.dub_oauth_plugin(oauth_options) ⇒ Object
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/better_auth/plugins/dub.rb', line 80 def dub_oauth_plugin() oauth = normalize_hash( || {}) return nil if oauth.empty? generic_oauth( config: [ { provider_id: "dub", authorization_url: "https://app.dub.co/oauth/authorize", token_url: "https://api.dub.co/oauth/token", client_id: oauth[:client_id], client_secret: oauth[:client_secret], pkce: oauth.key?(:pkce) ? oauth[:pkce] : true } ] ) end |
.dub_track_lead(config, user, ctx) ⇒ Object
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/better_auth/plugins/dub.rb', line 98 def dub_track_lead(config, user, ctx) return unless ctx dub_id = ctx.("dub_id") return if dub_id.to_s.empty? return if config[:disable_lead_tracking] custom = config[:custom_lead_track] if custom.respond_to?(:call) custom.call(user, ctx) else dub_default_lead_track(config, user, dub_id, ctx) end ctx.("dub_id", "", expires: Time.at(0), max_age: 0) end |
.duration_seconds(value) ⇒ Object
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 399 def duration_seconds(value) return value if value.is_a?(Integer) match = value.to_s.match(/\A(\d+)(ms|s|m|min|h|d)?\z/) raise Error, "Invalid time string" unless match amount = match[1].to_i case match[2] when "ms" then (amount / 1000.0).ceil when "m", "min" then amount * 60 when "h" then amount * 3600 when "d" then amount * 86_400 else amount end end |
.ec_curve_for_alg(alg) ⇒ Object
443 444 445 |
# File 'lib/better_auth/plugins/jwt.rb', line 443 def ec_curve_for_alg(alg) (alg == "ES512") ? "P-521" : "P-256" end |
.email_otp(options = {}) ⇒ Object
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/better_auth/plugins/email_otp.rb', line 13 def email_otp( = {}) config = { expires_in: 5 * 60, otp_length: 6, store_otp: "plain", allowed_attempts: 3 }.merge(normalize_hash()) 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: sign_in_email_otp_endpoint(config), request_password_reset_email_otp: request_password_reset_email_otp_endpoint(config), reset_password_email_otp: reset_password_email_otp_endpoint(config), request_email_change_email_otp: request_email_change_email_otp_endpoint(config), change_email_email_otp: change_email_email_otp_endpoint(config) }, hooks: { after: [ { matcher: ->(ctx) { ctx.path.to_s.start_with?("/sign-up") && config[:send_verification_on_sign_up] && !config[:override_default_email_verification] }, handler: ->(ctx) { email_otp_after_sign_up(ctx, config) } } ] }, rate_limit: email_otp_rate_limits(config), error_codes: EMAIL_OTP_ERROR_CODES, options: config ) end |
.email_otp_after_sign_up(ctx, config) ⇒ Object
511 512 513 514 515 516 517 518 519 520 521 |
# File 'lib/better_auth/plugins/email_otp.rb', line 511 def email_otp_after_sign_up(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
728 729 730 731 732 |
# File 'lib/better_auth/plugins/email_otp.rb', line 728 def email_otp_change_email_enabled!(config) return if config.dig(:change_email, :enabled) raise APIError.new("BAD_REQUEST", message: "Change email with OTP is disabled") end |
.email_otp_deliver(config, data, ctx) ⇒ Object
701 702 703 704 |
# File 'lib/better_auth/plugins/email_otp.rb', line 701 def email_otp_deliver(config, data, ctx) sender = config[:send_verification_otp] sender.call(data, ctx) if sender.respond_to?(:call) end |
.email_otp_generate(config, email:, type:, ctx:) ⇒ Object
629 630 631 632 633 634 635 |
# File 'lib/better_auth/plugins/email_otp.rb', line 629 def email_otp_generate(config, email:, type:, ctx:) generator = config[:generate_otp] generated = generator.call({email: email, type: type}, ctx) if generator.respond_to?(:call) return generated.to_s if generated && !generated.to_s.empty? Array.new(config[:otp_length].to_i) { SecureRandom.random_number(10).to_s }.join end |
.email_otp_identifier(email, type) ⇒ Object
706 707 708 |
# File 'lib/better_auth/plugins/email_otp.rb', line 706 def email_otp_identifier(email, type) "#{type}-otp-#{email}" end |
.email_otp_init(config) ⇒ Object
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/better_auth/plugins/email_otp.rb', line 50 def email_otp_init(config) lambda do |context| next unless config[:override_default_email_verification] { options: { email_verification: { send_verification_email: lambda do |data, request = nil| user = fetch_value(data, :user) || data email = fetch_value(user, :email).to_s endpoint_context = Endpoint::Context.new( path: "/send-verification-email", method: "POST", query: {}, body: {"email" => email, "type" => "email-verification"}, params: {}, headers: {}, context: context, request: request ) email_otp_send_verification(endpoint_context, config, email: email, type: "email-verification") end } } } end end |
.email_otp_matches?(ctx, config, stored_otp, otp) ⇒ Boolean
673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 |
# File 'lib/better_auth/plugins/email_otp.rb', line 673 def email_otp_matches?(ctx, config, stored_otp, otp) storage = config[:store_otp] actual, expected = if storage.to_s == "hashed" [Crypto.sha256(otp, encoding: :base64url), stored_otp] elsif storage.to_s == "encrypted" [Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_otp), otp] elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call) [storage[:hash].call(otp), stored_otp] elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call) [storage[:decrypt].call(stored_otp), otp] else [otp, stored_otp] end return false unless actual return false unless actual.to_s.bytesize == expected.to_s.bytesize Crypto.constant_time_compare(actual.to_s, expected.to_s) end |
.email_otp_password_reset_request(ctx, config) ⇒ Object
523 524 525 526 527 528 529 530 531 532 533 534 535 536 |
# File 'lib/better_auth/plugins/email_otp.rb', line 523 def email_otp_password_reset_request(ctx, config) body = normalize_hash(ctx.body) email = body[:email].to_s.downcase otp = email_otp_resolve(ctx, config, email: email, type: "forget-password") found = ctx.context.internal_adapter.find_user_by_email(email) unless found ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier(email, "forget-password")) return ctx.json({success: true}) end email_otp_deliver(config, {email: email, otp: otp, type: "forget-password"}, ctx) ctx.json({success: true}) end |
.email_otp_plain_value(ctx, config, stored_otp) ⇒ Object
692 693 694 695 696 697 698 699 |
# File 'lib/better_auth/plugins/email_otp.rb', line 692 def email_otp_plain_value(ctx, config, stored_otp) storage = config[:store_otp] return stored_otp if storage.to_s == "plain" || storage.nil? return Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_otp) if storage.to_s == "encrypted" return storage[:decrypt].call(stored_otp) if storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call) nil end |
.email_otp_rate_limits(config) ⇒ Object
739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 |
# File 'lib/better_auth/plugins/email_otp.rb', line 739 def email_otp_rate_limits(config) rate_limit = normalize_hash(config[:rate_limit] || {}) window = rate_limit[:window] || 60 max = rate_limit[:max] || 3 %w[ /email-otp/send-verification-otp /email-otp/check-verification-otp /email-otp/verify-email /sign-in/email-otp /email-otp/request-password-reset /email-otp/reset-password /email-otp/request-email-change /email-otp/change-email ].map do |path| { path_matcher: ->(request_path) { request_path == path }, window: window, max: max } end end |
.email_otp_resolve(ctx, config, email:, type:, identifier_email: email) ⇒ Object
565 566 567 568 569 570 571 572 573 574 |
# File 'lib/better_auth/plugins/email_otp.rb', line 565 def email_otp_resolve(ctx, config, email:, type:, identifier_email: email) if config[:resend_strategy].to_s == "reuse" reused = email_otp_reuse(ctx, config, email: identifier_email, type: type) return reused if reused end otp = email_otp_generate(config, email: email, type: type, ctx: ctx) email_otp_store(ctx, config, email: identifier_email, type: type, otp: otp) otp end |
.email_otp_reuse(ctx, config, email:, type:) ⇒ Object
576 577 578 579 580 581 582 583 584 585 586 587 588 589 |
# File 'lib/better_auth/plugins/email_otp.rb', line 576 def email_otp_reuse(ctx, config, email:, type:) identifier = email_otp_identifier(email, type) verification = ctx.context.internal_adapter.find_verification_value(identifier) return nil unless verification && !Routes.expired_time?(verification["expiresAt"]) stored_otp, attempts = email_otp_split(verification["value"]) return nil if attempts.to_i >= config[:allowed_attempts].to_i plain = email_otp_plain_value(ctx, config, stored_otp) return nil unless plain ctx.context.internal_adapter.update_verification_value(verification["id"], expiresAt: Time.now + config[:expires_in].to_i) plain end |
.email_otp_send_verification(ctx, config, email:, type:) ⇒ Object
538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 |
# File 'lib/better_auth/plugins/email_otp.rb', line 538 def email_otp_send_verification(ctx, config, email:, type:) otp = email_otp_resolve(ctx, config, email: email, type: type) found = ctx.context.internal_adapter.find_user_by_email(email) unless found if type == "sign-in" && !config[:disable_sign_up] # Upstream allows sign-in OTP creation for new users when sign-up is enabled. else ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier(email, type)) return end end email_otp_deliver(config, {email: email, otp: otp, type: type}, ctx) end |
.email_otp_sign_up_user_data(ctx, body, email) ⇒ Object
637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 |
# File 'lib/better_auth/plugins/email_otp.rb', line 637 def email_otp_sign_up_user_data(ctx, body, email) reserved = %i[email otp name image callback_url callbackURL callbackUrl] user_fields = Schema.auth_tables(ctx.context.).fetch("user").fetch(:fields) core_fields = %w[id name email emailVerified image createdAt updatedAt] additional = body.each_with_object({}) do |(key, value), result| next if reserved.include?(key.to_sym) field = Schema.storage_key(key) attributes = user_fields[field] next unless attributes next if core_fields.include?(field) next if attributes[:input] == false result[field] = value end additional.merge( "email" => email, "emailVerified" => true, "name" => body[:name].to_s, "image" => body[:image] ) end |
.email_otp_split(value) ⇒ Object
710 711 712 713 714 715 716 |
# File 'lib/better_auth/plugins/email_otp.rb', line 710 def email_otp_split(value) string = value.to_s index = string.rindex(":") return [string, ""] unless index [string[0...index], string[(index + 1)..]] end |
.email_otp_store(ctx, config, email:, type:, otp:) ⇒ Object
554 555 556 557 558 559 560 561 562 563 |
# File 'lib/better_auth/plugins/email_otp.rb', line 554 def email_otp_store(ctx, config, email:, type:, otp:) stored = email_otp_stored_value(ctx, config, otp) identifier = email_otp_identifier(email, type) ctx.context.internal_adapter.delete_verification_by_identifier(identifier) ctx.context.internal_adapter.create_verification_value( identifier: identifier, value: "#{stored}:0", expiresAt: Time.now + config[:expires_in].to_i ) end |
.email_otp_stored_value(ctx, config, otp) ⇒ Object
660 661 662 663 664 665 666 667 668 669 670 671 |
# File 'lib/better_auth/plugins/email_otp.rb', line 660 def email_otp_stored_value(ctx, config, otp) storage = config[:store_otp] return Crypto.sha256(otp, encoding: :base64url) if storage.to_s == "hashed" return Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: otp) if storage.to_s == "encrypted" if storage.is_a?(Hash) return storage[:hash].call(otp) if storage[:hash].respond_to?(:call) return storage[:encrypt].call(otp) if storage[:encrypt].respond_to?(:call) end otp end |
.email_otp_verify!(ctx, config, email:, type:, otp:, consume: true) ⇒ Object
591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 |
# File 'lib/better_auth/plugins/email_otp.rb', line 591 def email_otp_verify!(ctx, config, email:, type:, otp:, consume: true) identifier = email_otp_identifier(email, type) existing = ctx.context.internal_adapter.find_verification_value(identifier) if existing && Routes.expired_time?(existing["expiresAt"]) ctx.context.internal_adapter.delete_verification_by_identifier(identifier) raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["OTP_EXPIRED"]) end verification = ctx.context.internal_adapter.consume_verification_value(identifier) raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) unless verification otp_value, attempts = email_otp_split(verification["value"]) attempts_count = attempts.to_i if attempts_count >= config[:allowed_attempts].to_i raise APIError.new("FORBIDDEN", message: EMAIL_OTP_ERROR_CODES["TOO_MANY_ATTEMPTS"]) end unless email_otp_matches?(ctx, config, otp_value, otp) ctx.context.internal_adapter.create_verification_value( identifier: identifier, value: "#{otp_value}:#{attempts_count + 1}", expiresAt: verification["expiresAt"] ) raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) end unless consume ctx.context.internal_adapter.create_verification_value( identifier: identifier, value: verification["value"], expiresAt: verification["expiresAt"] ) end true end |
.encode_eddsa_jwt(payload, private_key, kid) ⇒ Object
447 448 449 450 451 452 453 454 455 |
# File 'lib/better_auth/plugins/jwt.rb', line 447 def encode_eddsa_jwt(payload, private_key, kid) header = {"alg" => "EdDSA", "kid" => kid} signing_input = [ Crypto.base64url_encode(JSON.generate(header)), Crypto.base64url_encode(JSON.generate(payload)) ].join(".") signature = private_key.sign(nil, signing_input) "#{signing_input}.#{Crypto.base64url_encode(signature)}" end |
.ensure_external_plugin_loaded!(gem_name:, entry:, implementation_constant:) ⇒ Object
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/better_auth/plugin_loader.rb', line 114 def ensure_external_plugin_loaded!(gem_name:, entry:, implementation_constant:) return if const_defined?(implementation_constant, false) spec = Gem.loaded_specs[gem_name] || Gem::Specification.find_by_name(gem_name) entry_path = File.join(spec.full_gem_path, entry) load entry_path unless $LOADED_FEATURES.include?(entry_path) return if const_defined?(implementation_constant, false) raise LoadError, "BetterAuth requires the #{gem_name} gem. Add it to your Gemfile and require its entrypoint." rescue Gem::MissingSpecError raise LoadError, "BetterAuth requires the #{gem_name} gem. Add it to your Gemfile and require its entrypoint." end |
.ensure_not_last_owner!(ctx, member, adapter: ctx.context.adapter) ⇒ Object
1350 1351 1352 1353 1354 1355 1356 1357 1358 |
# File 'lib/better_auth/plugins/organization.rb', line 1350 def ensure_not_last_owner!(ctx, member, adapter: ctx.context.adapter) return unless member["role"].to_s.split(",").include?("owner") owner_count = 0 organization_each_adapter_record(adapter, "member", where: [{field: "organizationId", value: member["organizationId"]}]) do |entry| owner_count += 1 if entry["role"].to_s.split(",").include?("owner") end raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER")) if owner_count <= 1 end |
.ensure_organization_member_capacity!(ctx, config, user, organization, adapter: ctx.context.adapter) ⇒ Object
1277 1278 1279 1280 1281 1282 1283 |
# File 'lib/better_auth/plugins/organization.rb', line 1277 def ensure_organization_member_capacity!(ctx, config, user, organization, adapter: ctx.context.adapter) limit = organization_membership_capacity_limit(ctx, config, user, organization) count = adapter.count(model: "member", where: [{field: "organizationId", value: organization["id"]}]) return if count < limit raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_MEMBERSHIP_LIMIT_REACHED")) end |
.ensure_plugin_loaded_for!(plugin) ⇒ Object
176 177 178 179 180 181 |
# File 'lib/better_auth/plugin_loader.rb', line 176 def ensure_plugin_loaded_for!(plugin) return unless plugin.is_a?(BetterAuth::Plugin) loader = PLUGIN_ID_TO_LOADER[plugin.id] load_plugin!(loader) if loader end |
.ensure_team_member_capacity!(ctx, config, team_ids, adapter: ctx.context.adapter, lock: false) ⇒ Object
1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 |
# File 'lib/better_auth/plugins/organization.rb', line 1307 def ensure_team_member_capacity!(ctx, config, team_ids, adapter: ctx.context.adapter, lock: false) max_members = config.dig(:teams, :maximum_members_per_team) return unless max_members && team_ids.any? team_ids.each do |team_id| team = adapter.find_one(model: "team", where: [{field: "id", value: team_id}]) lock_team_capacity!(adapter, team) if lock && team count = adapter.count(model: "teamMember", where: [{field: "teamId", value: team_id}]) if count >= max_members.to_i raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_MEMBER_LIMIT_REACHED")) end end end |
.expire_cookie(ctx, name) ⇒ Object
222 223 224 |
# File 'lib/better_auth/plugins/multi_session.rb', line 222 def (ctx, name) ctx.(name, "", ctx.context.[:session_token].attributes.merge(max_age: 0)) end |
.expired_bearer_cookie?(cookie) ⇒ Boolean
89 90 91 92 |
# File 'lib/better_auth/plugins/bearer.rb', line 89 def () max_age = [: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( = {}) config = normalize_hash() 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) { (ctx) } } ] }, endpoints: { expo_authorization_proxy: }, options: config ) end |
.expo_authorization_proxy_endpoint ⇒ Object
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/better_auth/plugins/expo.rb', line 31 def Endpoint.new( path: "/expo-authorization-proxy", method: "GET", metadata: { openapi: { operationId: "expoAuthorizationProxy", description: "Proxy an Expo authorization redirect", parameters: [ {in: "query", name: "authorizationURL", required: true, schema: {type: "string", format: "uri"}}, {in: "query", name: "oauthState", required: false, schema: {type: "string"}} ], responses: { "302" => {description: "Redirects to the authorization URL"} } } } ) do |ctx| = 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 .to_s.empty? if oauth_state = ctx.context.("oauth_state", max_age: 600) ctx.(.name, oauth_state, .attributes) else state = URI.parse().then { |uri| Rack::Utils.parse_query(uri.query)["state"] } raise APIError.new("BAD_REQUEST", message: "Unexpected error") if state.to_s.empty? = ctx.context.("state", max_age: 300) ctx.(.name, state, ctx.context.secret, .attributes) end [302, ctx.response_headers.merge("location" => ), [""]] rescue URI::InvalidURIError raise APIError.new("BAD_REQUEST", message: "Unexpected error") end end |
.expo_development_environment? ⇒ Boolean
100 101 102 |
# File 'lib/better_auth/plugins/expo.rb', line 100 def expo_development_environment? [ENV["RACK_ENV"], ENV["RAILS_ENV"], ENV["APP_ENV"]].include?("development") end |
.expo_inject_cookie_into_deep_link(ctx) ⇒ Object
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/better_auth/plugins/expo.rb', line 82 def (ctx) location = ctx.response_headers["location"] = ctx.response_headers["set-cookie"] return unless location && 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"] = uri.query = URI.encode_www_form(query) ctx.set_header("location", uri.to_s) rescue URI::InvalidURIError nil end |
.expo_on_request(config) ⇒ Object
69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/better_auth/plugins/expo.rb', line 69 def expo_on_request(config) lambda do |request, _context| next if config[:disable_origin_override] || request.get_header("HTTP_ORIGIN") expo_origin = request.get_header("HTTP_EXPO_ORIGIN") next unless expo_origin env = request.env.dup env["HTTP_ORIGIN"] = expo_origin {request: Rack::Request.new(env)} end end |
.expose_auth_token(ctx) ⇒ Object
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/better_auth/plugins/bearer.rb', line 94 def expose_auth_token(ctx) token_name = ctx.context.[:session_token].name = Cookies.(ctx.response_headers["set-cookie"]).reverse_each.filter_map do |line| = Cookies.(line) next unless && [:name] == token_name end.first return unless return if [:value].empty? || () token = [:value] exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?) exposed << "set-auth-token" ctx.set_header("set-auth-token", token) ctx.set_header("access-control-expose-headers", exposed.uniq.join(", ")) nil end |
.extra_input(hash, *exclude) ⇒ Object
1561 1562 1563 |
# File 'lib/better_auth/plugins/organization.rb', line 1561 def extra_input(hash, *exclude) normalize_hash(hash).except(*exclude.map(&:to_sym)) end |
.fetch_i18n_option(options, name) ⇒ Object
46 47 48 49 50 |
# File 'lib/better_auth/plugins/i18n.rb', line 46 def fetch_i18n_option(, name) snake = name.to_s camel = snake.split("_").map.with_index { |part, index| index.zero? ? part : part.capitalize }.join [snake.to_sym] || [snake] || [camel.to_sym] || [camel] end |
.fetch_value(data, key) ⇒ Object
38 39 40 41 42 |
# File 'lib/better_auth/plugins.rb', line 38 def fetch_value(data, key) return nil unless data.respond_to?(:[]) data[key] || data[key.to_s] || data[Schema.storage_key(key)] || data[Schema.storage_key(key).to_sym] || data[normalize_key(key)] end |
.field_schema(attributes) ⇒ Object
400 401 402 403 404 405 406 407 408 409 410 411 412 |
# File 'lib/better_auth/plugins/open_api.rb', line 400 def field_schema(attributes) type = case attributes[:type].to_s when "date" then "string" when "number" then "number" when "boolean" then "boolean" else "string" end schema = {type: type} schema[:format] = "date-time" if attributes[:type].to_s == "date" schema[:default] = attributes[:default_value].respond_to?(:call) ? "Generated at runtime" : attributes[:default_value] if attributes.key?(:default_value) schema[:readOnly] = true if attributes[:input] == false schema end |
.find_device_code(ctx, code) ⇒ Object
363 364 365 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 363 def find_device_code(ctx, code) ctx.context.adapter.find_one(model: "deviceCode", where: [{field: "deviceCode", value: code.to_s}]) end |
.find_device_user_code(ctx, code) ⇒ Object
367 368 369 370 371 372 373 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 367 def find_device_user_code(ctx, code) (code).each do |candidate| record = ctx.context.adapter.find_one(model: "deviceCode", where: [{field: "userCode", value: candidate}]) return record if record end nil end |
.find_member_by_email(ctx, organization_id, email) ⇒ Object
1202 1203 1204 1205 |
# File 'lib/better_auth/plugins/organization.rb', line 1202 def find_member_by_email(ctx, organization_id, email) user = ctx.context.adapter.find_one(model: "user", where: [{field: "email", value: email.to_s.downcase}]) user && require_member(ctx, user["id"], organization_id) end |
.generate_device_authorization_device_code(config) ⇒ Object
379 380 381 382 383 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 379 def (config) return config[:generate_device_code].call.to_s if config[:generate_device_code].respond_to?(:call) SecureRandom.alphanumeric(config[:device_code_length].to_i) end |
.generate_device_authorization_user_code(config) ⇒ Object
385 386 387 388 389 390 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 385 def (config) return config[:generate_user_code].call.to_s if config[:generate_user_code].respond_to?(:call) charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" Array.new(config[:user_code_length].to_i) { charset[SecureRandom.random_number(charset.length)] }.join end |
.generate_key_pair(alg) ⇒ Object
385 386 387 388 389 390 391 392 393 394 395 396 397 398 |
# File 'lib/better_auth/plugins/jwt.rb', line 385 def generate_key_pair(alg) case alg when "EdDSA" OpenSSL::PKey.generate_key("ED25519") when "RS256", "PS256" OpenSSL::PKey::RSA.generate(2048) when "ES256" OpenSSL::PKey::EC.generate("prime256v1") when "ES512" OpenSSL::PKey::EC.generate("secp521r1") else raise Error, "JWT/JWKS algorithm #{alg} is not supported by the Ruby server" end end |
.generate_one_time_token_endpoint(config) ⇒ Object
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 31 def generate_one_time_token_endpoint(config) Endpoint.new( path: "/one-time-token/generate", method: "GET", metadata: { openapi: { operationId: "generateOneTimeToken", description: "Generate a one-time token for the current session", responses: { "200" => OpenAPI.json_response( "One-time token", OpenAPI.object_schema( { token: {type: "string"} }, required: ["token"] ) ) } } } ) do |ctx| if config[:disable_client_request] && ctx.request raise APIError.new("BAD_REQUEST", message: "Client requests are disabled") end session = Routes.current_session(ctx) token = one_time_token_create(ctx, config, session) ctx.json({token: token}) end end |
.generic_oauth(options = {}) ⇒ Object
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 23 def generic_oauth( = {}) config = normalize_hash() providers = Array(config[:config]).map { |provider| normalize_hash(provider) } generic_oauth_warn_duplicate_providers(providers) config[:config] = providers Plugin.new( id: "generic-oauth", init: ->(context) { { options: { social_providers: context..merge((config, context)) } } }, endpoints: { sign_in_with_oauth2: sign_in_with_oauth2_endpoint(config), oauth2_callback: oauth2_callback_endpoint(config), oauth2_link_account: oauth2_link_account_endpoint(config) }, error_codes: GENERIC_OAUTH_ERROR_CODES, options: config ) end |
.generic_oauth_account_info(ctx, provider_id, account_id, tokens) ⇒ Object
557 558 559 560 561 562 563 564 565 566 567 568 569 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 557 def generic_oauth_account_info(ctx, provider_id, account_id, tokens) data = normalize_hash(tokens || {}) { "providerId" => provider_id, "accountId" => account_id, "accessToken" => generic_oauth_token_for_storage(ctx, data[:access_token] || data[:accessToken]), "refreshToken" => generic_oauth_token_for_storage(ctx, data[:refresh_token] || data[:refreshToken]), "idToken" => data[:id_token] || data[:idToken], "accessTokenExpiresAt" => data[:access_token_expires_at] || data[:accessTokenExpiresAt], "refreshTokenExpiresAt" => data[:refresh_token_expires_at] || data[:refreshTokenExpiresAt], "scope" => Array(data[:scopes] || data[:scope]).join(",") } end |
.generic_oauth_authorization_url(ctx, provider, body, link:, code_verifier: nil, preserve_additional_data: false) ⇒ Object
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 402 def (ctx, provider, body, link:, code_verifier: nil, preserve_additional_data: false) = 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 .to_s.empty? || token_url.to_s.empty? code_verifier ||= Crypto.random_string(43) additional_data = if preserve_additional_data raw = body[:additional_data] || body[:additionalData] raw.is_a?(Hash) ? raw.each_with_object({}) { |(key, value), data| data[key.to_s] = value } : {} else normalize_hash(body[:additional_data] || body[:additionalData]).transform_keys(&:to_s) end additional_data = additional_data.reject { |key, _value| OAuthState::INTERNAL_KEYS.include?(key.to_s) } state_data = additional_data.merge( "callbackURL" => body[:callback_url] || body[:callbackURL] || "/", "errorURL" => body[:error_callback_url] || body[:errorCallbackURL], "newUserURL" => body[:new_user_callback_url] || body[:newUserCallbackURL], "requestSignUp" => body[:request_sign_up] || body[:requestSignUp], "codeVerifier" => provider[:pkce] ? code_verifier : nil, "link" => link, "expiresAt" => Time.now.to_i + 600 ) state = generic_oauth_generate_state(ctx, state_data) uri = URI.parse(.to_s) params = URI.decode_www_form(uri.query.to_s) generic_oauth_set_query_param(params, "client_id", provider[:client_id].to_s) generic_oauth_set_query_param(params, "response_type", provider[:response_type] || "code") generic_oauth_set_query_param(params, "redirect_uri", generic_oauth_redirect_uri(ctx, provider)) generic_oauth_set_query_param(params, "state", state) scopes = Array(body[:scopes]) + Array(provider[:scopes]) generic_oauth_set_query_param(params, "scope", scopes.join(" ")) unless scopes.empty? if provider[:pkce] generic_oauth_set_query_param(params, "code_challenge", generic_oauth_pkce_challenge(code_verifier)) generic_oauth_set_query_param(params, "code_challenge_method", "S256") end generic_oauth_set_query_param(params, "prompt", provider[:prompt]) if provider[:prompt] generic_oauth_set_query_param(params, "access_type", provider[:access_type]) if provider[:access_type] generic_oauth_set_query_param(params, "response_mode", provider[:response_mode]) if provider[:response_mode] = if provider[:authorization_url_params].respond_to?(:call) provider[:authorization_url_params].call(ctx) else provider[:authorization_url_params] end normalize_hash( || {}).each { |key, value| generic_oauth_set_query_param(params, key, value) } uri.query = URI.encode_www_form(params) uri.to_s end |
.generic_oauth_discovery(provider) ⇒ Object
610 611 612 613 614 615 616 617 618 619 620 621 622 623 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 610 def generic_oauth_discovery(provider) return {} if provider[:discovery_url].to_s.empty? return provider[:_discovery] if provider[:_discovery] uri = URI(provider[:discovery_url]) request = Net::HTTP::Get.new(uri) normalize_hash(provider[:discovery_headers] || provider[:discoveryHeaders]).each do |key, value| request[key.to_s.tr("_", "-")] = value.to_s end response = HTTPClient.request(uri, request) provider[:_discovery] = response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : {} rescue {} end |
.generic_oauth_error_url(base_url, error, description = nil) ⇒ Object
816 817 818 819 820 821 822 823 824 825 826 827 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 816 def generic_oauth_error_url(base_url, error, description = nil) uri = URI.parse(base_url.to_s) query = URI.decode_www_form(uri.query.to_s) generic_oauth_set_query_param(query, "error", error) if description.to_s.empty? query.delete_if { |name, _value| name == "error_description" } else generic_oauth_set_query_param(query, "error_description", description) end uri.query = URI.encode_www_form(query) uri.to_s end |
.generic_oauth_exchange_token(ctx, provider, code, state_data) ⇒ Object
468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 468 def generic_oauth_exchange_token(ctx, provider, code, state_data) token_callback = provider[:get_token] if token_callback.respond_to?(:call) return generic_oauth_normalize_tokens(token_callback.call( code: code, redirectURI: generic_oauth_redirect_uri(ctx, provider), redirect_uri: generic_oauth_redirect_uri(ctx, provider), codeVerifier: provider[:pkce] ? state_data["codeVerifier"] : nil, code_verifier: provider[:pkce] ? state_data["codeVerifier"] : nil ), access_token_expires_in: provider[:access_token_expires_in]) end token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"] raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty? generic_oauth_post_token(ctx, token_url, provider, code, provider[:pkce] ? state_data["codeVerifier"] : nil, generic_oauth_redirect_uri(ctx, provider)) end |
.generic_oauth_expiry_time(seconds) ⇒ Object
689 690 691 692 693 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 689 def generic_oauth_expiry_time(seconds) return nil if seconds.to_i <= 0 Time.now + seconds.to_i end |
.generic_oauth_fetch_json(url, headers = {}) ⇒ Object
716 717 718 719 720 721 722 723 724 725 726 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 716 def generic_oauth_fetch_json(url, headers = {}) uri = URI(url) request = Net::HTTP::Get.new(uri) normalize_hash(headers).each { |key, value| request[key.to_s.tr("_", "-")] = value.to_s } response = HTTPClient.request(uri, request) return nil unless response.is_a?(Net::HTTPSuccess) JSON.parse(response.body) rescue nil end |
.generic_oauth_generate_state(ctx, state_data) ⇒ Object
464 465 466 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 464 def generic_oauth_generate_state(ctx, state_data) OAuthState.generate(ctx, state_data) end |
.generic_oauth_link_account(ctx, provider, tokens, user_info, link, redirect_error) ⇒ Object
539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 539 def generic_oauth_link_account(ctx, provider, tokens, user_info, link, redirect_error) if !ctx.context..account.dig(:account_linking, :allow_different_emails) && link["email"].to_s.downcase != fetch_value(user_info, "email").to_s.downcase redirect_error.call("email_doesn't_match") end account_id = fetch_value(user_info, "id").to_s existing_account = ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider[:provider_id].to_s) account_info = generic_oauth_account_info(ctx, provider[:provider_id].to_s, account_id, tokens).merge("userId" => link["user_id"]) if existing_account redirect_error.call("account_already_linked_to_different_user") if existing_account["userId"] != link["user_id"] account = ctx.context.internal_adapter.update_account(existing_account["id"], account_info) else account = ctx.context.internal_adapter.create_account(account_info) end Cookies.(ctx, account) if account end |
.generic_oauth_map_user(provider, user_info) ⇒ Object
533 534 535 536 537 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 533 def generic_oauth_map_user(provider, user_info) mapper = provider[:map_profile_to_user] mapped = mapper.respond_to?(:call) ? mapper.call(user_info) : user_info normalize_hash(user_info).merge(normalize_hash(mapped || {})) end |
.generic_oauth_normalize_tokens(data, access_token_expires_in: nil) ⇒ Object
674 675 676 677 678 679 680 681 682 683 684 685 686 687 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 674 def generic_oauth_normalize_tokens(data, access_token_expires_in: nil) token_data = normalize_hash(data) access_token_expires_at = token_data[:access_token_expires_at] || generic_oauth_expiry_time(token_data[:expires_in]) access_token_expires_at ||= generic_oauth_expiry_time(access_token_expires_in) token_data.merge( access_token: token_data[:access_token], refresh_token: token_data[:refresh_token], id_token: token_data[:id_token], access_token_expires_at: access_token_expires_at, refresh_token_expires_at: token_data[:refresh_token_expires_at] || generic_oauth_expiry_time(token_data[:refresh_token_expires_in]), scopes: generic_oauth_token_scopes(token_data[:scopes] || token_data[:scope]), raw: token_data ).compact end |
.generic_oauth_normalize_user_info(data) ⇒ Object
706 707 708 709 710 711 712 713 714 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 706 def generic_oauth_normalize_user_info(data) profile = normalize_hash(data) profile.merge( id: profile[:id] || profile[:sub], email_verified: profile[:email_verified] || false, emailVerified: profile[:email_verified] || false, image: profile[:image] || profile[:picture] ) end |
.generic_oauth_parse_state(ctx, state) ⇒ Object
486 487 488 489 490 491 492 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 486 def generic_oauth_parse_state(ctx, state) OAuthState.parse(ctx, state) rescue OAuthState::Error => error code = (error.code == "state_not_found") ? "please_restart_the_process" : error.code error_url = error.error_url || generic_oauth_state_error_url(ctx) raise ctx.redirect(generic_oauth_error_url(error_url, code)) end |
.generic_oauth_pkce_challenge(code_verifier) ⇒ Object
702 703 704 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 702 def generic_oauth_pkce_challenge(code_verifier) Crypto.base64url_encode(OpenSSL::Digest.digest("SHA256", code_verifier.to_s)) end |
.generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token) ⇒ Object
795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 795 def generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token) uri = URI(token_url) request = Net::HTTP::Post.new(uri) form_data = {grant_type: "refresh_token", refresh_token: refresh_token} authentication = (provider[:authentication] || "post").to_s if authentication == "basic" request["authorization"] = "Basic #{Base64.strict_encode64("#{provider[:client_id]}:#{provider[:client_secret]}")}" else form_data[:client_id] = provider[:client_id] form_data[:client_secret] = provider[:client_secret] if provider[:client_secret] end token_url_params = provider[:token_url_params] || provider[:tokenUrlParams] token_url_params = token_url_params.call(ctx) if token_url_params.respond_to?(:call) normalize_hash(token_url_params || {}).each { |key, value| form_data[key] = value } request.set_form_data(form_data.compact) response = HTTPClient.request(uri, request) raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIG"]) unless response.is_a?(Net::HTTPSuccess) generic_oauth_normalize_tokens(JSON.parse(response.body), access_token_expires_in: provider[:access_token_expires_in]) end |
.generic_oauth_post_token(ctx, token_url, provider, code, code_verifier, redirect_uri) ⇒ Object
625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 625 def generic_oauth_post_token(ctx, token_url, provider, code, code_verifier, redirect_uri) uri = URI(token_url) request = Net::HTTP::Post.new(uri) normalize_hash(provider[:authorization_headers] || provider[:authorizationHeaders]).each do |key, value| request[key.to_s.tr("_", "-")] = value.to_s end form_data = { grant_type: "authorization_code", code: code, redirect_uri: redirect_uri }.compact form_data[:code_verifier] = code_verifier if code_verifier authentication = (provider[:authentication] || "post").to_s if authentication == "basic" request["authorization"] = "Basic #{Base64.strict_encode64("#{provider[:client_id]}:#{provider[:client_secret]}")}" else form_data[:client_id] = provider[:client_id] form_data[:client_secret] = provider[:client_secret] if provider[:client_secret] end token_url_params = if provider[:token_url_params].respond_to?(:call) provider[:token_url_params].call(ctx) else provider[:token_url_params] || provider[:tokenUrlParams] end normalize_hash(token_url_params || {}).each do |key, value| form_data[key] = value unless form_data.key?(key) end request.set_form_data(form_data) response = HTTPClient.request(uri, request) return nil unless response.is_a?(Net::HTTPSuccess) generic_oauth_normalize_tokens(JSON.parse(response.body), access_token_expires_in: provider[:access_token_expires_in]) rescue nil end |
.generic_oauth_provider(config, provider_id) ⇒ Object
592 593 594 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 592 def generic_oauth_provider(config, provider_id) Array(config[:config]).find { |provider| provider[:provider_id].to_s == provider_id.to_s } end |
.generic_oauth_provider!(config, provider_id) ⇒ Object
585 586 587 588 589 590 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 585 def generic_oauth_provider!(config, provider_id) provider = generic_oauth_provider(config, provider_id) raise APIError.new("BAD_REQUEST", message: "#{GENERIC_OAUTH_ERROR_CODES["PROVIDER_CONFIG_NOT_FOUND"]} #{provider_id}") unless provider provider end |
.generic_oauth_provider_config(options, defaults) ⇒ Object
737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 737 def generic_oauth_provider_config(, defaults) data = normalize_hash() config = defaults.merge( client_id: data[:client_id], client_secret: data[:client_secret], redirect_uri: data[:redirect_uri], pkce: data[:pkce], disable_implicit_sign_up: data[:disable_implicit_sign_up], disable_sign_up: data[:disable_sign_up], override_user_info: data[:override_user_info], access_token_expires_in: data[:access_token_expires_in] ) config[:scopes] = data[:scopes] if data[:scopes] config.compact end |
.generic_oauth_provider_user_info(provider, tokens) ⇒ Object
778 779 780 781 782 783 784 785 786 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 778 def generic_oauth_provider_user_info(provider, tokens) user_info = generic_oauth_user_info(provider, tokens) return nil unless user_info { user: generic_oauth_map_user(provider, user_info), data: user_info } end |
.generic_oauth_recovered_state_error_url(ctx, state_data) ⇒ Object
498 499 500 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 498 def generic_oauth_recovered_state_error_url(ctx, state_data) state_data["errorURL"] || state_data["errorCallbackURL"] || generic_oauth_state_error_url(ctx) end |
.generic_oauth_redirect_uri(ctx, provider) ⇒ Object
596 597 598 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 596 def generic_oauth_redirect_uri(ctx, provider) provider[:redirect_uri] || provider[:redirectURI] || "#{ctx.context.canonical_base_url}/oauth2/callback/#{provider[:provider_id]}" end |
.generic_oauth_refresh_access_token(ctx, provider, refresh_token) ⇒ Object
788 789 790 791 792 793 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 788 def generic_oauth_refresh_access_token(ctx, provider, refresh_token) token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"] raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty? generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token) end |
.generic_oauth_set_account_cookie(ctx, provider_id, account_id, user_id) ⇒ Object
578 579 580 581 582 583 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 578 def (ctx, provider_id, account_id, user_id) account = ctx.context.internal_adapter.find_accounts(user_id).find do |entry| entry["providerId"] == provider_id && entry["accountId"] == account_id end Cookies.(ctx, account) if account end |
.generic_oauth_set_query_param(params, key, value) ⇒ Object
451 452 453 454 455 456 457 458 459 460 461 462 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 451 def generic_oauth_set_query_param(params, key, value) name = key.to_s first_index = params.index { |param_name, _param_value| param_name == name } if first_index params[first_index] = [name, value.to_s] (params.length - 1).downto(first_index + 1) do |index| params.delete_at(index) if params[index].first == name end else params << [name, value.to_s] end end |
.generic_oauth_social_providers(config, context) ⇒ Object
753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 753 def (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) { generic_oauth_provider_user_info(provider, tokens) }, refresh_access_token: ->(refresh_token) { generic_oauth_refresh_access_token(context, provider, refresh_token) }, oauth_popup_authorization_url: lambda do |ctx, data| additional_data = data[:additionalData] || data["additionalData"] || data[:additional_data] || data["additional_data"] normalized = normalize_hash(data) normalized[:additional_data] = additional_data if additional_data.is_a?(Hash) ( ctx, provider, normalized, link: nil, code_verifier: normalized[:code_verifier], preserve_additional_data: true ) end } end end |
.generic_oauth_start_body_schema ⇒ Object
387 388 389 390 391 392 393 394 395 396 397 398 399 400 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 387 def generic_oauth_start_body_schema OpenAPI.object_schema( { providerId: {type: "string", description: "OAuth provider ID"}, callbackURL: {type: "string", description: "URL to redirect to after success"}, errorCallbackURL: {type: "string", description: "URL to redirect to after error"}, newUserCallbackURL: {type: "string", description: "URL to redirect to for new users"}, requestSignUp: {type: "boolean", description: "Whether this request is a sign-up flow"}, scopes: {type: "array", items: {type: "string"}, description: "Additional OAuth scopes"}, disableRedirect: {type: "boolean", description: "Return the URL instead of redirecting"}, additionalData: {type: "object", additionalProperties: true} } ) end |
.generic_oauth_state_error_url(ctx) ⇒ Object
494 495 496 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 494 def generic_oauth_state_error_url(ctx) ctx.context..on_api_error[:error_url] || "#{ctx.context.base_url}/error" end |
.generic_oauth_token_for_storage(ctx, token) ⇒ Object
571 572 573 574 575 576 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 571 def generic_oauth_token_for_storage(ctx, token) return token if token.to_s.empty? return token unless ctx.context..account[:encrypt_oauth_tokens] Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: token) end |
.generic_oauth_token_scopes(scope) ⇒ Object
695 696 697 698 699 700 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 695 def generic_oauth_token_scopes(scope) return [] unless scope return scope if scope.is_a?(Array) scope.to_s.split(/\s+/) end |
.generic_oauth_url_response_schema ⇒ Object
377 378 379 380 381 382 383 384 385 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 377 def generic_oauth_url_response_schema OpenAPI.object_schema( { url: {type: "string"}, redirect: {type: "boolean"} }, required: ["url", "redirect"] ) end |
.generic_oauth_user_from_id_token(id_token) ⇒ Object
661 662 663 664 665 666 667 668 669 670 671 672 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 661 def generic_oauth_user_from_id_token(id_token) payload = ::JWT.decode(id_token, nil, false).first normalize_hash( id: payload["sub"], email: payload["email"], emailVerified: payload["email_verified"], name: payload["name"], image: payload["picture"] ) rescue nil end |
.generic_oauth_user_info(provider, tokens) ⇒ Object
502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 502 def generic_oauth_user_info(provider, tokens) callback = provider[:get_user_info] if callback.respond_to?(:call) user_info = callback.call(tokens) return nil if user_info.nil? return normalize_hash(user_info) end id_token = tokens[:id_token] || tokens[:idToken] if id_token token_user = generic_oauth_user_from_id_token(id_token) id = fetch_value(token_user, "id") email = fetch_value(token_user, "email") return token_user if !id.to_s.empty? && !email.to_s.empty? end user_info_url = provider[:user_info_url] || generic_oauth_discovery(provider)["userinfo_endpoint"] return nil if user_info_url.to_s.empty? uri = URI(user_info_url) request = Net::HTTP::Get.new(uri) request["authorization"] = "Bearer #{fetch_value(tokens, "accessToken")}" response = HTTPClient.request(uri, request) return nil unless response.is_a?(Net::HTTPSuccess) generic_oauth_normalize_user_info(JSON.parse(response.body)) rescue nil end |
.generic_oauth_validate_issuer!(ctx, provider, query, redirect_error) ⇒ Object
600 601 602 603 604 605 606 607 608 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 600 def generic_oauth_validate_issuer!(ctx, provider, query, redirect_error) expected = provider[:issuer] || generic_oauth_discovery(provider)["issuer"] return if expected.to_s.empty? return if query[:iss].to_s == expected.to_s return redirect_error.call("issuer_missing") if query[:iss].to_s.empty? && provider[:require_issuer_validation] return if query[:iss].to_s.empty? redirect_error.call("issuer_mismatch") end |
.generic_oauth_warn_duplicate_providers(providers) ⇒ Object
829 830 831 832 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 829 def generic_oauth_warn_duplicate_providers(providers) duplicates = providers.group_by { |provider| provider[:provider_id].to_s }.select { |id, entries| !id.empty? && entries.length > 1 }.keys warn "Duplicate provider IDs found: #{duplicates.join(", ")}" unless duplicates.empty? end |
.generic_oidc_helper_provider(options, provider_id, issuer, discovery_url, _user_info_url) ⇒ Object
728 729 730 731 732 733 734 735 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 728 def generic_oidc_helper_provider(, provider_id, issuer, discovery_url, _user_info_url) generic_oauth_provider_config( , provider_id: provider_id, discovery_url: discovery_url, scopes: ["openid", "profile", "email"] ) end |
.get_jwks_endpoint(config, path) ⇒ Object
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/better_auth/plugins/jwt.rb', line 112 def get_jwks_endpoint(config, path) Endpoint.new( path: path, method: "GET", metadata: { openapi: { operationId: "getJSONWebKeySet", description: "Get the JSON Web Key Set", responses: { "200" => OpenAPI.json_response( "JSON Web Key Set retrieved successfully", OpenAPI.object_schema( {keys: {type: "array", description: "Array of public JSON Web Keys", items: {type: "object"}}}, required: ["keys"] ) ) } } } ) do |ctx| raise APIError.new("NOT_FOUND") if config.dig(:jwks, :remote_url) create_jwk(ctx, config) if all_jwks(ctx, config).empty? ctx.json({keys: public_jwks(ctx, config).map { |key| public_jwk(key, config) }}) end end |
.get_siwe_nonce_endpoint(config, path:, operation_id:) ⇒ Object
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/better_auth/plugins/siwe.rb', line 30 def get_siwe_nonce_endpoint(config, path:, operation_id:) Endpoint.new( path: path, method: "POST", body_schema: ->(body) { siwe_nonce_body(body) }, metadata: { openapi: { operationId: operation_id, description: "Generate a nonce for Sign-In with Ethereum", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { address: {type: "string"}, walletAddress: {type: "string"}, chainId: {type: ["number", "string", "null"]} } ) ), responses: { "200" => OpenAPI.json_response( "SIWE nonce", OpenAPI.object_schema( { nonce: {type: "string"} }, required: ["nonce"] ) ) } } } ) do |ctx| body = normalize_hash(ctx.body) wallet_address = siwe_normalize_wallet!(body[:wallet_address] || body[:address]) chain_id = siwe_chain_id(body[:chain_id]) nonce_callback = config[:get_nonce] raise APIError.new("INTERNAL_SERVER_ERROR", message: "SIWE nonce callback is required") unless nonce_callback.respond_to?(:call) nonce = nonce_callback.call.to_s ctx.context.internal_adapter.create_verification_value( identifier: siwe_identifier(wallet_address, chain_id), value: nonce, expiresAt: Time.now + (15 * 60) ) ctx.json({nonce: nonce}) end end |
.get_token_endpoint(config) ⇒ Object
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/better_auth/plugins/jwt.rb', line 139 def get_token_endpoint(config) Endpoint.new( path: "/token", method: "GET", metadata: { openapi: { operationId: "getJSONWebToken", description: "Get a JWT token", responses: { "200" => OpenAPI.json_response( "Success", OpenAPI.object_schema({token: {type: "string"}}, required: ["token"]) ) } } } ) do |ctx| session = Session.find_current(ctx) raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"]) unless session ctx.json({token: jwt_token(ctx, session, config)}) end end |
.get_verification_otp_endpoint(config) ⇒ Object
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/better_auth/plugins/email_otp.rb', line 133 def get_verification_otp_endpoint(config) Endpoint.new( method: "GET", metadata: { server_only: true, openapi: { operationId: "getVerificationOTP", description: "Get a stored verification OTP when storage allows plaintext access", parameters: [ {name: "email", in: "query", required: true, schema: {type: "string"}}, {name: "type", in: "query", required: true, schema: {type: "string"}} ], responses: { "200" => OpenAPI.json_response( "Stored OTP", OpenAPI.object_schema( { otp: {type: ["string", "null"]} } ) ) } } } ) do |ctx| query = normalize_hash(ctx.query) email = query[:email].to_s.downcase type = query[:type].to_s validate_email_otp_type!(type) verification = ctx.context.internal_adapter.find_verification_value(email_otp_identifier(email, type)) next ctx.json({otp: nil}) unless verification && !Routes.expired_time?(verification["expiresAt"]) stored_otp, = email_otp_split(verification["value"]) case config[:store_otp].to_s when "hashed" raise APIError.new("BAD_REQUEST", message: "OTP is hashed, cannot return the plain text OTP") when "encrypted" next ctx.json({otp: Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_otp)}) end storage = config[:store_otp] if storage.is_a?(Hash) && storage[:hash].respond_to?(:call) raise APIError.new("BAD_REQUEST", message: "OTP is hashed, cannot return the plain text OTP") elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call) next ctx.json({otp: storage[:decrypt].call(stored_otp)}) end ctx.json({otp: stored_otp}) end end |
.gumroad(options = {}) ⇒ Object
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 59 def gumroad( = {}) data = normalize_hash() 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( = {}) config = normalize_hash() config[:paths] = Array(config[:paths]).empty? ? HAVE_I_BEEN_PWNED_DEFAULT_PATHS : Array(config[:paths]) Plugin.new( id: "have-i-been-pwned", init: ->(context) { have_i_been_pwned_wrap_password_hasher!(context, config) }, error_codes: HAVE_I_BEEN_PWNED_ERROR_CODES, options: config ) end |
.have_i_been_pwned_check_password!(password, config) ⇒ Object
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/better_auth/plugins/have_i_been_pwned.rb', line 55 def have_i_been_pwned_check_password!(password, config) return if password.to_s.empty? hash = OpenSSL::Digest.hexdigest("SHA1", password.to_s).upcase prefix = hash[0, 5] suffix = hash[5..] data = if config[:range_lookup].respond_to?(:call) config[:range_lookup].call(prefix) else have_i_been_pwned_range_lookup(prefix) end found = data.to_s.lines.any? { |line| line.split(":").first.to_s.upcase == suffix } return unless found raise APIError.new( "BAD_REQUEST", message: config[:custom_password_compromised_message] || HAVE_I_BEEN_PWNED_ERROR_CODES["PASSWORD_COMPROMISED"], code: "PASSWORD_COMPROMISED" ) rescue APIError raise rescue raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Please try again later.") end |
.have_i_been_pwned_range_lookup(prefix) ⇒ Object
81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/better_auth/plugins/have_i_been_pwned.rb', line 81 def have_i_been_pwned_range_lookup(prefix) uri = URI.parse("https://api.pwnedpasswords.com/range/#{prefix}") request = Net::HTTP::Get.new(uri) request["Add-Padding"] = "true" request["User-Agent"] = "BetterAuth Password Checker" response = HTTPClient.request(uri, request) unless response.is_a?(Net::HTTPSuccess) raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Status: #{response.code}") end response.body.to_s end |
.have_i_been_pwned_wrap_password_hasher!(context, config) ⇒ Object
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/better_auth/plugins/have_i_been_pwned.rb', line 33 def have_i_been_pwned_wrap_password_hasher!(context, config) email_config = context..email_and_password password_config = email_config[:password] ||= {} original_hasher = password_config[:hash] algorithm = context..password_hasher password_config[:hash] = lambda do |password, hash_ctx = nil| if config[:enabled] != false && hash_ctx && config[:paths].include?(hash_ctx.path) have_i_been_pwned_check_password!(password, config) end if original_hasher.respond_to?(:call) arity = original_hasher.arity return original_hasher.call(password, hash_ctx) if arity != 1 && arity != -1 return original_hasher.call(password) end Password.hash(password, algorithm: algorithm) end nil end |
.hubspot(options = {}) ⇒ Object
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 83 def hubspot( = {}) data = normalize_hash() generic_oauth_provider_config( data, provider_id: "hubspot", authorization_url: "https://app.hubspot.com/oauth/authorize", token_url: "https://api.hubapi.com/oauth/v1/token", scopes: ["oauth"], authentication: "post", get_user_info: ->(tokens) { profile = generic_oauth_fetch_json("https://api.hubapi.com/oauth/v1/access-tokens/#{fetch_value(tokens, "accessToken")}", "Content-Type" => "application/json") return nil unless profile id = fetch_value(profile, "user_id") || fetch_value(fetch_value(profile, "signed_access_token"), "userId") return nil if id.to_s.empty? {id: id, name: fetch_value(profile, "user"), email: fetch_value(profile, "user"), emailVerified: false} } ) end |
.i18n(options = {}) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# File 'lib/better_auth/plugins/i18n.rb', line 7 def i18n( = {}) config = () validate_i18n_translations!(config) Plugin.new( id: "i18n", hooks: { after: [ { matcher: ->(_ctx) { true }, handler: ->(ctx) { apply_i18n_translation(ctx, config) } } ] }, options: config ) end |
.invitation_by_id(ctx, id) ⇒ Object
1219 1220 1221 1222 1223 |
# File 'lib/better_auth/plugins/organization.rb', line 1219 def invitation_by_id(ctx, id) return nil if id.to_s.empty? ctx.context.adapter.find_one(model: "invitation", where: [{field: "id", value: id}]) end |
.invitation_wire(ctx, invitation) ⇒ Object
1334 1335 1336 |
# File 'lib/better_auth/plugins/organization.rb', line 1334 def invitation_wire(ctx, invitation) Schema.parse_output(ctx.context., "invitation", invitation) end |
.is_username_available_endpoint(config) ⇒ Object
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/better_auth/plugins/username.rb', line 150 def is_username_available_endpoint(config) Endpoint.new( path: "/is-username-available", method: "POST", metadata: { openapi: { operationId: "isUsernameAvailable", description: "Check whether a username is available", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { username: {type: "string"} }, required: ["username"] ) ), responses: { "200" => OpenAPI.json_response( "Username availability", OpenAPI.object_schema( { available: {type: "boolean"} }, required: ["available"] ) ) } } } ) do |ctx| body = normalize_hash(ctx.body) username = body[:username].to_s raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) if username.empty? validate_username!(username, config, status: "UNPROCESSABLE_ENTITY") user = ctx.context.adapter.find_one( model: "user", where: [{field: "username", value: normalize_username(username, config)}] ) ctx.json({available: user.nil?}) end end |
.jwk_expired?(key) ⇒ Boolean
480 481 482 483 |
# File 'lib/better_auth/plugins/jwt.rb', line 480 def jwk_expired?(key) expires_at = normalize_time(key["expiresAt"]) expires_at && expires_at < Time.now end |
.jwk_private_key_for_storage(ctx, private_key, config) ⇒ Object
364 365 366 367 368 |
# File 'lib/better_auth/plugins/jwt.rb', line 364 def jwk_private_key_for_storage(ctx, private_key, config) return private_key if config.dig(:jwks, :disable_private_key_encryption) Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: private_key) end |
.jwk_private_key_value(ctx, key, _config) ⇒ Object
370 371 372 373 |
# File 'lib/better_auth/plugins/jwt.rb', line 370 def jwk_private_key_value(ctx, key, _config) value = key["privateKey"] Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: value) || value end |
.jwt(options = {}) ⇒ Object
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/better_auth/plugins/jwt.rb', line 44 def jwt( = {}) config = normalize_hash() (config) # Mutable caches are owned by this plugin configuration, which is scoped # to one auth instance even when endpoint overrides are merged. config[:local_jwks_cache] = JWT::LocalJwksCache.new config[:remote_jwks_cache] = JWT::RemoteJwksCache.new({}) jwks_path = config.dig(:jwks, :jwks_path) || "/jwks" Plugin.new( id: "jwt", endpoints: { get_jwks: get_jwks_endpoint(config, jwks_path), get_token: get_token_endpoint(config), sign_jwt: sign_jwt_endpoint(config), verify_jwt: verify_jwt_endpoint(config) }, hooks: { after: [ { matcher: ->(ctx) { ctx.path == "/get-session" }, handler: ->(ctx) { set_jwt_header(ctx, config) } } ] }, schema: { jwks: { fields: { id: {type: "string", required: true}, publicKey: {type: "string", required: true}, privateKey: {type: "string", required: true}, createdAt: {type: "date", required: true}, expiresAt: {type: "date", required: false}, alg: {type: "string", required: false}, kty: {type: "string", required: false}, crv: {type: "string", required: false}, x: {type: "string", required: false}, y: {type: "string", required: false}, pem: {type: "string", required: false}, n: {type: "string", required: false}, e: {type: "string", required: false} } } }, options: config ) end |
.jwt_expiration(value, iat) ⇒ Object
485 486 487 488 489 490 |
# File 'lib/better_auth/plugins/jwt.rb', line 485 def jwt_expiration(value, iat) return value.to_i if value.is_a?(Integer) return value.to_i if value.is_a?(Time) iat.to_i + parse_duration(value.to_s) end |
.jwt_payload_valid?(payload) ⇒ Boolean
375 376 377 378 379 380 381 382 383 |
# File 'lib/better_auth/plugins/jwt.rb', line 375 def jwt_payload_valid?(payload) return false if payload["sub"].to_s.empty? audience = payload["aud"] return false if audience.nil? return false if audience.respond_to?(:empty?) && audience.empty? true end |
.jwt_token(ctx, session, config) ⇒ Object
194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/better_auth/plugins/jwt.rb', line 194 def jwt_token(ctx, session, config) jwt_config = config[:jwt] || {} payload = if jwt_config[:define_payload].respond_to?(:call) jwt_config[:define_payload].call(session) else session[:user] end subject = if jwt_config[:get_subject].respond_to?(:call) jwt_config[:get_subject].call(session) else session[:user]["id"] end sign_jwt_payload(ctx, stringify_payload(payload).merge("sub" => subject), config) end |
.key_type_for_alg(alg) ⇒ Object
437 438 439 440 441 |
# File 'lib/better_auth/plugins/jwt.rb', line 437 def key_type_for_alg(alg) return "OKP" if alg == "EdDSA" alg.to_s.start_with?("ES") ? "EC" : "RSA" end |
.keycloak(options = {}) ⇒ Object
104 105 106 107 108 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 104 def keycloak( = {}) data = normalize_hash() 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 last_login_method( = {}) config = { cookie_name: "better-auth.last_used_login_method", max_age: 60 * 60 * 24 * 30 }.merge(normalize_hash()) Plugin.new( id: "last-login-method", schema: last_login_method_schema(config), hooks: { after: [ { matcher: ->(_ctx) { true }, handler: ->(ctx) { apply_last_login_method(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 last_login_method_schema(config) return {} unless config[:store_in_database] field_name = config.dig(:schema, :user, :last_login_method) || "lastLoginMethod" { user: { fields: { lastLoginMethod: { type: "string", input: false, required: false, field_name: field_name } } } } end |
.latest_jwk(ctx, config) ⇒ Object
247 248 249 |
# File 'lib/better_auth/plugins/jwt.rb', line 247 def latest_jwk(ctx, config) all_jwks(ctx, config, force_refresh: true).max_by { |entry| normalize_time(entry["createdAt"]) || Time.at(0) } end |
.lazy_plugin_method?(name) ⇒ Boolean
183 184 185 |
# File 'lib/better_auth/plugin_loader.rb', line 183 def lazy_plugin_method?(name) !plugin_loader_for_method(name).nil? end |
.line(options = {}) ⇒ Object
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 110 def line( = {}) data = normalize_hash() 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 |
.link_anonymous_user(ctx, config) ⇒ Object
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/better_auth/plugins/anonymous.rb', line 168 def link_anonymous_user(ctx, config) = ctx.response_headers["set-cookie"].to_s return if .empty? return unless (, ctx.context.[: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] on_link_account = config[:on_link_account] if on_link_account.respond_to?(:call) on_link_account.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_endpoint ⇒ Object
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/better_auth/plugins/multi_session.rb', line 38 def list_device_sessions_endpoint Endpoint.new( path: "/multi-session/list-device-sessions", method: "GET", metadata: { openapi: { operationId: "listDeviceSessions", description: "List device sessions", responses: { "200" => OpenAPI.json_response( "Device sessions", { type: "array", items: OpenAPI.session_response_schema_pair } ) } } } ) do |ctx| tokens = verified_multi_session_tokens(ctx) sessions = ctx.context.internal_adapter.find_sessions(tokens) .reject { |entry| entry[:session]["expiresAt"] && entry[:session]["expiresAt"] <= Time.now } unique = sessions.each_with_object({}) { |entry, by_user| by_user[entry[:user]["id"]] ||= entry }.values ctx.json(unique.map { |entry| parsed_session(ctx, entry) }) end end |
.list_members_for(ctx, organization_id, query = {}, config = nil, user = nil) ⇒ Object
1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 |
# File 'lib/better_auth/plugins/organization.rb', line 1243 def list_members_for(ctx, organization_id, query = {}, config = nil, user = nil) where = [{field: "organizationId", value: organization_id}] if query[:filter_field] where << {field: query[:filter_field], value: query[:filter_value], operator: query[:filter_operator]} elsif query[:filter].is_a?(Hash) filter = normalize_hash(query[:filter]) where << {field: filter[:field], value: filter[:value], operator: filter[:operator]} end limit = member_list_limit(ctx, organization_id, query, config, user) members = ctx.context.adapter.find_many( model: "member", where: where, limit: limit, offset: query[:offset], sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_direction] || query[:sort_order] || "asc"} : nil ) users_by_id = member_users_by_id(ctx, members) { members: members.map { |entry| member_wire(ctx, entry, users_by_id: users_by_id) }, total: ctx.context.adapter.count(model: "member", where: where) } end |
.load_boot_plugins! ⇒ Object
216 217 218 |
# File 'lib/better_auth/plugin_loader.rb', line 216 def load_boot_plugins! BOOT_PLUGINS.each { |plugin| load_plugin!(plugin) } end |
.load_plugin!(name) ⇒ Object
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/better_auth/plugin_loader.rb', line 146 def load_plugin!(name) name = name.to_sym return true if @loaded_plugins[name] implementation_constant = EXTERNAL_PLUGIN_IMPLEMENTATIONS[name] if implementation_constant && const_defined?(implementation_constant, false) @loaded_plugins[name] = true return true end Array(PLUGIN_DEPENDENCIES[name]).each { |dependency| load_plugin!(dependency) } relative_path = PLUGIN_FILES.fetch(name) do raise ArgumentError, "Unknown plugin loader: #{name}" end absolute_path = File.(relative_path, __dir__) unless File.file?("#{absolute_path}.rb") @loaded_plugins[name] = true return false end require_relative relative_path @loaded_plugins[name] = true true end |
.load_plugin_for_constant!(name) ⇒ Object
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/better_auth/plugin_loader.rb', line 198 def load_plugin_for_constant!(name) if (loader = NESTED_MODULE_LOADERS[name]) return load_plugin!(loader) end if name == :OpenAPI return load_plugin!(:open_api) end constant = name.to_s if constant.end_with?("_ERROR_CODES") plugin_name = constant.delete_suffix("_ERROR_CODES").downcase.to_sym return load_plugin!(plugin_name) if PLUGIN_FILES.key?(plugin_name) end false end |
.lock_organization_capacity!(adapter, organization) ⇒ Object
1540 1541 1542 |
# File 'lib/better_auth/plugins/organization.rb', line 1540 def lock_organization_capacity!(adapter, organization) adapter.update(model: "organization", where: [{field: "id", value: organization["id"]}], update: {name: organization["name"]}) end |
.lock_team_capacity!(adapter, team) ⇒ Object
1544 1545 1546 |
# File 'lib/better_auth/plugins/organization.rb', line 1544 def lock_team_capacity!(adapter, team) adapter.update(model: "team", where: [{field: "id", value: team["id"]}], update: {name: team["name"]}) end |
.magic_link(options = {}) ⇒ Object
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
# File 'lib/better_auth/plugins/magic_link.rb', line 10 def magic_link( = {}) config = {store_token: "plain", allowed_attempts: 1}.merge(normalize_hash()) if .key?(:allowed_attempts) && [:allowed_attempts] != 1 warn "[better-auth/magic-link] `allowed_attempts` is ignored: tokens are consumed atomically on the first verification call. Any value other than `1` has no effect; remove the option to silence this warning." end Plugin.new( id: "magic-link", endpoints: { sign_in_magic_link: sign_in_magic_link_endpoint(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 |
.magic_link_attempts_exceeded?(attempt, config) ⇒ Boolean
195 196 197 198 199 200 |
# File 'lib/better_auth/plugins/magic_link.rb', line 195 def magic_link_attempts_exceeded?(attempt, config) allowed = config[:allowed_attempts] return false if allowed.respond_to?(:infinite?) && allowed.infinite? attempt >= allowed.to_i end |
.magic_link_error_url(url, error) ⇒ Object
231 232 233 234 235 236 237 238 239 |
# File 'lib/better_auth/plugins/magic_link.rb', line 231 def magic_link_error_url(url, error) uri = URI.parse(url.to_s.empty? ? "/" : url.to_s) query = URI.decode_www_form(uri.query.to_s) query << ["error", error] uri.query = URI.encode_www_form(query) uri.to_s rescue URI::InvalidURIError "/?error=#{URI.encode_www_form_component(error)}" end |
.magic_link_token(email, config) ⇒ Object
188 189 190 191 192 193 |
# File 'lib/better_auth/plugins/magic_link.rb', line 188 def magic_link_token(email, config) generator = config[:generate_token] return generator.call(email) if generator.respond_to?(:call) Crypto.random_string(32, alphabet: Crypto::ALPHABETIC_ALPHABET) end |
.magic_link_url(base_url, token, body) ⇒ Object
214 215 216 217 218 219 220 221 222 |
# File 'lib/better_auth/plugins/magic_link.rb', line 214 def magic_link_url(base_url, token, body) params = { token: token, callbackURL: body[:callback_url] || "/" } params[:newUserCallbackURL] = body[:new_user_callback_url] if body[:new_user_callback_url] params[:errorCallbackURL] = body[:error_callback_url] if body[:error_callback_url] "#{base_url}/magic-link/verify?#{URI.encode_www_form(params)}" end |
.magic_link_verify_endpoint(config) ⇒ Object
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 |
# File 'lib/better_auth/plugins/magic_link.rb', line 83 def magic_link_verify_endpoint(config) Endpoint.new( path: "/magic-link/verify", method: "GET", metadata: { openapi: { operationId: "magicLinkVerify", description: "Verify a magic link token", parameters: [ { name: "token", in: "query", required: true, schema: {type: "string"} }, { name: "callbackURL", in: "query", required: false, schema: {type: "string"} } ], responses: { "200" => OpenAPI.json_response( "Magic link verified", OpenAPI.object_schema( { token: {type: "string"}, user: {type: "object", "$ref": "#/components/schemas/User"}, session: {type: "object", "$ref": "#/components/schemas/Session"} }, required: ["token", "user", "session"] ) ), "302" => {description: "Redirects to callback URL when callbackURL is provided"} } } } ) do |ctx| query = normalize_hash(ctx.query) token = query[:token].to_s callback_url = query[:callback_url] || "/" error_callback_url = query[:error_callback_url] || callback_url new_user_callback_url = query[:new_user_callback_url] || callback_url validate_magic_link_callback!(ctx, callback_url, "callbackURL") validate_magic_link_callback!(ctx, error_callback_url, "errorCallbackURL") validate_magic_link_callback!(ctx, new_user_callback_url, "newUserCallbackURL") redirect_with_error = lambda do |error| raise ctx.redirect(magic_link_error_url(error_callback_url, error)) end stored_token = store_magic_link_token(token, config) verification = ctx.context.internal_adapter.consume_verification_value(stored_token) redirect_with_error.call("INVALID_TOKEN") unless verification payload = JSON.parse(verification["value"]) email = payload.fetch("email").to_s.downcase name = payload["name"] found = ctx.context.internal_adapter.find_user_by_email(email) user = found && found[:user] new_user = false unless user redirect_with_error.call("new_user_signup_disabled") if config[:disable_sign_up] user = ctx.context.internal_adapter.create_user( email: email, emailVerified: true, name: name || "", context: ctx ) new_user = true redirect_with_error.call("failed_to_create_user") unless user end unless user["emailVerified"] user = ctx.context.adapter.transaction do ctx.context.internal_adapter.revoke_unproven_account_access(user["id"]) updated = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true) raise Error, "Failed to verify magic-link user" unless updated updated end end session = ctx.context.internal_adapter.create_session(user["id"]) redirect_with_error.call("failed_to_create_session") unless session Cookies.(ctx, {session: session, user: user}) unless query.key?(:callback_url) next ctx.json({ token: session["token"], user: Schema.parse_output(ctx.context., "user", user), session: Schema.parse_output(ctx.context., "session", session) }) end raise ctx.redirect(new_user ? new_user_callback_url : callback_url) rescue JSON::ParserError, KeyError raise ctx.redirect(magic_link_error_url(error_callback_url || "/", "INVALID_TOKEN")) end end |
.max_username_length(config) ⇒ Object
364 365 366 |
# File 'lib/better_auth/plugins/username.rb', line 364 def max_username_length(config) (config[:max_username_length] || 30).to_i end |
.member_by_id(ctx, id) ⇒ Object
1196 1197 1198 1199 1200 |
# File 'lib/better_auth/plugins/organization.rb', line 1196 def member_by_id(ctx, id) return nil if id.to_s.empty? ctx.context.adapter.find_one(model: "member", where: [{field: "id", value: id}]) end |
.member_list_limit(ctx, organization_id, query, config, user) ⇒ Object
1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 |
# File 'lib/better_auth/plugins/organization.rb', line 1266 def member_list_limit(ctx, organization_id, query, config, user) configured = config && config[:membership_limit] configured = 100 if configured.nil? default = numeric_member_limit(configured) default = 100 unless default.positive? requested = query[:limit].to_i if query.key?(:limit) && !query[:limit].to_s.empty? return default unless requested&.positive? [requested, default].min end |
.member_users_by_id(ctx, members) ⇒ Object
1298 1299 1300 1301 1302 1303 1304 1305 |
# File 'lib/better_auth/plugins/organization.rb', line 1298 def member_users_by_id(ctx, members) user_ids = members.map { |member| member["userId"] }.compact.uniq return {} if user_ids.empty? ctx.context.adapter.find_many(model: "user", where: [{field: "id", operator: "in", value: user_ids}]).each_with_object({}) do |user, result| result[user["id"]] = user end end |
.member_wire(ctx, member, users_by_id: nil) ⇒ Object
1321 1322 1323 1324 1325 1326 |
# File 'lib/better_auth/plugins/organization.rb', line 1321 def member_wire(ctx, member, users_by_id: nil) data = Schema.parse_output(ctx.context., "member", member) user = users_by_id ? users_by_id[member["userId"]] : ctx.context.internal_adapter.find_user_by_id(member["userId"]) data["user"] = user.slice("id", "name", "email", "image") if user data end |
.merge_hook_data!(target, response) ⇒ Object
1436 1437 1438 1439 1440 1441 1442 |
# File 'lib/better_auth/plugins/organization.rb', line 1436 def merge_hook_data!(target, response) data = if response.is_a?(Hash) normalize_hash(response)[:data] end target.merge!(normalize_hash(data)) if data.is_a?(Hash) target end |
.merge_permissions(base, extra) ⇒ Object
1170 1171 1172 1173 1174 |
# File 'lib/better_auth/plugins/organization.rb', line 1170 def (base, extra) (base).merge((extra)) do |_resource, base_actions, extra_actions| (Array(base_actions) + Array(extra_actions)).map(&:to_s).uniq end end |
.merged_i18n_error_catalog(ctx) ⇒ Object
202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/better_auth/plugins/i18n.rb', line 202 def merged_i18n_error_catalog(ctx) catalog = BetterAuth::BASE_ERROR_CODES.dup ctx.context..plugins.each do |plugin| next unless plugin.respond_to?(:error_codes) codes = plugin.error_codes next if codes.nil? || codes.empty? catalog.merge!(codes) end catalog end |
.method_missing(name) ⇒ Object
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/better_auth/plugins.rb', line 68 def method_missing(name, ...) if (replacement = REMOVED_PLUGIN_FACTORIES[name.to_sym]) raise ArgumentError, "BetterAuth::Plugins.#{name} was removed. Use #{replacement} instead." end if (loader = plugin_loader_for_method(name)) load_plugin!(loader) return public_send(name, ...) if respond_to?(name, true) raise NoMethodError, "plugin file for #{loader} did not define BetterAuth::Plugins.#{name}" end if (loader = PLUGIN_FACTORY_LOADERS[name.to_sym]) load_plugin!(loader) return public_send(name, ...) if respond_to?(name, true) raise NoMethodError, "plugin file for #{loader} did not define BetterAuth::Plugins.#{name}" end super end |
.microsoft_entra_id(options = {}) ⇒ Object
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 135 def microsoft_entra_id( = {}) data = normalize_hash() tenant_id = data.fetch(:tenant_id).to_s generic_oauth_provider_config( data, provider_id: "microsoft-entra-id", authorization_url: "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/authorize", token_url: "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token", user_info_url: "https://graph.microsoft.com/oidc/userinfo", scopes: ["openid", "profile", "email"], get_user_info: ->(tokens) { profile = generic_oauth_fetch_json("https://graph.microsoft.com/oidc/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}") return nil unless profile { id: fetch_value(profile, "sub"), name: fetch_value(profile, "name") || [fetch_value(profile, "given_name"), fetch_value(profile, "family_name")].compact.join(" ").strip, email: fetch_value(profile, "email") || fetch_value(profile, "preferred_username"), image: fetch_value(profile, "picture"), emailVerified: fetch_value(profile, "email_verified") || false } } ) end |
.min_username_length(config) ⇒ Object
360 361 362 |
# File 'lib/better_auth/plugins/username.rb', line 360 def min_username_length(config) (config[:min_username_length] || 3).to_i end |
.mirror_username_fields!(ctx) ⇒ Object
309 310 311 312 313 314 |
# File 'lib/better_auth/plugins/username.rb', line 309 def mirror_username_fields!(ctx) body = normalize_hash(ctx.body) body[:display_username] = body[:username] if present?(body[:username]) && !present?(body[:display_username]) ctx.body = body nil end |
.multi_cookie_names(ctx) ⇒ Object
203 204 205 |
# File 'lib/better_auth/plugins/multi_session.rb', line 203 def (ctx) ctx..keys.select { |name| (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( = {}) config = {maximum_sessions: 5}.merge(normalize_hash()) 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) { (ctx, config) } }, { matcher: ->(ctx) { ctx.path == "/sign-out" }, handler: ->(ctx) { (ctx) } } ] }, error_codes: MULTI_SESSION_ERROR_CODES, options: config ) end |
.multi_session_cookie?(name) ⇒ Boolean
207 208 209 |
# File 'lib/better_auth/plugins/multi_session.rb', line 207 def (name) name.to_s.include?("_multi-") end |
.multi_session_cookie_name(ctx, token) ⇒ Object
211 212 213 |
# File 'lib/better_auth/plugins/multi_session.rb', line 211 def (ctx, token) "#{ctx.context.[:session_token].name}_multi-#{token.to_s.downcase}" end |
.normalize_display_username(display_username, config) ⇒ Object
346 347 348 349 |
# File 'lib/better_auth/plugins/username.rb', line 346 def normalize_display_username(display_username, config) normalizer = config[:display_username_normalization] normalizer.respond_to?(:call) ? normalizer.call(display_username) : display_username end |
.normalize_field(value) ⇒ Object
31 32 33 34 35 36 |
# File 'lib/better_auth/plugins.rb', line 31 def normalize_field(value) data = normalize_hash(value || {}) data[:default_value] = data.delete(:defaultValue) if data.key?(:defaultValue) data[:field_name] = data.delete(:fieldName) if data.key?(:fieldName) data end |
.normalize_hash(value) ⇒ Object
9 10 11 12 13 14 15 |
# File 'lib/better_auth/plugins.rb', line 9 def normalize_hash(value) return {} unless value.is_a?(Hash) value.each_with_object({}) do |(key, object), result| result[normalize_key(key)] = object.is_a?(Hash) ? normalize_hash(object) : object end end |
.normalize_i18n_detection(detection) ⇒ Object
69 70 71 72 73 |
# File 'lib/better_auth/plugins/i18n.rb', line 69 def normalize_i18n_detection(detection) return ["header"] if detection.nil? Array(detection).map { |strategy| strategy.to_s.downcase } end |
.normalize_i18n_options(options) ⇒ Object
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
# File 'lib/better_auth/plugins/i18n.rb', line 25 def () return {} unless .is_a?(Hash) translations = normalize_i18n_translations(fetch_i18n_option(, :translations)) default_locale = fetch_i18n_option(, :default_locale) detection = normalize_i18n_detection(fetch_i18n_option(, :detection)) = fetch_i18n_option(, :locale_cookie) || "locale" user_locale_field = fetch_i18n_option(, :user_locale_field) || "locale" get_locale = fetch_i18n_option(, :get_locale) available_locales = translations.keys { translations: translations, default_locale: resolve_i18n_default_locale(default_locale, available_locales), detection: detection, locale_cookie: .to_s, user_locale_field: user_locale_field.to_s, get_locale: get_locale } end |
.normalize_i18n_translations(translations) ⇒ Object
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/better_auth/plugins/i18n.rb', line 52 def normalize_i18n_translations(translations) return {} unless translations.is_a?(Hash) translations.each_with_object({}) do |(locale, codes), result| locale_key = locale.to_s next if locale_key.empty? normalized_codes = {} next unless codes.is_a?(Hash) codes.each do |(code, )| normalized_codes[code.to_s.tr("-", "_").upcase] = end result[locale_key] = normalized_codes end end |
.normalize_key(key) ⇒ Object
17 18 19 20 21 22 23 |
# File 'lib/better_auth/plugins.rb', line 17 def normalize_key(key) key.to_s .gsub(/([a-z\d])([A-Z])/, "\\1_\\2") .tr("-", "_") .downcase .to_sym end |
.normalize_remote_jwk(entry) ⇒ Object
313 314 315 316 317 318 |
# File 'lib/better_auth/plugins/jwt.rb', line 313 def normalize_remote_jwk(entry) data = stringify_payload(entry || {}) data["id"] ||= data["kid"] data["publicKey"] ||= data["pem"] data end |
.normalize_signed_bearer_token(token) ⇒ Object
73 74 75 |
# File 'lib/better_auth/plugins/bearer.rb', line 73 def normalize_signed_bearer_token(token) token.include?("%") ? safe_decode_bearer_token(token) : safe_decode_bearer_token(safe_encode_bearer_token(token)) end |
.normalize_time(value) ⇒ Object
533 534 535 536 537 538 |
# File 'lib/better_auth/plugins/jwt.rb', line 533 def normalize_time(value) return value if value.is_a?(Time) return nil if value.nil? Time.parse(value.to_s) end |
.normalize_user_code(value) ⇒ Object
375 376 377 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 375 def normalize_user_code(value) value.to_s end |
.normalize_username(username, config) ⇒ Object
338 339 340 341 342 343 344 |
# File 'lib/better_auth/plugins/username.rb', line 338 def normalize_username(username, config) normalizer = config[:username_normalization] return username if normalizer == false return normalizer.call(username) if normalizer.respond_to?(:call) username.downcase end |
.normalized_role_names(roles) ⇒ Object
1106 1107 1108 |
# File 'lib/better_auth/plugins/organization.rb', line 1106 def normalized_role_names(roles) Array(roles).flat_map { |role| role.to_s.split(",") }.map(&:strip).reject(&:empty?) end |
.numeric_member_limit(value) ⇒ Object
1291 1292 1293 1294 1295 1296 |
# File 'lib/better_auth/plugins/organization.rb', line 1291 def numeric_member_limit(value) return value.to_i if value.is_a?(Numeric) return value.to_i if value.to_s.match?(/\A-?\d+\z/) 100 end |
.oauth2_callback_endpoint(config) ⇒ Object
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 299 def oauth2_callback_endpoint(config) Endpoint.new( path: "/oauth2/callback/:providerId", method: "GET", metadata: { allowed_media_types: ["application/x-www-form-urlencoded", "application/json"], openapi: { operationId: "oauth2Callback", description: "OAuth2 callback", responses: { "200" => OpenAPI.json_response( "OAuth2 callback", OpenAPI.object_schema({url: {type: "string"}}, required: ["url"]) ) } } } ) do |ctx| query = normalize_hash(ctx.query) provider_id = (fetch_value(ctx.params, "providerId") || query[:provider_id]).to_s raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["PROVIDER_ID_REQUIRED"]) if provider_id.empty? provider = generic_oauth_provider!(config, provider_id) state_data = generic_oauth_parse_state(ctx, query[:state].to_s) error_url = state_data["errorURL"] || state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error" redirect_error = ->(error, description = nil) { raise ctx.redirect(generic_oauth_error_url(error_url, error, description)) } redirect_error.call(query[:error] || "oAuth_code_missing", query[:error_description]) if query[:error] || query[:code].to_s.empty? generic_oauth_validate_issuer!(ctx, provider, query, redirect_error) tokens = begin generic_oauth_exchange_token(ctx, provider, query[:code].to_s, state_data) rescue nil end redirect_error.call("oauth_code_verification_failed") unless tokens user_info = generic_oauth_user_info(provider, tokens) redirect_error.call("user_info_is_missing") unless user_info mapped_user = generic_oauth_map_user(provider, user_info) email = fetch_value(mapped_user, "email").to_s.downcase name = fetch_value(mapped_user, "name").to_s account_id = fetch_value(mapped_user, "id").to_s redirect_error.call("email_is_missing") if email.empty? redirect_error.call("name_is_missing") if name.empty? redirect_error.call("user_info_is_missing") if Routes.blank_remote_id?(account_id) link = state_data["link"] callback_url = state_data["callbackURL"] || "/" if link generic_oauth_link_account(ctx, provider, tokens, mapped_user, link, redirect_error) raise ctx.redirect(callback_url) end existing = ctx.context.internal_adapter.find_oauth_user(email, account_id, provider_id) if !existing && (provider[:disable_sign_up] || (provider[:disable_implicit_sign_up] && !state_data["requestSignUp"])) redirect_error.call("signup_disabled") end session_data = begin Routes.( ctx, provider_id, mapped_user.merge("email" => email, "name" => name, "id" => account_id), generic_oauth_account_info(ctx, provider_id, account_id, tokens), override_user_info: provider[:override_user_info] ) rescue APIError => error raise if error.code.to_s.empty? redirect_error.call(error.code, error.) end redirect_error.call(session_data[:error].tr(" ", "_")) if session_data[:error] (ctx, provider_id, account_id, session_data[:user]["id"]) Cookies.(ctx, session_data) raise ctx.redirect(existing ? callback_url : (state_data["newUserURL"] || state_data["newUserCallbackURL"] || callback_url)) end end |
.oauth2_link_account_endpoint(config) ⇒ Object
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 268 def oauth2_link_account_endpoint(config) Endpoint.new( path: "/oauth2/link", method: "POST", metadata: { openapi: { operationId: "linkOAuth2", description: "Link an OAuth2 account to the current user session", requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema), responses: { "200" => OpenAPI.json_response("Authorization URL generated successfully for linking an OAuth2 account", generic_oauth_url_response_schema) } } } ) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) provider_id = body[:provider_id].to_s provider = generic_oauth_provider(config, provider_id) raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider auth_url = ( ctx, provider, body, link: {user_id: session[:user]["id"], email: session[:user]["email"]} ) ctx.json({url: auth_url, redirect: true}) end end |
.oauth_popup ⇒ Object
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 51 def oauth_popup Plugin.new( id: "oauth-popup", endpoints: { oauth_popup_start: oauth_popup_start_endpoint }, hooks: { after: [ { matcher: ->(ctx) { oauth_popup_callback_path?(ctx.path) }, handler: ->(ctx) { oauth_popup_after_callback(ctx) } } ] }, error_codes: OAUTH_POPUP_ERROR_CODES ) end |
.oauth_popup_additional_data(value) ⇒ Object
202 203 204 205 206 207 208 209 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 202 def oauth_popup_additional_data(value) parsed = value.is_a?(Hash) ? value : JSON.parse(value.to_s) return {} unless parsed.is_a?(Hash) parsed.reject { |key, _entry| OAUTH_POPUP_INTERNAL_STATE_KEYS.include?(key.to_s) } rescue JSON::ParserError {} end |
.oauth_popup_after_callback(ctx) ⇒ Object
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 225 def oauth_popup_after_callback(ctx) location = ctx.response_headers["location"].to_s return if location.empty? = ctx.context.(OAUTH_POPUP_MARKER_COOKIE) marker = ctx.(.name, ctx.context.secret) return unless marker Cookies.(ctx, ) data = JSON.parse(marker) popup_origin = data.fetch("popupOrigin").to_s nonce = data["popupNonce"].to_s return if popup_origin.empty? token = oauth_popup_session_token(ctx) = if token {nonce: nonce, token: token, redirectTo: location} else error = oauth_popup_redirect_error(location) return unless error {nonce: nonce, error: error} end oauth_popup_completion(ctx, popup_origin, ) rescue JSON::ParserError, KeyError nil end |
.oauth_popup_callback_path?(path) ⇒ Boolean
221 222 223 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 221 def oauth_popup_callback_path?(path) path.to_s.start_with?("/callback/", "/oauth2/callback/") end |
.oauth_popup_completion(ctx, popup_origin, message) ⇒ Object
279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 279 def oauth_popup_completion(ctx, popup_origin, ) oauth_popup_warn_missing_bearer(ctx) if [:token] payload = { type: OAUTH_POPUP_MESSAGE_TYPE, targetOrigin: popup_origin }.merge() serialized = JSON.generate(payload) .gsub("<", "\\u003c") .gsub("\u2028", "\\u2028") .gsub("\u2029", "\\u2029") html = <<~HTML <!doctype html> <html> <head><meta charset="utf-8"><title>Completing sign-in</title></head> <body> <script type="application/json" id="#{OAUTH_POPUP_DATA_ELEMENT_ID}">#{serialized}</script> <script>#{OAUTH_POPUP_COMPLETE_SCRIPT}</script> </body> </html> HTML headers = ctx.response_headers.merge( "content-type" => "text/html; charset=utf-8", "content-security-policy" => "default-src 'none'; script-src '#{OAUTH_POPUP_SCRIPT_CSP_HASH}'; base-uri 'none'", "cache-control" => "no-store", "pragma" => "no-cache" ) headers.delete("location") Endpoint::Result.new(response: html, status: 200, headers: headers) end |
.oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce) ⇒ Object
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 183 def oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce) { callback_url: "invalid_callback_url", error_callback_url: "invalid_error_callback_url", new_user_callback_url: "invalid_new_user_callback_url" }.each do |key, code| value = query[key] next if value.to_s.empty? next if ctx.context.trusted_origin?(value.to_s, allow_relative_paths: true) oauth_popup_log(ctx.context, :error, "OAuth popup redirect URL is not trusted") return oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: { code: code, description: "Untrusted redirect URL" }) end nil end |
.oauth_popup_log(context, level, message, *details) ⇒ Object
321 322 323 324 325 326 327 328 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 321 def oauth_popup_log(context, level, , *details) logger = context.logger if logger.respond_to?(:call) logger.call(level, , *details) elsif logger.respond_to?(level) logger.public_send(level, , *details) end end |
.oauth_popup_redirect_error(location) ⇒ Object
269 270 271 272 273 274 275 276 277 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 269 def oauth_popup_redirect_error(location) uri = URI.parse(location) params = URI.decode_www_form(uri.query.to_s).to_h return if params["error"].to_s.empty? {code: params["error"], description: params["error_description"]}.compact rescue URI::InvalidURIError nil end |
.oauth_popup_session_token(ctx) ⇒ Object
254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 254 def oauth_popup_session_token(ctx) name = ctx.context.[:session_token].name Cookies.(ctx.response_headers["set-cookie"]).each do |line| = Cookies.(line) next unless && [:name] == name max_age = .dig(:attributes, "max-age") next if [:value].empty? || (max_age.to_s.strip.match?(/\A[+-]?\d+\z/) && max_age.to_i == 0) return URI.decode_uri_component([:value]) rescue ArgumentError return [:value] end nil end |
.oauth_popup_set_marker(ctx, popup_origin, nonce) ⇒ Object
211 212 213 214 215 216 217 218 219 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 211 def oauth_popup_set_marker(ctx, popup_origin, nonce) = ctx.context.(OAUTH_POPUP_MARKER_COOKIE, max_age: 600) ctx.( .name, JSON.generate({popupOrigin: popup_origin, popupNonce: nonce}), ctx.context.secret, .attributes ) end |
.oauth_popup_start_endpoint ⇒ Object
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 69 def oauth_popup_start_endpoint Endpoint.new( path: "/oauth-popup/start", method: "GET", query_schema: Routes.request_query_schema( required_strings: %w[provider popupOrigin], optional_strings: %w[popupNonce callbackURL errorCallbackURL newUserCallbackURL scopes requestSignUp additionalData] ), metadata: { hide: true, openapi: { parameters: [ {name: "provider", in: "query", required: true, schema: {type: "string"}}, {name: "popupOrigin", in: "query", required: true, schema: {type: "string"}}, {name: "popupNonce", in: "query", required: false, schema: {type: "string"}}, {name: "callbackURL", in: "query", required: false, schema: {type: "string"}}, {name: "errorCallbackURL", in: "query", required: false, schema: {type: "string"}}, {name: "newUserCallbackURL", in: "query", required: false, schema: {type: "string"}}, {name: "scopes", in: "query", required: false, schema: {type: "string"}}, {name: "requestSignUp", in: "query", required: false, schema: {type: "string"}}, {name: "additionalData", in: "query", required: false, schema: {type: "string"}} ] } } ) do |ctx| query = normalize_hash(ctx.query) popup_origin = oauth_popup_validate_origin!(ctx, query[:popup_origin].to_s) nonce = query[:popup_nonce].to_s invalid_redirect = oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce) next invalid_redirect if invalid_redirect provider_id = query[:provider].to_s provider = Routes.(ctx.context, provider_id) unless provider next oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: { code: "provider_not_found", description: "Unknown provider: #{provider_id}" }) end code_verifier = Crypto.random_string(128) scopes = query[:scopes]&.to_s&.split(",") additional_data = oauth_popup_additional_data(query[:additional_data]) callback_url = query[:callback_url].to_s callback_url = ctx.context.base_url if callback_url.empty? request_sign_up = (query[:request_sign_up].to_s == "true") ? true : nil begin = if (start = fetch_value(provider, "oauthPopupAuthorizationUrl")).respond_to?(:call) start.call( ctx, callbackURL: callback_url, errorCallbackURL: query[:error_callback_url], newUserCallbackURL: query[:new_user_callback_url], requestSignUp: request_sign_up, scopes: scopes, additionalData: additional_data, codeVerifier: code_verifier ) else state_data = additional_data.merge( "callbackURL" => callback_url, "errorURL" => query[:error_callback_url], "newUserURL" => query[:new_user_callback_url], "requestSignUp" => request_sign_up, "codeVerifier" => code_verifier, "expiresAt" => Time.now.to_i + 600 ).compact state = OAuthState.generate(ctx, state_data) Routes.call_provider(provider, :create_authorization_url, { state: state, codeVerifier: code_verifier, code_verifier: code_verifier, redirectURI: "#{ctx.context.canonical_base_url}/callback/#{provider_id}", redirect_uri: "#{ctx.context.canonical_base_url}/callback/#{provider_id}", scopes: scopes }) end oauth_popup_set_marker(ctx, popup_origin, nonce) rescue => error oauth_popup_log(ctx.context, :error, "OAuth popup failed to start", error) next oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: { code: "popup_sign_in_failed", description: "Failed to start the OAuth flow." }) end raise ctx.redirect(.to_s) end end |
.oauth_popup_validate_origin!(ctx, popup_origin) ⇒ Object
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 162 def oauth_popup_validate_origin!(ctx, popup_origin) uri = URI.parse(popup_origin) valid_scheme = %w[http https].include?(uri.scheme) valid_path = uri.path.to_s.empty? || uri.path == "/" absolute = valid_scheme && !uri.host.to_s.empty? canonical = Configuration.origin_for(uri) if absolute valid = absolute && uri.userinfo.nil? && valid_path && uri.query.nil? && uri.fragment.nil? && !popup_origin.match?(/[?*]/) && ctx.context.trusted_origin?(canonical, allow_relative_paths: false) return canonical if valid oauth_popup_log(ctx.context, :error, "OAuth popup origin is not trusted") raise APIError.new("FORBIDDEN", code: "INVALID_ORIGIN", message: BASE_ERROR_CODES["INVALID_ORIGIN"]) rescue URI::InvalidURIError raise APIError.new("FORBIDDEN", code: "INVALID_ORIGIN", message: BASE_ERROR_CODES["INVALID_ORIGIN"]) end |
.oauth_popup_warn_missing_bearer(ctx) ⇒ Object
309 310 311 312 313 314 315 316 317 318 319 |
# File 'lib/better_auth/plugins/oauth_popup.rb', line 309 def oauth_popup_warn_missing_bearer(ctx) return if ctx.context..plugins.any? { |plugin| plugin.id == "bearer" } return if @oauth_popup_warned_missing_bearer @oauth_popup_warned_missing_bearer = true oauth_popup_log( ctx.context, :warn, "OAuth popup hands the session token back via postMessage, but the `bearer` plugin is not registered. Add bearer() for embedded cross-site authentication." ) end |
.oauth_provider(*args, &block) ⇒ Object
9 10 11 |
# File 'lib/better_auth/plugins/oauth_provider.rb', line 9 def oauth_provider(*args, &block) call_external_plugin!(:oauth_provider, *args, implementation_constant: :OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-oauth-provider", entry: "lib/better_auth/oauth_provider.rb", &block) end |
.oauth_proxy(options = {}) ⇒ Object
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 11 def oauth_proxy( = {}) config = {max_age: 60}.merge(normalize_hash()) Plugin.new( id: "oauth-proxy", endpoints: { oauth_proxy: oauth_proxy_endpoint(config) }, hooks: { before: [ { matcher: ->(ctx) { oauth_proxy_sign_in_path?(ctx.path) }, handler: ->(ctx) { oauth_proxy_before_sign_in(ctx, config) } }, { matcher: ->(ctx) { oauth_proxy_callback_path?(ctx.path) }, handler: ->(ctx) { oauth_proxy_restore_state_package(ctx, config) } } ], after: [ { matcher: ->(ctx) { oauth_proxy_sign_in_path?(ctx.path) }, handler: ->(ctx) { oauth_proxy_after_sign_in(ctx, config) } }, { matcher: ->(ctx) { oauth_proxy_callback_path?(ctx.path) }, handler: ->(ctx) { oauth_proxy_after_callback(ctx, config) } } ] }, options: config ) end |
.oauth_proxy_after_callback(ctx, config) ⇒ Object
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 162 def oauth_proxy_after_callback(ctx, config) location = ctx.response_headers["location"] return unless location.to_s.include?("/oauth-proxy-callback?callbackURL") return unless location.to_s.start_with?("http") location_uri = URI.parse(location) production = oauth_proxy_production_uri(ctx, config) if location_uri.origin == production.origin original = Rack::Utils.parse_query(location_uri.query).fetch("callbackURL", nil) oauth_proxy_set_location(ctx, original) if original return nil end = ctx.response_headers["set-cookie"] return if .to_s.empty? encrypted = Crypto.symmetric_encrypt( key: oauth_proxy_secret(ctx, config), data: JSON.generate({ cookies: , timestamp: (Time.now.to_f * 1000).to_i }) ) separator = location.include?("?") ? "&" : "?" oauth_proxy_set_location(ctx, "#{location}#{separator}cookies=#{URI.encode_www_form_component(encrypted)}") nil rescue URI::InvalidURIError nil end |
.oauth_proxy_after_sign_in(ctx, config) ⇒ Object
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 123 def oauth_proxy_after_sign_in(ctx, config) return if oauth_proxy_skip?(ctx, config) return unless ctx.context..account[: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? = (ctx) return if .to_s.empty? encrypted_package = Crypto.symmetric_encrypt( key: oauth_proxy_secret(ctx, config), data: JSON.generate({ state: original_state, stateCookie: , isOAuthProxy: true }) ) params["state"] = encrypted_package uri.query = URI.encode_www_form(params) response = ctx.returned.dup if response.key?(:url) response[:url] = uri.to_s else response["url"] = uri.to_s end ctx.returned = response ctx.json(response) rescue URI::InvalidURIError nil end |
.oauth_proxy_before_sign_in(ctx, config) ⇒ Object
91 92 93 94 95 96 97 98 99 100 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 91 def oauth_proxy_before_sign_in(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..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
207 208 209 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 207 def oauth_proxy_callback_path?(path) path.to_s.start_with?("/callback", "/oauth2/callback") end |
.oauth_proxy_current_uri(ctx, config) ⇒ Object
219 220 221 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 219 def oauth_proxy_current_uri(ctx, config) URI.parse((config[:current_url] || ctx.context..base_url || ctx.context.base_url).to_s) end |
.oauth_proxy_endpoint(config) ⇒ Object
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 45 def oauth_proxy_endpoint(config) Endpoint.new( path: "/oauth-proxy-callback", method: "GET", metadata: { openapi: { operationId: "oauthProxyCallback", description: "OAuth Proxy Callback", parameters: [ {in: "query", name: "callbackURL", required: true, schema: {type: "string", format: "uri"}}, {in: "query", name: "cookies", required: true, schema: {type: "string"}} ], responses: { "302" => {description: "Redirects to the callback URL"} } } } ) do |ctx| query = normalize_hash(ctx.query) callback_url = query[:callback_url] || "/" oauth_proxy_validate_callback!(ctx, callback_url) decrypted = Crypto.symmetric_decrypt(key: oauth_proxy_secret(ctx, config), data: query[:cookies].to_s) raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid cookies or secret")) unless decrypted payload = JSON.parse(decrypted) = payload["cookies"] = payload["timestamp"] unless .is_a?(String) && .is_a?(Numeric) raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid payload structure")) end age = ((Time.now.to_f * 1000) - .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 ().each do || ctx.([:name], [:value], [:options]) end raise ctx.redirect(callback_url) rescue JSON::ParserError raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid payload format")) end end |
.oauth_proxy_error_url(ctx, message) ⇒ Object
238 239 240 241 242 243 244 245 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 238 def oauth_proxy_error_url(ctx, ) base = ctx.context..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", ] uri.query = URI.encode_www_form(params) uri.to_s end |
.oauth_proxy_parse_set_cookie(header) ⇒ Object
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 255 def (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? = {} parts.each do |part| key, option_value = part.split("=", 2) case key.to_s.downcase when "path" then [:path] = option_value when "expires" then [:expires] = option_value when "samesite" then [:same_site] = option_value when "httponly" then [:http_only] = true when "secure" then [:secure] = true when "max-age" then [:max_age] = option_value end end {name: Cookies.(name), value: URI.decode_www_form_component(value.to_s), options: } end end |
.oauth_proxy_production_uri(ctx, config) ⇒ Object
223 224 225 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 223 def oauth_proxy_production_uri(ctx, config) URI.parse((config[:production_url] || ctx.context..base_url || ctx.context.base_url).to_s) end |
.oauth_proxy_restore_state_package(ctx, config) ⇒ Object
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 102 def oauth_proxy_restore_state_package(ctx, config) state = fetch_value(ctx.query, "state") || fetch_value(ctx.body, "state") return if state.to_s.empty? decrypted = Crypto.symmetric_decrypt(key: oauth_proxy_secret(ctx, config), data: state.to_s) return unless decrypted package = JSON.parse(decrypted) return unless package["isOAuthProxy"] && package["state"] && package["stateCookie"] = ctx.context.("oauth_state") = ctx.headers["cookie"].to_s = "#{.name}=#{package["stateCookie"]}" ctx.headers["cookie"] = .empty? ? : "#{}; #{}" ctx.query = ctx.query.merge(:state => package["state"], "state" => package["state"]) ctx.body = ctx.body.merge(:state => package["state"], "state" => package["state"]) if ctx.body.is_a?(Hash) nil rescue JSON::ParserError nil end |
.oauth_proxy_secret(ctx, config) ⇒ Object
199 200 201 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 199 def oauth_proxy_secret(ctx, config) config[:secret] || ctx.context.secret_config end |
.oauth_proxy_set_location(ctx, location) ⇒ Object
247 248 249 250 251 252 253 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 247 def oauth_proxy_set_location(ctx, location) ctx.set_header("location", location) return unless ctx.returned.is_a?(APIError) headers = ctx.returned.headers.merge("location" => location) ctx.returned.instance_variable_set(:@headers, headers) end |
.oauth_proxy_sign_in_path?(path) ⇒ Boolean
203 204 205 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 203 def oauth_proxy_sign_in_path?(path) path.to_s.start_with?("/sign-in/social", "/sign-in/oauth2") end |
.oauth_proxy_skip?(ctx, config) ⇒ Boolean
211 212 213 214 215 216 217 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 211 def oauth_proxy_skip?(ctx, config) current = oauth_proxy_current_uri(ctx, config) production = oauth_proxy_production_uri(ctx, config) current.origin == production.origin rescue URI::InvalidURIError false end |
.oauth_proxy_state_cookie_value(ctx) ⇒ Object
192 193 194 195 196 197 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 192 def (ctx) = ctx.context.("oauth_state") parsed = (ctx.response_headers["set-cookie"]) exact = parsed.find { |entry| entry[:name] == .name || entry[:name] == Cookies.(.name) } exact && exact[:value] end |
.oauth_proxy_strip_trailing(value) ⇒ Object
227 228 229 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 227 def oauth_proxy_strip_trailing(value) value.to_s.sub(%r{/+\z}, "") end |
.oauth_proxy_validate_callback!(ctx, callback_url) ⇒ Object
231 232 233 234 235 236 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 231 def oauth_proxy_validate_callback!(ctx, callback_url) return if callback_url.to_s.empty? return if ctx.context.trusted_origin?(callback_url.to_s, allow_relative_paths: true) raise APIError.new("FORBIDDEN", message: "Invalid callbackURL") end |
.okta(options = {}) ⇒ Object
160 161 162 163 164 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 160 def okta( = {}) data = normalize_hash() 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( = {}) config = normalize_hash() Plugin.new( id: "one-tap", endpoints: { one_tap_callback: one_tap_callback_endpoint(config) }, options: config ) end |
.one_tap_blank_audience?(audience) ⇒ Boolean
145 146 147 |
# File 'lib/better_auth/plugins/one_tap.rb', line 145 def one_tap_blank_audience?(audience) Array(audience).empty? || Array(audience).all? { |value| value.to_s.strip.empty? } end |
.one_tap_boolean_value(value) ⇒ Object
168 169 170 |
# File 'lib/better_auth/plugins/one_tap.rb', line 168 def one_tap_boolean_value(value) value == true || value.to_s.downcase == "true" end |
.one_tap_callback_endpoint(config) ⇒ Object
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/better_auth/plugins/one_tap.rb', line 22 def one_tap_callback_endpoint(config) Endpoint.new( path: "/one-tap/callback", method: "POST", body_schema: ->(body) { data = normalize_hash(body) data[:id_token].to_s.empty? ? false : data }, metadata: { openapi: { operationId: "oneTapCallback", summary: "One tap callback", description: "Use this endpoint to authenticate with Google One Tap", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { id_token: {type: "string", description: "Google One Tap ID token"} }, required: ["id_token"] ) ), responses: { "200" => OpenAPI.json_response("Success", OpenAPI.session_response_schema_pair) } } } ) do |ctx| body = normalize_hash(ctx.body) id_token = body[:id_token].to_s payload = one_tap_verify_id_token(ctx, config, id_token) email = fetch_value(payload, "email").to_s.downcase raise APIError.new("BAD_REQUEST", message: "invalid id token") if Routes.blank_remote_id?(fetch_value(payload, "sub")) if email.empty? next ctx.json({error: "Email not available in token"}) end sub = fetch_value(payload, "sub").to_s session_data = Routes.( ctx, "google", { id: sub, email: email, emailVerified: one_tap_boolean_value(fetch_value(payload, "email_verified")), name: fetch_value(payload, "name").to_s, image: fetch_value(payload, "picture") }, { providerId: "google", accountId: sub, idToken: id_token, scope: "openid,profile,email" }, disable_sign_up: config[:disable_signup] ) one_tap_raise_persistence_error!(session_data[:error]) if session_data[:error] Cookies.(ctx, session_data) ctx.json({ token: session_data[:session]["token"], user: Schema.parse_output(ctx.context., "user", session_data[:user]) }) end end |
.one_tap_google_jwks ⇒ Object
133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/better_auth/plugins/one_tap.rb', line 133 def one_tap_google_jwks cached = @one_tap_google_jwks_cache return cached[:jwks] if cached && cached[:expires_at] > Time.now payload = HTTPClient.get_json("https://www.googleapis.com/oauth2/v3/certs") raise "Unable to fetch Google JWKS" unless payload jwks = ::JWT::JWK::Set.new(payload) @one_tap_google_jwks_cache = {jwks: jwks, expires_at: Time.now + 300} jwks end |
.one_tap_raise_persistence_error!(error) ⇒ Object
149 150 151 152 153 154 155 156 157 158 |
# File 'lib/better_auth/plugins/one_tap.rb', line 149 def one_tap_raise_persistence_error!(error) case error when "signup disabled" raise APIError.new("BAD_GATEWAY", message: "User not found") when "account not linked", "banned" raise APIError.new("UNAUTHORIZED", message: "Google sub doesn't match") else raise APIError.new("INTERNAL_SERVER_ERROR", message: "Could not create user") end end |
.one_tap_stringify_payload(payload) ⇒ Object
160 161 162 163 164 165 166 |
# File 'lib/better_auth/plugins/one_tap.rb', line 160 def one_tap_stringify_payload(payload) raise "Invalid payload" unless payload.is_a?(Hash) payload.each_with_object({}) do |(key, value), result| result[key.to_s] = value end end |
.one_tap_verify_google_id_token(id_token, audience) ⇒ Object
120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/better_auth/plugins/one_tap.rb', line 120 def one_tap_verify_google_id_token(id_token, audience) jwks = one_tap_google_jwks = { algorithms: ["RS256"], iss: ["https://accounts.google.com", "accounts.google.com"], verify_iss: true } [:aud] = audience [:verify_aud] = true payload, = ::JWT.decode(id_token, nil, true, .merge(jwks: jwks)) payload end |
.one_tap_verify_id_token(ctx, config, id_token) ⇒ Object
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/better_auth/plugins/one_tap.rb', line 88 def one_tap_verify_id_token(ctx, config, id_token) google_provider = ctx.context..[:google] || {} = fetch_value(google_provider, "options") || {} audience = config[:client_id] audience = fetch_value(google_provider, "client_id") if one_tap_blank_audience?(audience) if one_tap_blank_audience?(audience) raise APIError.new( "BAD_REQUEST", message: "Google client ID is required for One Tap. Set it on the one_tap plugin (client_id) or on social_providers.google." ) end begin verifier = config[:verify_id_token] payload = if verifier.respond_to?(:call) verifier.call(id_token, ctx, audience: audience) else one_tap_verify_google_id_token(id_token, audience) end payload = one_tap_stringify_payload(payload) hosted_domain = fetch_value(google_provider, "hd") hosted_domain = fetch_value(, "hd") if hosted_domain.nil? unless SocialProviders.google_hosted_domain_allowed?(hosted_domain, payload["hd"]) raise "Invalid Google hosted domain" end payload rescue raise APIError.new("BAD_REQUEST", message: "invalid id token") end end |
.one_time_token(options = {}) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 7 def one_time_token( = {}) config = { expires_in: 3, store_token: "plain" }.merge(normalize_hash()) Plugin.new( id: "one-time-token", endpoints: { generate_one_time_token: generate_one_time_token_endpoint(config), verify_one_time_token: verify_one_time_token_endpoint(config) }, hooks: { after: [ { matcher: ->(_ctx) { true }, handler: ->(ctx) { one_time_token_after_response(ctx, config) } } ] }, options: config ) end |
.one_time_token_after_response(ctx, config) ⇒ Object
100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 100 def one_time_token_after_response(ctx, config) return unless config[:set_ott_header_on_new_session] session = ctx.context.new_session return unless session && session[:session] && session[:user] token = one_time_token_create(ctx, config, session) existing = ctx.response_headers["access-control-expose-headers"].to_s exposed = existing.split(",").map(&:strip).reject(&:empty?) exposed << "set-ott" ctx.set_header("set-ott", token) ctx.set_header("access-control-expose-headers", exposed.uniq.join(", ")) nil end |
.one_time_token_create(ctx, config, session) ⇒ Object
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 115 def one_time_token_create(ctx, config, session) generator = config[:generate_token] token = if generator.respond_to?(:call) generator.call(session, ctx) else Crypto.random_string(32) end.to_s stored_token = one_time_token_stored_value(config, token) ctx.context.internal_adapter.create_verification_value( identifier: "one-time-token:#{stored_token}", value: session[:session]["token"], expiresAt: Time.now + config[:expires_in].to_i * 60 ) token end |
.one_time_token_stored_value(config, token) ⇒ Object
131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 131 def one_time_token_stored_value(config, token) storage = config[:store_token] return Crypto.sha256(token, encoding: :base64url) if storage.to_s == "hashed" if storage.is_a?(Hash) && storage[:type].to_s.tr("_", "-") == "custom-hasher" hasher = storage[:hash] return hasher.call(token) if hasher.respond_to?(:call) end token end |
.open_api(options = {}) ⇒ Object
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/better_auth/plugins/open_api.rb', line 235 def open_api( = {}) config = {path: "/reference", theme: "default"}.merge(normalize_hash()) Plugin.new( id: "open-api", endpoints: { generate_openapi_schema: Endpoint.new(path: "/open-api/generate-schema", method: "GET") do |ctx| ctx.json(open_api_schema(ctx.context)) end, open_api_reference: Endpoint.new(path: config[:path], method: "GET", metadata: {hide: true}) do |ctx| raise APIError.new("NOT_FOUND") if config[:disable_default_reference] [200, {"content-type" => "text/html"}, [open_api_html(open_api_schema(ctx.context), config)]] end }, options: config ) end |
.open_api_components(options) ⇒ Object
381 382 383 384 385 386 |
# File 'lib/better_auth/plugins/open_api.rb', line 381 def open_api_components() Schema.auth_tables().each_with_object({}) do |(model, table), schemas| name = model.to_s.split(/[_-]/).map(&:capitalize).join schemas[name.to_sym] = schema_for_table(table) end end |
.open_api_documented_hidden_endpoint?(key, endpoint, tag) ⇒ Boolean
335 336 337 |
# File 'lib/better_auth/plugins/open_api.rb', line 335 def open_api_documented_hidden_endpoint?(key, endpoint, tag) tag == "Default" && [:ok, :error].include?(key) && endpoint.[:openapi] end |
.open_api_encode_uri_component(value) ⇒ Object
461 462 463 464 465 466 |
# File 'lib/better_auth/plugins/open_api.rb', line 461 def open_api_encode_uri_component(value) value.to_s.bytes.map do |byte| char = byte.chr char.match?(/[A-Za-z0-9\-_.!~*'()]/) ? char : "%%%02X" % byte end.join end |
.open_api_endpoints(options) ⇒ Object
287 288 289 290 291 292 293 294 295 |
# File 'lib/better_auth/plugins/open_api.rb', line 287 def open_api_endpoints() Core.base_endpoints.map { |key, endpoint| [key, endpoint, "Default"] } + .plugins.flat_map do |plugin| next [] if plugin.id == "open-api" tag = plugin.id.to_s.split("-").map(&:capitalize).join("-") plugin.endpoints.map { |key, endpoint| [key, endpoint, tag] } end end |
.open_api_html(schema, config) ⇒ Object
414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 |
# File 'lib/better_auth/plugins/open_api.rb', line 414 def open_api_html(schema, config) nonce = config[:nonce] ? " nonce=\"#{config[:nonce]}\"" : "" nonce_attr = config[:nonce] ? "nonce=\"#{config[:nonce]}\"" : "" encoded_logo = open_api_encode_uri_component(open_api_logo) <<~HTML <!doctype html> <html> <head> <title>Scalar API Reference</title> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> </head> <body> <script id="api-reference" type="application/json"> #{JSON.generate(schema)} </script> <script #{nonce_attr}> var configuration = { favicon: "data:image/svg+xml;utf8,#{encoded_logo}", theme: "#{config[:theme] || "default"}", metaData: { title: "Better Auth API", description: "API Reference for your Better Auth Instance", } } document.getElementById('api-reference').dataset.configuration = JSON.stringify(configuration) </script> <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"#{nonce}></script> </body> </html> HTML end |
.open_api_logo ⇒ Object
451 452 453 454 455 456 457 458 459 |
# File 'lib/better_auth/plugins/open_api.rb', line 451 def open_api_logo <<~SVG <svg width="75" height="75" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg"> <rect width="75" height="75" rx="14" fill="#050505"/> <path d="M19 50V25h15.5c5.5 0 9 2.6 9 6.6 0 2.7-1.6 4.7-4.1 5.7 3.2.9 5.2 3 5.2 6.1 0 4.2-3.7 6.6-9.6 6.6H19Zm7.1-14.8h7.2c2.1 0 3.2-.9 3.2-2.4s-1.1-2.4-3.2-2.4h-7.2v4.8Zm0 9.5h7.9c2.2 0 3.5-.9 3.5-2.5s-1.3-2.6-3.5-2.6h-7.9v5.1Z" fill="white"/> <path d="M47 50V25h7.1v25H47Z" fill="white"/> </svg> SVG end |
.open_api_operation(endpoint, method, tag, used_operation_ids) ⇒ Object
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 |
# File 'lib/better_auth/plugins/open_api.rb', line 339 def open_api_operation(endpoint, method, tag, used_operation_ids) = endpoint.[:openapi] || {} operation = { tags: Array([:tags] || [tag]), description: [:description], operationId: open_api_operation_id([:operationId], method, used_operation_ids), security: [ { bearerAuth: [] } ], parameters: [:parameters] || [], responses: OpenAPI.responses([:responses]) } if %w[POST PATCH PUT].include?(method) operation[:requestBody] = [:requestBody] || OpenAPI.empty_request_body end operation end |
.open_api_operation_id(base_id, method, used_operation_ids) ⇒ Object
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 |
# File 'lib/better_auth/plugins/open_api.rb', line 361 def open_api_operation_id(base_id, method, used_operation_ids) return if base_id.to_s.empty? operation_id = base_id.to_s unless used_operation_ids.include?(operation_id) used_operation_ids << operation_id return operation_id end suffix = method.to_s.downcase.capitalize candidate = "#{operation_id}#{suffix}" duplicate_index = 2 while used_operation_ids.include?(candidate) candidate = "#{operation_id}#{suffix}#{duplicate_index}" duplicate_index += 1 end used_operation_ids << candidate candidate end |
.open_api_path(path) ⇒ Object
331 332 333 |
# File 'lib/better_auth/plugins/open_api.rb', line 331 def open_api_path(path) path.split("/").map { |part| part.start_with?(":") ? "{#{part.delete_prefix(":")}}" : part }.join("/") end |
.open_api_paths(endpoints, options) ⇒ Object
313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 |
# File 'lib/better_auth/plugins/open_api.rb', line 313 def open_api_paths(endpoints, ) disabled_paths = Array(.disabled_paths).map(&:to_s) used_operation_ids = Set.new endpoints.each_with_object({}) do |(key, endpoint, tag), paths| next unless endpoint.path next if endpoint.[:exclude_from_openapi] || endpoint.[:SERVER_ONLY] || endpoint.[:server_only] next if endpoint.[:hide] && !open_api_documented_hidden_endpoint?(key, endpoint, tag) next if disabled_paths.include?(endpoint.path) next if key == :set_password path = open_api_path(endpoint.path) paths[path] ||= {} endpoint.methods.reject { |method| method == "*" }.each do |method| paths[path][method.downcase.to_sym] = open_api_operation(endpoint, method, tag, used_operation_ids) end end end |
.open_api_schema(context) ⇒ Object
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'lib/better_auth/plugins/open_api.rb', line 254 def open_api_schema(context) { openapi: "3.1.1", info: { title: "Better Auth", description: "API Reference for your Better Auth Instance", version: "1.1.0" }, components: { schemas: open_api_components(context.), securitySchemes: open_api_security_schemes }, security: [ { apiKeyCookie: [], bearerAuth: [] } ], servers: [ { url: context.canonical_base_url } ], tags: [ { name: "Default", description: "Default endpoints that are included with Better Auth by default. These endpoints are not part of any plugin." } ], paths: open_api_paths(open_api_endpoints(context.), context.) } end |
.open_api_security_schemes ⇒ Object
297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/better_auth/plugins/open_api.rb', line 297 def open_api_security_schemes { apiKeyCookie: { type: "apiKey", in: "cookie", name: "apiKeyCookie", description: "API Key authentication via cookie" }, bearerAuth: { type: "http", scheme: "bearer", description: "Bearer token authentication" } } end |
.org_hook_candidates(options, key) ⇒ Object
1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 |
# File 'lib/better_auth/plugins/organization.rb', line 1409 def org_hook_candidates(, key) data = normalize_hash( || {}) camel_key = camelize_lower(key) candidates = [ data.dig(:organization_hooks, key), data.dig(:organization_hooks, camel_key), data.dig(:hooks, key), data.dig(:hooks, camel_key) ] if .respond_to?(:dig) candidates << .dig("organizationHooks", camel_key) candidates << .dig("organizationHooks", key.to_s) candidates << .dig(:organizationHooks, camel_key) candidates << .dig(:organizationHooks, key.to_s) end candidates end |
.org_truthy?(value) ⇒ Boolean
1565 1566 1567 |
# File 'lib/better_auth/plugins/organization.rb', line 1565 def org_truthy?(value) value == true || value.to_s == "true" end |
.organization(options = {}) ⇒ Object
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'lib/better_auth/plugins/organization.rb', line 80 def organization( = {}) config = organization_config() endpoints = { create_organization: organization_create_endpoint(config), update_organization: organization_update_endpoint(config), delete_organization: organization_delete_endpoint(config), check_organization_slug: organization_check_slug_endpoint, set_active_organization: organization_set_active_endpoint, get_full_organization: organization_get_full_endpoint(config), list_organizations: organization_list_endpoint, create_invitation: organization_invite_endpoint(config), cancel_invitation: organization_cancel_invitation_endpoint(config), accept_invitation: organization_accept_invitation_endpoint(config), reject_invitation: organization_reject_invitation_endpoint(config), get_invitation: organization_get_invitation_endpoint(config), list_invitations: organization_list_invitations_endpoint(config), list_user_invitations: organization_list_user_invitations_endpoint, add_member: organization_add_member_endpoint(config), remove_member: organization_remove_member_endpoint(config), update_member_role: organization_update_member_role_endpoint(config), get_active_member: organization_get_active_member_endpoint(config), leave_organization: organization_leave_endpoint(config), list_members: organization_list_members_endpoint(config), get_active_member_role: organization_get_active_member_role_endpoint(config), has_permission: (config) } if org_truthy?(config.dig(:teams, :enabled)) endpoints.merge!( create_team: organization_create_team_endpoint(config), remove_team: organization_remove_team_endpoint(config), update_team: organization_update_team_endpoint(config), list_organization_teams: organization_list_teams_endpoint(config), set_active_team: organization_set_active_team_endpoint(config), list_user_teams: organization_list_user_teams_endpoint, list_team_members: organization_list_team_members_endpoint(config), add_team_member: organization_add_team_member_endpoint(config), remove_team_member: organization_remove_team_member_endpoint(config) ) end if org_truthy?(config.dig(:dynamic_access_control, :enabled)) endpoints.merge!( create_org_role: organization_create_role_endpoint(config), delete_org_role: organization_delete_role_endpoint(config), list_org_roles: organization_list_roles_endpoint(config), get_org_role: organization_get_role_endpoint(config), update_org_role: organization_update_role_endpoint(config) ) end Plugin.new( id: "organization", schema: OrganizationSchema.build(config), endpoints: endpoints, error_codes: ORGANIZATION_ERROR_CODES, options: config ) end |
.organization_accept_invitation_endpoint(config) ⇒ Object
385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 |
# File 'lib/better_auth/plugins/organization.rb', line 385 def organization_accept_invitation_endpoint(config) Endpoint.new(path: "/organization/accept-invitation", method: "POST", metadata: organization_openapi("acceptOrganizationInvitation", "Accept an organization invitation", response: organization_accept_invitation_schema)) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) invitation = invitation_by_id(ctx, body[:invitation_id] || body[:id]) require_pending_invitation!(invitation) require_invitation_recipient!(invitation, session) require_verified_invitation_recipient!(ctx, config, session, "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION") organization = organization_by_id(ctx, invitation["organizationId"]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization run_org_hook(config, :before_accept_invitation, {invitation: invitation_wire(ctx, invitation), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) member = nil updated = nil organization_transaction(ctx) do |adapter| lock_organization_capacity!(adapter, organization) accepted = adapter.increment_one( model: "invitation", where: [{field: "id", value: invitation["id"]}, {field: "status", value: "pending"}], increment: {}, set: {status: "accepted"} ) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless accepted validate_organization_roles!(ctx, config, invitation["organizationId"], normalized_role_names(invitation["role"]), adapter: adapter) ensure_organization_member_capacity!(ctx, config, session[:user], organization, adapter: adapter) team_ids = organization_team_ids(invitation["teamId"]) validate_stored_invitation_teams!(adapter, invitation["organizationId"], team_ids) ensure_team_member_capacity!(ctx, config, team_ids, adapter: adapter, lock: true) if adapter.find_one(model: "member", where: [{field: "userId", value: session[:user]["id"]}, {field: "organizationId", value: invitation["organizationId"]}]) raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION")) end member = adapter.create(model: "member", data: {organizationId: invitation["organizationId"], userId: session[:user]["id"], role: invitation["role"], createdAt: Time.now}) team_ids.each do |team_id| adapter.create(model: "teamMember", data: {teamId: team_id, userId: session[:user]["id"], createdAt: Time.now}) end updated = accepted end run_org_hook(config, :after_accept_invitation, {invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) ctx.json({invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member)}) end end |
.organization_accept_invitation_schema ⇒ Object
1039 1040 1041 1042 1043 1044 1045 1046 1047 |
# File 'lib/better_auth/plugins/organization.rb', line 1039 def organization_accept_invitation_schema OpenAPI.object_schema( { invitation: organization_ref_schema("Invitation"), member: organization_ref_schema("Member") }, required: ["invitation", "member"] ) end |
.organization_active_member_role_schema ⇒ Object
1049 1050 1051 1052 1053 1054 1055 1056 1057 |
# File 'lib/better_auth/plugins/organization.rb', line 1049 def organization_active_member_role_schema OpenAPI.object_schema( { role: {type: "string"}, member: organization_ref_schema("Member") }, required: ["role", "member"] ) end |
.organization_add_member_endpoint(config) ⇒ Object
497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 |
# File 'lib/better_auth/plugins/organization.rb', line 497 def organization_add_member_endpoint(config) Endpoint.new(path: "/organization/add-member", method: "POST", metadata: organization_openapi("addOrganizationMember", "Add an organization member", response: organization_ref_schema("Member"))) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id organization = organization_by_id(ctx, organization_id) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization (ctx, config, session, organization_id, {member: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_MEMBERSHIP_LIMIT_REACHED")) user_id = body[:user_id].to_s raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty? user = ctx.context.internal_adapter.find_user_by_id(user_id) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user if require_member(ctx, user_id, organization_id) raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION")) end team_id = body[:team_id] if team_id unless org_truthy?(config.dig(:teams, :enabled)) raise APIError.new("BAD_REQUEST", message: "Teams are not enabled") end team = team_by_id(ctx, team_id) unless team && team["organizationId"] == organization_id raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) end end member_data = {organizationId: organization_id, userId: user_id, role: parse_roles(body[:role] || "member"), createdAt: Time.now}.merge(additional_input(body, :organization_id, :user_id, :role, :team_id)) merge_hook_data!(member_data, run_org_hook(config, :before_add_member, {member: member_data, user: user, organization: organization_wire(ctx, organization)}, ctx)) member = organization_transaction(ctx) do |adapter| lock_organization_capacity!(adapter, organization) validate_organization_roles!(ctx, config, organization_id, normalized_role_names(member_data[:role]), adapter: adapter) ensure_organization_member_capacity!(ctx, config, user, organization, adapter: adapter) ensure_team_member_capacity!(ctx, config, [team_id], adapter: adapter, lock: true) if team_id if adapter.find_one(model: "member", where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]) raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION")) end created = adapter.create(model: "member", data: member_data) adapter.create(model: "teamMember", data: {teamId: team_id, userId: user_id, createdAt: Time.now}) if team_id created end run_org_hook(config, :after_add_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx) ctx.json(member_wire(ctx, member)) end end |
.organization_add_team_member_endpoint(config) ⇒ Object
818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 |
# File 'lib/better_auth/plugins/organization.rb', line 818 def organization_add_team_member_endpoint(config) Endpoint.new(path: "/organization/add-team-member", method: "POST", metadata: organization_openapi("addTeamMember", "Add a team member", response: organization_ref_schema("TeamMember"))) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) team = team_by_id(ctx, body[:team_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team (ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER")) user_id = body[:user_id].to_s require_member!(ctx, user_id, team["organizationId"]) user = ctx.context.internal_adapter.find_user_by_id(user_id) organization = organization_by_id(ctx, team["organizationId"]) member_data = {teamId: team["id"], userId: user_id, createdAt: Time.now} run_org_hook(config, :before_add_team_member, {team_member: member_data, team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx) member = organization_transaction(ctx) do |adapter| existing = adapter.find_one(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: user_id}]) next existing if existing ensure_team_member_capacity!(ctx, config, [team["id"]], adapter: adapter, lock: true) adapter.create(model: "teamMember", data: member_data) end run_org_hook(config, :after_add_team_member, {team_member: team_member_wire(ctx, member), team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx) ctx.json(team_member_wire(ctx, member)) end end |
.organization_array_schema(name) ⇒ Object
1032 1033 1034 1035 1036 1037 |
# File 'lib/better_auth/plugins/organization.rb', line 1032 def organization_array_schema(name) { type: "array", items: organization_ref_schema(name) } end |
.organization_by_id(ctx, id) ⇒ Object
1207 1208 1209 1210 1211 |
# File 'lib/better_auth/plugins/organization.rb', line 1207 def organization_by_id(ctx, id) return nil if id.to_s.empty? ctx.context.adapter.find_one(model: "organization", where: [{field: "id", value: id}]) end |
.organization_by_slug(ctx, slug) ⇒ Object
1213 1214 1215 1216 1217 |
# File 'lib/better_auth/plugins/organization.rb', line 1213 def organization_by_slug(ctx, slug) return nil if slug.to_s.empty? ctx.context.adapter.find_one(model: "organization", where: [{field: "slug", value: slug}]) end |
.organization_can_delegate_permissions?(ctx, config, role_string, permissions, organization_id) ⇒ Boolean
1155 1156 1157 1158 1159 1160 1161 |
# File 'lib/better_auth/plugins/organization.rb', line 1155 def (ctx, config, role_string, , organization_id) .all? do |resource, actions| actions.all? do |action| (ctx, config, role_string, {resource => [action]}, organization_id) end end end |
.organization_cancel_invitation_endpoint(config) ⇒ Object
444 445 446 447 448 449 450 451 452 453 454 455 456 457 |
# File 'lib/better_auth/plugins/organization.rb', line 444 def organization_cancel_invitation_endpoint(config) Endpoint.new(path: "/organization/cancel-invitation", method: "POST", metadata: organization_openapi("cancelOrganizationInvitation", "Cancel an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx| session = Routes.current_session(ctx) invitation = invitation_by_id(ctx, normalize_hash(ctx.body)[:invitation_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation (ctx, config, session, invitation["organizationId"], {invitation: ["cancel"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION")) organization = organization_by_id(ctx, invitation["organizationId"]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization run_org_hook(config, :before_cancel_invitation, {invitation: invitation_wire(ctx, invitation), cancelled_by: session[:user], organization: organization_wire(ctx, organization)}, ctx) updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "canceled"}) run_org_hook(config, :after_cancel_invitation, {invitation: invitation_wire(ctx, updated), cancelled_by: session[:user], organization: organization_wire(ctx, organization)}, ctx) ctx.json(invitation_wire(ctx, updated)) end end |
.organization_check_slug_endpoint ⇒ Object
209 210 211 212 213 214 215 216 217 218 |
# File 'lib/better_auth/plugins/organization.rb', line 209 def organization_check_slug_endpoint Endpoint.new(path: "/organization/check-slug", method: "POST", metadata: organization_openapi("checkOrganizationSlug", "Check if an organization slug is available", response: OpenAPI.status_response_schema)) do |ctx| Routes.request_only_session(ctx) slug = normalize_hash(ctx.body)[:slug].to_s if slug.empty? || organization_by_slug(ctx, slug) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_SLUG_ALREADY_TAKEN")) end ctx.json({status: true}) end end |
.organization_config(options) ⇒ Object
140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/better_auth/plugins/organization.rb', line 140 def organization_config() config = normalize_hash() config[:allow_user_to_create_organization] = true unless config.key?(:allow_user_to_create_organization) config[:creator_role] ||= "owner" config[:membership_limit] ||= 100 config[:invitation_expires_in] ||= 60 * 60 * 48 config[:invitation_limit] ||= 100 config[:ac] ||= create_access_control(ORGANIZATION_DEFAULT_STATEMENTS) config[:roles] ||= organization_default_roles(config) config end |
.organization_create_endpoint(config) ⇒ Object
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/better_auth/plugins/organization.rb', line 161 def organization_create_endpoint(config) Endpoint.new(path: "/organization/create", method: "POST", metadata: organization_openapi("createOrganization", "Create an organization", response: organization_ref_schema("Organization"))) do |ctx| body = normalize_hash(ctx.body) session = Routes.current_session(ctx, allow_nil: true) user = session ? session[:user] : ctx.context.internal_adapter.find_user_by_id(body[:user_id]) raise APIError.new("UNAUTHORIZED") unless user name = body[:name].to_s slug = body[:slug].to_s raise APIError.new("BAD_REQUEST", message: "name is required") if name.empty? raise APIError.new("BAD_REQUEST", message: "slug is required") if slug.empty? allowed = config[:allow_user_to_create_organization] allowed = allowed.call(user) if allowed.respond_to?(:call) raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION")) unless allowed if config[:organization_limit] limit_reached = config[:organization_limit].respond_to?(:call) ? config[:organization_limit].call(user) : organization_created_count(ctx, user["id"]) >= config[:organization_limit].to_i raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS")) if limit_reached end if organization_by_slug(ctx, slug) raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_ALREADY_EXISTS")) end data = { name: name, slug: slug, logo: body[:logo], metadata: (body[:metadata]), createdAt: Time.now }.merge(additional_input(body, :name, :slug, :logo, :metadata, :keep_current_active_organization, :user_id)) merge_hook_data!(data, run_org_hook(config, :before_create_organization, {organization: data, user: user}, ctx)) organization = ctx.context.adapter.create(model: "organization", data: data, force_allow_id: true) member_data = {organizationId: organization["id"], userId: user["id"], role: config[:creator_role], createdAt: Time.now} merge_hook_data!(member_data, run_org_hook(config, :before_add_member, {member: member_data, user: user, organization: organization_wire(ctx, organization)}, ctx)) member = ctx.context.adapter.create(model: "member", data: member_data) run_org_hook(config, :after_add_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx) default_team = create_default_team(ctx, config, organization, {user: user}) if org_truthy?(config.dig(:teams, :enabled)) && config.dig(:teams, :default_team, :enabled) != false run_org_hook(config, :after_create_organization, {organization: organization_wire(ctx, organization), member: member, user: user}, ctx) if session && !org_truthy?(body[:keep_current_active_organization]) update = {activeOrganizationId: organization["id"], activeTeamId: default_team && default_team["id"]} updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], update) Cookies.(ctx, {session: updated_session || session[:session].merge(update.transform_keys(&:to_s)), user: user}) end ctx.json(organization_wire(ctx, organization).merge(members: [member_wire(ctx, member)])) end end |
.organization_create_role_endpoint(config) ⇒ Object
861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 |
# File 'lib/better_auth/plugins/organization.rb', line 861 def organization_create_role_endpoint(config) Endpoint.new(path: "/organization/create-role", method: "POST", metadata: organization_openapi("createOrganizationRole", "Create an organization role", response: organization_role_action_schema)) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] (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) = (body[:permission] || body[:permissions]) (config, ) unless (ctx, config, require_member!(ctx, session[:user]["id"], organization_id)["role"], , 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(), 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().statements}) end end |
.organization_create_team_endpoint(config) ⇒ Object
695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 |
# File 'lib/better_auth/plugins/organization.rb', line 695 def organization_create_team_endpoint(config) Endpoint.new(path: "/organization/create-team", method: "POST", metadata: organization_openapi("createOrganizationTeam", "Create an organization team", response: organization_ref_schema("Team"))) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] (ctx, config, session, organization_id, {team: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION")) organization = organization_by_id(ctx, organization_id) team_data = {organizationId: organization_id, name: body[:name].to_s, createdAt: Time.now}.merge(additional_input(body, :organization_id, :name)) merge_hook_data!(team_data, run_org_hook(config, :before_create_team, {team: team_data, user: session[:user], organization: organization_wire(ctx, organization)}, ctx)) team = organization_transaction(ctx) do |adapter| lock_organization_capacity!(adapter, organization) max_teams = config.dig(:teams, :maximum_teams) if max_teams && adapter.count(model: "team", where: [{field: "organizationId", value: organization_id}]) >= max_teams.to_i raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS")) end created = adapter.create(model: "team", data: team_data, force_allow_id: true) adapter.create(model: "teamMember", data: {teamId: created["id"], userId: session[:user]["id"], createdAt: Time.now}) created end run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) ctx.json(team_wire(ctx, team)) end end |
.organization_created_count(ctx, user_id) ⇒ Object
1382 1383 1384 1385 1386 1387 1388 |
# File 'lib/better_auth/plugins/organization.rb', line 1382 def organization_created_count(ctx, user_id) count = 0 organization_each_adapter_record(ctx.context.adapter, "member", where: [{field: "userId", value: user_id}]) do |member| count += 1 if member["role"].to_s.split(",").include?("owner") end count end |
.organization_default_roles(config = {}) ⇒ Object
152 153 154 155 156 157 158 159 |
# File 'lib/better_auth/plugins/organization.rb', line 152 def organization_default_roles(config = {}) ac = config[:ac] || create_access_control(ORGANIZATION_DEFAULT_STATEMENTS) { "admin" => ac.new_role(organization: ["update"], invitation: ["create", "cancel"], member: ["create", "update", "delete"], team: ["create", "update", "delete"], ac: ["create", "read", "update", "delete"]), "owner" => ac.new_role(organization: ["update", "delete"], member: ["create", "update", "delete"], invitation: ["create", "cancel"], team: ["create", "update", "delete"], ac: ["create", "read", "update", "delete"]), "member" => ac.new_role(organization: [], member: [], invitation: [], team: [], ac: ["read"]) } end |
.organization_delete_endpoint(config) ⇒ Object
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 |
# File 'lib/better_auth/plugins/organization.rb', line 255 def organization_delete_endpoint(config) Endpoint.new(path: "/organization/delete", method: "POST", metadata: organization_openapi("deleteOrganization", "Delete an organization", response: OpenAPI.status_response_schema)) do |ctx| if org_truthy?(config[:disable_organization_deletion]) raise APIError.new("NOT_FOUND", code: "ORGANIZATION_DELETION_DISABLED", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_DELETION_DISABLED")) end session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization = organization_by_id(ctx, body[:organization_id]) || organization_by_slug(ctx, body[:organization_slug]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization (ctx, config, session, organization["id"], {organization: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION")) run_org_hook(config, :before_delete_organization, {organization: organization_wire(ctx, organization), user: session[:user]}, ctx) organization_transaction(ctx) do |adapter| if org_truthy?(config.dig(:teams, :enabled)) team_ids = adapter.find_many(model: "team", where: [{field: "organizationId", value: organization["id"]}]).map { |team| team["id"] } adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any? adapter.delete_many(model: "team", where: [{field: "organizationId", value: organization["id"]}]) end adapter.delete_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}]) adapter.delete_many(model: "member", where: [{field: "organizationId", value: organization["id"]}]) adapter.delete_many(model: "organizationRole", where: [{field: "organizationId", value: organization["id"]}]) if org_truthy?(config.dig(:dynamic_access_control, :enabled)) adapter.delete(model: "organization", where: [{field: "id", value: organization["id"]}]) end clear_active_organization_session(ctx, session, organization["id"]) run_org_hook(config, :after_delete_organization, {organization: organization_wire(ctx, organization), user: session[:user]}, ctx) ctx.json({status: true}) end end |
.organization_delete_role_endpoint(config) ⇒ Object
928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 |
# File 'lib/better_auth/plugins/organization.rb', line 928 def organization_delete_role_endpoint(config) Endpoint.new(path: "/organization/delete-role", method: "POST", metadata: organization_openapi("deleteOrganizationRole", "Delete an organization role", response: OpenAPI.success_response_schema)) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] (ctx, config, session, organization_id, {ac: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE")) role_name = body[:role] || body[:role_name] if role_name && organization_roles(config).key?(role_name.to_s) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("CANNOT_DELETE_A_PRE_DEFINED_ROLE")) end role = organization_role_by_id(ctx, body[:role_id]) || organization_role_by_name(ctx, organization_id, role_name) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND")) unless role assigned = ctx.context.adapter.find_many(model: "member", where: [{field: "organizationId", value: organization_id}]).any? do |member| member["role"].to_s.split(",").map(&:strip).include?(role["role"]) end raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_IS_ASSIGNED_TO_MEMBERS")) if assigned ctx.context.adapter.delete(model: "organizationRole", where: [{field: "id", value: role["id"]}]) ctx.json({success: true}) end end |
.organization_each_adapter_record(adapter, model, where:, page_size: 100) ⇒ Object
1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 |
# File 'lib/better_auth/plugins/organization.rb', line 1390 def organization_each_adapter_record(adapter, model, where:, page_size: 100) offset = 0 loop do records = adapter.find_many(model: model, where: where, limit: page_size, offset: offset) break if records.empty? records.each { |record| yield record } break if records.length < page_size offset += records.length end end |
.organization_get_active_member_endpoint(_config) ⇒ Object
630 631 632 633 634 635 636 637 638 |
# File 'lib/better_auth/plugins/organization.rb', line 630 def organization_get_active_member_endpoint(_config) Endpoint.new(path: "/organization/get-active-member", method: "GET", metadata: organization_openapi("getActiveOrganizationMember", "Get the active organization member", response: organization_ref_schema("Member"))) do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id member = require_member!(ctx, session[:user]["id"], organization_id) ctx.json(member_wire(ctx, member)) end end |
.organization_get_active_member_role_endpoint(_config) ⇒ Object
640 641 642 643 644 645 646 647 648 649 650 |
# File 'lib/better_auth/plugins/organization.rb', line 640 def organization_get_active_member_role_endpoint(_config) Endpoint.new(path: "/organization/get-active-member-role", method: "GET", metadata: organization_openapi("getActiveOrganizationMemberRole", "Get the active organization member role", response: organization_active_member_role_schema)) do |ctx| session = Routes.current_session(ctx) query = normalize_hash(ctx.query) organization_id = query[:organization_id] || session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id require_member!(ctx, session[:user]["id"], organization_id) member = require_member!(ctx, query[:user_id] || session[:user]["id"], organization_id) ctx.json({role: member["role"], member: member_wire(ctx, member)}) end end |
.organization_get_full_endpoint(config) ⇒ Object
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 |
# File 'lib/better_auth/plugins/organization.rb', line 302 def organization_get_full_endpoint(config) Endpoint.new(path: "/organization/get-full-organization", method: "GET", metadata: organization_openapi("getOrganization", "Get the full organization", response: organization_nullable_schema("Organization"))) do |ctx| session = Routes.current_session(ctx) query = normalize_hash(ctx.query) explicit_lookup = query.key?(:organization_slug) || query.key?(:organization_id) organization = organization_by_slug(ctx, query[:organization_slug]) || organization_by_id(ctx, query[:organization_id] || session[:session]["activeOrganizationId"]) unless organization raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) if explicit_lookup next ctx.json(nil) end require_member!(ctx, session[:user]["id"], organization["id"]) members = list_members_for(ctx, organization["id"], {limit: query[:members_limit] || config[:membership_limit]}) invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}]) result = organization_wire(ctx, organization).merge( members: members.fetch(:members), invitations: invitations.map { |entry| invitation_wire(ctx, entry) } ) if org_truthy?(config.dig(:teams, :enabled)) teams = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: organization["id"]}]) result[:teams] = teams.map { |team| team_wire(ctx, team) } end ctx.json(result) end end |
.organization_get_invitation_endpoint(config) ⇒ Object
459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 |
# File 'lib/better_auth/plugins/organization.rb', line 459 def organization_get_invitation_endpoint(config) Endpoint.new(path: "/organization/get-invitation", method: "GET", metadata: organization_openapi("getOrganizationInvitation", "Get an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx| session = Routes.current_session(ctx) invitation = invitation_by_id(ctx, normalize_hash(ctx.query)[:id] || normalize_hash(ctx.query)[:invitation_id]) require_pending_invitation!(invitation) require_invitation_recipient!(invitation, session) require_verified_invitation_recipient!(ctx, config, session, "EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION") organization = organization_by_id(ctx, invitation["organizationId"]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization inviter = require_member(ctx, invitation["inviterId"], invitation["organizationId"]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION")) unless inviter inviter_user = ctx.context.internal_adapter.find_user_by_id(invitation["inviterId"]) ctx.json(invitation_wire(ctx, invitation).merge(organizationName: organization["name"], organizationSlug: organization["slug"], inviterEmail: inviter_user && inviter_user["email"])) end end |
.organization_get_role_endpoint(config) ⇒ Object
891 892 893 894 895 896 897 898 899 900 901 |
# File 'lib/better_auth/plugins/organization.rb', line 891 def organization_get_role_endpoint(config) Endpoint.new(path: "/organization/get-role", method: "GET", metadata: organization_openapi("getOrganizationRole", "Get an organization role", response: organization_role_schema)) do |ctx| session = Routes.current_session(ctx) query = normalize_hash(ctx.query) organization_id = query[:organization_id] || session[:session]["activeOrganizationId"] (ctx, config, session, organization_id, {ac: ["read"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE")) role = organization_role_by_id(ctx, query[:role_id]) || organization_role_by_name(ctx, organization_id, query[:role]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND")) unless role ctx.json(organization_role_wire(role)) end end |
.organization_has_permission_endpoint(config) ⇒ Object
683 684 685 686 687 688 689 690 691 692 693 |
# File 'lib/better_auth/plugins/organization.rb', line 683 def (config) Endpoint.new(path: "/organization/has-permission", method: "POST", metadata: organization_openapi("hasOrganizationPermission", "Check if the member has organization permission", response: )) 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) = body[:permissions] || body[:permission] ctx.json({error: nil, success: (ctx, config, member["role"], , organization_id)}) end end |
.organization_invite_endpoint(config) ⇒ Object
329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 |
# File 'lib/better_auth/plugins/organization.rb', line 329 def organization_invite_endpoint(config) Endpoint.new(path: "/organization/invite-member", method: "POST", metadata: organization_openapi("createOrganizationInvitation", "Create an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization = organization_by_id(ctx, body[:organization_id] || session[:session]["activeOrganizationId"]) || organization_by_slug(ctx, body[:organization_slug]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization (ctx, config, session, organization["id"], {invitation: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION")) email = body[:email].to_s.downcase raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email) role = parse_roles(body[:role] || "member") role.split(",").each do |entry| unless organization_roles(config).key?(entry) || organization_role_by_name(ctx, organization["id"], entry) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND")) end end existing_member = find_member_by_email(ctx, organization["id"], email) raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION")) if existing_member team_ids = organization_team_ids(body[:team_id] || body[:team_ids]) validate_invitation_team_ids!(ctx, config, organization["id"], body[:team_id] || body[:team_ids], team_ids) invitation_data = { organizationId: organization["id"], email: email, role: role, status: "pending", expiresAt: Time.now + config[:invitation_expires_in].to_i, inviterId: session[:user]["id"], teamId: team_ids.any? ? team_ids.join(",") : nil, createdAt: Time.now }.merge(additional_input(body, :organization_id, :organization_slug, :email, :role, :team_id, :team_ids)) merge_hook_data!(invitation_data, run_org_hook(config, :before_create_invitation, {invitation: invitation_data, inviter: session[:user], organization: organization_wire(ctx, organization)}, ctx)) invitation = organization_transaction(ctx) do |adapter| lock_organization_capacity!(adapter, organization) pending = adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}, {field: "email", value: email}, {field: "status", value: "pending"}]) if pending.any? if config[:cancel_pending_invitations_on_re_invite] pending.each { |entry| adapter.update(model: "invitation", where: [{field: "id", value: entry["id"]}], update: {status: "canceled"}) } else raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION")) end end pending_count = adapter.count(model: "invitation", where: [{field: "organizationId", value: organization["id"]}, {field: "status", value: "pending"}]) limit = config[:invitation_limit] if limit && pending_count >= limit.to_i raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_LIMIT_REACHED")) end validate_stored_invitation_teams!(adapter, organization["id"], team_ids) ensure_team_member_capacity!(ctx, config, team_ids, adapter: adapter) adapter.create(model: "invitation", data: invitation_data, force_allow_id: true) end sender = config[:send_invitation_email] sender.call({id: invitation["id"], role: role, email: email, organization: organization_wire(ctx, organization), invitation: invitation_wire(ctx, invitation), inviter: require_member!(ctx, session[:user]["id"], organization["id"])}, ctx.request) if sender.respond_to?(:call) run_org_hook(config, :after_create_invitation, {invitation: invitation_wire(ctx, invitation), inviter: session[:user], organization: organization_wire(ctx, organization)}, ctx) ctx.json(invitation_wire(ctx, invitation)) end end |
.organization_leave_endpoint(config) ⇒ Object
652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 |
# File 'lib/better_auth/plugins/organization.rb', line 652 def organization_leave_endpoint(config) Endpoint.new(path: "/organization/leave", method: "POST", metadata: organization_openapi("leaveOrganization", "Leave an organization", response: OpenAPI.status_response_schema)) do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.body)[:organization_id] member = require_member!(ctx, session[:user]["id"], organization_id) organization = organization_by_id(ctx, organization_id) organization_transaction(ctx) do |adapter| lock_organization_capacity!(adapter, organization) ensure_not_last_owner!(ctx, member, adapter: adapter) adapter.delete(model: "member", where: [{field: "id", value: member["id"]}]) if org_truthy?(config.dig(:teams, :enabled)) team_ids = adapter.find_many(model: "team", where: [{field: "organizationId", value: organization_id}]).map { |team| team["id"] } adapter.delete_many(model: "teamMember", where: [{field: "userId", value: session[:user]["id"]}, {field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any? end end clear_active_organization_session(ctx, session, organization_id) ctx.json({status: true}) end end |
.organization_list_endpoint ⇒ Object
220 221 222 223 224 225 226 227 |
# File 'lib/better_auth/plugins/organization.rb', line 220 def organization_list_endpoint Endpoint.new(path: "/organization/list", method: "GET", metadata: organization_openapi("listOrganizations", "List organizations", response: organization_array_schema("Organization"))) do |ctx| session = Routes.current_session(ctx) members = ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: session[:user]["id"]}]) organizations = members.filter_map { |member| organization_by_id(ctx, member["organizationId"]) } ctx.json(organizations.map { |entry| organization_wire(ctx, entry) }) end end |
.organization_list_invitations_endpoint(config) ⇒ Object
475 476 477 478 479 480 481 482 483 484 |
# File 'lib/better_auth/plugins/organization.rb', line 475 def organization_list_invitations_endpoint(config) Endpoint.new(path: "/organization/list-invitations", method: "GET", metadata: organization_openapi("listOrganizationInvitations", "List organization invitations", response: organization_array_schema("Invitation"))) do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id (ctx, config, session, organization_id, {invitation: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION")) invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization_id}]) ctx.json(invitations.map { |entry| invitation_wire(ctx, entry) }) end end |
.organization_list_members_endpoint(config) ⇒ Object
672 673 674 675 676 677 678 679 680 681 |
# File 'lib/better_auth/plugins/organization.rb', line 672 def organization_list_members_endpoint(config) Endpoint.new(path: "/organization/list-members", method: "GET", metadata: organization_openapi("listOrganizationMembers", "List organization members", response: organization_members_response_schema)) do |ctx| session = Routes.current_session(ctx) query = normalize_hash(ctx.query) organization_id = query[:organization_id] || organization_by_slug(ctx, query[:organization_slug])&.fetch("id") || session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id require_member!(ctx, session[:user]["id"], organization_id) ctx.json(list_members_for(ctx, organization_id, query, config, session[:user])) end end |
.organization_list_roles_endpoint(config) ⇒ Object
880 881 882 883 884 885 886 887 888 889 |
# File 'lib/better_auth/plugins/organization.rb', line 880 def organization_list_roles_endpoint(config) Endpoint.new(path: "/organization/list-roles", method: "GET", metadata: organization_openapi("listOrganizationRoles", "List organization roles", response: {type: "array", items: organization_role_schema})) do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"] (ctx, config, session, organization_id, {ac: ["read"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE")) defaults = organization_roles(config).keys.map { |role| {"role" => role, "permission" => {}} } dynamic = ctx.context.adapter.find_many(model: "organizationRole", where: [{field: "organizationId", value: organization_id}]).map { |role| organization_role_wire(role) } ctx.json(defaults + dynamic) end end |
.organization_list_team_members_endpoint(_config) ⇒ Object
806 807 808 809 810 811 812 813 814 815 816 |
# File 'lib/better_auth/plugins/organization.rb', line 806 def organization_list_team_members_endpoint(_config) Endpoint.new(path: "/organization/list-team-members", method: "GET", metadata: organization_openapi("listTeamMembers", "List team members", response: organization_array_schema("TeamMember"))) do |ctx| session = Routes.current_session(ctx) team_id = normalize_hash(ctx.query)[:team_id] || session[:session]["activeTeamId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM")) unless team_id team = team_by_id(ctx, team_id) require_member!(ctx, session[:user]["id"], team["organizationId"]) members = ctx.context.adapter.find_many(model: "teamMember", where: [{field: "teamId", value: team_id}]) ctx.json(members.map { |entry| team_member_wire(ctx, entry) }) end end |
.organization_list_teams_endpoint(_config) ⇒ Object
719 720 721 722 723 724 725 726 727 |
# File 'lib/better_auth/plugins/organization.rb', line 719 def organization_list_teams_endpoint(_config) Endpoint.new(path: "/organization/list-teams", method: "GET", metadata: organization_openapi("listOrganizationTeams", "List organization teams", response: organization_array_schema("Team"))) do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"] require_member!(ctx, session[:user]["id"], organization_id) teams = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: organization_id}]) ctx.json(teams.map { |team| team_wire(ctx, team) }) end end |
.organization_list_user_invitations_endpoint ⇒ Object
486 487 488 489 490 491 492 493 494 495 |
# File 'lib/better_auth/plugins/organization.rb', line 486 def organization_list_user_invitations_endpoint Endpoint.new(path: "/organization/list-user-invitations", method: "GET", metadata: organization_openapi("listUserInvitations", "List user invitations", response: organization_array_schema("Invitation"))) do |ctx| session = Routes.current_session(ctx) unless session[:user]["emailVerified"] raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION")) end invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "email", value: session[:user]["email"].to_s.downcase}, {field: "status", value: "pending"}]) ctx.json(invitations.map { |entry| invitation_wire(ctx, entry) }) end end |
.organization_list_user_teams_endpoint ⇒ Object
798 799 800 801 802 803 804 |
# File 'lib/better_auth/plugins/organization.rb', line 798 def organization_list_user_teams_endpoint Endpoint.new(path: "/organization/list-user-teams", method: "GET", metadata: organization_openapi("listUserTeams", "List user teams", response: organization_array_schema("Team"))) do |ctx| session = Routes.current_session(ctx) memberships = ctx.context.adapter.find_many(model: "teamMember", where: [{field: "userId", value: session[:user]["id"]}]) ctx.json(memberships.filter_map { |entry| team_by_id(ctx, entry["teamId"]) }.map { |team| team_wire(ctx, team) }) end end |
.organization_members_response_schema ⇒ Object
1059 1060 1061 1062 1063 1064 1065 1066 1067 |
# File 'lib/better_auth/plugins/organization.rb', line 1059 def organization_members_response_schema OpenAPI.object_schema( { members: organization_array_schema("Member"), total: {type: "number"} }, required: ["members", "total"] ) end |
.organization_membership_capacity_limit(ctx, config, user, organization) ⇒ Object
1285 1286 1287 1288 1289 |
# File 'lib/better_auth/plugins/organization.rb', line 1285 def organization_membership_capacity_limit(ctx, config, user, organization) configured = config[:membership_limit] || 100 value = configured.respond_to?(:call) ? configured.call(user, organization_wire(ctx, organization)) : configured numeric_member_limit(value) end |
.organization_nullable_schema(name) ⇒ Object
1025 1026 1027 1028 1029 1030 |
# File 'lib/better_auth/plugins/organization.rb', line 1025 def organization_nullable_schema(name) { type: ["object", "null"], "$ref": "#/components/schemas/#{name}" } end |
.organization_openapi(operation_id, description, response:, response_description: "Success", request: nil, required: [], parameters: nil) ⇒ Object
949 950 951 952 953 954 955 956 957 958 959 960 961 962 |
# File 'lib/better_auth/plugins/organization.rb', line 949 def organization_openapi(operation_id, description, response:, response_description: "Success", request: nil, required: [], parameters: nil) request ||= organization_request_schema(operation_id) openapi = { operationId: operation_id, description: description, responses: { "200" => OpenAPI.json_response(response_description, response) } } openapi[:requestBody] = OpenAPI.json_request_body(OpenAPI.object_schema(request, required: required)) if request openapi[:parameters] = parameters if parameters {openapi: openapi} end |
.organization_permission?(ctx, config, role_string, permissions, organization_id) ⇒ Boolean
1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 |
# File 'lib/better_auth/plugins/organization.rb', line 1139 def (ctx, config, role_string, , 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| = (entry["permission"]) if roles.key?(entry["role"]) = (roles[entry["role"]].statements, ) end roles[entry["role"]] = (config[:ac] || create_access_control(ORGANIZATION_DEFAULT_STATEMENTS)).new_role() end end role_string.to_s.split(",").any? do |role| roles[role]&.( || {})&.fetch(:success, false) end end |
.organization_permission_response_schema ⇒ Object
1069 1070 1071 1072 1073 1074 1075 1076 1077 |
# File 'lib/better_auth/plugins/organization.rb', line 1069 def OpenAPI.object_schema( { error: {type: ["string", "null"]}, success: {type: "boolean"} }, required: ["success"] ) end |
.organization_ref_schema(name) ⇒ Object
1018 1019 1020 1021 1022 1023 |
# File 'lib/better_auth/plugins/organization.rb', line 1018 def organization_ref_schema(name) { type: "object", "$ref": "#/components/schemas/#{name}" } end |
.organization_reject_invitation_endpoint(config) ⇒ Object
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 |
# File 'lib/better_auth/plugins/organization.rb', line 428 def organization_reject_invitation_endpoint(config) Endpoint.new(path: "/organization/reject-invitation", method: "POST", metadata: organization_openapi("rejectOrganizationInvitation", "Reject an organization invitation", response: organization_ref_schema("Invitation"))) do |ctx| session = Routes.current_session(ctx) invitation = invitation_by_id(ctx, normalize_hash(ctx.body)[:invitation_id]) require_pending_invitation!(invitation, check_expiration: false) require_invitation_recipient!(invitation, session) require_verified_invitation_recipient!(ctx, config, session, "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION") organization = organization_by_id(ctx, invitation["organizationId"]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization run_org_hook(config, :before_reject_invitation, {invitation: invitation_wire(ctx, invitation), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "rejected"}) run_org_hook(config, :after_reject_invitation, {invitation: invitation_wire(ctx, updated), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) ctx.json(invitation_wire(ctx, updated)) end end |
.organization_remove_member_endpoint(config) ⇒ Object
547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 |
# File 'lib/better_auth/plugins/organization.rb', line 547 def organization_remove_member_endpoint(config) Endpoint.new(path: "/organization/remove-member", method: "POST", metadata: organization_openapi("removeOrganizationMember", "Remove an organization member", response: OpenAPI.status_response_schema)) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) member = member_by_id(ctx, body[:member_id]) || require_member(ctx, body[:user_id], body[:organization_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless member (ctx, config, session, member["organizationId"], {member: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER")) organization = organization_by_id(ctx, member["organizationId"]) user = ctx.context.internal_adapter.find_user_by_id(member["userId"]) run_org_hook(config, :before_remove_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx) organization_transaction(ctx) do |adapter| lock_organization_capacity!(adapter, organization) ensure_not_last_owner!(ctx, member, adapter: adapter) adapter.delete(model: "member", where: [{field: "id", value: member["id"]}]) if org_truthy?(config.dig(:teams, :enabled)) team_ids = adapter.find_many(model: "team", where: [{field: "organizationId", value: member["organizationId"]}]).map { |team| team["id"] } adapter.delete_many(model: "teamMember", where: [{field: "userId", value: member["userId"]}, {field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any? end end run_org_hook(config, :after_remove_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx) ctx.json({status: true}) end end |
.organization_remove_team_endpoint(config) ⇒ Object
745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 |
# File 'lib/better_auth/plugins/organization.rb', line 745 def organization_remove_team_endpoint(config) Endpoint.new(path: "/organization/remove-team", method: "POST", metadata: organization_openapi("removeOrganizationTeam", "Remove an organization team", response: OpenAPI.status_response_schema)) do |ctx| session = Routes.current_session(ctx) team = team_by_id(ctx, normalize_hash(ctx.body)[:team_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team (ctx, config, session, team["organizationId"], {team: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM")) organization = organization_by_id(ctx, team["organizationId"]) run_org_hook(config, :before_delete_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) organization_transaction(ctx) do |adapter| lock_organization_capacity!(adapter, organization) teams = adapter.find_many(model: "team", where: [{field: "organizationId", value: team["organizationId"]}]) if teams.length <= 1 && config.dig(:teams, :allow_removing_all_teams) != true raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("UNABLE_TO_REMOVE_LAST_TEAM")) end adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}]) adapter.delete(model: "team", where: [{field: "id", value: team["id"]}]) pending = adapter.find_many(model: "invitation", where: [{field: "organizationId", value: team["organizationId"]}, {field: "status", value: "pending"}]) pending.each do |invitation| team_ids = organization_team_ids(invitation["teamId"]) next unless team_ids.include?(team["id"]) remaining = team_ids.reject { |team_id| team_id == team["id"] } adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {teamId: remaining.any? ? remaining.join(",") : nil}) end end run_org_hook(config, :after_delete_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) ctx.json({status: true}) end end |
.organization_remove_team_member_endpoint(config) ⇒ Object
843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 |
# File 'lib/better_auth/plugins/organization.rb', line 843 def organization_remove_team_member_endpoint(config) Endpoint.new(path: "/organization/remove-team-member", method: "POST", metadata: organization_openapi("removeTeamMember", "Remove a team member", response: OpenAPI.status_response_schema)) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) team = team_by_id(ctx, body[:team_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team (ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER")) member = ctx.context.adapter.find_one(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: body[:user_id]}]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_NOT_A_MEMBER_OF_THE_TEAM")) unless member user = ctx.context.internal_adapter.find_user_by_id(body[:user_id]) organization = organization_by_id(ctx, team["organizationId"]) run_org_hook(config, :before_remove_team_member, {team_member: team_member_wire(ctx, member), team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx) ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: body[:user_id]}]) run_org_hook(config, :after_remove_team_member, {team_member: team_member_wire(ctx, member), team: team_wire(ctx, team), user: user, organization: organization_wire(ctx, organization)}, ctx) ctx.json({status: true}) end end |
.organization_request_schema(operation_id) ⇒ Object
964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 |
# File 'lib/better_auth/plugins/organization.rb', line 964 def organization_request_schema(operation_id) string = {type: "string"} boolean = {type: "boolean"} array = ->(items = string) { {type: "array", items: items} } object = {type: "object", additionalProperties: true} { "createOrganization" => { name: string, slug: string, logo: string, metadata: object, userId: string, keepCurrentActiveOrganization: boolean }, "checkOrganizationSlug" => {slug: string}, "updateOrganization" => { organizationId: string, organizationSlug: string, data: object, name: string, slug: string, logo: string, metadata: object }, "deleteOrganization" => {organizationId: string, organizationSlug: string}, "setActiveOrganization" => {organizationId: string, organizationSlug: string}, "createOrganizationInvitation" => { organizationId: string, organizationSlug: string, email: {type: "string", format: "email"}, role: {oneOf: [string, array.call]}, teamId: string, teamIds: array.call }, "acceptOrganizationInvitation" => {invitationId: string, id: string}, "rejectOrganizationInvitation" => {invitationId: string}, "cancelOrganizationInvitation" => {invitationId: string}, "addOrganizationMember" => {organizationId: string, userId: string, role: {oneOf: [string, array.call]}}, "removeOrganizationMember" => {memberId: string, userId: string, organizationId: string}, "updateOrganizationMemberRole" => {memberId: string, userId: string, organizationId: string, role: {oneOf: [string, array.call]}}, "leaveOrganization" => {organizationId: string}, "hasOrganizationPermission" => {organizationId: string, permission: object, permissions: object}, "createOrganizationTeam" => {organizationId: string, name: string}, "updateOrganizationTeam" => {teamId: string, name: string}, "removeOrganizationTeam" => {teamId: string}, "setActiveOrganizationTeam" => {teamId: string}, "addTeamMember" => {teamId: string, userId: string}, "removeTeamMember" => {teamId: string, userId: string}, "createOrganizationRole" => {organizationId: string, role: string, roleName: string, permission: object, permissions: object}, "updateOrganizationRole" => {organizationId: string, roleId: string, role: string, roleName: string, permission: object, permissions: object, data: object}, "deleteOrganizationRole" => {organizationId: string, roleId: string, role: string, roleName: string} }[operation_id] end |
.organization_role_action_schema ⇒ Object
1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 |
# File 'lib/better_auth/plugins/organization.rb', line 1091 def organization_role_action_schema OpenAPI.object_schema( { success: {type: "boolean"}, roleData: organization_role_schema, statements: {type: "object"} }, required: ["success", "roleData"] ) end |
.organization_role_by_id(ctx, id) ⇒ Object
1231 1232 1233 1234 1235 |
# File 'lib/better_auth/plugins/organization.rb', line 1231 def organization_role_by_id(ctx, id) return nil if id.to_s.empty? ctx.context.adapter.find_one(model: "organizationRole", where: [{field: "id", value: id}]) end |
.organization_role_by_name(ctx, organization_id, role) ⇒ Object
1237 1238 1239 1240 1241 |
# File 'lib/better_auth/plugins/organization.rb', line 1237 def organization_role_by_name(ctx, organization_id, role) return nil if role.to_s.empty? ctx.context.adapter.find_one(model: "organizationRole", where: [{field: "organizationId", value: organization_id}, {field: "role", value: role}]) end |
.organization_role_schema ⇒ Object
1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 |
# File 'lib/better_auth/plugins/organization.rb', line 1079 def organization_role_schema OpenAPI.object_schema( { id: {type: "string"}, organizationId: {type: "string"}, role: {type: "string"}, permission: {type: "object"} }, required: ["role", "permission"] ) end |
.organization_role_wire(role) ⇒ Object
1346 1347 1348 |
# File 'lib/better_auth/plugins/organization.rb', line 1346 def organization_role_wire(role) role.merge("permission" => (role["permission"])) end |
.organization_roles(config) ⇒ Object
1135 1136 1137 |
# File 'lib/better_auth/plugins/organization.rb', line 1135 def organization_roles(config) (config[:roles] || organization_default_roles(config)).transform_keys(&:to_s) end |
.organization_set_active_endpoint ⇒ Object
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 |
# File 'lib/better_auth/plugins/organization.rb', line 283 def organization_set_active_endpoint Endpoint.new(path: "/organization/set-active", method: "POST", metadata: organization_openapi("setActiveOrganization", "Set the active organization", response: organization_nullable_schema("Organization"))) do |ctx| session = Routes.current_session(ctx, sensitive: true) body = normalize_hash(ctx.body) if body.key?(:organization_id) && body[:organization_id].nil? updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeOrganizationId: nil, activeTeamId: nil}) Cookies.(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => nil, "activeTeamId" => nil), user: session[:user]}) next ctx.json(nil) end organization = organization_by_id(ctx, body[:organization_id]) || organization_by_slug(ctx, body[:organization_slug]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization require_member!(ctx, session[:user]["id"], organization["id"]) updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeOrganizationId: organization["id"], activeTeamId: nil}) Cookies.(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => organization["id"], "activeTeamId" => nil), user: session[:user]}) ctx.json(organization_wire(ctx, organization)) end end |
.organization_set_active_team_endpoint(_config) ⇒ Object
775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 |
# File 'lib/better_auth/plugins/organization.rb', line 775 def organization_set_active_team_endpoint(_config) Endpoint.new(path: "/organization/set-active-team", method: "POST", metadata: organization_openapi("setActiveOrganizationTeam", "Set the active organization team", response: organization_nullable_schema("Team"))) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) if body.key?(:team_id) && body[:team_id].nil? updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeTeamId: nil}) Cookies.(ctx, {session: updated_session || session[:session].merge("activeTeamId" => nil), user: session[:user]}) next ctx.json({status: true}) end team = team_by_id(ctx, body[:team_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team active_organization_id = session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless active_organization_id unless team["organizationId"] == active_organization_id raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) end require_team_member!(ctx, session[:user]["id"], team["id"]) updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeTeamId: team["id"]}) Cookies.(ctx, {session: updated_session || session[:session].merge("activeTeamId" => team["id"]), user: session[:user]}) ctx.json(team_wire(ctx, team)) end end |
.organization_team_ids(value) ⇒ Object
1477 1478 1479 |
# File 'lib/better_auth/plugins/organization.rb', line 1477 def organization_team_ids(value) Array(value).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?) end |
.organization_time(value) ⇒ Object
1527 1528 1529 |
# File 'lib/better_auth/plugins/organization.rb', line 1527 def organization_time(value) value.is_a?(Time) ? value : Time.parse(value.to_s) end |
.organization_transaction(ctx) ⇒ Object
1531 1532 1533 1534 1535 1536 1537 1538 |
# File 'lib/better_auth/plugins/organization.rb', line 1531 def organization_transaction(ctx) adapter = ctx.context.adapter if adapter.method(:transaction).owner == BetterAuth::Adapters::Base raise BetterAuth::Error, "Organization mutations require an adapter with real transaction support" end adapter.transaction { |transaction_adapter| yield(transaction_adapter || adapter) } end |
.organization_update_endpoint(config) ⇒ Object
229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'lib/better_auth/plugins/organization.rb', line 229 def organization_update_endpoint(config) Endpoint.new(path: "/organization/update", method: "POST", metadata: organization_openapi("updateOrganization", "Update an organization", response: organization_ref_schema("Organization"))) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) id = body[:organization_id] || body[:organizationId] data = normalize_hash(body[:data] || body) organization = organization_by_id(ctx, id) || organization_by_slug(ctx, body[:organization_slug]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization member = (ctx, config, session, organization["id"], {organization: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION")) if data[:slug] && data[:slug].to_s.empty? raise APIError.new("BAD_REQUEST", message: "slug is required") end if data[:name] && data[:name].to_s.empty? raise APIError.new("BAD_REQUEST", message: "name is required") end existing = data[:slug] ? organization_by_slug(ctx, data[:slug]) : nil raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_SLUG_ALREADY_TAKEN")) if existing && existing["id"] != organization["id"] update = additional_input(data, :organization_id, :organizationId, :organization_slug, :data) update[:metadata] = (update[:metadata]) if update.key?(:metadata) merge_hook_data!(update, run_org_hook(config, :before_update_organization, {organization: update, user: session[:user], member: member_wire(ctx, member)}, ctx)) updated = ctx.context.adapter.update(model: "organization", where: [{field: "id", value: organization["id"]}], update: update) run_org_hook(config, :after_update_organization, {organization: organization_wire(ctx, updated), user: session[:user], member: member_wire(ctx, member)}, ctx) ctx.json(organization_wire(ctx, updated)) end end |
.organization_update_member_role_endpoint(config) ⇒ Object
571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 |
# File 'lib/better_auth/plugins/organization.rb', line 571 def organization_update_member_role_endpoint(config) Endpoint.new(path: "/organization/update-member-role", method: "POST", metadata: organization_openapi("updateOrganizationMemberRole", "Update an organization member role", response: organization_ref_schema("Member"))) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id role_names = normalized_role_names(body[:role]) raise APIError.new("BAD_REQUEST") if role_names.empty? validate_organization_roles!(ctx, config, organization_id, role_names) updating_member = require_member(ctx, session[:user]["id"], organization_id) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless updating_member member = if body[:member_id] (body[:member_id].to_s == updating_member["id"]) ? updating_member : member_by_id(ctx, body[:member_id]) else require_member(ctx, body[:user_id], organization_id) end raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless member unless member["organizationId"] == organization_id raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER")) end creator_role = config[:creator_role] updating_member_roles = normalized_role_names(updating_member["role"]) member_roles = normalized_role_names(member["role"]) updater_is_creator = updating_member_roles.include?(creator_role) updating_creator = member_roles.include?(creator_role) setting_creator_role = role_names.include?(creator_role) if (updating_creator && !updater_is_creator) || (setting_creator_role && !updater_is_creator) raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER")) end if updater_is_creator && updating_member["id"] == member["id"] && !setting_creator_role creator_count = count_members_with_role(ctx, organization_id, creator_role) if creator_count <= 1 raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER")) end end unless updater_is_creator (ctx, config, session, organization_id, {member: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER")) end organization = organization_by_id(ctx, organization_id) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization user = ctx.context.internal_adapter.find_user_by_id(member["userId"]) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user previous_role = member["role"] update = {role: parse_roles(role_names)} merge_hook_data!(update, run_org_hook(config, :before_update_member_role, {member: member_wire(ctx, member), new_role: update[:role], user: user, organization: organization_wire(ctx, organization)}, ctx)) updated = ctx.context.adapter.update(model: "member", where: [{field: "id", value: member["id"]}], update: update) run_org_hook(config, :after_update_member_role, {member: member_wire(ctx, updated), previous_role: previous_role, user: user, organization: organization_wire(ctx, organization)}, ctx) ctx.json(member_wire(ctx, updated)) end end |
.organization_update_role_endpoint(config) ⇒ Object
903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 |
# File 'lib/better_auth/plugins/organization.rb', line 903 def organization_update_role_endpoint(config) Endpoint.new(path: "/organization/update-role", method: "POST", metadata: organization_openapi("updateOrganizationRole", "Update an organization role", response: organization_role_action_schema)) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] (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]) = body[:permission] || body[:permissions] || body.dig(:data, :permission) || body.dig(:data, :permissions) if = () (config, ) unless (ctx, config, require_member!(ctx, session[:user]["id"], organization_id)["role"], , organization_id) raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE")) end update[:permission] = JSON.generate() end update.merge!(additional_input(body[:data], :role, :role_name, :permission, :permissions)) if body[:data].is_a?(Hash) updated = ctx.context.adapter.update(model: "organizationRole", where: [{field: "id", value: role["id"]}], update: update) ctx.json({success: true, roleData: organization_role_wire(updated)}) end end |
.organization_update_team_endpoint(config) ⇒ Object
729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 |
# File 'lib/better_auth/plugins/organization.rb', line 729 def organization_update_team_endpoint(config) Endpoint.new(path: "/organization/update-team", method: "POST", metadata: organization_openapi("updateOrganizationTeam", "Update an organization team", response: organization_ref_schema("Team"))) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) team = team_by_id(ctx, body[:team_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team (ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM")) organization = organization_by_id(ctx, team["organizationId"]) update = additional_input(body[:data].is_a?(Hash) ? body[:data] : body, :team_id, :organization_id) merge_hook_data!(update, run_org_hook(config, :before_update_team, {team: team_wire(ctx, team), updates: update, user: session[:user], organization: organization_wire(ctx, organization)}, ctx)) updated = ctx.context.adapter.update(model: "team", where: [{field: "id", value: team["id"]}], update: update) run_org_hook(config, :after_update_team, {team: team_wire(ctx, updated), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) ctx.json(team_wire(ctx, updated)) end end |
.organization_wire(ctx, organization) ⇒ Object
1328 1329 1330 1331 1332 |
# File 'lib/better_auth/plugins/organization.rb', line 1328 def organization_wire(ctx, organization) data = Schema.parse_output(ctx.context., "organization", organization) data["metadata"] = (data["metadata"]) if data&.key?("metadata") data end |
.parse_accept_language(header) ⇒ Object
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/better_auth/plugins/i18n.rb', line 90 def parse_accept_language(header) return [] if header.nil? || header.to_s.empty? header.to_s .split(",") .map do |part| locale_str, quality = part.strip.split(";", 2) q_string = quality ? quality.strip.sub(/\Aq=/i, "") : "1" q = Float(q_string) locale = locale_str.to_s.strip.split("-").first.to_s {locale: locale, q: q} end .select { |item| !item[:locale].empty? } .sort_by { |item| -item[:q] } .map { |item| item[:locale] } end |
.parse_duration(value) ⇒ Object
492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 |
# File 'lib/better_auth/plugins/jwt.rb', line 492 def parse_duration(value) match = value.strip.match(/\A(-?\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|y|yr|yrs|year|years)(?:\s+from now|\s+ago)?\z/i) raise TypeError, "Invalid time string" unless match amount = match[1].to_i amount = -amount if value.include?("ago") unit = match[2].downcase multiplier = case unit when "s", "sec", "secs", "second", "seconds" then 1 when "m", "min", "mins", "minute", "minutes" then 60 when "h", "hr", "hrs", "hour", "hours" then 3600 when "d", "day", "days" then 86_400 when "w", "week", "weeks" then 604_800 else 31_557_600 end amount * multiplier end |
.parse_metadata(value) ⇒ Object
1444 1445 1446 1447 1448 1449 1450 |
# File 'lib/better_auth/plugins/organization.rb', line 1444 def (value) return value if value.nil? || value.is_a?(Hash) JSON.parse(value) rescue JSON::ParserError value end |
.parse_permission(value) ⇒ Object
1456 1457 1458 1459 1460 1461 1462 1463 |
# File 'lib/better_auth/plugins/organization.rb', line 1456 def (value) return value if value.is_a?(Hash) return {} if value.nil? || value.to_s.empty? JSON.parse(value) rescue JSON::ParserError {} end |
.parse_roles(roles) ⇒ Object
1102 1103 1104 |
# File 'lib/better_auth/plugins/organization.rb', line 1102 def parse_roles(roles) Array(roles).join(",") end |
.parsed_session(ctx, entry) ⇒ Object
215 216 217 218 219 220 |
# File 'lib/better_auth/plugins/multi_session.rb', line 215 def parsed_session(ctx, entry) { session: Schema.parse_output(ctx.context., "session", entry[:session]), user: Schema.parse_output(ctx.context., "user", entry[:user]) } end |
.passkey(*args, &block) ⇒ Object
9 10 11 |
# File 'lib/better_auth/plugins/passkey.rb', line 9 def passkey(*args, &block) call_external_plugin!(:passkey, *args, implementation_constant: :PASSKEY_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-passkey", entry: "lib/better_auth/passkey.rb", &block) end |
.patreon(options = {}) ⇒ Object
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 166 def patreon( = {}) data = normalize_hash() 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( = {}) config = { expires_in: 300, otp_length: 6, allowed_attempts: 3, phone_number: "phoneNumber", phone_number_verified: "phoneNumberVerified" }.merge(normalize_hash()) 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: sign_in_phone_number_endpoint(config), send_phone_number_otp: send_phone_number_otp_endpoint(config), verify_phone_number: verify_phone_number_endpoint(config), request_password_reset_phone_number: request_password_reset_phone_number_endpoint(config), reset_password_phone_number: reset_password_phone_number_endpoint(config) }, schema: phone_number_schema(config[:schema]), rate_limit: [ { path_matcher: ->(path) { path.start_with?("/phone-number") }, window: 60, max: 10 } ], error_codes: PHONE_NUMBER_ERROR_CODES, options: config ) end |
.phone_number_create_user(ctx, config, body, phone_number) ⇒ Object
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 |
# File 'lib/better_auth/plugins/phone_number.rb', line 345 def phone_number_create_user(ctx, config, body, phone_number) sign_up = config[:sign_up_on_verification] email_callback = sign_up[:get_temp_email] name_callback = sign_up[:get_temp_name] email = email_callback.respond_to?(:call) ? email_callback.call(phone_number) : "temp-#{phone_number}" name = name_callback.respond_to?(:call) ? name_callback.call(phone_number) : phone_number reserved = %i[phone_number code disable_session update_phone_number] additional = body.reject { |key, _value| reserved.include?(key.to_sym) } ctx.context.internal_adapter.create_user( additional.merge( "email" => email, "name" => name, "phoneNumber" => phone_number, "phoneNumberVerified" => true, "emailVerified" => false ), context: ctx ) end |
.phone_number_deliver_otp(config, data, ctx) ⇒ Object
418 419 420 421 |
# File 'lib/better_auth/plugins/phone_number.rb', line 418 def phone_number_deliver_otp(config, data, ctx) sender = config[:send_otp] sender.call(data, ctx) if sender.respond_to?(:call) end |
.phone_number_generate_code(config) ⇒ Object
438 439 440 |
# File 'lib/better_auth/plugins/phone_number.rb', line 438 def phone_number_generate_code(config) Array.new(config[:otp_length].to_i) { SecureRandom.random_number(10).to_s }.join end |
.phone_number_schema(custom_schema) ⇒ Object
333 334 335 336 337 338 339 340 341 342 343 |
# File 'lib/better_auth/plugins/phone_number.rb', line 333 def phone_number_schema(custom_schema) base = { user: { fields: { phoneNumber: {type: "string", required: false, unique: true, sortable: true, returned: true}, phoneNumberVerified: {type: "boolean", required: false, returned: true, input: false} } } } deep_merge_hashes(base, normalize_hash(custom_schema || {})) end |
.phone_number_split_code(value) ⇒ Object
442 443 444 445 446 447 448 |
# File 'lib/better_auth/plugins/phone_number.rb', line 442 def phone_number_split_code(value) string = value.to_s index = string.rindex(":") return [string, ""] unless index [string[0...index], string[(index + 1)..]] end |
.phone_number_store_code(ctx, config, identifier, code) ⇒ Object
408 409 410 411 412 413 414 415 416 |
# File 'lib/better_auth/plugins/phone_number.rb', line 408 def phone_number_store_code(ctx, config, identifier, code) ctx.context.internal_adapter.delete_verification_by_identifier(identifier) expires_at = Time.now + config[:expires_in].to_i ctx.context.internal_adapter.create_verification_value( identifier: identifier, value: "#{code}:0", expiresAt: expires_at ) end |
.phone_number_verify_code!(ctx, config, identifier, code, consume: true) ⇒ Object
366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 |
# File 'lib/better_auth/plugins/phone_number.rb', line 366 def phone_number_verify_code!(ctx, config, identifier, code, consume: true) verifier = config[:verify_otp] if verifier.respond_to?(:call) # Custom verify_otp owns single-use and expiry state completely. valid = verifier.call({phone_number: identifier.delete_suffix("-request-password-reset"), code: code}, ctx) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"]) unless valid return true end existing = ctx.context.internal_adapter.find_verification_value(identifier) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_NOT_FOUND"]) unless existing if Routes.expired_time?(existing["expiresAt"]) ctx.context.internal_adapter.delete_verification_by_identifier(identifier) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_EXPIRED"]) end _, existing_attempts = phone_number_split_code(existing["value"]) if existing_attempts.to_i >= config[:allowed_attempts].to_i ctx.context.internal_adapter.delete_verification_by_identifier(identifier) raise APIError.new("FORBIDDEN", message: PHONE_NUMBER_ERROR_CODES["TOO_MANY_ATTEMPTS"]) end verification = ctx.context.internal_adapter.consume_verification_value(identifier) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"]) unless verification stored_code, attempts = phone_number_split_code(verification["value"]) attempts_count = attempts.to_i if attempts_count >= config[:allowed_attempts].to_i raise APIError.new("FORBIDDEN", message: PHONE_NUMBER_ERROR_CODES["TOO_MANY_ATTEMPTS"]) end unless stored_code == code ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: "#{stored_code}:#{attempts_count + 1}", expiresAt: verification["expiresAt"]) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"]) end ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: verification["value"], expiresAt: verification["expiresAt"]) unless consume verification end |
.plugin_loaded?(name) ⇒ Boolean
172 173 174 |
# File 'lib/better_auth/plugin_loader.rb', line 172 def plugin_loaded?(name) @loaded_plugins.key?(name.to_sym) end |
.plugin_loader_for_method(name) ⇒ Object
187 188 189 190 191 192 193 194 195 196 |
# File 'lib/better_auth/plugin_loader.rb', line 187 def plugin_loader_for_method(name) symbol = name.to_sym return symbol if LAZY_PLUGIN_METHODS.include?(symbol) PLUGIN_FILES.each_key do |plugin| return plugin if name.to_s.start_with?("#{plugin}_") end nil end |
.plugin_options(plugin) ⇒ Object
1427 1428 1429 |
# File 'lib/better_auth/plugins/organization.rb', line 1427 def (plugin) plugin.respond_to?(:dig) ? (plugin.dig(:options) || plugin.dig("options") || {}) : {} end |
.positive_integer?(value) ⇒ Boolean
427 428 429 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 427 def positive_integer?(value) value.is_a?(Integer) && value.positive? end |
.present?(value) ⇒ Boolean
372 373 374 |
# File 'lib/better_auth/plugins/username.rb', line 372 def present?(value) !value.nil? && value != false && !value.to_s.empty? end |
.present_string?(value) ⇒ Boolean
230 231 232 |
# File 'lib/better_auth/plugins/anonymous.rb', line 230 def present_string?(value) value.is_a?(String) && !value.empty? end |
.private_key_pem(pair) ⇒ Object
404 405 406 |
# File 'lib/better_auth/plugins/jwt.rb', line 404 def private_key_pem(pair) pair.respond_to?(:private_to_pem) ? pair.private_to_pem : pair.to_pem end |
.process_device_decision(ctx, session, status) ⇒ Object
297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 297 def process_device_decision(ctx, session, status) body = OAuthProtocol.stringify_keys(ctx.body) code = normalize_user_code(body["userCode"] || body["user_code"]) record = find_device_user_code(ctx, code) action = (status == "approved") ? "approve" : "deny" raise ("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_USER_CODE"]) unless record record = OAuthProtocol.stringify_keys(record) raise ("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_USER_CODE"]) if (record["expiresAt"]) <= Time.now raise ("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["DEVICE_CODE_ALREADY_PROCESSED"]) unless record["status"] == "pending" unless record["userId"] raise ("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["DEVICE_CODE_NOT_CLAIMED"]) end if record["userId"] != session[:user]["id"] raise ("FORBIDDEN", "access_denied", "You are not authorized to #{action} this device authorization") end updated = ctx.context.adapter.increment_one( model: "deviceCode", where: [ {field: "id", value: record["id"]}, {field: "status", value: "pending"}, {field: "userId", value: session[:user]["id"]} ], increment: {}, set: {"status" => status, "userId" => session[:user]["id"]} ) raise ("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["DEVICE_CODE_ALREADY_PROCESSED"]) unless updated ctx.json({success: true}) end |
.public_jwk(key, _config) ⇒ Object
348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 |
# File 'lib/better_auth/plugins/jwt.rb', line 348 def public_jwk(key, _config) data = { kid: key["id"], kty: key["kty"] || key_type_for_alg(key["alg"] || "RS256"), alg: key["alg"] || "EdDSA", use: "sig", pem: key["pem"] || key["publicKey"] } data[:n] = key["n"] if key["n"] data[:e] = key["e"] if key["e"] data[:crv] = key["crv"] if key["crv"] data[:x] = key["x"] if key["x"] data[:y] = key["y"] if key["y"] data end |
.public_jwks(ctx, config) ⇒ Object
258 259 260 261 262 263 264 265 |
# File 'lib/better_auth/plugins/jwt.rb', line 258 def public_jwks(ctx, config) now = Time.now grace_period = config.dig(:jwks, :grace_period) || 60 * 60 * 24 * 30 all_jwks(ctx, config).select do |key| expires_at = normalize_time(key["expiresAt"]) !expires_at || expires_at + grace_period.to_i > now end end |
.public_key_for(pair) ⇒ Object
400 401 402 |
# File 'lib/better_auth/plugins/jwt.rb', line 400 def public_key_for(pair) OpenSSL::PKey.read(pair.public_to_pem) end |
.public_key_jwk_fields(public_key, alg) ⇒ Object
412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 |
# File 'lib/better_auth/plugins/jwt.rb', line 412 def public_key_jwk_fields(public_key, alg) if public_key.is_a?(OpenSSL::PKey::RSA) { "kty" => "RSA", "n" => base64url_bn(public_key.n), "e" => base64url_bn(public_key.e) } elsif alg == "EdDSA" { "kty" => "OKP", "crv" => "Ed25519", "x" => Crypto.base64url_encode(public_key.raw_public_key) } else point = public_key.public_key.to_octet_string(:uncompressed).bytes length = (point.length - 1) / 2 { "kty" => "EC", "crv" => ec_curve_for_alg(alg), "x" => Crypto.base64url_encode(point[1, length].pack("C*")), "y" => Crypto.base64url_encode(point[(1 + length), length].pack("C*")) } end end |
.public_key_pem(pair) ⇒ Object
408 409 410 |
# File 'lib/better_auth/plugins/jwt.rb', line 408 def public_key_pem(pair) pair.respond_to?(:public_to_pem) ? pair.public_to_pem : pair.to_pem end |
.remote_jwks(ctx, config) ⇒ Object
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/better_auth/plugins/jwt.rb', line 291 def remote_jwks(ctx, config) url = config.dig(:jwks, :remote_url) fetcher = config.dig(:jwks, :fetch) || config.dig(:jwks, :fetcher) payload = if fetcher.respond_to?(:call) fetcher.call(url) else cached = config[:remote_jwks_cache] ||= JWT::RemoteJwksCache.new({}) entry = cached.entries[url.to_s] if entry && entry[:expires_at] > Time.now entry[:payload] else fetched = HTTPClient.get_json(url) cached.entries[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched fetched end end keys = fetch_value(payload, "keys") Array(keys).map { |entry| normalize_remote_jwk(entry) } rescue JSON::ParserError, URI::InvalidURIError, SocketError, SystemCallError [] end |
.request_email_change_email_otp_endpoint(config) ⇒ Object
343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 |
# File 'lib/better_auth/plugins/email_otp.rb', line 343 def request_email_change_email_otp_endpoint(config) Endpoint.new( path: "/email-otp/request-email-change", method: "POST", metadata: { openapi: { operationId: "requestEmailChangeOTP", description: "Request an OTP to change the current user's email", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { newEmail: {type: "string"}, otp: {type: ["string", "null"]} }, required: ["newEmail"] ) ), responses: { "200" => OpenAPI.json_response("Change email OTP sent", OpenAPI.success_response_schema) } } } ) do |ctx| email_otp_change_email_enabled!(config) session = Routes.current_session(ctx) body = normalize_hash(ctx.body) current_email = session[:user]["email"].to_s.downcase new_email = body[:new_email].to_s.downcase validate_email_otp_email!(new_email) raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == current_email if config.dig(:change_email, :verify_current_email) raise APIError.new("BAD_REQUEST", message: "OTP is required to verify current email") if body[:otp].to_s.empty? email_otp_verify!(ctx, config, email: current_email, type: "email-verification", otp: body[:otp]) end otp = email_otp_resolve(ctx, config, email: new_email, type: "change-email", identifier_email: "#{current_email}-#{new_email}") if ctx.context.internal_adapter.find_user_by_email(new_email) ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier("#{current_email}-#{new_email}", "change-email")) next ctx.json({success: true}) end email_otp_deliver(config, {email: new_email, otp: otp, type: "change-email"}, ctx) ctx.json({success: true}) end end |
.request_password_reset_email_otp_endpoint(config) ⇒ Object
435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 |
# File 'lib/better_auth/plugins/email_otp.rb', line 435 def request_password_reset_email_otp_endpoint(config) Endpoint.new( path: "/email-otp/request-password-reset", method: "POST", metadata: { openapi: { operationId: "requestPasswordResetEmailOTP", description: "Request a password reset OTP by email", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { email: {type: "string"} }, required: ["email"] ) ), responses: { "200" => OpenAPI.json_response("Password reset OTP requested", OpenAPI.status_response_schema) } } } ) do |ctx| email_otp_password_reset_request(ctx, config) end end |
.request_password_reset_phone_number_endpoint(config) ⇒ Object
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/better_auth/plugins/phone_number.rb', line 257 def request_password_reset_phone_number_endpoint(config) Endpoint.new( path: "/phone-number/request-password-reset", method: "POST", metadata: { openapi: { operationId: "requestPasswordResetPhoneNumber", description: "Request a phone number password reset OTP", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { phoneNumber: {type: "string"} }, required: ["phoneNumber"] ) ), responses: { "200" => OpenAPI.json_response("Password reset OTP requested", OpenAPI.status_response_schema) } } } ) do |ctx| body = normalize_hash(ctx.body) phone_number = body[:phone_number].to_s user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}]) code = phone_number_generate_code(config) phone_number_store_code(ctx, config, "#{phone_number}-request-password-reset", code) unless config[:verify_otp].respond_to?(:call) if user && config[:send_password_reset_otp].respond_to?(:call) config[:send_password_reset_otp].call({phone_number: phone_number, code: code}, ctx) end ctx.json({status: true}) end end |
.require_invitation_recipient!(invitation, session) ⇒ Object
1507 1508 1509 1510 1511 |
# File 'lib/better_auth/plugins/organization.rb', line 1507 def require_invitation_recipient!(invitation, session) return if invitation["email"].to_s.downcase == session[:user]["email"].to_s.downcase raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION")) end |
.require_member(ctx, user_id, organization_id) ⇒ Object
1183 1184 1185 1186 1187 |
# File 'lib/better_auth/plugins/organization.rb', line 1183 def require_member(ctx, user_id, organization_id) return nil if user_id.to_s.empty? || organization_id.to_s.empty? ctx.context.adapter.find_one(model: "member", where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]) end |
.require_member!(ctx, user_id, organization_id) ⇒ Object
1176 1177 1178 1179 1180 1181 |
# File 'lib/better_auth/plugins/organization.rb', line 1176 def require_member!(ctx, user_id, organization_id) member = require_member(ctx, user_id, organization_id) raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION")) unless member member end |
.require_org_permission!(ctx, config, session, organization_id, permissions, message) ⇒ Object
1163 1164 1165 1166 1167 1168 |
# File 'lib/better_auth/plugins/organization.rb', line 1163 def (ctx, config, session, organization_id, , ) member = require_member!(ctx, session[:user]["id"], organization_id) return member if (ctx, config, member["role"], , organization_id) raise APIError.new("FORBIDDEN", message: ) end |
.require_pending_invitation!(invitation, check_expiration: true) ⇒ Object
1497 1498 1499 1500 1501 1502 1503 1504 1505 |
# File 'lib/better_auth/plugins/organization.rb', line 1497 def require_pending_invitation!(invitation, check_expiration: true) expired = invitation && invitation["expiresAt"] && organization_time(invitation["expiresAt"]) < Time.now valid = invitation && invitation["status"] == "pending" valid &&= !expired if check_expiration unless valid raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) end invitation end |
.require_team_member!(ctx, user_id, team_id) ⇒ Object
1189 1190 1191 1192 1193 1194 |
# File 'lib/better_auth/plugins/organization.rb', line 1189 def require_team_member!(ctx, user_id, team_id) member = ctx.context.adapter.find_one(model: "teamMember", where: [{field: "userId", value: user_id}, {field: "teamId", value: team_id}]) raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_NOT_A_MEMBER_OF_THE_TEAM")) unless member member end |
.require_verified_email_for_invitation_id_action?(ctx, config) ⇒ Boolean
1520 1521 1522 1523 1524 1525 |
# File 'lib/better_auth/plugins/organization.rb', line 1520 def require_verified_email_for_invitation_id_action?(ctx, config) return org_truthy?(config[:require_email_verification_on_invitation]) if config.key?(:require_email_verification_on_invitation) generator = ctx.context..advanced.dig(:database, :generate_id) !generator.nil? && generator != "uuid" end |
.require_verified_invitation_recipient!(ctx, config, session, error_code) ⇒ Object
1513 1514 1515 1516 1517 1518 |
# File 'lib/better_auth/plugins/organization.rb', line 1513 def require_verified_invitation_recipient!(ctx, config, session, error_code) return unless require_verified_email_for_invitation_id_action?(ctx, config) return if session[:user]["emailVerified"] raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch(error_code)) end |
.reset_password_email_otp_endpoint(config) ⇒ Object
461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 |
# File 'lib/better_auth/plugins/email_otp.rb', line 461 def reset_password_email_otp_endpoint(config) Endpoint.new( path: "/email-otp/reset-password", method: "POST", metadata: { openapi: { operationId: "resetPasswordEmailOTP", description: "Reset a password with an email OTP", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { email: {type: "string"}, otp: {type: "string"}, password: {type: "string"} }, required: ["email", "otp", "password"] ) ), responses: { "200" => OpenAPI.json_response("Password reset", OpenAPI.status_response_schema) } } } ) do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase otp = body[:otp].to_s password = body[:password].to_s email_otp_verify!(ctx, config, email: email, type: "forget-password", otp: otp) found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless found Routes.validate_password_length!(password, ctx.context..email_and_password) hashed = Routes.hash_password(ctx, password) account = found[:accounts].find { |entry| entry["providerId"] == "credential" } if account ctx.context.internal_adapter.update_password(found[:user]["id"], hashed) else ctx.context.internal_adapter.create_account(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..email_and_password[:on_password_reset] callback.call({user: found[:user]}, ctx.request) if callback.respond_to?(:call) ctx.context.internal_adapter.delete_user_sessions(found[:user]["id"]) if ctx.context..email_and_password[:revoke_sessions_on_password_reset] ctx.json({success: true}) end end |
.reset_password_phone_number_endpoint(config) ⇒ Object
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 |
# File 'lib/better_auth/plugins/phone_number.rb', line 293 def reset_password_phone_number_endpoint(config) Endpoint.new( path: "/phone-number/reset-password", method: "POST", metadata: { openapi: { operationId: "resetPasswordPhoneNumber", description: "Reset a password with a phone number OTP", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { phoneNumber: {type: "string"}, otp: {type: "string"}, newPassword: {type: "string"} }, required: ["phoneNumber", "otp", "newPassword"] ) ), responses: { "200" => OpenAPI.json_response("Password reset", OpenAPI.status_response_schema) } } } ) do |ctx| body = normalize_hash(ctx.body) phone_number = body[:phone_number].to_s otp = body[:otp].to_s new_password = body[:new_password] user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}]) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["UNEXPECTED_ERROR"]) unless user Routes.validate_password_length!(new_password, ctx.context..email_and_password) phone_number_verify_code!(ctx, config, "#{phone_number}-request-password-reset", otp) ctx.context.internal_adapter.update_password(user["id"], Routes.hash_password(ctx, new_password)) ctx.context.internal_adapter.delete_user_sessions(user["id"]) if ctx.context..email_and_password[:revoke_sessions_on_password_reset] ctx.json({status: true}) end end |
.resolve_i18n_default_locale(explicit, available_locales) ⇒ Object
82 83 84 85 86 87 88 |
# File 'lib/better_auth/plugins/i18n.rb', line 82 def resolve_i18n_default_locale(explicit, available_locales) explicit_locale = explicit.to_s return explicit_locale if explicit && available_locales.include?(explicit_locale) return "en" if available_locales.include?("en") nil end |
.resolve_i18n_error_code(error, ctx) ⇒ Object
184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/better_auth/plugins/i18n.rb', line 184 def resolve_i18n_error_code(error, ctx) body = error.body if body.is_a?(Hash) code = body[:code] || body["code"] return code.to_s.tr("-", "_").upcase if code end unless error.code.to_s.upcase == error.status.to_s.upcase return error.code.to_s.tr("-", "_").upcase end reverse_lookup_i18n_error_code(error., ctx) end |
.resolve_login_method(ctx, config) ⇒ Object
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/better_auth/plugins/last_login_method.rb', line 63 def resolve_login_method(ctx, config) custom = config[:custom_resolve_method] resolve_context = ctx unless ctx.path resolve_context = ctx.dup resolve_context.path = "" end resolved = custom.call(resolve_context) if custom.respond_to?(:call) return resolved if resolved path = resolve_context.path.to_s case path when "/sign-in/email", "/sign-up/email" "email" when "/callback/:id" fetch_value(ctx.params, "id") || fetch_value(ctx.params, "providerId") when "/oauth2/callback/:providerId" fetch_value(ctx.params, "providerId") else return Regexp.last_match(1) if path =~ %r{\A/callback/([^/]+)\z} return Regexp.last_match(1) if path =~ %r{\A/oauth2/callback/([^/]+)\z} return "siwe" if path.include?("siwe") return "passkey" if path.include?("/passkey/verify-authentication") return "magic-link" if path.start_with?("/magic-link/verify") nil end end |
.respond_to_missing?(name, include_private = false) ⇒ Boolean
90 91 92 93 94 |
# File 'lib/better_auth/plugins.rb', line 90 def respond_to_missing?(name, include_private = false) return false if REMOVED_PLUGIN_FACTORIES.key?(name.to_sym) PLUGIN_FACTORY_LOADERS.key?(name.to_sym) || !plugin_loader_for_method(name).nil? || super end |
.reverse_lookup_i18n_error_code(message, ctx) ⇒ Object
198 199 200 |
# File 'lib/better_auth/plugins/i18n.rb', line 198 def reverse_lookup_i18n_error_code(, ctx) merged_i18n_error_catalog(ctx).find { |_code, text| text == }&.first end |
.revoke_device_session_endpoint ⇒ Object
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/better_auth/plugins/multi_session.rb', line 106 def revoke_device_session_endpoint Endpoint.new( path: "/multi-session/revoke", method: "POST", metadata: { openapi: { operationId: "revokeDeviceSession", description: "Revoke a device session", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { sessionToken: {type: "string", description: "The session token"} }, required: ["sessionToken"] ) ), responses: { "200" => OpenAPI.json_response("Device session revoked", OpenAPI.status_response_schema) } } } ) do |ctx| current = Routes.current_session(ctx) token = fetch_value(ctx.body, "sessionToken").to_s = (ctx, token) unless !token.empty? && ctx.(, ctx.context.secret) raise APIError.new("UNAUTHORIZED", message: MULTI_SESSION_ERROR_CODES["INVALID_SESSION_TOKEN"]) end ctx.context.internal_adapter.delete_session(token) (ctx, ) 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.(ctx, next_session) else Cookies.(ctx) end end ctx.json({status: true}) end end |
.run_org_hook(config, key, data, ctx) ⇒ Object
1403 1404 1405 1406 1407 |
# File 'lib/better_auth/plugins/organization.rb', line 1403 def run_org_hook(config, key, data, ctx) hooks = org_hook_candidates(config, key) hooks.concat(ctx.context..plugins.flat_map { |plugin| org_hook_candidates((plugin), key) }) if ctx&.context&. hooks.compact.uniq.filter_map { |hook| hook.call(data, ctx) if hook.respond_to?(:call) }.find { |response| response.is_a?(Hash) && normalize_hash(response).key?(:data) } end |
.safe_decode_bearer_token(token) ⇒ Object
83 84 85 86 87 |
# File 'lib/better_auth/plugins/bearer.rb', line 83 def safe_decode_bearer_token(token) token.to_s.gsub(/%[0-9a-fA-F]{2}/) { |encoded| encoded[1, 2].to_i(16).chr } rescue token.to_s end |
.safe_encode_bearer_token(token) ⇒ Object
77 78 79 80 81 |
# File 'lib/better_auth/plugins/bearer.rb', line 77 def safe_encode_bearer_token(token) URI.encode_www_form_component(token.to_s).gsub("+", "%20") rescue token.to_s end |
.schema_for_table(table) ⇒ Object
388 389 390 391 392 393 394 395 396 397 398 |
# File 'lib/better_auth/plugins/open_api.rb', line 388 def schema_for_table(table) required = ["id"] properties = table[:fields].each_with_object({}) do |(field, attributes), result| next if field.to_s == "id" result[field.to_sym] = field_schema(attributes) required << field if attributes[:required] && attributes[:returned] != false end properties = {id: {type: "string", readOnly: true}}.merge(properties) {type: "object", properties: properties, required: required} end |
.scim(*args, &block) ⇒ Object
9 10 11 |
# File 'lib/better_auth/plugins/scim.rb', line 9 def scim(*args, &block) call_external_plugin!(:scim, *args, implementation_constant: :SCIM_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-scim", entry: "lib/better_auth/scim.rb", &block) end |
.send_phone_number_otp_endpoint(config) ⇒ Object
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'lib/better_auth/plugins/phone_number.rb', line 134 def send_phone_number_otp_endpoint(config) Endpoint.new( path: "/phone-number/send-otp", method: "POST", metadata: { openapi: { operationId: "sendPhoneNumberOTP", description: "Send a phone number OTP", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { phoneNumber: {type: "string"} }, required: ["phoneNumber"] ) ), responses: { "200" => OpenAPI.json_response( "OTP sent", OpenAPI.object_schema( { message: {type: "string"} }, required: ["message"] ) ) } } } ) do |ctx| sender = config[:send_otp] unless sender.respond_to?(:call) raise APIError.new("NOT_IMPLEMENTED", message: PHONE_NUMBER_ERROR_CODES["SEND_OTP_NOT_IMPLEMENTED"]) end body = normalize_hash(ctx.body) phone_number = body[:phone_number].to_s validate_phone_number!(config, phone_number) code = phone_number_generate_code(config) phone_number_store_code(ctx, config, phone_number, code) unless config[:verify_otp].respond_to?(:call) phone_number_deliver_otp(config, {phone_number: phone_number, code: code}, ctx) ctx.json({message: "code sent"}) end end |
.send_verification_otp_endpoint(config) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/better_auth/plugins/email_otp.rb', line 78 def send_verification_otp_endpoint(config) Endpoint.new( path: "/email-otp/send-verification-otp", method: "POST", metadata: { openapi: { operationId: "sendVerificationOTP", description: "Send an email verification OTP", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { email: {type: "string"}, type: {type: "string", enum: ["email-verification", "sign-in", "forget-password"]} }, required: ["email", "type"] ) ), responses: { "200" => OpenAPI.json_response("OTP sent", OpenAPI.success_response_schema) } } } ) do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase type = body[:type].to_s validate_email_otp_type!(type) validate_email_otp_email!(email) if type == "change-email" raise APIError.new("BAD_REQUEST", message: "Invalid OTP type") end sender = config[:send_verification_otp] unless sender.respond_to?(:call) raise APIError.new("BAD_REQUEST", message: "send email verification is not implemented") end email_otp_send_verification(ctx, config, email: email, type: type) ctx.json({success: true}) end end |
.serialize_metadata(value) ⇒ Object
1452 1453 1454 |
# File 'lib/better_auth/plugins/organization.rb', line 1452 def (value) value.is_a?(Hash) ? JSON.generate(value) : value end |
.set_active_session_endpoint ⇒ Object
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/better_auth/plugins/multi_session.rb', line 67 def set_active_session_endpoint Endpoint.new( path: "/multi-session/set-active", method: "POST", metadata: { openapi: { operationId: "setActiveSession", description: "Set the active session", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { sessionToken: {type: "string", description: "The session token"} }, required: ["sessionToken"] ) ), responses: { "200" => OpenAPI.json_response("Active session", OpenAPI.session_response_schema_pair) } } } ) do |ctx| token = fetch_value(ctx.body, "sessionToken").to_s = (ctx, token) unless !token.empty? && ctx.(, 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 (ctx, ) raise APIError.new("UNAUTHORIZED", message: MULTI_SESSION_ERROR_CODES["INVALID_SESSION_TOKEN"]) end Cookies.(ctx, session) ctx.json(parsed_session(ctx, session)) end end |
.set_cookie_value(set_cookie, name) ⇒ Object
197 198 199 200 201 202 203 204 205 |
# File 'lib/better_auth/plugins/anonymous.rb', line 197 def (, name) .to_s.lines.each do |line| = line.split(";", 2).first.to_s.strip , value = .split("=", 2) return value if == name && !value.nil? end nil end |
.set_jwt_header(ctx, config) ⇒ Object
180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/better_auth/plugins/jwt.rb', line 180 def set_jwt_header(ctx, config) return if config[:disable_setting_jwt_header] session = ctx.context.current_session || ctx.context.new_session return unless session && session[:session] token = jwt_token(ctx, session, config) exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?) exposed << "set-auth-jwt" ctx.set_header("set-auth-jwt", token) ctx.set_header("access-control-expose-headers", exposed.uniq.join(", ")) nil end |
.set_multi_session_cookie(ctx, config) ⇒ Object
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 |
# File 'lib/better_auth/plugins/multi_session.rb', line 153 def (ctx, config) new_session = ctx.context.new_session return unless new_session && new_session[:session] = ctx.response_headers["set-cookie"].to_s token = new_session[:session]["token"] = ctx.context.[:session_token] = (ctx, token) = ctx. return unless .include?(.name) return if .key?() deleted_count = 0 = (ctx) .each do |name| existing_token = ctx.(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) (ctx, name) deleted_count += 1 end current_count = .length - deleted_count + 1 return if current_count > config[:maximum_sessions].to_i ctx.(, token, ctx.context.secret, .attributes) nil end |
.sign_bearer_token(ctx, token, config) ⇒ Object
57 58 59 60 61 62 |
# File 'lib/better_auth/plugins/bearer.rb', line 57 def sign_bearer_token(ctx, token, config) return if config[:require_signature] signature = Crypto.hmac_signature(token, ctx.context.secret, encoding: :base64url) "#{token}.#{signature}" end |
.sign_in_anonymous_endpoint(config) ⇒ Object
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/better_auth/plugins/anonymous.rb', line 42 def sign_in_anonymous_endpoint(config) Endpoint.new( path: "/sign-in/anonymous", method: "POST", metadata: { openapi: { operationId: "signInAnonymous", description: "Sign in anonymously", requestBody: OpenAPI.empty_request_body, responses: { "200" => OpenAPI.json_response( "Anonymous session created", OpenAPI.object_schema( { token: {type: "string"}, user: {type: "object", "$ref": "#/components/schemas/User"} }, required: ["token", "user"] ) ) } } } ) do |ctx| existing_session = Session.find_current(ctx, disable_refresh: true) if existing_session&.dig(:user, "isAnonymous") raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY"]) end email = anonymous_email(config) name = anonymous_name(ctx, config) user = ctx.context.internal_adapter.create_user( email: email, emailVerified: false, isAnonymous: true, name: name, createdAt: Time.now, updatedAt: Time.now, context: ctx ) raise APIError.new("INTERNAL_SERVER_ERROR", message: ANONYMOUS_ERROR_CODES["FAILED_TO_CREATE_USER"]) unless user session = ctx.context.internal_adapter.create_session(user["id"]) raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["COULD_NOT_CREATE_SESSION"]) unless session Cookies.(ctx, {session: session, user: user}) ctx.json({token: session["token"], user: Schema.parse_output(ctx.context., "user", user)}) end end |
.sign_in_email_otp_endpoint(config) ⇒ Object
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 |
# File 'lib/better_auth/plugins/email_otp.rb', line 281 def sign_in_email_otp_endpoint(config) Endpoint.new( path: "/sign-in/email-otp", method: "POST", metadata: { openapi: { operationId: "signInEmailOTP", description: "Sign in with an email OTP", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { email: {type: "string"}, otp: {type: "string"} }, required: ["email", "otp"] ) ), responses: { "200" => OpenAPI.json_response( "Signed in", OpenAPI.object_schema( { token: {type: "string"}, user: {type: "object", "$ref": "#/components/schemas/User"} }, required: ["token", "user"] ) ) } } } ) do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase otp = body[:otp].to_s email_otp_verify!(ctx, config, email: email, type: "sign-in", otp: otp) found = ctx.context.internal_adapter.find_user_by_email(email) user = if found found[:user] else raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) if config[:disable_sign_up] ctx.context.internal_adapter.create_user(email_otp_sign_up_user_data(ctx, body, email), context: ctx) end unless user["emailVerified"] user = ctx.context.adapter.transaction do ctx.context.internal_adapter.revoke_unproven_account_access(user["id"]) updated = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true) raise Error, "Failed to verify email-OTP user" unless updated updated end end session = ctx.context.internal_adapter.create_session(user["id"]) Cookies.(ctx, {session: session, user: user}) ctx.json({token: session["token"], user: Schema.parse_output(ctx.context., "user", user)}) end end |
.sign_in_magic_link_endpoint(config) ⇒ Object
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/better_auth/plugins/magic_link.rb', line 33 def sign_in_magic_link_endpoint(config) Endpoint.new( path: "/sign-in/magic-link", method: "POST", metadata: { openapi: { operationId: "signInMagicLink", description: "Send a magic sign-in link", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { email: {type: "string", description: "The email address to sign in"}, name: {type: ["string", "null"], description: "The user name to use when creating a new user"}, callbackURL: {type: ["string", "null"]}, errorCallbackURL: {type: ["string", "null"]}, newUserCallbackURL: {type: ["string", "null"]}, metadata: {type: ["object", "null"]} }, required: ["email"] ) ), responses: { "200" => OpenAPI.json_response("Magic link sent", OpenAPI.status_response_schema) } } } ) do |ctx| sender = config[:send_magic_link] link_base_url = ctx.context.base_url link_base_url = ctx.context.token_link_base_url if sender.respond_to?(:call) body = normalize_hash(ctx.body) email = body[:email].to_s.downcase raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email) token = magic_link_token(email, config) stored_token = store_magic_link_token(token, config) ctx.context.internal_adapter.create_verification_value( identifier: stored_token, value: JSON.generate({"email" => email, "name" => body[:name], "attempt" => 0}), expiresAt: Time.now + (config[:expires_in] || 60 * 5).to_i ) link = magic_link_url(link_base_url, token, body) data = {email: email, url: link, token: token} data[:metadata] = body[:metadata] if body.key?(:metadata) sender.call(data, ctx) if sender.respond_to?(:call) ctx.json({status: true}) end end |
.sign_in_phone_number_endpoint(config) ⇒ Object
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/better_auth/plugins/phone_number.rb', line 67 def sign_in_phone_number_endpoint(config) Endpoint.new( path: "/sign-in/phone-number", method: "POST", metadata: { openapi: { operationId: "signInPhoneNumber", description: "Sign in with phone number and password", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { phoneNumber: {type: "string"}, password: {type: "string"}, rememberMe: {type: ["boolean", "null"]} }, required: ["phoneNumber", "password"] ) ), responses: { "200" => OpenAPI.json_response( "Signed in", OpenAPI.object_schema( { token: {type: "string"}, user: {type: "object", "$ref": "#/components/schemas/User"} }, required: ["token", "user"] ) ) } } } ) do |ctx| body = normalize_hash(ctx.body) phone_number = body[:phone_number].to_s password = body[:password].to_s validate_phone_number!(config, phone_number) found = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}]) unless found Routes.hash_password(ctx, password) raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER_OR_PASSWORD"]) end if config[:require_verification] && !found["phoneNumberVerified"] code = phone_number_generate_code(config) phone_number_store_code(ctx, config, phone_number, code) unless config[:verify_otp].respond_to?(:call) phone_number_deliver_otp(config, {phone_number: phone_number, code: code}, ctx) raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_NOT_VERIFIED"]) end credential = ctx.context.internal_adapter.find_accounts(found["id"]).find { |entry| entry["providerId"] == "credential" } current_password = credential && credential["password"] unless current_password && Routes.verify_password_value(ctx, password, current_password) Routes.hash_password(ctx, password) unless current_password raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER_OR_PASSWORD"]) end dont_remember_me = body.key?(:remember_me) && (body[:remember_me] == false || body[:remember_me].to_s == "false") session = ctx.context.internal_adapter.create_session(found["id"], dont_remember_me) raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session Cookies.(ctx, {session: session, user: found}, dont_remember_me) ctx.json({token: session["token"], user: Schema.parse_output(ctx.context., "user", found)}) end end |
.sign_in_username_endpoint(config) ⇒ Object
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/better_auth/plugins/username.rb', line 48 def sign_in_username_endpoint(config) Endpoint.new( path: "/sign-in/username", method: "POST", metadata: { allowed_media_types: [ "application/x-www-form-urlencoded", "application/json" ], openapi: { operationId: "signInUsername", description: "Sign in with username and password", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { username: {type: "string"}, password: {type: "string"}, callbackURL: {type: ["string", "null"]}, rememberMe: {type: ["boolean", "null"]} }, required: ["username", "password"] ) ), responses: { "200" => OpenAPI.json_response( "Signed in", OpenAPI.object_schema( { redirect: {type: "boolean"}, token: {type: "string"}, url: {type: ["string", "null"]}, user: {type: "object", "$ref": "#/components/schemas/User"} }, required: ["redirect", "token", "user"] ) ) } } } ) do |ctx| body = normalize_hash(ctx.body) raw_username = body[:username].to_s password = body[:password].to_s callback_url = body[:callback_url] || body[:callbackURL] remember_me = body.key?(:remember_me) ? body[:remember_me] : body[:rememberMe] Routes.preflight_sign_in_token_link!(ctx) if raw_username.empty? || password.empty? raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"]) end username = username_for_sign_in_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(raw_username, config)}] ) unless user Routes.hash_password(ctx, password) raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"]) end account = ctx.context.adapter.find_one( model: "account", where: [ {field: "userId", value: user["id"]}, {field: "providerId", value: "credential"} ] ) current_password = account && account["password"] email_config = ctx.context..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.send_sign_in_verification_email(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.(ctx, {session: session, user: user}, dont_remember_me) ctx.set_header("location", callback_url) if callback_url ctx.json({ redirect: !callback_url.nil?, token: session["token"], url: callback_url, user: Schema.parse_output(ctx.context., "user", user) }) end end |
.sign_in_with_oauth2_endpoint(config) ⇒ Object
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 245 def sign_in_with_oauth2_endpoint(config) Endpoint.new( path: "/sign-in/oauth2", method: "POST", metadata: { openapi: { operationId: "signInOAuth2", description: "Sign in with OAuth2", requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema), responses: { "200" => OpenAPI.json_response("Sign in with OAuth2", generic_oauth_url_response_schema) } } } ) do |ctx| body = normalize_hash(ctx.body) provider_id = body[:provider_id].to_s provider = generic_oauth_provider!(config, provider_id) auth_url = (ctx, provider, body, link: nil) ctx.json({url: auth_url, redirect: !body[:disable_redirect]}) end end |
.sign_jwt_endpoint(config) ⇒ Object
163 164 165 166 167 168 169 |
# File 'lib/better_auth/plugins/jwt.rb', line 163 def sign_jwt_endpoint(config) Endpoint.new(path: nil, method: "POST") do |ctx| payload = fetch_value(ctx.body, "payload") || {} override = normalize_hash(fetch_value(ctx.body, "overrideOptions") || {}) ctx.json({token: sign_jwt_payload(ctx, stringify_payload(payload), deep_merge(config, override))}) end end |
.sign_jwt_payload(ctx, payload, config) ⇒ Object
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/better_auth/plugins/jwt.rb', line 209 def sign_jwt_payload(ctx, payload, config) jwt_config = config[:jwt] || {} now = Time.now.to_i payload = stringify_payload(payload).dup payload["iat"] ||= now payload["exp"] ||= jwt_expiration(jwt_config[:expiration_time] || "15m", payload["iat"]) payload["iss"] ||= jwt_config[:issuer] || ctx.context.canonical_base_url payload["aud"] ||= jwt_config[:audience] || ctx.context.canonical_base_url return jwt_config[:sign].call(payload) if jwt_config[:sign].respond_to?(:call) key = signing_jwk(ctx, config) private_key = OpenSSL::PKey.read(jwk_private_key_value(ctx, key, config)) alg = key["alg"] || "RS256" return encode_eddsa_jwt(payload, private_key, key["id"]) if alg == "EdDSA" ::JWT.encode(payload, private_key, alg, kid: key["id"]) end |
.signing_jwk(ctx, config) ⇒ Object
251 252 253 254 255 256 |
# File 'lib/better_auth/plugins/jwt.rb', line 251 def signing_jwk(ctx, config) key = latest_jwk(ctx, config) return key if key && !jwk_expired?(key) create_jwk(ctx, config) end |
.siwe(options = {}) ⇒ Object
15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
# File 'lib/better_auth/plugins/siwe.rb', line 15 def siwe( = {}) config = normalize_hash() Plugin.new( id: "siwe", schema: siwe_schema(config[:schema]), endpoints: { get_siwe_nonce: get_siwe_nonce_endpoint(config, path: "/siwe/nonce", operation_id: "getSiweNonce"), get_nonce: get_siwe_nonce_endpoint(config, path: "/siwe/get-nonce", operation_id: "getNonce"), verify_siwe_message: (config) }, options: config ) end |
.siwe_chain_id(value) ⇒ Object
228 229 230 231 232 233 |
# File 'lib/better_auth/plugins/siwe.rb', line 228 def siwe_chain_id(value) chain_id = (value.nil? || value.to_s.empty?) ? 1 : value.to_i raise APIError.new("BAD_REQUEST", message: "Invalid chainId") unless chain_id.positive? && chain_id <= 2_147_483_647 chain_id end |
.siwe_create_user(ctx, config, wallet_address, _chain_id, email, anonymous) ⇒ Object
369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 |
# File 'lib/better_auth/plugins/siwe.rb', line 369 def siwe_create_user(ctx, config, wallet_address, _chain_id, email, anonymous) domain = config[:email_domain_name] || URI.parse(ctx.context.canonical_base_url).host || ctx.context.canonical_base_url lookup = config[:ens_lookup] ens = lookup.respond_to?(:call) ? normalize_hash(lookup.call(wallet_address: wallet_address) || {}) : {} normalized_email = email.to_s.downcase user_email = "#{wallet_address}@#{domain}".downcase if anonymous == false && !normalized_email.empty? && !ctx.context.internal_adapter.find_user_by_email(normalized_email) user_email = normalized_email end ctx.context.internal_adapter.create_user( name: ens[:name] || wallet_address, email: user_email, image: ens[:avatar] || "", context: ctx ) end |
.siwe_ensure_wallet_and_account(ctx, user, wallet_address, chain_id) ⇒ Object
386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 |
# File 'lib/better_auth/plugins/siwe.rb', line 386 def siwe_ensure_wallet_and_account(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.create_account( userId: user["id"], providerId: "siwe", accountId: "#{wallet_address}:#{chain_id}" ) end |
.siwe_expired_time?(value) ⇒ Boolean
414 415 416 |
# File 'lib/better_auth/plugins/siwe.rb', line 414 def siwe_expired_time?(value) value && value < Time.now end |
.siwe_find_user(ctx, wallet_address, chain_id) ⇒ Object
357 358 359 360 361 362 363 364 365 366 367 |
# File 'lib/better_auth/plugins/siwe.rb', line 357 def siwe_find_user(ctx, wallet_address, chain_id) existing = ctx.context.adapter.find_one( model: "walletAddress", where: [ {field: "address", value: wallet_address}, {field: "chainId", value: chain_id} ] ) existing ||= ctx.context.adapter.find_one(model: "walletAddress", where: [{field: "address", value: wallet_address}]) existing && ctx.context.internal_adapter.find_user_by_id(existing["userId"]) end |
.siwe_identifier(wallet_address, chain_id) ⇒ Object
235 236 237 |
# File 'lib/better_auth/plugins/siwe.rb', line 235 def siwe_identifier(wallet_address, chain_id) "siwe:#{wallet_address}:#{chain_id}" end |
.siwe_merge_schema_fields(base_fields, custom_fields) ⇒ Object
179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/better_auth/plugins/siwe.rb', line 179 def siwe_merge_schema_fields(base_fields, custom_fields) fields = base_fields.each_with_object({}) do |(raw_field, attributes), result| result[Schema.storage_key(raw_field)] = normalize_hash(attributes) end normalize_hash(custom_fields).each do |raw_field, value| field = Schema.storage_key(raw_field) custom_attributes = (value.is_a?(String) || value.is_a?(Symbol)) ? {field_name: value.to_s} : normalize_hash(value) fields[field] = (fields[field] || {}).merge(custom_attributes) end fields end |
.siwe_nonce_body(body) ⇒ Object
193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/better_auth/plugins/siwe.rb', line 193 def siwe_nonce_body(body) data = normalize_hash(body) wallet_address_keys = [:wallet_address, :address].select { |key| data.key?(key) } if wallet_address_keys.empty? raise APIError.new("BAD_REQUEST", message: "walletAddress or address is required") end wallet_address_keys.each { |key| siwe_normalize_wallet!(data[key]) } data[:chain_id] = siwe_chain_id(data[:chain_id]) data end |
.siwe_normalize_domain(domain) ⇒ Object
290 291 292 293 294 295 296 |
# File 'lib/better_auth/plugins/siwe.rb', line 290 def siwe_normalize_domain(domain) normalized = domain.to_s.strip.downcase.sub(/\A[a-z][a-z0-9+.-]*:\/\//, "") path_start = normalized.index("/") path_start ? normalized[0...path_start] : normalized rescue "" end |
.siwe_normalize_wallet!(value) ⇒ Object
221 222 223 224 225 226 |
# File 'lib/better_auth/plugins/siwe.rb', line 221 def siwe_normalize_wallet!(value) wallet = value.to_s raise APIError.new("BAD_REQUEST", message: "Invalid walletAddress") unless SIWE_WALLET_PATTERN.match?(wallet) Crypto.to_checksum_address(wallet) end |
.siwe_parse_message(message) ⇒ Object
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 |
# File 'lib/better_auth/plugins/siwe.rb', line 239 def () result = {} lines = .to_s.split(/\r?\n/) if (header = SIWE_HEADER_PATTERN.match(lines[0].to_s)) result[:scheme] = header[1] if header[1] result[:domain] = header[2] end address = lines[1].to_s.strip result[:address] = address if SIWE_WALLET_PATTERN.match?(address) lines.each do |line| field = SIWE_FIELD_PATTERN.match(line) next unless field key = field[1] value = field[2] case key when "URI" result[:uri] = value when "Version" result[:version] = value when "Chain ID" parsed_chain_id = (value) result[:chain_id] = parsed_chain_id unless parsed_chain_id.nil? when "Nonce" result[:nonce] = value when "Issued At" result[:issued_at] = value when "Expiration Time" result[:expiration_time] = value when "Not Before" result[:not_before] = value when "Request ID" result[:request_id] = value end end result rescue {} end |
.siwe_parse_message_chain_id(value) ⇒ Object
283 284 285 286 287 288 |
# File 'lib/better_auth/plugins/siwe.rb', line 283 def (value) number = Float(value) number.to_i if number.finite? && number == number.to_i rescue ArgumentError, TypeError, RangeError nil end |
.siwe_parse_message_time(value) ⇒ Object
324 325 326 327 328 |
# File 'lib/better_auth/plugins/siwe.rb', line 324 def (value) Time.parse(value.to_s) rescue ArgumentError, TypeError, RangeError nil end |
.siwe_schema(custom_schema = nil) ⇒ Object
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'lib/better_auth/plugins/siwe.rb', line 156 def siwe_schema(custom_schema = nil) base = { "walletAddress" => { fields: { userId: {type: "string", references: {model: "user", field: "id"}, required: true, index: true}, address: {type: "string", required: true}, chainId: {type: "number", required: true}, isPrimary: {type: "boolean", default_value: false}, createdAt: {type: "date", required: true} } } } return base unless custom_schema.is_a?(Hash) normalize_hash(custom_schema).each_with_object(base) do |(raw_model, table), result| model = Schema.storage_key(raw_model) current = result[model] || {} custom_table = normalize_hash(table) fields = siwe_merge_schema_fields(current[:fields] || current["fields"] || {}, custom_table.delete(:fields) || {}) result[model] = current.merge(custom_table).merge(fields: fields) end end |
.siwe_unauthorized_error(status, message) ⇒ Object
330 331 332 |
# File 'lib/better_auth/plugins/siwe.rb', line 330 def (status, ) APIError.new("UNAUTHORIZED", code: status, message: ) end |
.siwe_validate_message!(message, domain, wallet_address, chain_id, nonce) ⇒ Object
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 |
# File 'lib/better_auth/plugins/siwe.rb', line 298 def (, domain, wallet_address, chain_id, nonce) parsed = () matches = parsed[:nonce] == nonce && parsed[:address]&.downcase == wallet_address.downcase && parsed[:chain_id] == chain_id && parsed[:domain] && siwe_normalize_domain(parsed[:domain]) == siwe_normalize_domain(domain) unless matches raise ( "UNAUTHORIZED_SIWE_MESSAGE_MISMATCH", "Unauthorized: SIWE message does not match the expected nonce, domain, address, or chain ID" ) end now = Time.now expiration_time = (parsed[:expiration_time]) if parsed[:expiration_time] if expiration_time && now >= expiration_time raise ("UNAUTHORIZED_SIWE_MESSAGE_EXPIRED", "Unauthorized: SIWE message has expired") end not_before = (parsed[:not_before]) if parsed[:not_before] if not_before && now < not_before raise ("UNAUTHORIZED_SIWE_MESSAGE_NOT_YET_VALID", "Unauthorized: SIWE message is not yet valid") end end |
.siwe_verify_body(body, config) ⇒ Object
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 |
# File 'lib/better_auth/plugins/siwe.rb', line 205 def siwe_verify_body(body, config) data = normalize_hash(body) raise APIError.new("BAD_REQUEST", message: "message is required") if data[:message].to_s.empty? raise APIError.new("BAD_REQUEST", message: "signature is required") if data[:signature].to_s.empty? siwe_normalize_wallet!(data[:wallet_address]) data[:chain_id] = siwe_chain_id(data[:chain_id]) anonymous = config.key?(:anonymous) ? config[:anonymous] : true email = data[:email].to_s.downcase raise APIError.new("BAD_REQUEST", message: "Email is required when anonymous is disabled.") if anonymous == false && email.empty? raise APIError.new("BAD_REQUEST", message: "Invalid email address") if !email.empty? && !SIWE_EMAIL_PATTERN.match?(email) data[:email] = email unless email.empty? data end |
.siwe_verify_message(config, body, wallet_address, chain_id, nonce, ctx) ⇒ Object
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/better_auth/plugins/siwe.rb', line 334 def (config, body, wallet_address, chain_id, nonce, ctx) verifier = config[:verify_message] raise APIError.new("INTERNAL_SERVER_ERROR", message: "SIWE verify_message callback is required") unless verifier.respond_to?(:call) verifier.call( message: body[:message].to_s, signature: body[:signature].to_s, address: wallet_address, chain_id: chain_id, cacao: { h: {t: "caip122"}, p: { domain: config[:domain], aud: config[:domain], nonce: nonce, iss: config[:domain], version: "1" }, s: {t: "eip191", s: body[:signature].to_s} } ) end |
.slack(options = {}) ⇒ Object
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 191 def slack( = {}) data = normalize_hash() generic_oauth_provider_config( data, provider_id: "slack", authorization_url: "https://slack.com/openid/connect/authorize", token_url: "https://slack.com/api/openid.connect.token", user_info_url: "https://slack.com/api/openid.connect.userInfo", scopes: ["openid", "profile", "email"], get_user_info: ->(tokens) { profile = generic_oauth_fetch_json("https://slack.com/api/openid.connect.userInfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}") return nil unless profile { id: fetch_value(profile, "https://slack.com/user_id") || fetch_value(profile, "sub"), name: fetch_value(profile, "name"), email: fetch_value(profile, "email"), image: fetch_value(profile, "picture") || fetch_value(profile, "https://slack.com/user_image_512"), emailVerified: fetch_value(profile, "email_verified") || false } } ) end |
.sso(*args, &block) ⇒ Object
9 10 11 |
# File 'lib/better_auth/plugins/sso.rb', line 9 def sso(*args, &block) call_external_plugin!(:sso, *args, implementation_constant: :SSO_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-sso", entry: "lib/better_auth/sso.rb", &block) end |
.storage_fields(fields) ⇒ Object
25 26 27 28 29 |
# File 'lib/better_auth/plugins.rb', line 25 def storage_fields(fields) normalize_hash(fields).each_with_object({}) do |(key, value), result| result[Schema.storage_key(key)] = normalize_field(value) end end |
.store_magic_link_token(token, config) ⇒ Object
202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/better_auth/plugins/magic_link.rb', line 202 def store_magic_link_token(token, config) storage = config[:store_token] return Crypto.sha256(token, encoding: :base64url) if storage.to_s == "hashed" if storage.is_a?(Hash) && %w[custom-hasher custom_hasher].include?(storage[:type].to_s) hasher = storage[:hash] return hasher.call(token) if hasher.respond_to?(:call) end token end |
.stringify_keys(value) ⇒ Object
77 78 79 80 81 82 |
# File 'lib/better_auth/plugins/custom_session.rb', line 77 def stringify_keys(value) return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash) return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array) value end |
.stringify_payload(value) ⇒ Object
526 527 528 529 530 531 |
# File 'lib/better_auth/plugins/jwt.rb', line 526 def stringify_payload(value) return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_payload(object_value) } if value.is_a?(Hash) return value.map { |entry| stringify_payload(entry) } if value.is_a?(Array) value end |
.stringify_permission(value) ⇒ Object
1465 1466 1467 1468 1469 |
# File 'lib/better_auth/plugins/organization.rb', line 1465 def (value) normalize_hash(value || {}).each_with_object({}) do |(resource, actions), result| result[resource.to_s] = Array(actions).map(&:to_s) end end |
.stripe(*args, &block) ⇒ Object
9 10 11 |
# File 'lib/better_auth/plugins/stripe.rb', line 9 def stripe(*args, &block) call_external_plugin!(:stripe, *args, implementation_constant: :STRIPE_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-stripe", entry: "lib/better_auth/stripe.rb", &block) end |
.symbolize_session(entry) ⇒ Object
69 70 71 72 73 74 75 |
# File 'lib/better_auth/plugins/custom_session.rb', line 69 def symbolize_session(entry) data = stringify_keys(entry) { session: data["session"], user: data["user"] } end |
.team_by_id(ctx, id) ⇒ Object
1225 1226 1227 1228 1229 |
# File 'lib/better_auth/plugins/organization.rb', line 1225 def team_by_id(ctx, id) return nil if id.to_s.empty? ctx.context.adapter.find_one(model: "team", where: [{field: "id", value: id}]) end |
.team_member_wire(ctx, member) ⇒ Object
1342 1343 1344 |
# File 'lib/better_auth/plugins/organization.rb', line 1342 def team_member_wire(ctx, member) Schema.parse_output(ctx.context., "teamMember", member) end |
.team_wire(ctx, team) ⇒ Object
1338 1339 1340 |
# File 'lib/better_auth/plugins/organization.rb', line 1338 def team_wire(ctx, team) Schema.parse_output(ctx.context., "team", team) end |
.truthy?(value) ⇒ Boolean
450 451 452 |
# File 'lib/better_auth/plugins/phone_number.rb', line 450 def truthy?(value) value == true || value.to_s == "true" end |
.truthy_value?(value) ⇒ Boolean
65 66 67 |
# File 'lib/better_auth/plugins/custom_session.rb', line 65 def truthy_value?(value) value == true || value.to_s == "true" end |
.two_factor(options = {}) ⇒ Object
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/better_auth/plugins/two_factor.rb', line 36 def two_factor( = {}) config = { two_factor_table: "twoFactor", trust_device_max_age: TRUST_DEVICE_COOKIE_MAX_AGE, two_factor_cookie_max_age: TWO_FACTOR_COOKIE_MAX_AGE, account_lockout: { enabled: true, max_failed_attempts: DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS, duration_seconds: DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS }, backup_code_options: {store_backup_codes: "encrypted"}, otp_options: {}, totp_options: {} }.merge(normalize_hash()) config[:backup_code_options] = {store_backup_codes: "encrypted"}.merge(normalize_hash(config[:backup_code_options])) config[:otp_options] = normalize_hash(config[:otp_options]) config[:totp_options] = normalize_hash(config[:totp_options]) config[:account_lockout] = { enabled: true, max_failed_attempts: DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS, duration_seconds: DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS }.merge(normalize_hash(config[:account_lockout])) config[:backup_code_options][:allow_passwordless] = config[:allow_passwordless] unless config[:backup_code_options].key?(:allow_passwordless) config[:totp_options][:allow_passwordless] = config[:allow_passwordless] unless config[:totp_options].key?(:allow_passwordless) Plugin.new( id: "two-factor", endpoints: { enable_two_factor: two_factor_enable_endpoint(config), disable_two_factor: two_factor_disable_endpoint(config), generate_totp: two_factor_generate_totp_endpoint(config), get_totp_uri: two_factor_get_totp_uri_endpoint(config), verify_totp: two_factor_verify_totp_endpoint(config), send_two_factor_otp: two_factor_send_otp_endpoint(config), verify_two_factor_otp: two_factor_verify_otp_endpoint(config), verify_backup_code: two_factor_verify_backup_code_endpoint(config), generate_backup_codes: two_factor_generate_backup_codes_endpoint(config), view_backup_codes: two_factor_view_backup_codes_endpoint(config) }, hooks: { after: [ { matcher: ->(ctx) { ["/sign-in/email", "/sign-in/username", "/sign-in/phone-number"].include?(ctx.path) }, handler: ->(ctx) { two_factor_after_sign_in(ctx, config) } } ] }, schema: two_factor_schema(config), rate_limit: [ { path_matcher: ->(path) { path.start_with?("/two-factor/") }, window: 10, max: 3 } ], error_codes: TWO_FACTOR_ERROR_CODES, options: config ) end |
.two_factor_after_sign_in(ctx, config) ⇒ Object
442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 |
# File 'lib/better_auth/plugins/two_factor.rb', line 442 def two_factor_after_sign_in(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.(ctx, skip_dont_remember_me: true) ctx.context.internal_adapter.delete_session(data[:session]["token"]) = ctx.context.(TWO_FACTOR_COOKIE_NAME, max_age: config[:two_factor_cookie_max_age]) identifier = "2fa-#{Crypto.random_string(20)}" expires_at = Time.now + config[:two_factor_cookie_max_age].to_i ctx.context.internal_adapter.create_verification_value( identifier: identifier, value: data[:user]["id"], expiresAt: expires_at ) ctx.context.internal_adapter.create_verification_value( identifier: "2fa-attempts-#{identifier}", value: "0", expiresAt: expires_at ) ctx.(.name, identifier, ctx.context.secret, .attributes) ctx.json({twoFactorRedirect: true, twoFactorMethods: two_factor_methods(ctx, config, data[:user]["id"])}) end |
.two_factor_assert_not_locked!(ctx, config, record) ⇒ Object
578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 |
# File 'lib/better_auth/plugins/two_factor.rb', line 578 def two_factor_assert_not_locked!(ctx, config, record) lockout = config[:account_lockout] return unless lockout && lockout[:enabled] != false && record["lockedUntil"] locked_until = two_factor_lock_time(record["lockedUntil"]) if locked_until > Time.now raise APIError.new("TOO_MANY_REQUESTS", message: TWO_FACTOR_ERROR_CODES["ACCOUNT_TEMPORARILY_LOCKED"]) end cleared = ctx.context.adapter.increment_one( model: TWO_FACTOR_MODEL, where: [ {field: "id", value: record["id"]}, {field: "lockedUntil", operator: "lte", value: Time.now} ], increment: {}, set: {failedVerificationCount: 0, lockedUntil: nil} ) raise Error, "Failed to clear expired two-factor account lock" unless cleared end |
.two_factor_backup_codes_response_schema ⇒ Object
407 408 409 410 411 412 413 414 415 |
# File 'lib/better_auth/plugins/two_factor.rb', line 407 def two_factor_backup_codes_response_schema OpenAPI.object_schema( { status: {type: "boolean"}, backupCodes: {type: "array", items: {type: "string"}} }, required: ["status", "backupCodes"] ) end |
.two_factor_check_password!(ctx, user_id, password, allow_passwordless: false) ⇒ Object
646 647 648 649 650 651 652 653 |
# File 'lib/better_auth/plugins/two_factor.rb', line 646 def two_factor_check_password!(ctx, user_id, password, allow_passwordless: false) account = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" } return if allow_passwordless && !account unless account && account["password"] && Routes.verify_password_value(ctx, password.to_s, account["password"]) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"]) end end |
.two_factor_disable_endpoint(config) ⇒ Object
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/better_auth/plugins/two_factor.rb', line 132 def two_factor_disable_endpoint(config) Endpoint.new(path: "/two-factor/disable", method: "POST", metadata: two_factor_openapi("disableTwoFactor", "Disable two factor authentication", OpenAPI.status_response_schema)) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) two_factor_check_password!(ctx, session[:user]["id"], body[:password], allow_passwordless: config[:allow_passwordless]) updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: false) ctx.context.adapter.delete(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: updated_user["id"]}]) new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false) Cookies.(ctx, {session: new_session, user: updated_user}) ctx.context.internal_adapter.delete_session(session[:session]["token"]) = ctx.context.(TRUST_DEVICE_COOKIE_NAME, max_age: config[:trust_device_max_age]) trust_value = ctx.(.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.(ctx, ) end ctx.json({status: true}) end end |
.two_factor_enable_endpoint(config) ⇒ Object
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/better_auth/plugins/two_factor.rb', line 96 def two_factor_enable_endpoint(config) Endpoint.new(path: "/two-factor/enable", method: "POST", metadata: two_factor_openapi("enableTwoFactor", "Enable two factor authentication", two_factor_enable_response_schema)) do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) two_factor_check_password!(ctx, session[:user]["id"], body[:password], allow_passwordless: config[:allow_passwordless]) secret = two_factor_generate_secret backup = two_factor_generate_backup_codes(ctx.context.secret_config, config[:backup_code_options]) if config[:skip_verification_on_enable] updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: true) new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false) Cookies.(ctx, {session: new_session, user: updated_user}) ctx.context.internal_adapter.delete_session(session[:session]["token"]) end existing = two_factor_record(ctx, config, session[:user]["id"]) verified = (!!existing && existing["verified"] != false) || !!config[:skip_verification_on_enable] ctx.context.adapter.delete_many(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: session[:user]["id"]}]) ctx.context.adapter.create( model: TWO_FACTOR_MODEL, data: { secret: Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: secret), backupCodes: backup[:stored], userId: session[:user]["id"], verified: verified }, force_allow_id: true ) ctx.json({ totpURI: two_factor_totp_uri(secret, issuer: body[:issuer] || config[:issuer] || ctx.context.app_name, account: session[:user]["email"], options: config[:totp_options]), backupCodes: backup[:codes] }) end end |
.two_factor_enable_response_schema ⇒ Object
387 388 389 390 391 392 393 394 395 |
# File 'lib/better_auth/plugins/two_factor.rb', line 387 def two_factor_enable_response_schema OpenAPI.object_schema( { totpURI: {type: "string"}, backupCodes: {type: "array", items: {type: "string"}} }, required: ["totpURI", "backupCodes"] ) end |
.two_factor_generate_backup_codes(secret, options) ⇒ Object
691 692 693 694 695 696 697 698 699 700 701 702 703 |
# File 'lib/better_auth/plugins/two_factor.rb', line 691 def two_factor_generate_backup_codes(secret, ) codes = if [:custom_backup_codes_generate].respond_to?(:call) [:custom_backup_codes_generate].call else amount = ([:amount] || 10).to_i length = ([: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, )} end |
.two_factor_generate_backup_codes_endpoint(config) ⇒ Object
329 330 331 332 333 334 335 336 337 338 339 340 341 342 |
# File 'lib/better_auth/plugins/two_factor.rb', line 329 def two_factor_generate_backup_codes_endpoint(config) Endpoint.new(path: "/two-factor/generate-backup-codes", method: "POST", metadata: two_factor_openapi("generateBackupCodes", "Generate two factor backup codes", two_factor_backup_codes_response_schema)) do |ctx| session = Routes.current_session(ctx) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless session[:user]["twoFactorEnabled"] two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password], allow_passwordless: config[:backup_code_options][:allow_passwordless]) record = two_factor_record(ctx, config, session[:user]["id"]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless record backup = two_factor_generate_backup_codes(ctx.context.secret_config, config[:backup_code_options]) ctx.context.adapter.update(model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {backupCodes: backup[:stored]}) ctx.json({status: true, backupCodes: backup[:codes]}) end end |
.two_factor_generate_secret ⇒ Object
661 662 663 664 |
# File 'lib/better_auth/plugins/two_factor.rb', line 661 def two_factor_generate_secret raw = SecureRandom.random_bytes(20) base32_encode(raw) end |
.two_factor_generate_totp_endpoint(config) ⇒ Object
155 156 157 158 159 160 161 |
# File 'lib/better_auth/plugins/two_factor.rb', line 155 def two_factor_generate_totp_endpoint(config) Endpoint.new(path: "/totp/generate", method: "POST", metadata: two_factor_openapi("generateTOTP", "Generate a TOTP code", OpenAPI.object_schema({code: {type: "string"}}, required: ["code"]))) do |ctx| two_factor_totp_enabled!(config) body = normalize_hash(ctx.body) ctx.json({code: two_factor_totp(body[:secret], options: config[:totp_options])}) end end |
.two_factor_get_totp_uri_endpoint(config) ⇒ Object
163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/better_auth/plugins/two_factor.rb', line 163 def two_factor_get_totp_uri_endpoint(config) Endpoint.new(path: "/two-factor/get-totp-uri", method: "POST", metadata: two_factor_openapi("getTOTPURI", "Get the TOTP URI", OpenAPI.object_schema({totpURI: {type: "string"}}, required: ["totpURI"]))) do |ctx| two_factor_totp_enabled!(config) session = Routes.current_session(ctx) two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password], allow_passwordless: config[:totp_options][:allow_passwordless]) record = two_factor_record(ctx, config, session[:user]["id"]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record secret = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: record["secret"]) ctx.json({totpURI: two_factor_totp_uri(secret, issuer: config[:issuer] || ctx.context.app_name, account: session[:user]["email"], options: config[:totp_options])}) end end |
.two_factor_lock_time(value) ⇒ Object
632 633 634 |
# File 'lib/better_auth/plugins/two_factor.rb', line 632 def two_factor_lock_time(value) value.is_a?(Time) ? value : Time.parse(value.to_s) end |
.two_factor_methods(ctx, config, user_id) ⇒ Object
636 637 638 639 640 641 642 643 644 |
# File 'lib/better_auth/plugins/two_factor.rb', line 636 def two_factor_methods(ctx, config, user_id) methods = [] unless config[:totp_options][:disable] record = two_factor_record(ctx, config, user_id) methods << "totp" if record && record["verified"] != false end methods << "otp" if config[:otp_options][:send_otp].respond_to?(:call) methods end |
.two_factor_openapi(operation_id, description, response_schema) ⇒ Object
354 355 356 357 358 359 360 361 362 363 364 365 |
# File 'lib/better_auth/plugins/two_factor.rb', line 354 def two_factor_openapi(operation_id, description, response_schema) { openapi: { operationId: operation_id, description: description, requestBody: two_factor_request_body(operation_id), responses: { "200" => OpenAPI.json_response("Success", response_schema) } } } end |
.two_factor_otp_matches?(ctx, stored, input, options) ⇒ Boolean
748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 |
# File 'lib/better_auth/plugins/two_factor.rb', line 748 def two_factor_otp_matches?(ctx, stored, input, ) storage = [:store_otp] expected, actual = if storage == "hashed" [stored, Crypto.sha256(input, encoding: :base64url)] elsif storage == "encrypted" [Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored), input] elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call) [stored, storage[:hash].call(input)] elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call) [storage[:decrypt].call(stored), input] else [stored, input] end expected && actual && Crypto.constant_time_compare(expected.to_s, actual.to_s) end |
.two_factor_random_digits(length) ⇒ Object
729 730 731 |
# File 'lib/better_auth/plugins/two_factor.rb', line 729 def two_factor_random_digits(length) Array.new(length) { SecureRandom.random_number(10) }.join end |
.two_factor_read_backup_codes(secret, stored, options) ⇒ Object
717 718 719 720 721 722 723 724 725 726 727 |
# File 'lib/better_auth/plugins/two_factor.rb', line 717 def two_factor_read_backup_codes(secret, stored, ) storage = [:store_backup_codes] data = if storage == "encrypted" Crypto.symmetric_decrypt(key: secret, data: stored) elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call) storage[:decrypt].call(stored) else stored end JSON.parse(data.to_s) end |
.two_factor_record(ctx, config, user_id) ⇒ Object
574 575 576 |
# File 'lib/better_auth/plugins/two_factor.rb', line 574 def two_factor_record(ctx, config, user_id) ctx.context.adapter.find_one(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: user_id}]) end |
.two_factor_record_failure!(ctx, config, record) ⇒ Object
599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 |
# File 'lib/better_auth/plugins/two_factor.rb', line 599 def two_factor_record_failure!(ctx, config, record) lockout = config[:account_lockout] return unless lockout && lockout[:enabled] != false updated = ctx.context.adapter.increment_one( model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], increment: {failedVerificationCount: 1}, allow_server_managed: true ) raise Error, "Failed to record two-factor verification failure" unless updated return if updated["failedVerificationCount"].to_i < lockout[:max_failed_attempts].to_i locked = ctx.context.adapter.update( model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {lockedUntil: Time.now + lockout[:duration_seconds].to_i} ) raise Error, "Failed to lock two-factor account" unless locked end |
.two_factor_request_body(operation_id) ⇒ Object
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 |
# File 'lib/better_auth/plugins/two_factor.rb', line 367 def two_factor_request_body(operation_id) schema = case operation_id when "enableTwoFactor" OpenAPI.object_schema({password: {type: "string"}, issuer: {type: "string"}}) when "disableTwoFactor", "getTOTPURI", "generateBackupCodes" OpenAPI.object_schema({password: {type: "string"}}) when "generateTOTP" OpenAPI.object_schema({secret: {type: "string"}}, required: ["secret"]) when "verifyTOTP", "verifyTwoFactorOTP" OpenAPI.object_schema({code: {type: "string"}, trustDevice: {type: "boolean"}}, required: ["code"]) when "verifyBackupCode" OpenAPI.object_schema({code: {type: "string"}, disableSession: {type: "boolean"}, trustDevice: {type: "boolean"}}, required: ["code"]) when "sendTwoFactorOTP" OpenAPI.empty_request_body.dig(:content, "application/json", :schema) else {type: "object", properties: {}} end OpenAPI.json_request_body(schema) end |
.two_factor_reset_failures!(ctx, config, record) ⇒ Object
620 621 622 623 624 625 626 627 628 629 630 |
# File 'lib/better_auth/plugins/two_factor.rb', line 620 def two_factor_reset_failures!(ctx, config, record) lockout = config[:account_lockout] return unless lockout && lockout[:enabled] != false reset = ctx.context.adapter.update( model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {failedVerificationCount: 0, lockedUntil: nil} ) raise Error, "Failed to reset two-factor verification failures" unless reset end |
.two_factor_schema(config = {}) ⇒ Object
417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 |
# File 'lib/better_auth/plugins/two_factor.rb', line 417 def two_factor_schema(config = {}) custom_schema = config[:schema] base = { user: { fields: { twoFactorEnabled: {type: "boolean", required: false, default_value: false, returned: true} } }, twoFactor: { fields: { secret: {type: "string", required: true, returned: false, index: true}, backupCodes: {type: "string", required: true, returned: false}, userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}}, verified: {type: "boolean", required: false, default_value: true, input: false}, failedVerificationCount: {type: "number", required: false, default_value: 0, input: false, returned: false}, lockedUntil: {type: "date", required: false, input: false, returned: false} } } } if config[:two_factor_table] && config[:two_factor_table] != TWO_FACTOR_MODEL base[:twoFactor][:model_name] = config[:two_factor_table].to_s end deep_merge_hashes(base, normalize_hash(custom_schema || {})) end |
.two_factor_send_otp_endpoint(config) ⇒ Object
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 |
# File 'lib/better_auth/plugins/two_factor.rb', line 222 def two_factor_send_otp_endpoint(config) Endpoint.new(path: "/two-factor/send-otp", method: "POST", metadata: two_factor_openapi("sendTwoFactorOTP", "Send a two factor OTP", OpenAPI.status_response_schema)) do |ctx| otp_config = config[:otp_options] sender = otp_config[:send_otp] unless sender.respond_to?(:call) raise APIError.new("BAD_REQUEST", message: "otp isn't configured") end data = two_factor_verification_context(ctx, config) code = two_factor_random_digits((otp_config[:digits] || 6).to_i) stored = two_factor_store_otp_value(ctx, code, otp_config) ctx.context.internal_adapter.create_verification_value( identifier: "2fa-otp-#{data[:key]}", value: "#{stored}:0", expiresAt: Time.now + ((otp_config[:period] || 3).to_i * 60) ) sender.call({user: data[:session][:user], otp: code}, ctx) ctx.json({status: true}) end end |
.two_factor_set_trusted_device(ctx, config, user_id) ⇒ Object
546 547 548 549 550 551 552 553 |
# File 'lib/better_auth/plugins/two_factor.rb', line 546 def two_factor_set_trusted_device(ctx, config, user_id) max_age = config[:trust_device_max_age].to_i identifier = "trust-device-#{Crypto.random_string(32)}" token = Crypto.hmac_signature("#{user_id}!#{identifier}", ctx.context.secret, encoding: :base64url) ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: user_id, expiresAt: Time.now + max_age) = ctx.context.(TRUST_DEVICE_COOKIE_NAME, max_age: max_age) ctx.(.name, "#{token}!#{identifier}", ctx.context.secret, .attributes) end |
.two_factor_store_backup_codes(secret, codes, options) ⇒ Object
705 706 707 708 709 710 711 712 713 714 715 |
# File 'lib/better_auth/plugins/two_factor.rb', line 705 def two_factor_store_backup_codes(secret, codes, ) data = JSON.generate(codes) storage = [:store_backup_codes] if storage == "encrypted" Crypto.symmetric_encrypt(key: secret, data: data) elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call) storage[:encrypt].call(data) else data end end |
.two_factor_store_otp_value(ctx, code, options) ⇒ Object
733 734 735 736 737 738 739 740 741 742 743 744 745 746 |
# File 'lib/better_auth/plugins/two_factor.rb', line 733 def two_factor_store_otp_value(ctx, code, ) storage = [:store_otp] if storage == "hashed" Crypto.sha256(code, encoding: :base64url) elsif storage == "encrypted" Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: code) elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call) storage[:hash].call(code) elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call) storage[:encrypt].call(code) else code end end |
.two_factor_totp(secret, options: {}) ⇒ Object
666 667 668 669 |
# File 'lib/better_auth/plugins/two_factor.rb', line 666 def two_factor_totp(secret, options: {}) interval = Time.now.to_i / ([:period] || 30).to_i two_factor_totp_at(secret, interval, digits: ([:digits] || 6).to_i) end |
.two_factor_totp_at(secret, counter, digits:) ⇒ Object
677 678 679 680 681 682 683 |
# File 'lib/better_auth/plugins/two_factor.rb', line 677 def two_factor_totp_at(secret, counter, digits:) key = base32_decode(secret) digest = OpenSSL::HMAC.digest("SHA1", key, [counter].pack("Q>")) offset = digest.bytes.last & 0x0f binary = digest.byteslice(offset, 4).unpack1("N") & 0x7fffffff (binary % (10**digits)).to_s.rjust(digits, "0") end |
.two_factor_totp_enabled!(config) ⇒ Object
655 656 657 658 659 |
# File 'lib/better_auth/plugins/two_factor.rb', line 655 def two_factor_totp_enabled!(config) if config[:totp_options][:disable] raise APIError.new("BAD_REQUEST", message: "totp isn't configured") end end |
.two_factor_totp_uri(secret, issuer:, account:, options: {}) ⇒ Object
685 686 687 688 689 |
# File 'lib/better_auth/plugins/two_factor.rb', line 685 def two_factor_totp_uri(secret, issuer:, account:, options: {}) label = "#{issuer}:#{account}" params = {secret: secret, issuer: issuer, digits: [:digits] || 6, period: [:period] || 30} "otpauth://totp/#{URI.encode_www_form_component(label)}?#{URI.encode_www_form(params)}" end |
.two_factor_totp_valid?(secret, code, options: {}) ⇒ Boolean
671 672 673 674 675 |
# File 'lib/better_auth/plugins/two_factor.rb', line 671 def two_factor_totp_valid?(secret, code, options: {}) period = ([: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: ([:digits] || 6).to_i), code.to_s) } end |
.two_factor_trusted_device_valid?(ctx, config, user_id) ⇒ Boolean
555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 |
# File 'lib/better_auth/plugins/two_factor.rb', line 555 def two_factor_trusted_device_valid?(ctx, config, user_id) = ctx.context.(TRUST_DEVICE_COOKIE_NAME, max_age: config[:trust_device_max_age]) value = ctx.(.name, ctx.context.secret) return false unless value token, identifier = value.split("!", 2) expected = Crypto.hmac_signature("#{user_id}!#{identifier}", ctx.context.secret, encoding: :base64url) verification = if token && identifier && Crypto.constant_time_compare(token, expected) ctx.context.internal_adapter.consume_verification_value(identifier) end if verification && verification["value"] == user_id two_factor_set_trusted_device(ctx, config, user_id) true else Cookies.(ctx, ) false end end |
.two_factor_verification_context(ctx, config) ⇒ Object
467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 |
# File 'lib/better_auth/plugins/two_factor.rb', line 467 def two_factor_verification_context(ctx, config) session = Routes.current_session(ctx, allow_nil: true) if session key = "#{session[:user]["id"]}!#{session[:session]["id"]}" no_op_attempt = ->(_allowed_attempts) { {record_failure: -> {}, restore: -> {}} } return { session: session, key: key, valid: -> { ctx.json({token: session[:session]["token"], user: Schema.parse_output(ctx.context., "user", session[:user])}) }, begin_attempt: no_op_attempt } end = ctx.context.(TWO_FACTOR_COOKIE_NAME) identifier = ctx.(.name, ctx.context.secret) raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless identifier verification = ctx.context.internal_adapter.find_verification_value(identifier) raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless verification && verification["expiresAt"] > Time.now user = ctx.context.internal_adapter.find_user_by_id(verification["value"]) raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless user valid = lambda do consumed = ctx.context.internal_adapter.consume_verification_value(identifier) unless consumed && consumed["value"] == user["id"] raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) end dont_remember_me = Cookies.dont_remember?(ctx) new_session = ctx.context.internal_adapter.create_session(user["id"], dont_remember_me) raise APIError.new("INTERNAL_SERVER_ERROR", message: "failed to create session") unless new_session Cookies.(ctx, {session: new_session, user: user}, dont_remember_me) Cookies.(ctx, ) if normalize_hash(ctx.body)[:trust_device] two_factor_set_trusted_device(ctx, config, user["id"]) Cookies.(ctx, ctx.context.[:dont_remember]) end ctx.json({token: new_session["token"], user: Schema.parse_output(ctx.context., "user", user)}) end begin_attempt = lambda do |allowed_attempts| attempts_identifier = "2fa-attempts-#{identifier}" consumed = begin ctx.context.internal_adapter.consume_verification_value(attempts_identifier) rescue nil end raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless consumed raw_count = consumed["value"].to_s attempts = raw_count.match?(/\A\d+\z/) ? raw_count.to_i : allowed_attempts if attempts >= allowed_attempts begin ctx.context.internal_adapter.consume_verification_value(identifier) rescue nil end Cookies.(ctx, ) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE"]) end rearm = lambda do |count| created = ctx.context.internal_adapter.create_verification_value( identifier: attempts_identifier, value: count.to_s, expiresAt: verification["expiresAt"] ) raise Error, "Failed to re-arm two-factor attempt counter" unless created created end {record_failure: -> { rearm.call(attempts + 1) }, restore: -> { rearm.call(attempts) }} end {session: {session: nil, user: user}, key: identifier, valid: valid, begin_attempt: begin_attempt} end |
.two_factor_verification_response_schema ⇒ Object
397 398 399 400 401 402 403 404 405 |
# File 'lib/better_auth/plugins/two_factor.rb', line 397 def two_factor_verification_response_schema OpenAPI.object_schema( { token: {type: ["string", "null"]}, user: {type: ["object", "null"], "$ref": "#/components/schemas/User"}, status: {type: ["boolean", "null"]} } ) end |
.two_factor_verify_backup_code_endpoint(config) ⇒ Object
288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 |
# File 'lib/better_auth/plugins/two_factor.rb', line 288 def two_factor_verify_backup_code_endpoint(config) Endpoint.new(path: "/two-factor/verify-backup-code", method: "POST", metadata: two_factor_openapi("verifyBackupCode", "Verify a two factor backup code", two_factor_verification_response_schema)) do |ctx| body = normalize_hash(ctx.body) data = two_factor_verification_context(ctx, config) record = two_factor_record(ctx, config, data[:session][:user]["id"]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record sign_in = data[:session][:session].nil? two_factor_assert_not_locked!(ctx, config, record) if sign_in attempt = sign_in ? data[:begin_attempt].call(DEFAULT_TWO_FACTOR_ALLOWED_ATTEMPTS) : nil codes = begin two_factor_read_backup_codes(ctx.context.secret_config, record["backupCodes"], config[:backup_code_options]) rescue attempt&.fetch(:restore)&.call raise end unless codes.include?(body[:code].to_s) attempt&.fetch(:record_failure)&.call two_factor_record_failure!(ctx, config, record) if sign_in raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_BACKUP_CODE"]) end remaining = codes.reject { |code| code == body[:code].to_s } stored = begin two_factor_store_backup_codes(ctx.context.secret_config, remaining, config[:backup_code_options]) rescue attempt&.fetch(:restore)&.call raise end updated = ctx.context.adapter.update( model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}, {field: "backupCodes", value: record["backupCodes"]}], update: {backupCodes: stored} ) raise APIError.new("CONFLICT", message: "Failed to verify backup code. Please try again.") unless updated two_factor_reset_failures!(ctx, config, record) if sign_in body[:disable_session] ? ctx.json({token: data[:session][:session]&.fetch("token", nil), user: Schema.parse_output(ctx.context., "user", data[:session][:user])}) : data[:valid].call end end |
.two_factor_verify_otp_endpoint(config) ⇒ Object
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 |
# File 'lib/better_auth/plugins/two_factor.rb', line 243 def two_factor_verify_otp_endpoint(config) Endpoint.new(path: "/two-factor/verify-otp", method: "POST", metadata: two_factor_openapi("verifyTwoFactorOTP", "Verify a two factor OTP", two_factor_verification_response_schema)) do |ctx| body = normalize_hash(ctx.body) data = two_factor_verification_context(ctx, config) sign_in = data[:session][:session].nil? record = nil if sign_in record = two_factor_record(ctx, config, data[:session][:user]["id"]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless record two_factor_assert_not_locked!(ctx, config, record) end identifier = "2fa-otp-#{data[:key]}" verification = ctx.context.internal_adapter.consume_verification_value(identifier) stored, counter = verification&.fetch("value", nil).to_s.split(":", 2) unless verification raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["OTP_HAS_EXPIRED"]) end allowed = (config[:otp_options][:allowed_attempts] || 5).to_i if counter.to_i >= allowed raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE"]) end unless two_factor_otp_matches?(ctx, stored, body[:code].to_s, config[:otp_options]) rearmed = ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: "#{stored}:#{counter.to_i + 1}", expiresAt: verification["expiresAt"]) raise Error, "Failed to re-arm two-factor OTP attempt counter" unless rearmed two_factor_record_failure!(ctx, config, record) if sign_in raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"]) end two_factor_reset_failures!(ctx, config, record) if sign_in 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.(ctx, {session: new_session, user: updated_user}) next ctx.json({token: new_session["token"], user: Schema.parse_output(ctx.context., "user", updated_user)}) end data[:valid].call end end |
.two_factor_verify_totp_endpoint(config) ⇒ Object
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'lib/better_auth/plugins/two_factor.rb', line 176 def two_factor_verify_totp_endpoint(config) Endpoint.new(path: "/two-factor/verify-totp", method: "POST", metadata: two_factor_openapi("verifyTOTP", "Verify a TOTP code", two_factor_verification_response_schema)) do |ctx| two_factor_totp_enabled!(config) body = normalize_hash(ctx.body) data = two_factor_verification_context(ctx, config) record = two_factor_record(ctx, config, data[:session][:user]["id"]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record if !data[:session][:session] && record["verified"] == false raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) end sign_in = data[:session][:session].nil? two_factor_assert_not_locked!(ctx, config, record) if sign_in attempt = sign_in ? data[:begin_attempt].call(DEFAULT_TWO_FACTOR_ALLOWED_ATTEMPTS) : nil valid_code = begin secret = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: record["secret"]) two_factor_totp_valid?(secret, body[:code], options: config[:totp_options]) rescue attempt&.fetch(:restore)&.call raise end unless valid_code attempt&.fetch(:record_failure)&.call two_factor_record_failure!(ctx, config, record) if sign_in raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"]) end two_factor_reset_failures!(ctx, config, record) if sign_in if record["verified"] != true if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session] updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true) new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false) ctx.context.internal_adapter.delete_session(data[:session][:session]["token"]) Cookies.(ctx, {session: new_session, user: updated_user}) end ctx.context.adapter.update(model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {verified: true}) elsif !data[:session][:user]["twoFactorEnabled"] && data[:session][:session] updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true) new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false) ctx.context.internal_adapter.delete_session(data[:session][:session]["token"]) Cookies.(ctx, {session: new_session, user: updated_user}) end data[:valid].call end end |
.two_factor_view_backup_codes_endpoint(config) ⇒ Object
344 345 346 347 348 349 350 351 352 |
# File 'lib/better_auth/plugins/two_factor.rb', line 344 def two_factor_view_backup_codes_endpoint(config) Endpoint.new(method: "POST") do |ctx| body = normalize_hash(ctx.body) record = two_factor_record(ctx, config, body[:user_id]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record ctx.json({status: true, backupCodes: two_factor_read_backup_codes(ctx.context.secret_config, record["backupCodes"], config[:backup_code_options])}) end end |
.username(options = {}) ⇒ Object
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
# File 'lib/better_auth/plugins/username.rb', line 20 def username( = {}) config = normalize_hash() Plugin.new( id: "username", init: ->(_context) { {options: {database_hooks: username_database_hooks(config)}} }, endpoints: { sign_in_username: sign_in_username_endpoint(config), is_username_available: is_username_available_endpoint(config) }, schema: username_schema(config), hooks: { before: [ { matcher: ->(ctx) { username_mutation_path?(ctx.path) }, handler: ->(ctx) { validate_username_mutation!(ctx, config) } }, { matcher: ->(ctx) { username_mutation_path?(ctx.path) }, handler: ->(ctx) { mirror_username_fields!(ctx) } } ] }, error_codes: USERNAME_ERROR_CODES, options: config ) end |
.username_database_hooks(config) ⇒ Object
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 |
# File 'lib/better_auth/plugins/username.rb', line 215 def username_database_hooks(config) before_hook = lambda do |user, endpoint_context| data = user.dup raw_username = data["username"] raw_display_username = data["displayUsername"] if raw_username.is_a?(String) && !raw_username.empty? unless endpoint_context && username_mutation_path?(endpoint_context.path) username = username_for_validation(raw_username, config) validate_username!(username, config, status: "BAD_REQUEST") adapter = endpoint_context&.context&.adapter if adapter existing = adapter.find_one(model: "user", where: [{field: "username", value: normalize_username(username, config)}]) current_user_id = endpoint_context&.context&.current_session&.dig(:user, "id") if existing && existing["id"] != current_user_id raise APIError.new("BAD_REQUEST", code: "USERNAME_IS_ALREADY_TAKEN", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"]) end end end data["username"] = normalize_username(raw_username, config) data["displayUsername"] = raw_username unless raw_display_username.is_a?(String) && !raw_display_username.empty? end if data["displayUsername"].is_a?(String) && !data["displayUsername"].empty? display_username = if validation_order(config, :display_username) == "post-normalization" normalize_display_username(data["displayUsername"], config) else data["displayUsername"] end validator = config[:display_username_validator] unless !validator.respond_to?(:call) || validator.call(display_username) raise APIError.new("BAD_REQUEST", code: "INVALID_DISPLAY_USERNAME", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"]) end data["displayUsername"] = normalize_display_username(data["displayUsername"], config) end {data: data} end { user: { create: {before: before_hook}, update: {before: before_hook} } } end |
.username_for_sign_in_validation(username, config) ⇒ Object
334 335 336 |
# File 'lib/better_auth/plugins/username.rb', line 334 def username_for_sign_in_validation(username, config) (validation_order(config, :username) == "pre-normalization") ? normalize_username(username, config) : username end |
.username_for_validation(username, config) ⇒ Object
330 331 332 |
# File 'lib/better_auth/plugins/username.rb', line 330 def username_for_validation(username, config) (validation_order(config, :username) == "post-normalization") ? normalize_username(username, config) : username end |
.username_mutation_path?(path) ⇒ Boolean
356 357 358 |
# File 'lib/better_auth/plugins/username.rb', line 356 def username_mutation_path?(path) path == "/sign-up/email" || path == "/update-user" end |
.username_schema(config) ⇒ Object
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/better_auth/plugins/username.rb', line 193 def username_schema(config) { user: { fields: { username: { type: "string", required: false, sortable: true, unique: true, returned: true, field_name: "username" }, displayUsername: { type: "string", required: false, field_name: "display_username" } } } } end |
.valid_signed_token?(ctx, signed_token) ⇒ Boolean
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
415 416 417 418 419 420 421 422 423 424 425 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 415 def (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
718 719 720 |
# File 'lib/better_auth/plugins/email_otp.rb', line 718 def validate_email_otp_email!(email) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email) end |
.validate_email_otp_type!(type) ⇒ Object
722 723 724 725 726 |
# File 'lib/better_auth/plugins/email_otp.rb', line 722 def validate_email_otp_type!(type) return if %w[email-verification sign-in forget-password change-email].include?(type) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) end |
.validate_i18n_translations!(config) ⇒ Object
75 76 77 78 79 80 |
# File 'lib/better_auth/plugins/i18n.rb', line 75 def validate_i18n_translations!(config) translations = config[:translations] if translations.nil? || translations.empty? raise BetterAuth::Error, "i18n plugin: translations object is empty" end end |
.validate_invitation_team_ids!(ctx, config, organization_id, raw_value, team_ids) ⇒ Object
1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 |
# File 'lib/better_auth/plugins/organization.rb', line 1481 def validate_invitation_team_ids!(ctx, config, organization_id, raw_value, team_ids) return if raw_value.nil? unless org_truthy?(config.dig(:teams, :enabled)) raise APIError.new("BAD_REQUEST", message: "Teams are not enabled") end if Array(raw_value).any? { |team_id| team_id.to_s.include?(",") } raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVALID_TEAM_ID")) end team_ids.each do |team_id| team = team_by_id(ctx, team_id) unless team && team["organizationId"] == organization_id raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) end end end |
.validate_jwt_options!(config) ⇒ Object
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/better_auth/plugins/jwt.rb', line 92 def (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 |
.validate_magic_link_callback!(ctx, value, label) ⇒ Object
224 225 226 227 228 229 |
# File 'lib/better_auth/plugins/magic_link.rb', line 224 def validate_magic_link_callback!(ctx, value, label) return if value.nil? || value.to_s.empty? return if ctx.context.trusted_origin?(value.to_s, allow_relative_paths: true) raise APIError.new("FORBIDDEN", message: "Invalid #{label}") end |
.validate_organization_roles!(ctx, config, organization_id, role_names, adapter: ctx.context.adapter) ⇒ Object
1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 |
# File 'lib/better_auth/plugins/organization.rb', line 1110 def validate_organization_roles!(ctx, config, organization_id, role_names, adapter: ctx.context.adapter) valid_static_roles = (organization_default_roles(config).keys + organization_roles(config).keys).uniq unknown_roles = role_names.reject { |role| valid_static_roles.include?(role) }.uniq return if unknown_roles.empty? if org_truthy?(config.dig(:dynamic_access_control, :enabled)) found_roles = adapter.find_many( model: "organizationRole", where: [{field: "organizationId", value: organization_id}, {field: "role", value: unknown_roles, operator: "in"}] ).map { |role| role["role"] } unknown_roles -= found_roles end return if unknown_roles.empty? raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND")) end |
.validate_permission_resources!(config, permission) ⇒ Object
1471 1472 1473 1474 1475 |
# File 'lib/better_auth/plugins/organization.rb', line 1471 def (config, ) valid = (config[:ac] || create_access_control(ORGANIZATION_DEFAULT_STATEMENTS)).statements.keys invalid = .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
430 431 432 433 434 435 436 |
# File 'lib/better_auth/plugins/phone_number.rb', line 430 def validate_phone_number!(config, phone_number) validator = config[:phone_number_validator] return unless validator.respond_to?(:call) return if validator.call(phone_number) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER"]) end |
.validate_stored_invitation_teams!(adapter, organization_id, team_ids) ⇒ Object
1548 1549 1550 1551 1552 1553 |
# File 'lib/better_auth/plugins/organization.rb', line 1548 def validate_stored_invitation_teams!(adapter, organization_id, team_ids) team_ids.each do |team_id| team = adapter.find_one(model: "team", where: [{field: "id", value: team_id}, {field: "organizationId", value: organization_id}]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team end end |
.validate_unique_phone_number!(ctx, phone_number) ⇒ Object
423 424 425 426 427 428 |
# File 'lib/better_auth/plugins/phone_number.rb', line 423 def validate_unique_phone_number!(ctx, phone_number) return if phone_number.to_s.empty? existing = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number.to_s}]) raise APIError.new("UNPROCESSABLE_ENTITY", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_EXIST"]) if existing end |
.validate_username!(username, config, status:) ⇒ Object
316 317 318 319 320 321 322 323 324 325 326 327 328 |
# File 'lib/better_auth/plugins/username.rb', line 316 def validate_username!(username, config, status:) if username.length < min_username_length(config) raise APIError.new(status, code: "USERNAME_TOO_SHORT", message: USERNAME_ERROR_CODES["USERNAME_TOO_SHORT"]) end if username.length > max_username_length(config) raise APIError.new(status, code: "USERNAME_TOO_LONG", message: USERNAME_ERROR_CODES["USERNAME_TOO_LONG"]) end validator = config[:username_validator] valid = validator.respond_to?(:call) ? validator.call(username) : default_username_valid?(username) raise APIError.new(status, code: "INVALID_USERNAME", message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) unless valid end |
.validate_username_mutation!(ctx, config) ⇒ Object
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
# File 'lib/better_auth/plugins/username.rb', line 259 def validate_username_mutation!(ctx, config) body = normalize_hash(ctx.body) raw_username = body.key?(:username) ? body[:username] : nil if raw_username.nil? && present?(body[:display_username]) begin validate_username!(username_for_validation(body[:display_username], config), config, status: "BAD_REQUEST") body[:username] = body[:display_username] raw_username = body[:username] rescue APIError # A display-only value that cannot be a username remains display-only. end end username = if raw_username.is_a?(String) && validation_order(config, :username) == "post-normalization" normalize_username(raw_username, config) else raw_username end if username.is_a?(String) validate_username!(username, config, status: "BAD_REQUEST") existing = ctx.context.adapter.find_one(model: "user", where: [{field: "username", value: normalize_username(username, config)}]) current = (ctx.path == "/update-user") ? Routes.current_session(ctx, allow_nil: true) : nil same_user = existing && current && existing["id"] == current[:session]["userId"] if existing && ctx.path == "/sign-up/email" raise APIError.new("UNPROCESSABLE_ENTITY", code: "USERNAME_IS_ALREADY_TAKEN", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"]) end if existing && ctx.path == "/update-user" && !same_user raise APIError.new("BAD_REQUEST", code: "USERNAME_IS_ALREADY_TAKEN", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"]) end end raw_display_username = body.key?(:display_username) ? body[:display_username] : nil display_username = if raw_display_username.is_a?(String) && validation_order(config, :display_username) == "post-normalization" normalize_display_username(raw_display_username, config) else raw_display_username end if display_username.is_a?(String) validator = config[:display_username_validator] unless !validator.respond_to?(:call) || validator.call(display_username) raise APIError.new("BAD_REQUEST", code: "INVALID_DISPLAY_USERNAME", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"]) end end ctx.body = body nil end |
.validation_order(config, field) ⇒ Object
351 352 353 354 |
# File 'lib/better_auth/plugins/username.rb', line 351 def validation_order(config, field) order = config[:validation_order] || {} order[field] || "pre-normalization" end |
.verification_jwks(ctx, config) ⇒ Object
284 285 286 287 288 289 |
# File 'lib/better_auth/plugins/jwt.rb', line 284 def verification_jwks(ctx, config) local = all_jwks(ctx, config) return local unless config.dig(:jwks, :remote_url) local + remote_jwks(ctx, config) end |
.verification_uri(ctx, config) ⇒ Object
392 393 394 395 396 397 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 392 def verification_uri(ctx, config) uri = config[:verification_uri] || "/device" return uri if uri.to_s.start_with?("http://", "https://") "#{OAuthProtocol.endpoint_base(ctx)}#{uri.to_s.start_with?("/") ? uri : "/#{uri}"}" end |
.verified_multi_session_tokens(ctx) ⇒ Object
199 200 201 |
# File 'lib/better_auth/plugins/multi_session.rb', line 199 def verified_multi_session_tokens(ctx) (ctx).filter_map { |name| ctx.(name, ctx.context.secret) } end |
.verify_eddsa_jwt(ctx, token, key, config) ⇒ Object
457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 |
# File 'lib/better_auth/plugins/jwt.rb', line 457 def verify_eddsa_jwt(ctx, token, key, config) header_segment, payload_segment, signature_segment = token.split(".", 3) return nil unless header_segment && payload_segment && signature_segment public_key = JWT.public_key(key) signing_input = "#{header_segment}.#{payload_segment}" signature = Crypto.base64url_decode(signature_segment) return nil unless public_key.verify(nil, signature, signing_input) payload = JSON.parse(Crypto.base64url_decode(payload_segment)) now = Time.now.to_i return nil if payload["exp"] && payload["exp"].to_i <= now issuer = config.dig(:jwt, :issuer) || ctx.context.canonical_base_url audience = config.dig(:jwt, :audience) || ctx.context.canonical_base_url return nil if issuer && payload["iss"] != issuer return nil if audience && Array(payload["aud"]).map(&:to_s).none?(audience.to_s) return nil unless jwt_payload_valid?(payload) payload rescue JSON::ParserError, OpenSSL::PKey::PKeyError, ArgumentError nil end |
.verify_email_otp_endpoint(config) ⇒ Object
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/better_auth/plugins/email_otp.rb', line 221 def verify_email_otp_endpoint(config) Endpoint.new( path: "/email-otp/verify-email", method: "POST", metadata: { openapi: { operationId: "verifyEmailOTP", description: "Verify an email address with an OTP", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { email: {type: "string"}, otp: {type: "string"} }, required: ["email", "otp"] ) ), responses: { "200" => OpenAPI.json_response( "Email verified", OpenAPI.object_schema( { status: {type: "boolean"}, token: {type: ["string", "null"]}, user: {type: "object", "$ref": "#/components/schemas/User"} }, required: ["status", "user"] ) ) } } } ) do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase otp = body[:otp].to_s validate_email_otp_email!(email) email_otp_verify!(ctx, config, email: email, type: "email-verification", otp: otp) found = ctx.context.internal_adapter.find_user_by_email(email) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless found user = found[:user] call_email_verification_option(ctx, :before_email_verification, user) updated = ctx.context.internal_adapter.update_user(user["id"], email: email, emailVerified: true) call_email_verification_option(ctx, :on_email_verification, updated) call_email_verification_option(ctx, :after_email_verification, updated) if ctx.context..email_verification[:auto_sign_in_after_verification] session = ctx.context.internal_adapter.create_session(updated["id"]) Cookies.(ctx, {session: session, user: updated}) next ctx.json({status: true, token: session["token"], user: Schema.parse_output(ctx.context., "user", updated)}) end current = Routes.current_session(ctx, allow_nil: true) Cookies.(ctx, {session: current[:session], user: updated}) if current ctx.json({status: true, token: nil, user: Schema.parse_output(ctx.context., "user", updated)}) end end |
.verify_jwt_endpoint(config) ⇒ Object
171 172 173 174 175 176 177 178 |
# File 'lib/better_auth/plugins/jwt.rb', line 171 def verify_jwt_endpoint(config) Endpoint.new(path: nil, method: "POST") do |ctx| token = fetch_value(ctx.body, "token") issuer = fetch_value(ctx.body, "issuer") = issuer ? deep_merge(config, jwt: {issuer: issuer}) : config ctx.json({payload: verify_jwt_token(ctx, token, )}) end end |
.verify_jwt_token(ctx, token, config) ⇒ Object
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/better_auth/plugins/jwt.rb', line 228 def verify_jwt_token(ctx, token, config) header = ::JWT.decode(token.to_s, nil, false).last key = verification_jwks(ctx, config).find { |entry| entry["id"] == header["kid"] || entry["kid"] == header["kid"] } return nil unless key return verify_eddsa_jwt(ctx, token.to_s, key, config) if (key["alg"] || header["alg"]) == "EdDSA" = { algorithm: key["alg"] || "RS256", iss: config.dig(:jwt, :issuer) || ctx.context.canonical_base_url, verify_iss: true, aud: config.dig(:jwt, :audience) || ctx.context.canonical_base_url, verify_aud: true } decoded, = ::JWT.decode(token.to_s, JWT.public_key(key), true, ) jwt_payload_valid?(decoded) ? decoded : nil rescue ::JWT::DecodeError, OpenSSL::PKey::PKeyError nil end |
.verify_one_time_token_endpoint(config) ⇒ Object
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 63 def verify_one_time_token_endpoint(config) Endpoint.new( path: "/one-time-token/verify", method: "POST", metadata: { openapi: { operationId: "verifyOneTimeToken", description: "Verify a one-time token and restore its session", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { token: {type: "string"} }, required: ["token"] ) ), responses: { "200" => OpenAPI.json_response("Session restored", OpenAPI.session_response_schema_pair) } } } ) do |ctx| body = normalize_hash(ctx.body) token = body[:token].to_s stored_token = one_time_token_stored_value(config, token) verification = ctx.context.internal_adapter.consume_verification_value("one-time-token:#{stored_token}") raise APIError.new("BAD_REQUEST", message: "Invalid token") unless verification session = ctx.context.internal_adapter.find_session(verification["value"]) raise APIError.new("BAD_REQUEST", message: "Session not found") unless session raise APIError.new("BAD_REQUEST", message: "Session expired") if Routes.expired_time?(session[:session]["expiresAt"]) Cookies.(ctx, session) unless config[:disable_set_session_cookie] ctx.json(session) end end |
.verify_phone_number_endpoint(config) ⇒ Object
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/better_auth/plugins/phone_number.rb', line 179 def verify_phone_number_endpoint(config) Endpoint.new( path: "/phone-number/verify", method: "POST", metadata: { openapi: { operationId: "verifyPhoneNumber", description: "Verify a phone number OTP", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { phoneNumber: {type: "string"}, code: {type: "string"}, updatePhoneNumber: {type: ["boolean", "null"]}, disableSession: {type: ["boolean", "null"]} }, required: ["phoneNumber", "code"] ) ), responses: { "200" => OpenAPI.json_response( "Phone number verified", OpenAPI.object_schema( { status: {type: "boolean"}, token: {type: ["string", "null"]}, user: {type: "object", "$ref": "#/components/schemas/User"} }, required: ["status", "user"] ) ) } } } ) do |ctx| body = normalize_hash(ctx.body) phone_number = body[:phone_number].to_s code = body[:code].to_s phone_number_verify_code!(ctx, config, phone_number, code) if truthy?(body[:update_phone_number]) session = Routes.current_session(ctx) existing = ctx.context.adapter.find_many(model: "user", where: [{field: "phoneNumber", value: phone_number}]) unless existing.empty? raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_EXIST"]) end updated = ctx.context.internal_adapter.update_user( session[:user]["id"], phoneNumber: phone_number, phoneNumberVerified: true ) next ctx.json({status: true, token: session[:session]["token"], user: Schema.parse_output(ctx.context., "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., "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.(ctx, {session: session, user: user}) ctx.json({status: true, token: session["token"], user: Schema.parse_output(ctx.context., "user", user)}) end end |
.verify_siwe_message_endpoint(config) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/better_auth/plugins/siwe.rb', line 78 def (config) Endpoint.new( path: "/siwe/verify", method: "POST", body_schema: ->(body) { siwe_verify_body(body, config) }, metadata: { openapi: { operationId: "verifySiweMessage", description: "Verify a Sign-In with Ethereum message", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { walletAddress: {type: "string"}, chainId: {type: ["number", "string", "null"]}, message: {type: "string"}, signature: {type: "string"}, email: {type: ["string", "null"]} }, required: ["walletAddress", "message", "signature"] ) ), responses: { "200" => OpenAPI.json_response( "SIWE message verified", OpenAPI.object_schema( { token: {type: "string"}, success: {type: "boolean"}, user: {type: "object"} }, required: ["token", "success", "user"] ) ) } } } ) do |ctx| body = normalize_hash(ctx.body) wallet_address = siwe_normalize_wallet!(body[:wallet_address]) chain_id = siwe_chain_id(body[:chain_id]) email = body[:email].to_s.downcase anonymous = config.key?(:anonymous) ? config[:anonymous] : true raise APIError.new("BAD_REQUEST", message: "Email is required when anonymous is disabled.") if anonymous == false && email.empty? raise APIError.new("BAD_REQUEST", message: "Invalid email address") if !email.empty? && !SIWE_EMAIL_PATTERN.match?(email) verification = ctx.context.internal_adapter.consume_verification_value(siwe_identifier(wallet_address, chain_id)) unless verification raise APIError.new("UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE", message: "Unauthorized: Invalid or expired nonce") end (body[:message], config[:domain], wallet_address, chain_id, verification["value"]) verified = (config, body, wallet_address, chain_id, verification["value"], ctx) raise APIError.new("UNAUTHORIZED", message: "Unauthorized: Invalid SIWE signature") unless verified user = siwe_find_user(ctx, wallet_address, chain_id) user ||= siwe_create_user(ctx, config, wallet_address, chain_id, email, anonymous) siwe_ensure_wallet_and_account(ctx, user, wallet_address, chain_id) session = ctx.context.internal_adapter.create_session(user["id"]) session_data = {session: session, user: user} Cookies.(ctx, session_data) ctx.json({ token: session["token"], success: true, user: { id: user["id"], walletAddress: wallet_address, chainId: chain_id } }) rescue APIError raise rescue raise APIError.new("UNAUTHORIZED", message: "Something went wrong. Please try again later.") end end |
.yandex(options = {}) ⇒ Object
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 215 def yandex( = {}) data = normalize_hash() generic_oauth_provider_config( data, provider_id: "yandex", authorization_url: "https://oauth.yandex.com/authorize", token_url: "https://oauth.yandex.com/token", scopes: ["login:info", "login:email", "login:avatar"], get_user_info: ->(tokens) { profile = generic_oauth_fetch_json( "https://login.yandex.ru/info?format=json", authorization: "OAuth #{fetch_value(tokens, "accessToken")}" ) return nil unless profile avatar_id = fetch_value(profile, "default_avatar_id") image = if !fetch_value(profile, "is_avatar_empty") && !avatar_id.to_s.empty? "https://avatars.yandex.net/get-yapic/#{avatar_id}/islands-200" end { id: fetch_value(profile, "id"), name: fetch_value(profile, "display_name") || fetch_value(profile, "real_name") || fetch_value(profile, "first_name") || fetch_value(profile, "login"), email: fetch_value(profile, "default_email") || Array(fetch_value(profile, "emails")).first, emailVerified: false, image: image }.compact } ) end |