Module: BetterAuth::Plugins
- Defined in:
- lib/better_auth/plugins.rb,
lib/better_auth/plugins/jwt.rb,
lib/better_auth/plugins/mcp.rb,
lib/better_auth/plugins/sso.rb,
lib/better_auth/plugins/expo.rb,
lib/better_auth/plugins/scim.rb,
lib/better_auth/plugins/siwe.rb,
lib/better_auth/plugins/admin.rb,
lib/better_auth/plugins/access.rb,
lib/better_auth/plugins/bearer.rb,
lib/better_auth/plugins/stripe.rb,
lib/better_auth/plugins/api_key.rb,
lib/better_auth/plugins/captcha.rb,
lib/better_auth/plugins/one_tap.rb,
lib/better_auth/plugins/passkey.rb,
lib/better_auth/plugins/open_api.rb,
lib/better_auth/plugins/username.rb,
lib/better_auth/plugins/anonymous.rb,
lib/better_auth/plugins/email_otp.rb,
lib/better_auth/plugins/magic_link.rb,
lib/better_auth/plugins/two_factor.rb,
lib/better_auth/plugins/oauth_proxy.rb,
lib/better_auth/plugins/admin/schema.rb,
lib/better_auth/plugins/organization.rb,
lib/better_auth/plugins/phone_number.rb,
lib/better_auth/plugins/generic_oauth.rb,
lib/better_auth/plugins/multi_session.rb,
lib/better_auth/plugins/oidc_provider.rb,
lib/better_auth/plugins/custom_session.rb,
lib/better_auth/plugins/oauth_protocol.rb,
lib/better_auth/plugins/oauth_provider.rb,
lib/better_auth/plugins/one_time_token.rb,
lib/better_auth/plugins/additional_fields.rb,
lib/better_auth/plugins/have_i_been_pwned.rb,
lib/better_auth/plugins/last_login_method.rb,
lib/better_auth/plugins/organization/schema.rb,
lib/better_auth/plugins/device_authorization.rb
Defined Under Namespace
Modules: AdminSchema, JWT, MCP, OAuthProtocol, OIDCProvider, OrganizationSchema Classes: AccessControl, Role
Constant Summary collapse
- SIWE_WALLET_PATTERN =
/\A0[xX][a-fA-F0-9]{40}\z/- SIWE_EMAIL_PATTERN =
/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/- ADMIN_ERROR_CODES =
{ "FAILED_TO_CREATE_USER" => "Failed to create user", "USER_ALREADY_EXISTS" => "User already exists.", "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL" => "User already exists. Use another email.", "YOU_CANNOT_BAN_YOURSELF" => "You cannot ban yourself", "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE" => "You are not allowed to change users role", "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS" => "You are not allowed to create users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS" => "You are not allowed to list users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS" => "You are not allowed to list users sessions", "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS" => "You are not allowed to ban users", "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS" => "You are not allowed to impersonate users", "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS" => "You are not allowed to revoke users sessions", "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS" => "You are not allowed to delete users", "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD" => "You are not allowed to set users password", "BANNED_USER" => "You have been banned from this application", "YOU_ARE_NOT_ALLOWED_TO_GET_USER" => "You are not allowed to get user", "NO_DATA_TO_UPDATE" => "No data to update", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS" => "You are not allowed to update users", "YOU_CANNOT_REMOVE_YOURSELF" => "You cannot remove yourself", "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE" => "You are not allowed to set a non-existent role value", "YOU_CANNOT_IMPERSONATE_ADMINS" => "You cannot impersonate admins", "INVALID_ROLE_TYPE" => "Invalid role type" }.freeze
- ADMIN_DEFAULT_STATEMENTS =
{ user: ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "get", "update"], session: ["list", "revoke", "delete"] }.freeze
- ADMIN_DEFAULT_ROLE_STATEMENTS =
{ user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "get", "update"], session: ["list", "revoke", "delete"] }.freeze
- BEARER_SCHEME =
"bearer "- CAPTCHA_EXTERNAL_ERROR_CODES =
{ "VERIFICATION_FAILED" => "Captcha verification failed", "MISSING_RESPONSE" => "Missing CAPTCHA response", "UNKNOWN_ERROR" => "Something went wrong" }.freeze
- CAPTCHA_INTERNAL_ERROR_CODES =
{ "MISSING_SECRET_KEY" => "Missing secret key", "SERVICE_UNAVAILABLE" => "CAPTCHA service unavailable" }.freeze
- CAPTCHA_DEFAULT_ENDPOINTS =
[ "/sign-up/email", "/sign-in/email", "/request-password-reset" ].freeze
- CAPTCHA_SITE_VERIFY_URLS =
{ "cloudflare-turnstile" => "https://challenges.cloudflare.com/turnstile/v0/siteverify", "google-recaptcha" => "https://www.google.com/recaptcha/api/siteverify", "hcaptcha" => "https://api.hcaptcha.com/siteverify", "captchafox" => "https://api.captchafox.com/siteverify" }.freeze
- USERNAME_ERROR_CODES =
{ "INVALID_USERNAME_OR_PASSWORD" => "Invalid username or password", "EMAIL_NOT_VERIFIED" => "Email not verified", "UNEXPECTED_ERROR" => "Unexpected error", "USERNAME_IS_ALREADY_TAKEN" => "Username is already taken. Please try another.", "USERNAME_TOO_SHORT" => "Username is too short", "USERNAME_TOO_LONG" => "Username is too long", "INVALID_USERNAME" => "Username is invalid", "INVALID_DISPLAY_USERNAME" => "Display username is invalid" }.freeze
- ANONYMOUS_ERROR_CODES =
{ "INVALID_EMAIL_FORMAT" => "Email was not generated in a valid format", "FAILED_TO_CREATE_USER" => "Failed to create user", "COULD_NOT_CREATE_SESSION" => "Could not create session", "ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY" => "Anonymous users cannot sign in again anonymously", "FAILED_TO_DELETE_ANONYMOUS_USER" => "Failed to delete anonymous user", "USER_IS_NOT_ANONYMOUS" => "User is not anonymous", "DELETE_ANONYMOUS_USER_DISABLED" => "Deleting anonymous users is disabled" }.freeze
- EMAIL_OTP_ERROR_CODES =
{ "OTP_EXPIRED" => "OTP expired", "INVALID_OTP" => "Invalid OTP", "TOO_MANY_ATTEMPTS" => "Too many attempts" }.freeze
- TWO_FACTOR_ERROR_CODES =
{ "OTP_NOT_ENABLED" => "OTP not enabled", "OTP_HAS_EXPIRED" => "OTP has expired", "TOTP_NOT_ENABLED" => "TOTP not enabled", "TWO_FACTOR_NOT_ENABLED" => "Two factor isn't enabled", "BACKUP_CODES_NOT_ENABLED" => "Backup codes aren't enabled", "INVALID_BACKUP_CODE" => "Invalid backup code", "INVALID_CODE" => "Invalid code", "TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE" => "Too many attempts. Please request a new code.", "INVALID_TWO_FACTOR_COOKIE" => "Invalid two factor cookie" }.freeze
- TWO_FACTOR_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
- BASE32_ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"- ORGANIZATION_ERROR_CODES =
{ "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION" => "You are not allowed to create a new organization", "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS" => "You have reached the maximum number of organizations", "ORGANIZATION_ALREADY_EXISTS" => "Organization already exists", "ORGANIZATION_SLUG_ALREADY_TAKEN" => "Organization slug already taken", "ORGANIZATION_NOT_FOUND" => "Organization not found", "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION" => "User is not a member of the organization", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION" => "You are not allowed to update this organization", "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION" => "You are not allowed to delete this organization", "NO_ACTIVE_ORGANIZATION" => "No active organization", "USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION" => "User is already a member of this organization", "MEMBER_NOT_FOUND" => "Member not found", "ROLE_NOT_FOUND" => "Role not found", "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM" => "You are not allowed to create a new team", "TEAM_ALREADY_EXISTS" => "Team already exists", "TEAM_NOT_FOUND" => "Team not found", "YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER" => "You cannot leave the organization as the only owner", "YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER" => "You cannot leave the organization without an owner", "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER" => "You are not allowed to delete this member", "YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION" => "You are not allowed to invite users to this organization", "USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION" => "User is already invited to this organization", "INVITATION_NOT_FOUND" => "Invitation not found", "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION" => "You are not the recipient of the invitation", "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION" => "Email verification required before accepting or rejecting invitation", "YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION" => "You are not allowed to cancel this invitation", "INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION" => "Inviter is no longer a member of the organization", "YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE" => "You are not allowed to invite a user with this role", "FAILED_TO_RETRIEVE_INVITATION" => "Failed to retrieve invitation", "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS" => "You have reached the maximum number of teams", "UNABLE_TO_REMOVE_LAST_TEAM" => "Unable to remove last team", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER" => "You are not allowed to update this member", "ORGANIZATION_MEMBERSHIP_LIMIT_REACHED" => "Organization membership limit reached", "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION" => "You are not allowed to create teams in this organization", "YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION" => "You are not allowed to delete teams in this organization", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM" => "You are not allowed to update this team", "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM" => "You are not allowed to delete this team", "INVITATION_LIMIT_REACHED" => "Invitation limit reached", "TEAM_MEMBER_LIMIT_REACHED" => "Team member limit reached", "USER_IS_NOT_A_MEMBER_OF_THE_TEAM" => "User is not a member of the team", "YOU_CAN_NOT_ACCESS_THE_MEMBERS_OF_THIS_TEAM" => "You are not allowed to list the members of this team", "YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM" => "You do not have an active team", "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER" => "You are not allowed to create a new member", "YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER" => "You are not allowed to remove a team member", "YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION" => "You are not allowed to access this organization as an owner", "YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION" => "You are not a member of this organization", "MISSING_AC_INSTANCE" => "Dynamic Access Control requires a pre-defined ac instance on the server auth plugin. Read server logs for more information", "YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE" => "You must be in an organization to create a role", "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE" => "You are not allowed to create a role", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE" => "You are not allowed to update a role", "YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE" => "You are not allowed to delete a role", "YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE" => "You are not allowed to read a role", "YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE" => "You are not allowed to list a role", "YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE" => "You are not allowed to get a role", "TOO_MANY_ROLES" => "This organization has too many roles", "INVALID_RESOURCE" => "The provided permission includes an invalid resource", "ROLE_NAME_IS_ALREADY_TAKEN" => "That role name is already taken", "CANNOT_DELETE_A_PRE_DEFINED_ROLE" => "Cannot delete a pre-defined role", "ROLE_IS_ASSIGNED_TO_MEMBERS" => "Cannot delete a role that is assigned to members. Please reassign the members to a different role first" }.freeze
- ORGANIZATION_DEFAULT_STATEMENTS =
{ organization: ["update", "delete"], member: ["create", "update", "delete"], invitation: ["create", "cancel"], team: ["create", "update", "delete"], ac: ["create", "read", "update", "delete"] }.freeze
- PHONE_NUMBER_ERROR_CODES =
{ "INVALID_PHONE_NUMBER" => "Invalid phone number", "PHONE_NUMBER_EXIST" => "Phone number already exists", "PHONE_NUMBER_NOT_EXIST" => "phone number isn't registered", "INVALID_PHONE_NUMBER_OR_PASSWORD" => "Invalid phone number or password", "UNEXPECTED_ERROR" => "Unexpected error", "OTP_NOT_FOUND" => "OTP not found", "OTP_EXPIRED" => "OTP expired", "INVALID_OTP" => "Invalid OTP", "PHONE_NUMBER_NOT_VERIFIED" => "Phone number not verified", "PHONE_NUMBER_CANNOT_BE_UPDATED" => "Phone number cannot be updated", "SEND_OTP_NOT_IMPLEMENTED" => "sendOTP not implemented", "TOO_MANY_ATTEMPTS" => "Too many attempts" }.freeze
- GENERIC_OAUTH_ERROR_CODES =
{ "INVALID_OAUTH_CONFIGURATION" => "Invalid OAuth configuration", "TOKEN_URL_NOT_FOUND" => "Invalid OAuth configuration. Token URL not found.", "PROVIDER_CONFIG_NOT_FOUND" => "No config found for provider", "PROVIDER_ID_REQUIRED" => "Provider ID is required", "INVALID_OAUTH_CONFIG" => "Invalid OAuth configuration.", "SESSION_REQUIRED" => "Session is required", "ISSUER_MISMATCH" => "OAuth issuer mismatch. The authorization server issuer does not match the expected value (RFC 9207).", "ISSUER_MISSING" => "OAuth issuer parameter missing. The authorization server did not include the required iss parameter (RFC 9207)." }.freeze
- MULTI_SESSION_ERROR_CODES =
{ "INVALID_SESSION_TOKEN" => "Invalid session token" }.freeze
- HAVE_I_BEEN_PWNED_ERROR_CODES =
{ "PASSWORD_COMPROMISED" => "The password you entered has been compromised. Please choose a different password." }.freeze
- HAVE_I_BEEN_PWNED_DEFAULT_PATHS =
[ "/sign-up/email", "/change-password", "/reset-password" ].freeze
- DEVICE_AUTHORIZATION_ERROR_CODES =
{ "INVALID_DEVICE_CODE" => "Invalid device code", "EXPIRED_DEVICE_CODE" => "Device code has expired", "EXPIRED_USER_CODE" => "User code has expired", "AUTHORIZATION_PENDING" => "Authorization pending", "ACCESS_DENIED" => "Access denied", "INVALID_USER_CODE" => "Invalid user code", "DEVICE_CODE_ALREADY_PROCESSED" => "Device code already processed", "POLLING_TOO_FREQUENTLY" => "Polling too frequently", "USER_NOT_FOUND" => "User not found", "FAILED_TO_CREATE_SESSION" => "Failed to create session", "INVALID_DEVICE_CODE_STATUS" => "Invalid device code status", "AUTHENTICATION_REQUIRED" => "Authentication required" }.freeze
Class Method Summary collapse
- .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_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_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_sort(query) ⇒ Object
- .admin_user_where(query) ⇒ Object
- .admin_validate_roles!(roles, config) ⇒ Object
- .all_jwks(ctx, config) ⇒ 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) ⇒ Object
- .apply_bearer_token(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
- .bearer_session_cookie(line) ⇒ Object
- .call_email_verification_option(ctx, key, user) ⇒ 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_multi_session_cookies(ctx) ⇒ Object
- .cookie_header_from_set_cookie(set_cookie) ⇒ 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_error_responses ⇒ Object
- .default_username_valid?(username) ⇒ Boolean
- .delete_anonymous_user_endpoint(config) ⇒ 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_deny_endpoint ⇒ Object
- .device_token_endpoint(config) ⇒ Object
- .device_verify_endpoint ⇒ 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(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
- .empty_request_body ⇒ Object
- .encode_eddsa_jwt(payload, private_key, kid) ⇒ Object
- .ensure_not_last_owner!(ctx, member) ⇒ Object
- .error_response(description, required: 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_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
- .forget_password_email_otp_endpoint(config) ⇒ 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:) ⇒ Object
- .generic_oauth_discovery(provider) ⇒ Object
- .generic_oauth_error_url(base_url, error) ⇒ 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) ⇒ 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_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_social_providers(config, context) ⇒ Object
- .generic_oauth_state_error_url(ctx) ⇒ Object
- .generic_oauth_token_for_storage(ctx, token) ⇒ Object
- .generic_oauth_token_scopes(scope) ⇒ 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) ⇒ 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
- .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
- .line(options = {}) ⇒ Object
- .link_anonymous_user(ctx, config) ⇒ Object
- .list_device_sessions_endpoint ⇒ Object
- .list_members_for(ctx, organization_id, query = {}) ⇒ 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(ctx, token, body) ⇒ Object
- .magic_link_verify_endpoint(config) ⇒ Object
- .max_username_length(config) ⇒ Object
- .mcp(options = {}) ⇒ Object
- .mcp_authenticate_token_client!(ctx, body, config) ⇒ Object
- .mcp_authorization_redirect(ctx, config, query, session) ⇒ Object
- .mcp_authorize_endpoint(config) ⇒ Object
- .mcp_endpoints(config) ⇒ Object
- .mcp_get_session_endpoint(config) ⇒ Object
- .mcp_jwks_endpoint(config) ⇒ Object
- .mcp_normalize_config(config) ⇒ Object
- .mcp_oauth_config_endpoint(config) ⇒ Object
- .mcp_parse_login_prompt(value) ⇒ Object
- .mcp_prompt_without_login(value) ⇒ Object
- .mcp_protected_resource_endpoint(config) ⇒ Object
- .mcp_register_endpoint(config) ⇒ Object
- .mcp_restore_login_prompt(ctx, config) ⇒ Object
- .mcp_set_cors_headers(ctx) ⇒ Object
- .mcp_token_endpoint(config) ⇒ Object
- .mcp_userinfo_endpoint(config) ⇒ Object
- .member_by_id(ctx, id) ⇒ Object
- .member_wire(ctx, member) ⇒ Object
- .merge_hook_data!(target, response) ⇒ Object
- .merge_permissions(base, extra) ⇒ 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_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
- .o_auth2_callback_endpoint(config) ⇒ Object
- .o_auth2_link_account_endpoint(config) ⇒ Object
- .oauth_provider(*args) ⇒ 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_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
- .object_schema(properties, required: []) ⇒ Object
- .oidc_authorize_endpoint(config) ⇒ Object
- .oidc_consent_endpoint(config) ⇒ Object
- .oidc_consent_granted?(ctx, client_id, user_id, scopes) ⇒ Boolean
- .oidc_delete_client_endpoint ⇒ Object
- .oidc_end_session_endpoint ⇒ Object
- .oidc_find_owned_client!(ctx, session) ⇒ Object
- .oidc_get_client_endpoint ⇒ Object
- .oidc_id_token_signer(ctx, config) ⇒ Object
- .oidc_introspect_endpoint(config) ⇒ Object
- .oidc_jwt_plugin(ctx) ⇒ Object
- .oidc_list_clients_endpoint ⇒ Object
- .oidc_metadata_endpoint(config) ⇒ Object
- .oidc_provider(options = {}) ⇒ Object
- .oidc_provider_endpoints(config) ⇒ Object
- .oidc_provider_schema ⇒ Object
- .oidc_register_endpoint(config) ⇒ Object
- .oidc_requires_login?(session, prompts, query) ⇒ Boolean
- .oidc_resume_login_prompt(ctx, config) ⇒ Object
- .oidc_revoke_endpoint(config) ⇒ Object
- .oidc_rotate_client_secret_endpoint(config) ⇒ Object
- .oidc_store_consent(ctx, client, session, scopes) ⇒ Object
- .oidc_token_endpoint(config) ⇒ Object
- .oidc_update_client_endpoint ⇒ Object
- .oidc_use_jwt_plugin?(ctx, config) ⇒ Boolean
- .oidc_userinfo_endpoint(config) ⇒ Object
- .okta(options = {}) ⇒ Object
- .one_tap(options = {}) ⇒ Object
- .one_tap_boolean_value(value) ⇒ Object
- .one_tap_callback_endpoint(config) ⇒ Object
- .one_tap_create_session(ctx, user) ⇒ Object
- .one_tap_google_jwks ⇒ Object
- .one_tap_link_account_unless_present!(ctx, _config, user, payload, id_token) ⇒ 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_endpoints(options) ⇒ Object
- .open_api_html(schema, config) ⇒ Object
- .open_api_operation(endpoint, method, tag) ⇒ Object
- .open_api_path(path) ⇒ Object
- .open_api_paths(endpoints, options) ⇒ Object
- .open_api_responses(responses = nil) ⇒ Object
- .open_api_schema(context) ⇒ Object
- .open_api_security_schemes ⇒ Object
- .open_api_user_response_schema ⇒ Object
- .org_truthy?(value) ⇒ Boolean
- .organization(options = {}) ⇒ Object
- .organization_accept_invitation_endpoint(config) ⇒ Object
- .organization_add_member_endpoint(config) ⇒ Object
- .organization_add_team_member_endpoint(config) ⇒ Object
- .organization_by_id(ctx, id) ⇒ Object
- .organization_by_slug(ctx, slug) ⇒ Object
- .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_get_active_member_endpoint(_config) ⇒ Object
- .organization_get_active_member_role_endpoint(_config) ⇒ Object
- .organization_get_full_endpoint(config) ⇒ Object
- .organization_get_invitation_endpoint ⇒ 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_permission?(ctx, config, role_string, permissions, organization_id) ⇒ Boolean
- .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_role_by_id(ctx, id) ⇒ Object
- .organization_role_by_name(ctx, organization_id, role) ⇒ 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_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_duration(value) ⇒ Object
- .parse_metadata(value) ⇒ Object
- .parse_permission(value) ⇒ Object
- .parse_roles(roles) ⇒ Object
- .parsed_session(ctx, entry) ⇒ Object
- .passkey(*args) ⇒ 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
- .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_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_team_member!(ctx, user_id, team_id) ⇒ Object
- .reset_password_email_otp_endpoint(config) ⇒ Object
- .reset_password_phone_number_endpoint(config) ⇒ Object
- .resolve_login_method(ctx, config) ⇒ Object
- .revoke_device_session_endpoint ⇒ Object
- .route_description(path, method) ⇒ Object
- .route_open_api_metadata(path, method) ⇒ Object
- .route_operation_id(path, method) ⇒ Object
- .route_request_body(path, method) ⇒ Object
- .route_responses(path, method) ⇒ 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) ⇒ Object
- .send_phone_number_otp_endpoint(config) ⇒ Object
- .send_verification_otp_endpoint(config) ⇒ Object
- .serialize_metadata(value) ⇒ Object
- .session_response_schema(description:, nullable_url: false) ⇒ 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_wallet!(value) ⇒ Object
- .siwe_schema(custom_schema = nil) ⇒ Object
- .siwe_verify_body(body, config) ⇒ Object
- .siwe_verify_message(config, body, wallet_address, chain_id, nonce, ctx) ⇒ Object
- .slack(options = {}) ⇒ Object
- .sso(*args) ⇒ 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) ⇒ Object
- .success_response ⇒ 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_check_password!(ctx, user_id, password) ⇒ Object
- .two_factor_disable_endpoint(config) ⇒ Object
- .two_factor_enable_endpoint(config) ⇒ 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_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_schema(custom_schema = nil) ⇒ 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_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_validation(username, config) ⇒ Object
- .username_mutation_path?(path) ⇒ Boolean
- .username_schema(config) ⇒ Object
- .valid_code_challenge_method?(method, config) ⇒ Boolean
- .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_jwt_options!(config) ⇒ Object
- .validate_magic_link_callback!(ctx, value, label) ⇒ Object
- .validate_permission_resources!(config, permission) ⇒ Object
- .validate_phone_number!(config, phone_number) ⇒ 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
Class Method Details
.additional_fields(schema = {}) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
# File 'lib/better_auth/plugins/additional_fields.rb', line 7 def additional_fields(schema = {}) config = normalize_hash(schema) user_fields = storage_fields(config[:user] || {}) session_fields = storage_fields(config[:session] || {}) Plugin.new( id: "additional-fields", schema: { user: {fields: user_fields}, session: {fields: session_fields} }, init: lambda do |_context| { options: { user: {additional_fields: user_fields}, session: {additional_fields: session_fields} } } end ) end |
.additional_input(hash, *exclude) ⇒ Object
976 977 978 979 980 |
# File 'lib/better_auth/plugins/organization.rb', line 976 def additional_input(hash, *exclude) data = normalize_hash(hash) additional = normalize_hash(data.delete(:additional_fields)) extra_input(data, *exclude, :additional_fields).merge(additional) end |
.admin(options = {}) ⇒ Object
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/better_auth/plugins/admin.rb', line 42 def admin( = {}) 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: ->(ctx) { ctx.json(Array(ctx.returned).reject { |session| session["impersonatedBy"] || session[:impersonatedBy] }) } } ] }, error_codes: ADMIN_ERROR_CODES, options: config ) end |
.admin_ban_user_endpoint(config) ⇒ Object
238 239 240 241 242 243 244 245 246 247 248 249 250 |
# File 'lib/better_auth/plugins/admin.rb', line 238 def admin_ban_user_endpoint(config) Endpoint.new(path: "/admin/ban-user", method: "POST") do |ctx| session = (ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS")) body = normalize_hash(ctx.body) found = ctx.context.internal_adapter.find_user_by_id(body[:user_id]) raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless found raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_BAN_YOURSELF")) if body[:user_id] == session[:user]["id"] expires_in = body[:ban_expires_in] || config[:default_ban_expires_in] user = ctx.context.internal_adapter.update_user(body[:user_id], banned: true, banReason: body[:ban_reason] || config[:default_ban_reason] || "No reason", banExpires: expires_in ? Time.now + expires_in.to_i : nil, updatedAt: Time.now) ctx.context.internal_adapter.delete_sessions(body[:user_id]) ctx.json({user: Schema.parse_output(ctx.context., "user", user)}) end end |
.admin_config(options) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/better_auth/plugins/admin.rb', line 78 def admin_config() 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
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/better_auth/plugins/admin.rb', line 163 def admin_create_user_endpoint(config) Endpoint.new(path: "/admin/create-user", method: "POST") do |ctx| session = Routes.current_session(ctx, allow_nil: true) if session unless (session[:user], session[:user]["role"], {user: ["create"]}, config) raise APIError.new("FORBIDDEN", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS")) end elsif !ctx.headers.empty? raise APIError.new("UNAUTHORIZED") end body = normalize_hash(ctx.body) email = body[:email].to_s.downcase raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email) if ctx.context.internal_adapter.find_user_by_email(email) raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL")) end data = normalize_hash(body[:data]).each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value } user = ctx.context.internal_adapter.create_user(data.merge( name: body[:name].to_s, email: email, role: admin_validate_roles!(body[:role] || config[:default_role], config) ).merge(body.key?(:image) ? {image: body[:image]} : {})) raise APIError.new("INTERNAL_SERVER_ERROR", message: ADMIN_ERROR_CODES.fetch("FAILED_TO_CREATE_USER")) unless user if body[:password].to_s != "" ctx.context.internal_adapter.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
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/better_auth/plugins/admin.rb', line 102 def admin_database_hooks(config) { user: { create: { before: lambda do |user, _ctx| {data: {"role" => config[:default_role]}.merge(user)} end } }, session: { create: { before: lambda do |session, ctx| next unless ctx user = ctx.context.internal_adapter.find_user_by_id(session["userId"] || session[:userId]) next unless user && user["banned"] if user["banExpires"] && Time.parse(user["banExpires"].to_s) < Time.now ctx.context.internal_adapter.update_user(user["id"], banned: false, banReason: nil, banExpires: nil, updatedAt: Time.now) next end if ctx.path.to_s.start_with?("/callback", "/oauth2/callback") error_url = ctx.context..on_api_error[:error_url] || "#{ctx.context.base_url}/error" url = "#{error_url}?error=banned&error_description=#{URI.encode_www_form_component(config[:banned_user_message])}" raise ctx.redirect(url) end raise APIError.new("FORBIDDEN", message: config[:banned_user_message], code: "BANNED_USER") end } } } end |
.admin_default_roles(config = {}) ⇒ Object
94 95 96 97 98 99 100 |
# File 'lib/better_auth/plugins/admin.rb', line 94 def admin_default_roles(config = {}) ac = config[:ac] || create_access_control(ADMIN_DEFAULT_STATEMENTS) { "admin" => ac.new_role(ADMIN_DEFAULT_ROLE_STATEMENTS), "user" => ac.new_role(user: [], session: []) } end |
.admin_filter_users(users, query) ⇒ Object
473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 |
# File 'lib/better_auth/plugins/admin.rb', line 473 def admin_filter_users(users, query) result = users search_value = query[:search_value].to_s if !search_value.empty? field = (query[:search_field] || "email").to_s result = result.select { |user| user[field].to_s.downcase.include?(search_value.downcase) } end filter_field = (query[:filter_field] || query.dig(:filter, :field)).to_s if !filter_field.empty? filter_value = if query.key?(:filter_value) query[:filter_value] else query.dig(:filter, :value) end operator = (query[:filter_operator] || query.dig(:filter, :operator) || "eq").to_s field = (filter_field == "_id") ? "id" : Schema.storage_key(filter_field) result = result.select do |user| current = user[field] case operator when "ne" then current != filter_value when "contains" then current.to_s.include?(filter_value.to_s) else current == filter_value end end end result end |
.admin_get_user_endpoint(config) ⇒ Object
149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/better_auth/plugins/admin.rb', line 149 def admin_get_user_endpoint(config) Endpoint.new(path: "/admin/get-user", method: "GET") do |ctx| (ctx, config, {user: ["get"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_GET_USER")) query = normalize_hash(ctx.query) user = if query[:id] || query[:user_id] ctx.context.internal_adapter.find_user_by_id(query[:id] || query[:user_id]) elsif query[:email] ctx.context.internal_adapter.find_user_by_email(query[:email])&.fetch(:user) end raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user ctx.json(Schema.parse_output(ctx.context., "user", user)) end end |
.admin_has_permission_endpoint(config) ⇒ Object
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 |
# File 'lib/better_auth/plugins/admin.rb', line 357 def (config) Endpoint.new(path: "/admin/has-permission", method: "POST") do |ctx| session = Routes.current_session(ctx, allow_nil: 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
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/better_auth/plugins/admin.rb', line 260 def admin_impersonate_user_endpoint(config) Endpoint.new(path: "/admin/impersonate-user", method: "POST") do |ctx| session = (ctx, config, {user: ["impersonate"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS")) body = normalize_hash(ctx.body) target = ctx.context.internal_adapter.find_user_by_id(body[:user_id]) raise APIError.new("NOT_FOUND", message: "User not found") unless target can_impersonate_admins = config[:allow_impersonating_admins] || (session[:user], session[:user]["role"], {user: ["impersonate-admins"]}, config) if !can_impersonate_admins && admin_user?(target, config) raise APIError.new("FORBIDDEN", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_IMPERSONATE_ADMINS")) end impersonated = ctx.context.internal_adapter.create_session(target["id"], true, {impersonatedBy: session[:user]["id"], expiresAt: Time.now + config[:impersonation_session_duration].to_i}, true, ctx) raise APIError.new("INTERNAL_SERVER_ERROR", message: ADMIN_ERROR_CODES.fetch("FAILED_TO_CREATE_USER")) unless impersonated = 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
230 231 232 233 234 235 236 |
# File 'lib/better_auth/plugins/admin.rb', line 230 def admin_list_user_sessions_endpoint(config) Endpoint.new(path: "/admin/list-user-sessions", method: "POST") do |ctx| (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
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/better_auth/plugins/admin.rb', line 211 def admin_list_users_endpoint(config) Endpoint.new(path: "/admin/list-users", method: "GET") do |ctx| (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_paginate_users(users, query) ⇒ Object
511 512 513 514 515 516 |
# File 'lib/better_auth/plugins/admin.rb', line 511 def admin_paginate_users(users, query) offset = query[:offset].to_i limit = query[:limit] result = offset.positive? ? users.drop(offset) : users limit ? result.first(limit.to_i) : result end |
.admin_parse_roles(roles) ⇒ Object
414 415 416 |
# File 'lib/better_auth/plugins/admin.rb', line 414 def admin_parse_roles(roles) Array(roles).join(",") end |
.admin_permission?(user, role_string, permissions, config) ⇒ Boolean
396 397 398 399 400 401 402 403 404 405 |
# File 'lib/better_auth/plugins/admin.rb', line 396 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
330 331 332 333 334 335 336 337 338 339 |
# File 'lib/better_auth/plugins/admin.rb', line 330 def admin_remove_user_endpoint(config) Endpoint.new(path: "/admin/remove-user", method: "POST") do |ctx| session = (ctx, config, {user: ["delete"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS")) user_id = normalize_hash(ctx.body)[:user_id] raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_REMOVE_YOURSELF")) if user_id == session[:user]["id"] raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id) ctx.context.internal_adapter.delete_user(user_id) ctx.json({success: true}) end end |
.admin_require_permission!(ctx, config, permissions, message) ⇒ Object
389 390 391 392 393 394 |
# File 'lib/better_auth/plugins/admin.rb', line 389 def (ctx, config, , ) session = Routes.current_session(ctx, sensitive: true) return session if (session[:user], session[:user]["role"], , config) raise APIError.new("FORBIDDEN", message: ) end |
.admin_revoke_user_session_endpoint(config) ⇒ Object
314 315 316 317 318 319 320 |
# File 'lib/better_auth/plugins/admin.rb', line 314 def admin_revoke_user_session_endpoint(config) Endpoint.new(path: "/admin/revoke-user-session", method: "POST") do |ctx| (ctx, config, {session: ["revoke"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS")) ctx.context.internal_adapter.delete_session(normalize_hash(ctx.body)[:session_token]) ctx.json({success: true}) end end |
.admin_revoke_user_sessions_endpoint(config) ⇒ Object
322 323 324 325 326 327 328 |
# File 'lib/better_auth/plugins/admin.rb', line 322 def admin_revoke_user_sessions_endpoint(config) Endpoint.new(path: "/admin/revoke-user-sessions", method: "POST") do |ctx| (ctx, config, {session: ["revoke"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS")) ctx.context.internal_adapter.delete_sessions(normalize_hash(ctx.body)[:user_id]) ctx.json({success: true}) end end |
.admin_role_for(roles, role) ⇒ Object
435 436 437 |
# File 'lib/better_auth/plugins/admin.rb', line 435 def admin_role_for(roles, role) roles[role.to_s] || roles.find { |key, _value| key.to_s.downcase == role.to_s.downcase }&.last end |
.admin_set_role_endpoint(config) ⇒ Object
137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/better_auth/plugins/admin.rb', line 137 def admin_set_role_endpoint(config) Endpoint.new(path: "/admin/set-role", method: "POST") do |ctx| (ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE")) body = normalize_hash(ctx.body) user_id = body[:user_id].to_s raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty? update = {role: admin_validate_roles!(body[:role], config)} user = ctx.context.internal_adapter.update_user(user_id, update) ctx.json({user: Schema.parse_output(ctx.context., "user", user || {})}) end end |
.admin_set_user_password_endpoint(config) ⇒ Object
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/better_auth/plugins/admin.rb', line 341 def admin_set_user_password_endpoint(config) Endpoint.new(path: "/admin/set-user-password", method: "POST") do |ctx| (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", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_SHORT")) if password.length < min raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_LONG")) if password.length > max ctx.context.internal_adapter.update_password(user_id, Routes.hash_password(ctx, password)) ctx.json({status: true}) end end |
.admin_sort_users(users, query) ⇒ Object
501 502 503 504 505 506 507 508 509 |
# File 'lib/better_auth/plugins/admin.rb', line 501 def admin_sort_users(users, query) sort_field = query[:sort_by] || query[:sort_field] return users unless sort_field field = Schema.storage_key(sort_field) sorted = users.sort_by { |user| user[field].to_s } direction = (query[:sort_direction] || query[:sort_order] || "asc").to_s (direction.downcase == "desc") ? sorted.reverse : sorted end |
.admin_stop_impersonating_endpoint ⇒ Object
286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 |
# File 'lib/better_auth/plugins/admin.rb', line 286 def admin_stop_impersonating_endpoint Endpoint.new(path: "/admin/stop-impersonating", method: "POST") do |ctx| session = Routes.current_session(ctx, sensitive: true) admin_id = session[:session]["impersonatedBy"] raise APIError.new("BAD_REQUEST", message: "You are not impersonating anyone") unless admin_id admin = ctx.context.internal_adapter.find_user_by_id(admin_id) raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to find user") unless admin = 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
252 253 254 255 256 257 258 |
# File 'lib/better_auth/plugins/admin.rb', line 252 def admin_unban_user_endpoint(config) Endpoint.new(path: "/admin/unban-user", method: "POST") do |ctx| (ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS")) user = ctx.context.internal_adapter.update_user(normalize_hash(ctx.body)[:user_id], banned: false, banReason: nil, banExpires: nil, updatedAt: Time.now) ctx.json({user: Schema.parse_output(ctx.context., "user", user)}) end end |
.admin_update_user_endpoint(config) ⇒ Object
196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/better_auth/plugins/admin.rb', line 196 def admin_update_user_endpoint(config) Endpoint.new(path: "/admin/update-user", method: "POST") do |ctx| (ctx, config, {user: ["update"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS")) body = normalize_hash(ctx.body) data = normalize_hash(body[:data] || body).except(:user_id, :data) raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("NO_DATA_TO_UPDATE")) if data.empty? if data.key?(:role) (ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE")) data[:role] = admin_validate_roles!(data[:role], config) end user = ctx.context.internal_adapter.update_user(body[:user_id], data) ctx.json(Schema.parse_output(ctx.context., "user", user)) end end |
.admin_user?(user, config) ⇒ Boolean
407 408 409 410 411 412 |
# File 'lib/better_auth/plugins/admin.rb', line 407 def admin_user?(user, config) return true if Array(config[:admin_user_ids]).map(&:to_s).include?(user["id"].to_s) admin_roles = config[:admin_roles].map { |role| role.to_s.downcase } user["role"].to_s.split(",").any? { |role| admin_roles.include?(role.to_s.downcase) } end |
.admin_user_sort(query) ⇒ Object
463 464 465 466 467 468 469 470 471 |
# File 'lib/better_auth/plugins/admin.rb', line 463 def admin_user_sort(query) sort_field = query[:sort_by] || query[:sort_field] return nil unless sort_field { field: sort_field, direction: query[:sort_direction] || query[:sort_order] || "asc" } end |
.admin_user_where(query) ⇒ Object
439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 |
# File 'lib/better_auth/plugins/admin.rb', line 439 def admin_user_where(query) where = [] search_value = query[:search_value] if search_value && !search_value.to_s.empty? where << { field: query[:search_field] || "email", operator: query[:search_operator] || "contains", value: search_value } end filter_value_defined = query.key?(:filter_value) || (query[:filter].is_a?(Hash) && query[:filter].key?(:value)) if filter_value_defined filter_field = query[:filter_field] || query.dig(:filter, :field) || "email" where << { field: (filter_field.to_s == "_id") ? "id" : filter_field, operator: query[:filter_operator] || query.dig(:filter, :operator) || "eq", value: query.key?(:filter_value) ? query[:filter_value] : query.dig(:filter, :value) } end where end |
.admin_validate_roles!(roles, config) ⇒ Object
418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 |
# File 'lib/better_auth/plugins/admin.rb', line 418 def admin_validate_roles!(roles, config) unless Array(roles).all? { |role| role.is_a?(String) || role.is_a?(Symbol) } raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("INVALID_ROLE_TYPE")) end parsed = admin_parse_roles(roles) if config[:roles_configured] defined_roles = (config[:roles] || {}).transform_keys(&:to_s) invalid = parsed.split(",", -1).reject { |role| admin_role_for(defined_roles, role) } if invalid.any? raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE")) end end parsed end |
.all_jwks(ctx, config) ⇒ Object
227 228 229 230 231 232 233 234 |
# File 'lib/better_auth/plugins/jwt.rb', line 227 def all_jwks(ctx, config) adapter = config[:adapter] if adapter && adapter[:get_jwks].respond_to?(:call) return Array(adapter[:get_jwks].call(ctx)).map { |entry| stringify_payload(entry) } end ctx.context.adapter.find_many(model: "jwks") end |
.anonymous(options = {}) ⇒ Object
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
# File 'lib/better_auth/plugins/anonymous.rb', line 19 def anonymous( = {}) 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
109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/better_auth/plugins/anonymous.rb', line 109 def anonymous_email(config) generator = config[:generate_random_email] email = generator.call if generator.respond_to?(:call) if email && email != "" unless email.is_a?(String) && !email.empty? && Routes::EMAIL_PATTERN.match?(email) raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["INVALID_EMAIL_FORMAT"]) end return email end id = SecureRandom.hex(16) domain = config[:email_domain_name] domain ? "temp-#{id}@#{domain}" : "temp@#{id}.com" end |
.anonymous_link_path?(path) ⇒ Boolean
171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/better_auth/plugins/anonymous.rb', line 171 def anonymous_link_path?(path) path.to_s.start_with?( "/sign-in", "/sign-up", "/callback", "/oauth2/callback", "/magic-link/verify", "/email-otp/verify-email", "/one-tap/callback", "/passkey/verify-authentication", "/phone-number/verify" ) end |
.anonymous_name(ctx, config) ⇒ Object
124 125 126 127 128 129 130 |
# File 'lib/better_auth/plugins/anonymous.rb', line 124 def anonymous_name(ctx, config) generator = config[:generate_name] name = generator.call(ctx) if generator.respond_to?(:call) return name if present_string?(name) "Anonymous" end |
.anonymous_schema(config) ⇒ Object
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/better_auth/plugins/anonymous.rb', line 92 def anonymous_schema(config) field_name = anonymous_schema_field_name(config) || "is_anonymous" { user: { fields: { isAnonymous: { type: "boolean", required: false, input: false, default_value: false, field_name: field_name } } } } end |
.anonymous_schema_field_name(config) ⇒ Object
185 186 187 188 189 190 191 192 |
# File 'lib/better_auth/plugins/anonymous.rb', line 185 def anonymous_schema_field_name(config) fields = config.dig(:schema, :user, :fields) || {} mapping = fields[:is_anonymous] || fields[:isAnonymous] || fields["isAnonymous"] return mapping if mapping.is_a?(String) return mapping[:field_name] || mapping[:fieldName] if mapping.is_a?(Hash) nil end |
.api_key(*args) ⇒ Object
7 8 9 10 11 12 13 14 |
# File 'lib/better_auth/plugins/api_key.rb', line 7 def api_key(*args) Kernel.require "better_auth/api_key" BetterAuth::Plugins.api_key(*args) rescue LoadError => error raise if error.path && error.path != "better_auth/api_key" raise LoadError, "BetterAuth::Plugins.api_key requires the better_auth-api-key gem. Add `gem \"better_auth-api-key\"` and `require \"better_auth/api_key\"`." end |
.apply_bearer_token(ctx, config) ⇒ Object
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/better_auth/plugins/bearer.rb', line 38 def apply_bearer_token(ctx, config) auth_header = (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 = [ctx.headers["cookie"], "#{}=#{signed_token}"].compact.reject(&:empty?).join("; ") {context: {headers: ctx.headers.merge("cookie" => )}} end |
.apply_last_login_method(ctx, config) ⇒ Object
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/better_auth/plugins/last_login_method.rb', line 46 def 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 58 59 60 61 62 63 64 65 66 67 68 69 |
# 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"], get_user_info: ->(tokens) { profile = generic_oauth_fetch_json("https://#{domain}/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}") return nil unless profile { id: fetch_value(profile, "sub"), name: fetch_value(profile, "name") || fetch_value(profile, "nickname"), email: fetch_value(profile, "email"), image: fetch_value(profile, "picture"), emailVerified: fetch_value(profile, "email_verified") || false } } ) end |
.authorization_header(ctx) ⇒ Object
34 35 36 |
# File 'lib/better_auth/plugins/bearer.rb', line 34 def (ctx) ctx.headers["authorization"] || ctx.headers["Authorization"] end |
.base32_decode(value) ⇒ Object
508 509 510 511 512 |
# File 'lib/better_auth/plugins/two_factor.rb', line 508 def base32_decode(value) clean = value.to_s.upcase.gsub(/[^A-Z2-7]/, "") bits = clean.chars.map { |char| BASE32_ALPHABET.index(char).to_i.to_s(2).rjust(5, "0") }.join bits.scan(/.{8}/).map { |byte| byte.to_i(2).chr }.join end |
.base32_encode(bytes) ⇒ Object
503 504 505 506 |
# File 'lib/better_auth/plugins/two_factor.rb', line 503 def base32_encode(bytes) bits = bytes.bytes.map { |byte| byte.to_s(2).rjust(8, "0") }.join bits.scan(/.{1,5}/).map { |chunk| BASE32_ALPHABET[chunk.ljust(5, "0").to_i(2)] }.join end |
.base64url_bn(number) ⇒ Object
452 453 454 455 456 |
# File 'lib/better_auth/plugins/jwt.rb', line 452 def base64url_bn(number) hex = number.to_s(16) hex = "0#{hex}" if hex.length.odd? Crypto.base64url_encode([hex].pack("H*")) end |
.bearer(options = {}) ⇒ Object
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
# File 'lib/better_auth/plugins/bearer.rb', line 11 def bearer( = {}) 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 |
.bearer_session_cookie(line) ⇒ Object
89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/better_auth/plugins/bearer.rb', line 89 def (line) first, *attributes = line.to_s.split(";").map(&:strip) name, value = first.split("=", 2) return unless name && value { name: name, value: value, attributes: attributes.each_with_object({}) do |attribute, result| key, attribute_value = attribute.split("=", 2) result[key.to_s.downcase] = attribute_value || true unless key.to_s.empty? end } end |
.call_email_verification_option(ctx, key, user) ⇒ Object
508 509 510 511 |
# File 'lib/better_auth/plugins/email_otp.rb', line 508 def call_email_verification_option(ctx, key, user) callback = ctx.context..email_verification[key] callback.call(user, ctx.request) if callback.respond_to?(:call) end |
.canonical_multi_session_cookie_name(name) ⇒ Object
168 169 170 171 |
# File 'lib/better_auth/plugins/multi_session.rb', line 168 def (name) prefix = "__Secure-" name.to_s.sub(/\A#{Regexp.escape(prefix)}/i, prefix) end |
.captcha(options = {}) ⇒ Object
35 36 37 38 39 40 41 42 43 44 45 |
# File 'lib/better_auth/plugins/captcha.rb', line 35 def captcha( = {}) 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
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/better_auth/plugins/captcha.rb', line 96 def captcha_http_verify(params) verifier = captcha_verifier_params(params) uri = URI.parse(verifier[:url]) request = Net::HTTP::Post.new(uri) request["Content-Type"] = verifier[:content_type] request.body = if verifier[:content_type] == "application/json" JSON.generate(verifier[:payload]) else URI.encode_www_form(verifier[:payload]) end response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) } raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"] unless response.is_a?(Net::HTTPSuccess) JSON.parse(response.body.to_s) rescue JSON::ParserError raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"] end |
.captcha_log(context, message) ⇒ Object
150 151 152 153 154 155 156 157 |
# File 'lib/better_auth/plugins/captcha.rb', line 150 def captcha_log(context, ) 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
136 137 138 139 140 |
# File 'lib/better_auth/plugins/captcha.rb', line 136 def captcha_normalize_verifier_response(value) return value.transform_keys(&:to_s) if value.is_a?(Hash) {} end |
.captcha_on_request(request, context, config) ⇒ Object
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/better_auth/plugins/captcha.rb', line 47 def captcha_on_request(request, context, config) endpoints = Array(config[:endpoints]).empty? ? CAPTCHA_DEFAULT_ENDPOINTS : Array(config[:endpoints]) return nil unless endpoints.any? { |endpoint| request.path_info.include?(endpoint.to_s) || request.url.include?(endpoint.to_s) } raise CAPTCHA_INTERNAL_ERROR_CODES["MISSING_SECRET_KEY"] if config[:secret_key].to_s.empty? response_token = request.get_header("HTTP_X_CAPTCHA_RESPONSE") if response_token.to_s.empty? return {response: captcha_response(400, "MISSING_RESPONSE", CAPTCHA_EXTERNAL_ERROR_CODES["MISSING_RESPONSE"])} end result = captcha_verify(config, response_token, captcha_remote_ip(request, context)) return nil if captcha_success?(config, result) {response: captcha_response(403, "VERIFICATION_FAILED", CAPTCHA_EXTERNAL_ERROR_CODES["VERIFICATION_FAILED"])} rescue => error captcha_log(context, error.) {response: captcha_response(500, "UNKNOWN_ERROR", CAPTCHA_EXTERNAL_ERROR_CODES["UNKNOWN_ERROR"])} end |
.captcha_payload(provider, params) ⇒ Object
114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/better_auth/plugins/captcha.rb', line 114 def captcha_payload(provider, params) payload = { "secret" => params[:secret_key], "response" => params[:captcha_response] } payload["sitekey"] = params[:site_key] if params[:site_key] && ["hcaptcha", "captchafox"].include?(provider) if params[:remote_ip] payload[(provider == "captchafox") ? "remoteIp" : "remoteip"] = params[:remote_ip] end payload end |
.captcha_remote_ip(request, context) ⇒ Object
146 147 148 |
# File 'lib/better_auth/plugins/captcha.rb', line 146 def captcha_remote_ip(request, context) RequestIP.client_ip(request, context.) end |
.captcha_response(status, code, message) ⇒ Object
142 143 144 |
# File 'lib/better_auth/plugins/captcha.rb', line 142 def captcha_response(status, code, ) [status, {"content-type" => "application/json"}, [JSON.generate({code: code, message: })]] end |
.captcha_success?(config, result) ⇒ Boolean
126 127 128 129 130 131 132 133 134 |
# File 'lib/better_auth/plugins/captcha.rb', line 126 def captcha_success?(config, result) return false unless result && result["success"] if config[:provider].to_s == "google-recaptcha" && result.key?("score") return result["score"].to_f >= (config[:min_score] || 0.5).to_f end true end |
.captcha_verifier_params(params) ⇒ Object
84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/better_auth/plugins/captcha.rb', line 84 def captcha_verifier_params(params) provider = params.fetch(:provider) payload = captcha_payload(provider, params) content_type = (provider == "cloudflare-turnstile") ? "application/json" : "application/x-www-form-urlencoded" { url: params.fetch(:site_verify_url), content_type: content_type, payload: payload, provider: provider } end |
.captcha_verify(config, response_token, remote_ip) ⇒ Object
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/better_auth/plugins/captcha.rb', line 67 def captcha_verify(config, response_token, remote_ip) provider = config[:provider].to_s url = config[:site_verify_url_override] || CAPTCHA_SITE_VERIFY_URLS.fetch(provider) params = { site_verify_url: url, secret_key: config[:secret_key], captcha_response: response_token, remote_ip: remote_ip, site_key: config[:site_key], min_score: config[:min_score], provider: provider } return captcha_normalize_verifier_response(config[:verifier].call(captcha_verifier_params(params))) if config[:verifier].respond_to?(:call) captcha_http_verify(params) end |
.change_email_email_otp_endpoint(config) ⇒ Object
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 |
# File 'lib/better_auth/plugins/email_otp.rb', line 237 def change_email_email_otp_endpoint(config) Endpoint.new(path: "/email-otp/change-email", method: "POST") do |ctx| email_otp_change_email_enabled!(config) session = Routes.current_session(ctx) body = normalize_hash(ctx.body) current_email = session[:user]["email"].to_s.downcase new_email = body[:new_email].to_s.downcase validate_email_otp_email!(new_email) raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == current_email email_otp_verify!(ctx, config, email: "#{current_email}-#{new_email}", type: "change-email", otp: body[:otp].to_s) raise APIError.new("BAD_REQUEST", message: "Email already in use") if ctx.context.internal_adapter.find_user_by_email(new_email) current = ctx.context.internal_adapter.find_user_by_email(current_email) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless current call_email_verification_option(ctx, :before_email_verification, current[:user]) updated = ctx.context.internal_adapter.update_user(current[:user]["id"], email: new_email, emailVerified: true) call_email_verification_option(ctx, :after_email_verification, updated) Cookies.(ctx, {session: session[:session], user: updated}) ctx.json({success: true}) end end |
.check_verification_otp_endpoint(config) ⇒ Object
141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/better_auth/plugins/email_otp.rb', line 141 def check_verification_otp_endpoint(config) Endpoint.new(path: "/email-otp/check-verification-otp", method: "POST") do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase type = body[:type].to_s otp = body[:otp].to_s validate_email_otp_type!(type) validate_email_otp_email!(email) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless ctx.context.internal_adapter.find_user_by_email(email) email_otp_verify!(ctx, config, email: email, type: type, otp: otp, consume: false) ctx.json({success: true}) end end |
.clear_multi_session_cookies(ctx) ⇒ Object
128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/better_auth/plugins/multi_session.rb', line 128 def (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 |
.cookie_header_from_set_cookie(set_cookie) ⇒ Object
42 43 44 |
# File 'lib/better_auth/plugins.rb', line 42 def () .to_s.lines.map { |line| line.split(";").first }.join("; ") end |
.create_access_control(statements) ⇒ Object Also known as: createAccessControl
81 82 83 |
# File 'lib/better_auth/plugins/access.rb', line 81 def create_access_control(statements) AccessControl.new(statements) end |
.create_default_team(ctx, config, organization, session) ⇒ Object
906 907 908 909 910 911 912 913 914 915 916 917 918 |
# File 'lib/better_auth/plugins/organization.rb', line 906 def create_default_team(ctx, config, organization, session) custom = config.dig(:teams, :default_team, :custom_create_default_team) team_data = {organizationId: organization["id"], name: organization["name"], createdAt: Time.now} merge_hook_data!(team_data, run_org_hook(config, :before_create_team, {team: team_data, user: session[:user], organization: organization_wire(ctx, organization)}, ctx)) team = if custom.respond_to?(:call) custom.call(organization_wire(ctx, organization), ctx) else ctx.context.adapter.create(model: "team", data: team_data) end ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now}) run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) team end |
.create_jwk(ctx, config) ⇒ Object
266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 |
# File 'lib/better_auth/plugins/jwt.rb', line 266 def create_jwk(ctx, config) adapter = config[:adapter] alg = (config.dig(:jwks, :key_pair_config, :alg) || "EdDSA").to_s pair = generate_key_pair(alg) public_key = public_key_for(pair) public_pem = public_key_pem(public_key) data = { "id" => Crypto.uuid, "publicKey" => public_pem, "privateKey" => jwk_private_key_for_storage(ctx, private_key_pem(pair), config), "createdAt" => Time.now, "alg" => alg, "pem" => public_pem } data.merge!(public_key_jwk_fields(public_key, alg)) data["expiresAt"] = Time.now + config.dig(:jwks, :rotation_interval).to_i if config.dig(:jwks, :rotation_interval) if adapter && adapter[:create_jwk].respond_to?(:call) return stringify_payload(adapter[:create_jwk].call(data, ctx)) end ctx.context.adapter.create(model: "jwks", data: data, force_allow_id: true) end |
.create_verification_otp_endpoint(config) ⇒ Object
100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/better_auth/plugins/email_otp.rb', line 100 def create_verification_otp_endpoint(config) Endpoint.new(method: "POST") do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase type = body[:type].to_s validate_email_otp_type!(type) otp = email_otp_generate(config, email: email, type: type, ctx: ctx) email_otp_store(ctx, config, email: email, type: type, otp: otp) otp end end |
.custom_session(resolver, options = nil, plugin_options = nil, **keywords) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/better_auth/plugins/custom_session.rb', line 7 def custom_session(resolver, = 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
458 459 460 461 462 463 464 465 466 |
# File 'lib/better_auth/plugins/jwt.rb', line 458 def deep_merge(base, override) normalize_hash(base || {}).merge(normalize_hash(override || {})) do |_key, old_value, new_value| if old_value.is_a?(Hash) && new_value.is_a?(Hash) deep_merge(old_value, new_value) else new_value end end end |
.deep_merge_hashes(base, override) ⇒ Object
311 312 313 314 315 316 317 318 319 |
# File 'lib/better_auth/plugins/phone_number.rb', line 311 def deep_merge_hashes(base, override) base.merge(override) do |_key, old_value, new_value| if old_value.is_a?(Hash) && new_value.is_a?(Hash) deep_merge_hashes(old_value, new_value) else new_value end end end |
.default_error_responses ⇒ Object
409 410 411 412 413 414 415 416 417 418 |
# File 'lib/better_auth/plugins/open_api.rb', line 409 def default_error_responses { "400" => error_response("Bad Request. Usually due to missing parameters, or invalid parameters.", required: true), "401" => error_response("Unauthorized. Due to missing or invalid authentication.", required: true), "403" => error_response("Forbidden. You do not have permission to access this resource or to perform this action."), "404" => error_response("Not Found. The requested resource was not found."), "429" => error_response("Too Many Requests. You have exceeded the rate limit. Try again later."), "500" => error_response("Internal Server Error. This is a problem with the server that you cannot fix.") } end |
.default_username_valid?(username) ⇒ Boolean
270 271 272 |
# File 'lib/better_auth/plugins/username.rb', line 270 def default_username_valid?(username) username.match?(/\A[a-zA-Z0-9_.]+\z/) end |
.delete_anonymous_user_endpoint(config) ⇒ Object
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/better_auth/plugins/anonymous.rb', line 69 def delete_anonymous_user_endpoint(config) Endpoint.new(path: "/delete-anonymous-user", method: "POST") do |ctx| session = Routes.current_session(ctx, sensitive: true) if config[:disable_delete_anonymous_user] raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["DELETE_ANONYMOUS_USER_DISABLED"]) end unless session[:user]["isAnonymous"] raise APIError.new("FORBIDDEN", message: ANONYMOUS_ERROR_CODES["USER_IS_NOT_ANONYMOUS"]) end begin ctx.context.internal_adapter.delete_user(session[:user]["id"]) rescue raise APIError.new("INTERNAL_SERVER_ERROR", message: ANONYMOUS_ERROR_CODES["FAILED_TO_DELETE_ANONYMOUS_USER"]) end Cookies.(ctx) ctx.json({success: true}) end end |
.device_approve_endpoint ⇒ Object
155 156 157 158 159 160 161 162 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 155 def device_approve_endpoint Endpoint.new(path: "/device/approve", method: "POST") do |ctx| session = Routes.current_session(ctx, allow_nil: true) raise ("UNAUTHORIZED", "unauthorized", DEVICE_AUTHORIZATION_ERROR_CODES["AUTHENTICATION_REQUIRED"]) unless session process_device_decision(ctx, session, "approved") end end |
.device_authorization(options = {}) ⇒ Object
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 25 def ( = {}) 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
271 272 273 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 271 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
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 281 def (custom_schema = nil) base = { deviceCode: { fields: { deviceCode: {type: "string", required: true}, userCode: {type: "string", required: true}, userId: {type: "string", required: false}, expiresAt: {type: "date", required: true}, status: {type: "string", required: true}, lastPolledAt: {type: "date", required: false}, pollingInterval: {type: "number", required: false}, clientId: {type: "string", required: false}, scope: {type: "string", required: false} } } } return base unless custom_schema.is_a?(Hash) deep_merge_hashes(base, normalize_hash(custom_schema)) end |
.device_authorization_time(value) ⇒ Object
275 276 277 278 279 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 275 def (value) return value if value.is_a?(Time) Time.parse(value.to_s) end |
.device_authorization_user_code_candidates(value) ⇒ Object
262 263 264 265 266 267 268 269 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 262 def (value) original = value.to_s upper = original.upcase clean = original.delete("-") upper_clean = upper.delete("-") dashed = (upper_clean.length == 8) ? "#{upper_clean[0, 4]}-#{upper_clean[4, 4]}" : upper_clean [original, upper, clean, upper_clean, dashed].uniq end |
.device_code_endpoint(config) ⇒ Object
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 49 def device_code_endpoint(config) Endpoint.new(path: "/device/code", method: "POST") do |ctx| body = OAuthProtocol.stringify_keys(ctx.body) client_id = body["client_id"] if config[:validate_client] && !config[:validate_client].call(client_id) raise ("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, "expiresAt" => Time.now + expires_in, "status" => "pending", "pollingInterval" => interval * 1000, "clientId" => client_id, "scope" => body["scope"] } ) verification_uri = verification_uri(ctx, config) complete = OAuthProtocol.redirect_uri_with_params(verification_uri, user_code: user_code) ctx.json({ device_code: device_code, user_code: user_code, verification_uri: verification_uri, verification_uri_complete: complete, expires_in: expires_in, interval: interval }, headers: {"Cache-Control" => "no-store"}) end end |
.device_deny_endpoint ⇒ Object
164 165 166 167 168 169 170 171 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 164 def device_deny_endpoint Endpoint.new(path: "/device/deny", method: "POST") do |ctx| session = Routes.current_session(ctx, allow_nil: true) raise ("UNAUTHORIZED", "unauthorized", DEVICE_AUTHORIZATION_ERROR_CODES["AUTHENTICATION_REQUIRED"]) unless session process_device_decision(ctx, session, "denied") end end |
.device_token_endpoint(config) ⇒ Object
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 88 def device_token_endpoint(config) Endpoint.new(path: "/device/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx| body = OAuthProtocol.stringify_keys(ctx.body) raise ("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" 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.context.adapter.delete(model: "deviceCode", where: [{field: "id", value: record["id"]}]) ctx.json({ access_token: session["token"], token_type: "Bearer", expires_in: [session["expiresAt"].to_i - Time.now.to_i, 0].max, scope: record["scope"].to_s }, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"}) else raise ("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_DEVICE_CODE_STATUS"]) end end end |
.device_verify_endpoint ⇒ Object
143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 143 def device_verify_endpoint Endpoint.new(path: "/device", method: "GET") do |ctx| code = normalize_user_code(OAuthProtocol.stringify_keys(ctx.query)["user_code"]) record = find_device_user_code(ctx, code) raise ("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 ctx.json({user_code: code, status: record["status"]}) end end |
.duration_seconds(value) ⇒ Object
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 230 def duration_seconds(value) return value if value.is_a?(Integer) match = value.to_s.match(/\A(\d+)(ms|s|m|min|h|d)?\z/) raise Error, "Invalid time string" unless match amount = match[1].to_i case match[2] when "ms" then (amount / 1000.0).ceil when "m", "min" then amount * 60 when "h" then amount * 3600 when "d" then amount * 86_400 else amount end end |
.ec_curve_for_alg(alg) ⇒ Object
385 386 387 |
# File 'lib/better_auth/plugins/jwt.rb', line 385 def ec_curve_for_alg(alg) (alg == "ES512") ? "P-521" : "P-256" end |
.email_otp(options = {}) ⇒ Object
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'lib/better_auth/plugins/email_otp.rb', line 13 def email_otp( = {}) 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), forget_password_email_otp: forget_password_email_otp_endpoint(config), reset_password_email_otp: reset_password_email_otp_endpoint(config), request_email_change_email_otp: request_email_change_email_otp_endpoint(config), change_email_email_otp: change_email_email_otp_endpoint(config) }, hooks: { after: [ { matcher: ->(ctx) { ctx.path.to_s.start_with?("/sign-up") && config[:send_verification_on_sign_up] && !config[:override_default_email_verification] }, handler: ->(ctx) { 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
301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/better_auth/plugins/email_otp.rb', line 301 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
502 503 504 505 506 |
# File 'lib/better_auth/plugins/email_otp.rb', line 502 def email_otp_change_email_enabled!(config) return if config.dig(:change_email, :enabled) raise APIError.new("BAD_REQUEST", message: "Change email with OTP is disabled") end |
.email_otp_deliver(config, data, ctx) ⇒ Object
475 476 477 478 |
# File 'lib/better_auth/plugins/email_otp.rb', line 475 def email_otp_deliver(config, data, ctx) sender = config[:send_verification_otp] sender.call(data, ctx) if sender.respond_to?(:call) end |
.email_otp_generate(config, email:, type:, ctx:) ⇒ Object
414 415 416 417 418 419 420 |
# File 'lib/better_auth/plugins/email_otp.rb', line 414 def email_otp_generate(config, email:, type:, ctx:) generator = config[:generate_otp] generated = generator.call({email: email, type: type}, ctx) if generator.respond_to?(:call) return generated.to_s if generated && !generated.to_s.empty? Array.new(config[:otp_length].to_i) { SecureRandom.random_number(10).to_s }.join end |
.email_otp_identifier(email, type) ⇒ Object
480 481 482 |
# File 'lib/better_auth/plugins/email_otp.rb', line 480 def email_otp_identifier(email, type) "#{type}-otp-#{email}" end |
.email_otp_init(config) ⇒ Object
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/better_auth/plugins/email_otp.rb', line 51 def email_otp_init(config) lambda do |context| next unless config[:override_default_email_verification] { options: { email_verification: { send_verification_email: lambda do |data, request = nil| user = fetch_value(data, :user) || data email = fetch_value(user, :email).to_s endpoint_context = Endpoint::Context.new( path: "/send-verification-email", method: "POST", query: {}, body: {"email" => email, "type" => "email-verification"}, params: {}, headers: {}, context: context, request: request ) email_otp_send_verification(endpoint_context, config, email: email, type: "email-verification") end } } } end end |
.email_otp_matches?(ctx, config, stored_otp, otp) ⇒ Boolean
447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 |
# File 'lib/better_auth/plugins/email_otp.rb', line 447 def email_otp_matches?(ctx, config, stored_otp, otp) storage = config[:store_otp] actual, expected = if storage.to_s == "hashed" [Crypto.sha256(otp, encoding: :base64url), stored_otp] elsif storage.to_s == "encrypted" [Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored_otp), otp] elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call) [storage[:hash].call(otp), stored_otp] elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call) [storage[:decrypt].call(stored_otp), otp] else [otp, stored_otp] end return false unless actual return false unless actual.to_s.bytesize == expected.to_s.bytesize Crypto.constant_time_compare(actual.to_s, expected.to_s) end |
.email_otp_password_reset_request(ctx, config) ⇒ Object
313 314 315 316 317 318 319 320 321 322 323 324 325 326 |
# File 'lib/better_auth/plugins/email_otp.rb', line 313 def email_otp_password_reset_request(ctx, config) body = normalize_hash(ctx.body) email = body[:email].to_s.downcase otp = email_otp_resolve(ctx, config, email: email, type: "forget-password") found = ctx.context.internal_adapter.find_user_by_email(email) unless found ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier(email, "forget-password")) return ctx.json({success: true}) end email_otp_deliver(config, {email: email, otp: otp, type: "forget-password"}, ctx) ctx.json({success: true}) end |
.email_otp_plain_value(ctx, config, stored_otp) ⇒ Object
466 467 468 469 470 471 472 473 |
# File 'lib/better_auth/plugins/email_otp.rb', line 466 def email_otp_plain_value(ctx, config, stored_otp) storage = config[:store_otp] return stored_otp if storage.to_s == "plain" || storage.nil? return Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored_otp) if storage.to_s == "encrypted" return storage[:decrypt].call(stored_otp) if storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call) nil end |
.email_otp_rate_limits(config) ⇒ Object
513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 |
# File 'lib/better_auth/plugins/email_otp.rb', line 513 def email_otp_rate_limits(config) rate_limit = normalize_hash(config[:rate_limit] || {}) window = rate_limit[:window] || 60 max = rate_limit[:max] || 3 %w[ /email-otp/send-verification-otp /email-otp/check-verification-otp /email-otp/verify-email /sign-in/email-otp /email-otp/request-password-reset /email-otp/reset-password /forget-password/email-otp /email-otp/request-email-change /email-otp/change-email ].map do |path| { path_matcher: ->(request_path) { request_path == path }, window: window, max: max } end end |
.email_otp_resolve(ctx, config, email:, type:, identifier_email: email) ⇒ Object
355 356 357 358 359 360 361 362 363 364 |
# File 'lib/better_auth/plugins/email_otp.rb', line 355 def email_otp_resolve(ctx, config, email:, type:, identifier_email: email) if config[:resend_strategy].to_s == "reuse" reused = email_otp_reuse(ctx, config, email: identifier_email, type: type) return reused if reused end otp = email_otp_generate(config, email: email, type: type, ctx: ctx) email_otp_store(ctx, config, email: identifier_email, type: type, otp: otp) otp end |
.email_otp_reuse(ctx, config, email:, type:) ⇒ Object
366 367 368 369 370 371 372 373 374 375 376 377 378 379 |
# File 'lib/better_auth/plugins/email_otp.rb', line 366 def email_otp_reuse(ctx, config, email:, type:) identifier = email_otp_identifier(email, type) verification = ctx.context.internal_adapter.find_verification_value(identifier) return nil unless verification && !Routes.expired_time?(verification["expiresAt"]) stored_otp, attempts = email_otp_split(verification["value"]) return nil if attempts.to_i >= config[:allowed_attempts].to_i plain = email_otp_plain_value(ctx, config, stored_otp) return nil unless plain ctx.context.internal_adapter.update_verification_value(verification["id"], expiresAt: Time.now + config[:expires_in].to_i) plain end |
.email_otp_send_verification(ctx, config, email:, type:) ⇒ Object
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 |
# File 'lib/better_auth/plugins/email_otp.rb', line 328 def email_otp_send_verification(ctx, config, email:, type:) otp = email_otp_resolve(ctx, config, email: email, type: type) found = ctx.context.internal_adapter.find_user_by_email(email) unless found if type == "sign-in" && !config[:disable_sign_up] # Upstream allows sign-in OTP creation for new users when sign-up is enabled. else ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier(email, type)) return end end email_otp_deliver(config, {email: email, otp: otp, type: type}, ctx) end |
.email_otp_sign_up_user_data(body, email) ⇒ Object
422 423 424 425 426 427 428 429 430 431 432 |
# File 'lib/better_auth/plugins/email_otp.rb', line 422 def email_otp_sign_up_user_data(body, email) reserved = %i[email otp name image callback_url callbackURL callbackUrl] additional = body.reject { |key, _value| reserved.include?(key.to_sym) } additional = additional.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value } additional.merge( "email" => email, "emailVerified" => true, "name" => body[:name].to_s, "image" => body[:image] ) end |
.email_otp_split(value) ⇒ Object
484 485 486 487 488 489 490 |
# File 'lib/better_auth/plugins/email_otp.rb', line 484 def email_otp_split(value) string = value.to_s index = string.rindex(":") return [string, ""] unless index [string[0...index], string[(index + 1)..]] end |
.email_otp_store(ctx, config, email:, type:, otp:) ⇒ Object
344 345 346 347 348 349 350 351 352 353 |
# File 'lib/better_auth/plugins/email_otp.rb', line 344 def email_otp_store(ctx, config, email:, type:, otp:) stored = email_otp_stored_value(ctx, config, otp) identifier = email_otp_identifier(email, type) ctx.context.internal_adapter.delete_verification_by_identifier(identifier) ctx.context.internal_adapter.create_verification_value( identifier: identifier, value: "#{stored}:0", expiresAt: Time.now + config[:expires_in].to_i ) end |
.email_otp_stored_value(ctx, config, otp) ⇒ Object
434 435 436 437 438 439 440 441 442 443 444 445 |
# File 'lib/better_auth/plugins/email_otp.rb', line 434 def email_otp_stored_value(ctx, config, otp) storage = config[:store_otp] return Crypto.sha256(otp, encoding: :base64url) if storage.to_s == "hashed" return Crypto.symmetric_encrypt(key: ctx.context.secret, data: otp) if storage.to_s == "encrypted" if storage.is_a?(Hash) return storage[:hash].call(otp) if storage[:hash].respond_to?(:call) return storage[:encrypt].call(otp) if storage[:encrypt].respond_to?(:call) end otp end |
.email_otp_verify!(ctx, config, email:, type:, otp:, consume: true) ⇒ Object
381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 |
# File 'lib/better_auth/plugins/email_otp.rb', line 381 def email_otp_verify!(ctx, config, email:, type:, otp:, consume: true) verification = ctx.context.internal_adapter.find_verification_value(email_otp_identifier(email, type)) raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) unless verification if Routes.expired_time?(verification["expiresAt"]) ctx.context.internal_adapter.delete_verification_value(verification["id"]) raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["OTP_EXPIRED"]) end otp_value, attempts = email_otp_split(verification["value"]) attempts_count = attempts.to_i if attempts_count >= config[:allowed_attempts].to_i ctx.context.internal_adapter.delete_verification_value(verification["id"]) raise APIError.new("FORBIDDEN", message: EMAIL_OTP_ERROR_CODES["TOO_MANY_ATTEMPTS"]) end ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume unless email_otp_matches?(ctx, config, otp_value, otp) if consume ctx.context.internal_adapter.create_verification_value( identifier: email_otp_identifier(email, type), value: "#{otp_value}:#{attempts_count + 1}", expiresAt: verification["expiresAt"] ) else ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{otp_value}:#{attempts_count + 1}") end raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) end true end |
.empty_request_body ⇒ Object
378 379 380 381 382 383 384 385 386 387 388 389 |
# File 'lib/better_auth/plugins/open_api.rb', line 378 def empty_request_body { content: { "application/json" => { schema: { type: "object", properties: {} } } } } end |
.encode_eddsa_jwt(payload, private_key, kid) ⇒ Object
389 390 391 392 393 394 395 396 397 |
# File 'lib/better_auth/plugins/jwt.rb', line 389 def encode_eddsa_jwt(payload, private_key, kid) header = {"alg" => "EdDSA", "kid" => kid} signing_input = [ Crypto.base64url_encode(JSON.generate(header)), Crypto.base64url_encode(JSON.generate(payload)) ].join(".") signature = private_key.sign(nil, signing_input) "#{signing_input}.#{Crypto.base64url_encode(signature)}" end |
.ensure_not_last_owner!(ctx, member) ⇒ Object
899 900 901 902 903 904 |
# File 'lib/better_auth/plugins/organization.rb', line 899 def ensure_not_last_owner!(ctx, member) return unless member["role"].to_s.split(",").include?("owner") owners = ctx.context.adapter.find_many(model: "member", where: [{field: "organizationId", value: member["organizationId"]}]).select { |entry| entry["role"].to_s.split(",").include?("owner") } raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER")) if owners.length <= 1 end |
.error_response(description, required: false) ⇒ Object
420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 |
# File 'lib/better_auth/plugins/open_api.rb', line 420 def error_response(description, required: false) schema = { type: "object", properties: { message: { type: "string" } } } schema[:required] = ["message"] if required { description: description, content: { "application/json" => { schema: schema } } } end |
.expire_cookie(ctx, name) ⇒ Object
164 165 166 |
# File 'lib/better_auth/plugins/multi_session.rb', line 164 def (ctx, name) ctx.(name, "", ctx.context.[:session_token].attributes.merge(max_age: 0)) end |
.expired_bearer_cookie?(cookie) ⇒ Boolean
104 105 106 107 |
# File 'lib/better_auth/plugins/bearer.rb', line 104 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 |
# File 'lib/better_auth/plugins/expo.rb', line 31 def Endpoint.new(path: "/expo-authorization-proxy", method: "GET") 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
84 85 86 |
# File 'lib/better_auth/plugins/expo.rb', line 84 def expo_development_environment? [ENV["RACK_ENV"], ENV["RAILS_ENV"], ENV["APP_ENV"]].include?("development") end |
.expo_inject_cookie_into_deep_link(ctx) ⇒ Object
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/better_auth/plugins/expo.rb', line 66 def (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
53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'lib/better_auth/plugins/expo.rb', line 53 def expo_on_request(config) lambda do |request, _context| next if config[:disable_origin_override] || request.get_header("HTTP_ORIGIN") expo_origin = request.get_header("HTTP_EXPO_ORIGIN") next unless expo_origin env = request.env.dup env["HTTP_ORIGIN"] = expo_origin {request: Rack::Request.new(env)} end end |
.expose_auth_token(ctx) ⇒ Object
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/better_auth/plugins/bearer.rb', line 109 def expose_auth_token(ctx) = ctx.response_headers["set-cookie"].to_s token_name = ctx.context.[:session_token].name token = .lines.filter_map do |line| = (line) next unless && [:name] == token_name next if [:value].empty? || () [:value] end.first return unless token exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?) exposed << "set-auth-token" ctx.set_header("set-auth-token", token) ctx.set_header("access-control-expose-headers", exposed.uniq.join(", ")) nil end |
.extra_input(hash, *exclude) ⇒ Object
982 983 984 |
# File 'lib/better_auth/plugins/organization.rb', line 982 def extra_input(hash, *exclude) normalize_hash(hash).except(*exclude.map(&:to_sym)) end |
.fetch_value(data, key) ⇒ Object
36 37 38 39 40 |
# File 'lib/better_auth/plugins.rb', line 36 def fetch_value(data, key) return nil unless data.respond_to?(:[]) data[key] || data[key.to_s] || data[Schema.storage_key(key)] || data[Schema.storage_key(key).to_sym] || data[normalize_key(key)] end |
.field_schema(attributes) ⇒ Object
456 457 458 459 460 461 462 463 464 465 466 467 468 |
# File 'lib/better_auth/plugins/open_api.rb', line 456 def field_schema(attributes) type = case attributes[:type].to_s when "date" then "string" when "number" then "number" when "boolean" then "boolean" else "string" end schema = {type: type} schema[:format] = "date-time" if attributes[:type].to_s == "date" schema[:default] = attributes[:default_value].respond_to?(:call) ? "Generated at runtime" : attributes[:default_value] if attributes.key?(:default_value) schema[:readOnly] = true if attributes[:input] == false schema end |
.find_device_code(ctx, code) ⇒ Object
194 195 196 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 194 def find_device_code(ctx, code) ctx.context.adapter.find_one(model: "deviceCode", where: [{field: "deviceCode", value: code.to_s}]) end |
.find_device_user_code(ctx, code) ⇒ Object
198 199 200 201 202 203 204 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 198 def find_device_user_code(ctx, code) (code).each do |candidate| record = ctx.context.adapter.find_one(model: "deviceCode", where: [{field: "userCode", value: candidate}]) return record if record end nil end |
.find_member_by_email(ctx, organization_id, email) ⇒ Object
808 809 810 811 |
# File 'lib/better_auth/plugins/organization.rb', line 808 def find_member_by_email(ctx, organization_id, email) user = ctx.context.adapter.find_one(model: "user", where: [{field: "email", value: email.to_s.downcase}]) user && require_member(ctx, user["id"], organization_id) end |
.forget_password_email_otp_endpoint(config) ⇒ Object
267 268 269 270 271 |
# File 'lib/better_auth/plugins/email_otp.rb', line 267 def forget_password_email_otp_endpoint(config) Endpoint.new(path: "/forget-password/email-otp", method: "POST") do |ctx| email_otp_password_reset_request(ctx, config) end end |
.generate_device_authorization_device_code(config) ⇒ Object
210 211 212 213 214 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 210 def (config) return config[:generate_device_code].call.to_s if config[:generate_device_code].respond_to?(:call) SecureRandom.alphanumeric(config[:device_code_length].to_i) end |
.generate_device_authorization_user_code(config) ⇒ Object
216 217 218 219 220 221 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 216 def (config) return config[:generate_user_code].call.to_s if config[:generate_user_code].respond_to?(:call) charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" Array.new(config[:user_code_length].to_i) { charset[SecureRandom.random_number(charset.length)] }.join end |
.generate_key_pair(alg) ⇒ Object
327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
# File 'lib/better_auth/plugins/jwt.rb', line 327 def generate_key_pair(alg) case alg when "EdDSA" OpenSSL::PKey.generate_key("ED25519") when "RS256", "PS256" OpenSSL::PKey::RSA.generate(2048) when "ES256" OpenSSL::PKey::EC.generate("prime256v1") when "ES512" OpenSSL::PKey::EC.generate("secp521r1") else raise Error, "JWT/JWKS algorithm #{alg} is not supported by the Ruby server" end end |
.generate_one_time_token_endpoint(config) ⇒ Object
31 32 33 34 35 36 37 38 39 40 41 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 31 def generate_one_time_token_endpoint(config) Endpoint.new(path: "/one-time-token/generate", method: "GET") do |ctx| if config[:disable_client_request] && ctx.request raise APIError.new("BAD_REQUEST", message: "Client requests are disabled") end session = Routes.current_session(ctx) token = one_time_token_create(ctx, config, session) ctx.json({token: token}) end end |
.generic_oauth(options = {}) ⇒ Object
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 23 def generic_oauth( = {}) 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: (config, context).merge(context.) } } }, endpoints: { sign_in_with_oauth2: sign_in_with_oauth2_endpoint(config), o_auth2_callback: o_auth2_callback_endpoint(config), o_auth2_link_account: o_auth2_link_account_endpoint(config) }, error_codes: GENERIC_OAUTH_ERROR_CODES, options: config ) end |
.generic_oauth_account_info(ctx, provider_id, account_id, tokens) ⇒ Object
522 523 524 525 526 527 528 529 530 531 532 533 534 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 522 def 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:) ⇒ Object
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 321 def (ctx, provider, body, link:) = 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) state_data = normalize_hash(body[:additional_data] || body[:additionalData]).transform_keys(&:to_s).merge( "callbackURL" => body[:callback_url] || body[:callbackURL] || "/", "errorURL" => body[:error_callback_url] || body[:errorCallbackURL], "newUserURL" => body[:new_user_callback_url] || body[:newUserCallbackURL], "requestSignUp" => body[:request_sign_up] || body[:requestSignUp], "codeVerifier" => provider[:pkce] ? code_verifier : nil, "link" => link, "expiresAt" => Time.now.to_i + 600 ) state = generic_oauth_generate_state(ctx, state_data) legacy_state = Crypto.sign_jwt( { "callbackURL" => body[:callback_url] || body[:callbackURL] || "/", "errorURL" => body[:error_callback_url] || body[:errorCallbackURL], "newUserURL" => body[:new_user_callback_url] || body[:newUserCallbackURL], "requestSignUp" => body[:request_sign_up] || body[:requestSignUp], "codeVerifier" => code_verifier, "link" => link }, ctx.context.secret, expires_in: 600 ) state ||= legacy_state uri = URI.parse(.to_s) params = URI.decode_www_form(uri.query.to_s) params.concat([ ["client_id", provider[:client_id].to_s], ["response_type", provider[:response_type] || "code"], ["redirect_uri", generic_oauth_redirect_uri(ctx, provider)], ["state", state] ]) scopes = Array(body[:scopes]) + Array(provider[:scopes]) params << ["scope", scopes.join(" ")] unless scopes.empty? if provider[:pkce] params << ["code_challenge", generic_oauth_pkce_challenge(code_verifier)] params << ["code_challenge_method", "S256"] end params << ["prompt", provider[:prompt]] if provider[:prompt] params << ["access_type", provider[:access_type]] if provider[:access_type] params << ["response_mode", provider[:response_mode]] if provider[:response_mode] = 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| params << [key.to_s, value.to_s] } uri.query = URI.encode_www_form(params) uri.to_s end |
.generic_oauth_discovery(provider) ⇒ Object
575 576 577 578 579 580 581 582 583 584 585 586 587 588 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 575 def generic_oauth_discovery(provider) return {} if provider[:discovery_url].to_s.empty? return provider[:_discovery] if provider[:_discovery] uri = URI(provider[:discovery_url]) request = Net::HTTP::Get.new(uri) normalize_hash(provider[:discovery_headers] || provider[:discoveryHeaders]).each do |key, value| request[key.to_s.tr("_", "-")] = value.to_s end response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) } provider[:_discovery] = response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : {} rescue {} end |
.generic_oauth_error_url(base_url, error) ⇒ Object
767 768 769 770 771 772 773 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 767 def generic_oauth_error_url(base_url, error) uri = URI.parse(base_url.to_s) query = URI.decode_www_form(uri.query.to_s) query << ["error", error.to_s] uri.query = URI.encode_www_form(query) uri.to_s end |
.generic_oauth_exchange_token(ctx, provider, code, state_data) ⇒ Object
400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 400 def generic_oauth_exchange_token(ctx, provider, code, state_data) token_callback = provider[:get_token] if token_callback.respond_to?(:call) return normalize_hash(token_callback.call( code: code, redirectURI: generic_oauth_redirect_uri(ctx, provider), redirect_uri: generic_oauth_redirect_uri(ctx, provider), codeVerifier: provider[:pkce] ? state_data["codeVerifier"] : nil, code_verifier: provider[:pkce] ? state_data["codeVerifier"] : nil )) end token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"] raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty? generic_oauth_post_token(ctx, token_url, provider, code, provider[:pkce] ? state_data["codeVerifier"] : nil, generic_oauth_redirect_uri(ctx, provider)) end |
.generic_oauth_expiry_time(seconds) ⇒ Object
652 653 654 655 656 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 652 def generic_oauth_expiry_time(seconds) return nil if seconds.to_i <= 0 Time.now + seconds.to_i end |
.generic_oauth_fetch_json(url, headers = {}) ⇒ Object
679 680 681 682 683 684 685 686 687 688 689 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 679 def generic_oauth_fetch_json(url, headers = {}) uri = URI(url) request = Net::HTTP::Get.new(uri) normalize_hash(headers).each { |key, value| request[key.to_s.tr("_", "-")] = value.to_s } response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) } return nil unless response.is_a?(Net::HTTPSuccess) JSON.parse(response.body) rescue nil end |
.generic_oauth_generate_state(ctx, state_data) ⇒ Object
378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 378 def generic_oauth_generate_state(ctx, state_data) strategy = ctx.context..account[:store_state_strategy] state = Crypto.random_string(32) if strategy.to_s == "cookie" = ctx.context.("oauth_state", max_age: 600) encrypted = Crypto.symmetric_encrypt(key: ctx.context.secret, data: JSON.generate(state_data.merge("state" => state))) ctx.(.name, encrypted, .attributes) return state end = ctx.context.("state", max_age: 300) ctx.(.name, state, ctx.context.secret, .attributes) ctx.context.internal_adapter.create_verification_value( identifier: state, value: JSON.generate(state_data), expiresAt: Time.now + 600 ) state rescue nil end |
.generic_oauth_link_account(ctx, provider, tokens, user_info, link, redirect_error) ⇒ Object
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 504 def 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
498 499 500 501 502 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 498 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) ⇒ Object
639 640 641 642 643 644 645 646 647 648 649 650 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 639 def generic_oauth_normalize_tokens(data) token_data = normalize_hash(data) token_data.merge( access_token: token_data[:access_token], refresh_token: token_data[:refresh_token], id_token: token_data[:id_token], access_token_expires_at: generic_oauth_expiry_time(token_data[:expires_in]), refresh_token_expires_at: generic_oauth_expiry_time(token_data[:refresh_token_expires_in]), scopes: generic_oauth_token_scopes(token_data[:scope]), raw: token_data ).compact end |
.generic_oauth_normalize_user_info(data) ⇒ Object
669 670 671 672 673 674 675 676 677 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 669 def 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
418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 418 def generic_oauth_parse_state(ctx, state) if state.to_s.empty? raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process")) end if ctx.context..account[:store_state_strategy].to_s == "cookie" = ctx.context.("oauth_state") encrypted = ctx.(.name) unless encrypted raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch")) end begin decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, data: encrypted) unless decrypted Cookies.(ctx, ) raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process")) end parsed = JSON.parse(decrypted) rescue JSON::ParserError Cookies.(ctx, ) raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process")) end Cookies.(ctx, ) if parsed["state"] != state raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch")) end if parsed["expiresAt"].to_i.positive? && parsed["expiresAt"].to_i < Time.now.to_i raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch")) end return parsed else verification = ctx.context.internal_adapter.find_verification_value(state) if verification = ctx.context.("state") = ctx.(.name, ctx.context.secret) if && != state Cookies.(ctx, ) raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch")) end parsed = JSON.parse(verification.fetch("value")) ctx.context.internal_adapter.delete_verification_value(verification.fetch("id")) Cookies.(ctx, ) if return parsed end end Crypto.verify_jwt(state.to_s, ctx.context.secret) || {} rescue JSON::ParserError {} end |
.generic_oauth_pkce_challenge(code_verifier) ⇒ Object
665 666 667 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 665 def generic_oauth_pkce_challenge(code_verifier) Crypto.base64url_encode(OpenSSL::Digest.digest("SHA256", code_verifier.to_s)) end |
.generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token) ⇒ Object
746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 746 def generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token) uri = URI(token_url) request = Net::HTTP::Post.new(uri) form_data = {grant_type: "refresh_token", refresh_token: refresh_token} authentication = (provider[:authentication] || "post").to_s if authentication == "basic" request["authorization"] = "Basic #{Base64.strict_encode64("#{provider[:client_id]}:#{provider[:client_secret]}")}" else form_data[:client_id] = provider[:client_id] form_data[:client_secret] = provider[:client_secret] if provider[:client_secret] end token_url_params = provider[:token_url_params] || provider[:tokenUrlParams] token_url_params = token_url_params.call(ctx) if token_url_params.respond_to?(:call) normalize_hash(token_url_params || {}).each { |key, value| form_data[key] = value } request.set_form_data(form_data.compact) response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) } raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIG"]) unless response.is_a?(Net::HTTPSuccess) generic_oauth_normalize_tokens(JSON.parse(response.body)) end |
.generic_oauth_post_token(ctx, token_url, provider, code, code_verifier, redirect_uri) ⇒ Object
590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 590 def generic_oauth_post_token(ctx, token_url, provider, code, code_verifier, redirect_uri) uri = URI(token_url) request = Net::HTTP::Post.new(uri) normalize_hash(provider[:authorization_headers] || provider[:authorizationHeaders]).each do |key, value| request[key.to_s.tr("_", "-")] = value.to_s end form_data = { grant_type: "authorization_code", code: code, redirect_uri: redirect_uri }.compact form_data[:code_verifier] = code_verifier if code_verifier authentication = (provider[:authentication] || "post").to_s if authentication == "basic" request["authorization"] = "Basic #{Base64.strict_encode64("#{provider[:client_id]}:#{provider[:client_secret]}")}" else form_data[:client_id] = provider[:client_id] form_data[:client_secret] = provider[:client_secret] if provider[:client_secret] end token_url_params = if provider[:token_url_params].respond_to?(:call) provider[:token_url_params].call(ctx) else provider[:token_url_params] || provider[:tokenUrlParams] end normalize_hash(token_url_params || {}).each do |key, value| form_data[key] = value unless form_data.key?(key) end request.set_form_data(form_data) response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) } return nil unless response.is_a?(Net::HTTPSuccess) generic_oauth_normalize_tokens(JSON.parse(response.body)) rescue nil end |
.generic_oauth_provider(config, provider_id) ⇒ Object
557 558 559 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 557 def generic_oauth_provider(config, provider_id) Array(config[:config]).find { |provider| provider[:provider_id].to_s == provider_id.to_s } end |
.generic_oauth_provider!(config, provider_id) ⇒ Object
550 551 552 553 554 555 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 550 def generic_oauth_provider!(config, provider_id) provider = generic_oauth_provider(config, provider_id) raise APIError.new("BAD_REQUEST", message: "#{GENERIC_OAUTH_ERROR_CODES["PROVIDER_CONFIG_NOT_FOUND"]} #{provider_id}") unless provider provider end |
.generic_oauth_provider_config(options, defaults) ⇒ Object
712 713 714 715 716 717 718 719 720 721 722 723 724 725 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 712 def generic_oauth_provider_config(, 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] ) config[:scopes] = data[:scopes] if data[:scopes] config.compact end |
.generic_oauth_redirect_uri(ctx, provider) ⇒ Object
561 562 563 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 561 def generic_oauth_redirect_uri(ctx, provider) provider[:redirect_uri] || provider[:redirectURI] || "#{ctx.context.base_url}/oauth2/callback/#{provider[:provider_id]}" end |
.generic_oauth_refresh_access_token(ctx, provider, refresh_token) ⇒ Object
739 740 741 742 743 744 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 739 def generic_oauth_refresh_access_token(ctx, provider, refresh_token) token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"] raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty? generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token) end |
.generic_oauth_set_account_cookie(ctx, provider_id, account_id, user_id) ⇒ Object
543 544 545 546 547 548 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 543 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_social_providers(config, context) ⇒ Object
727 728 729 730 731 732 733 734 735 736 737 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 727 def (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_user_info(provider, tokens) }, refresh_access_token: ->(refresh_token) { generic_oauth_refresh_access_token(context, provider, refresh_token) } } end end |
.generic_oauth_state_error_url(ctx) ⇒ Object
473 474 475 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 473 def generic_oauth_state_error_url(ctx) ctx.context..on_api_error[:error_url] || "#{ctx.context.base_url}/error" end |
.generic_oauth_token_for_storage(ctx, token) ⇒ Object
536 537 538 539 540 541 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 536 def generic_oauth_token_for_storage(ctx, token) return token if token.to_s.empty? return token unless ctx.context..account[:encrypt_oauth_tokens] Crypto.symmetric_encrypt(key: ctx.context.secret, data: token) end |
.generic_oauth_token_scopes(scope) ⇒ Object
658 659 660 661 662 663 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 658 def generic_oauth_token_scopes(scope) return [] unless scope return scope if scope.is_a?(Array) scope.to_s.split(/\s+/) end |
.generic_oauth_user_from_id_token(id_token) ⇒ Object
626 627 628 629 630 631 632 633 634 635 636 637 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 626 def generic_oauth_user_from_id_token(id_token) payload = JWT.decode(id_token, nil, false).first normalize_hash( id: payload["sub"], email: payload["email"], emailVerified: payload["email_verified"], name: payload["name"], image: payload["picture"] ) rescue nil end |
.generic_oauth_user_info(provider, tokens) ⇒ Object
477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 477 def generic_oauth_user_info(provider, tokens) callback = provider[:get_user_info] return normalize_hash(callback.call(tokens)) if callback.respond_to?(:call) id_token = tokens[:id_token] || tokens[:idToken] return generic_oauth_user_from_id_token(id_token) if id_token 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 = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) } return nil unless response.is_a?(Net::HTTPSuccess) generic_oauth_normalize_user_info(JSON.parse(response.body)) rescue nil end |
.generic_oauth_validate_issuer!(ctx, provider, query, redirect_error) ⇒ Object
565 566 567 568 569 570 571 572 573 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 565 def generic_oauth_validate_issuer!(ctx, provider, query, redirect_error) expected = provider[:issuer] || generic_oauth_discovery(provider)["issuer"] return if expected.to_s.empty? return if query[:iss].to_s == expected.to_s return redirect_error.call("issuer_missing") if query[:iss].to_s.empty? && provider[:require_issuer_validation] return if query[:iss].to_s.empty? redirect_error.call("issuer_mismatch") end |
.generic_oauth_warn_duplicate_providers(providers) ⇒ Object
775 776 777 778 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 775 def generic_oauth_warn_duplicate_providers(providers) duplicates = providers.group_by { |provider| provider[:provider_id].to_s }.select { |id, entries| !id.empty? && entries.length > 1 }.keys warn "Duplicate provider IDs found: #{duplicates.join(", ")}" unless duplicates.empty? end |
.generic_oidc_helper_provider(options, provider_id, issuer, discovery_url, user_info_url) ⇒ Object
691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 691 def generic_oidc_helper_provider(, provider_id, issuer, discovery_url, user_info_url) generic_oauth_provider_config( , provider_id: provider_id, discovery_url: discovery_url, scopes: ["openid", "profile", "email"], get_user_info: ->(tokens) { profile = generic_oauth_fetch_json(user_info_url, authorization: "Bearer #{fetch_value(tokens, "accessToken")}") return nil unless profile { id: fetch_value(profile, "sub"), name: fetch_value(profile, "name") || fetch_value(profile, "preferred_username"), email: fetch_value(profile, "email"), image: fetch_value(profile, "picture"), emailVerified: fetch_value(profile, "email_verified") || false } } ) end |
.get_jwks_endpoint(config, path) ⇒ Object
105 106 107 108 109 110 111 112 |
# File 'lib/better_auth/plugins/jwt.rb', line 105 def get_jwks_endpoint(config, path) Endpoint.new(path: path, method: "GET") do |ctx| raise APIError.new("NOT_FOUND") if config.dig(:jwks, :remote_url) create_jwk(ctx, config) if all_jwks(ctx, config).empty? ctx.json({keys: public_jwks(ctx, config).map { |key| public_jwk(key, config) }}) end end |
.get_siwe_nonce_endpoint(config) ⇒ Object
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/better_auth/plugins/siwe.rb', line 26 def get_siwe_nonce_endpoint(config) Endpoint.new(path: "/siwe/nonce", method: "POST", body_schema: ->(body) { siwe_nonce_body(body) }) do |ctx| body = normalize_hash(ctx.body) wallet_address = siwe_normalize_wallet!(body[:wallet_address]) chain_id = siwe_chain_id(body[:chain_id]) nonce_callback = config[:get_nonce] raise APIError.new("INTERNAL_SERVER_ERROR", message: "SIWE nonce callback is required") unless nonce_callback.respond_to?(:call) nonce = nonce_callback.call.to_s ctx.context.internal_adapter.create_verification_value( identifier: siwe_identifier(wallet_address, chain_id), value: nonce, expiresAt: Time.now + (15 * 60) ) ctx.json({nonce: nonce}) end end |
.get_token_endpoint(config) ⇒ Object
114 115 116 117 118 119 120 121 |
# File 'lib/better_auth/plugins/jwt.rb', line 114 def get_token_endpoint(config) Endpoint.new(path: "/token", method: "GET") do |ctx| session = Session.find_current(ctx) raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"]) unless session ctx.json({token: jwt_token(ctx, session, config)}) end end |
.get_verification_otp_endpoint(config) ⇒ Object
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/better_auth/plugins/email_otp.rb', line 113 def get_verification_otp_endpoint(config) Endpoint.new(path: "/email-otp/get-verification-otp", method: "GET") do |ctx| query = normalize_hash(ctx.query) email = query[:email].to_s.downcase type = query[:type].to_s validate_email_otp_type!(type) verification = ctx.context.internal_adapter.find_verification_value(email_otp_identifier(email, type)) next ctx.json({otp: nil}) unless verification && !Routes.expired_time?(verification["expiresAt"]) stored_otp, = email_otp_split(verification["value"]) case config[:store_otp].to_s when "hashed" raise APIError.new("BAD_REQUEST", message: "OTP is hashed, cannot return the plain text OTP") when "encrypted" next ctx.json({otp: Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored_otp)}) end storage = config[:store_otp] if storage.is_a?(Hash) && storage[:hash].respond_to?(:call) raise APIError.new("BAD_REQUEST", message: "OTP is hashed, cannot return the plain text OTP") elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call) next ctx.json({otp: storage[:decrypt].call(stored_otp)}) end ctx.json({otp: stored_otp}) end end |
.gumroad(options = {}) ⇒ Object
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 71 def gumroad( = {}) 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 = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) } unless response.is_a?(Net::HTTPSuccess) raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Status: #{response.code}") end response.body.to_s end |
.have_i_been_pwned_wrap_password_hasher!(context, config) ⇒ Object
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/better_auth/plugins/have_i_been_pwned.rb', line 33 def have_i_been_pwned_wrap_password_hasher!(context, config) email_config = context..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
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 95 def hubspot( = {}) 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 |
.invitation_by_id(ctx, id) ⇒ Object
825 826 827 828 829 |
# File 'lib/better_auth/plugins/organization.rb', line 825 def invitation_by_id(ctx, id) return nil if id.to_s.empty? ctx.context.adapter.find_one(model: "invitation", where: [{field: "id", value: id}]) end |
.invitation_wire(ctx, invitation) ⇒ Object
883 884 885 |
# File 'lib/better_auth/plugins/organization.rb', line 883 def invitation_wire(ctx, invitation) Schema.parse_output(ctx.context., "invitation", invitation) end |
.is_username_available_endpoint(config) ⇒ Object
117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/better_auth/plugins/username.rb', line 117 def is_username_available_endpoint(config) Endpoint.new(path: "/is-username-available", method: "POST") do |ctx| body = normalize_hash(ctx.body) username = body[:username].to_s raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) if username.empty? validate_username!(username, config, status: "UNPROCESSABLE_ENTITY") user = ctx.context.adapter.find_one( model: "user", where: [{field: "username", value: normalize_username(username, config)}] ) ctx.json({available: user.nil?}) end end |
.jwk_expired?(key) ⇒ Boolean
422 423 424 425 |
# File 'lib/better_auth/plugins/jwt.rb', line 422 def jwk_expired?(key) expires_at = normalize_time(key["expiresAt"]) expires_at && expires_at < Time.now end |
.jwk_private_key_for_storage(ctx, private_key, config) ⇒ Object
306 307 308 309 310 |
# File 'lib/better_auth/plugins/jwt.rb', line 306 def jwk_private_key_for_storage(ctx, private_key, config) return private_key if config.dig(:jwks, :disable_private_key_encryption) Crypto.symmetric_encrypt(key: ctx.context.secret, data: private_key) end |
.jwk_private_key_value(ctx, key, _config) ⇒ Object
312 313 314 315 |
# File 'lib/better_auth/plugins/jwt.rb', line 312 def jwk_private_key_value(ctx, key, _config) value = key["privateKey"] Crypto.symmetric_decrypt(key: ctx.context.secret, data: value) || value end |
.jwt(options = {}) ⇒ Object
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/better_auth/plugins/jwt.rb', line 42 def jwt( = {}) config = normalize_hash() (config) jwks_path = config.dig(:jwks, :jwks_path) || "/jwks" Plugin.new( id: "jwt", endpoints: { get_jwks: get_jwks_endpoint(config, jwks_path), get_token: get_token_endpoint(config), sign_jwt: sign_jwt_endpoint(config), verify_jwt: verify_jwt_endpoint(config) }, hooks: { after: [ { matcher: ->(ctx) { ctx.path == "/get-session" }, handler: ->(ctx) { set_jwt_header(ctx, config) } } ] }, schema: { jwks: { fields: { publicKey: {type: "string", required: true}, privateKey: {type: "string", required: true}, createdAt: {type: "date", required: true}, expiresAt: {type: "date", required: false}, alg: {type: "string", required: false}, kty: {type: "string", required: false}, crv: {type: "string", required: false}, x: {type: "string", required: false}, y: {type: "string", required: false}, pem: {type: "string", required: false}, n: {type: "string", required: false}, e: {type: "string", required: false} } } }, options: config ) end |
.jwt_expiration(value, iat) ⇒ Object
427 428 429 430 431 432 |
# File 'lib/better_auth/plugins/jwt.rb', line 427 def jwt_expiration(value, iat) return value.to_i if value.is_a?(Integer) return value.to_i if value.is_a?(Time) iat.to_i + parse_duration(value.to_s) end |
.jwt_payload_valid?(payload) ⇒ Boolean
317 318 319 320 321 322 323 324 325 |
# File 'lib/better_auth/plugins/jwt.rb', line 317 def jwt_payload_valid?(payload) return false if payload["sub"].to_s.empty? audience = payload["aud"] return false if audience.nil? return false if audience.respond_to?(:empty?) && audience.empty? true end |
.jwt_token(ctx, session, config) ⇒ Object
154 155 156 157 158 159 160 161 162 163 164 165 166 167 |
# File 'lib/better_auth/plugins/jwt.rb', line 154 def jwt_token(ctx, session, config) jwt_config = config[:jwt] || {} payload = if jwt_config[:define_payload].respond_to?(:call) jwt_config[:define_payload].call(session) else session[:user] end subject = if jwt_config[:get_subject].respond_to?(:call) jwt_config[:get_subject].call(session) else session[:user]["id"] end sign_jwt_payload(ctx, stringify_payload(payload).merge("sub" => subject), config) end |
.key_type_for_alg(alg) ⇒ Object
379 380 381 382 383 |
# File 'lib/better_auth/plugins/jwt.rb', line 379 def key_type_for_alg(alg) return "OKP" if alg == "EdDSA" alg.to_s.start_with?("ES") ? "EC" : "RSA" end |
.keycloak(options = {}) ⇒ Object
116 117 118 119 120 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 116 def keycloak( = {}) 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
207 208 209 |
# File 'lib/better_auth/plugins/jwt.rb', line 207 def latest_jwk(ctx, config) all_jwks(ctx, config).max_by { |entry| normalize_time(entry["createdAt"]) || Time.at(0) } end |
.line(options = {}) ⇒ Object
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 122 def line( = {}) 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
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/better_auth/plugins/anonymous.rb', line 132 def link_anonymous_user(ctx, config) = 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 |
# File 'lib/better_auth/plugins/multi_session.rb', line 38 def list_device_sessions_endpoint Endpoint.new(path: "/multi-session/list-device-sessions", method: "GET") do |ctx| tokens = verified_multi_session_tokens(ctx) sessions = ctx.context.internal_adapter.find_sessions(tokens) .reject { |entry| entry[:session]["expiresAt"] && entry[:session]["expiresAt"] <= Time.now } unique = sessions.each_with_object({}) { |entry, by_user| by_user[entry[:user]["id"]] ||= entry }.values ctx.json(unique.map { |entry| parsed_session(ctx, entry) }) end end |
.list_members_for(ctx, organization_id, query = {}) ⇒ Object
849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 |
# File 'lib/better_auth/plugins/organization.rb', line 849 def list_members_for(ctx, organization_id, query = {}) where = [{field: "organizationId", value: organization_id}] if query[:filter_field] where << {field: query[:filter_field], value: query[:filter_value], operator: query[:filter_operator]} elsif query[:filter].is_a?(Hash) filter = normalize_hash(query[:filter]) where << {field: filter[:field], value: filter[:value], operator: filter[:operator]} end members = ctx.context.adapter.find_many( model: "member", where: where, limit: query[:limit], offset: query[:offset], sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_order] || "asc"} : nil ) { members: members.map { |entry| member_wire(ctx, entry) }, total: ctx.context.adapter.count(model: "member", where: where) } end |
.magic_link(options = {}) ⇒ Object
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
# File 'lib/better_auth/plugins/magic_link.rb', line 10 def magic_link( = {}) config = {store_token: "plain", allowed_attempts: 1}.merge(normalize_hash()) 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
135 136 137 138 139 140 |
# File 'lib/better_auth/plugins/magic_link.rb', line 135 def magic_link_attempts_exceeded?(attempt, config) allowed = config[:allowed_attempts] return false if allowed.respond_to?(:infinite?) && allowed.infinite? attempt >= allowed.to_i end |
.magic_link_error_url(url, error) ⇒ Object
171 172 173 174 175 176 177 178 179 |
# File 'lib/better_auth/plugins/magic_link.rb', line 171 def magic_link_error_url(url, error) uri = URI.parse(url.to_s.empty? ? "/" : url.to_s) query = URI.decode_www_form(uri.query.to_s) query << ["error", error] uri.query = URI.encode_www_form(query) uri.to_s rescue URI::InvalidURIError "/?error=#{URI.encode_www_form_component(error)}" end |
.magic_link_token(email, config) ⇒ Object
128 129 130 131 132 133 |
# File 'lib/better_auth/plugins/magic_link.rb', line 128 def magic_link_token(email, config) generator = config[:generate_token] return generator.call(email) if generator.respond_to?(:call) Array.new(32) { [*"a".."z", *"A".."Z"].sample }.join end |
.magic_link_url(ctx, token, body) ⇒ Object
154 155 156 157 158 159 160 161 162 |
# File 'lib/better_auth/plugins/magic_link.rb', line 154 def magic_link_url(ctx, token, body) params = { token: token, callbackURL: body[:callback_url] || "/" } params[:newUserCallbackURL] = body[:new_user_callback_url] if body[:new_user_callback_url] params[:errorCallbackURL] = body[:error_callback_url] if body[:error_callback_url] "#{ctx.context.base_url}/magic-link/verify?#{URI.encode_www_form(params)}" end |
.magic_link_verify_endpoint(config) ⇒ Object
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/better_auth/plugins/magic_link.rb', line 53 def magic_link_verify_endpoint(config) Endpoint.new(path: "/magic-link/verify", method: "GET") do |ctx| query = normalize_hash(ctx.query) token = query[:token].to_s callback_url = query[:callback_url] || "/" error_callback_url = query[:error_callback_url] || callback_url new_user_callback_url = query[:new_user_callback_url] || callback_url validate_magic_link_callback!(ctx, callback_url, "callbackURL") validate_magic_link_callback!(ctx, error_callback_url, "errorCallbackURL") validate_magic_link_callback!(ctx, new_user_callback_url, "newUserCallbackURL") redirect_with_error = lambda do |error| raise ctx.redirect(magic_link_error_url(error_callback_url, error)) end stored_token = store_magic_link_token(token, config) verification = ctx.context.internal_adapter.find_verification_value(stored_token) redirect_with_error.call("INVALID_TOKEN") unless verification if Routes.expired_time?(verification["expiresAt"]) ctx.context.internal_adapter.delete_verification_value(verification["id"]) redirect_with_error.call("EXPIRED_TOKEN") end payload = JSON.parse(verification["value"]) email = payload.fetch("email").to_s.downcase name = payload["name"] attempt = payload["attempt"].to_i if magic_link_attempts_exceeded?(attempt, config) ctx.context.internal_adapter.delete_verification_value(verification["id"]) redirect_with_error.call("ATTEMPTS_EXCEEDED") end ctx.context.internal_adapter.update_verification_value( verification["id"], value: JSON.generate(payload.merge("attempt" => attempt + 1)) ) found = ctx.context.internal_adapter.find_user_by_email(email) user = found && found[:user] new_user = false unless user redirect_with_error.call("new_user_signup_disabled") if config[:disable_sign_up] user = ctx.context.internal_adapter.create_user( email: email, emailVerified: true, name: name || "" ) new_user = true redirect_with_error.call("failed_to_create_user") unless user end unless user["emailVerified"] user = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true) end session = ctx.context.internal_adapter.create_session(user["id"]) redirect_with_error.call("failed_to_create_session") unless session Cookies.(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
266 267 268 |
# File 'lib/better_auth/plugins/username.rb', line 266 def max_username_length(config) (config[:max_username_length] || 30).to_i end |
.mcp(options = {}) ⇒ Object
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/better_auth/plugins/mcp.rb', line 42 def mcp( = {}) config = { login_page: "/login", consent_page: "/oauth/consent", resource: nil, oidc_config: {}, code_expires_in: 600, default_scope: "openid", access_token_expires_in: 3600, refresh_token_expires_in: 604_800, allow_plain_code_challenge_method: true, scopes: %w[openid profile email offline_access], store: OAuthProtocol.stores }.merge(normalize_hash()) config = mcp_normalize_config(config) Plugin.new( id: "mcp", endpoints: mcp_endpoints(config), hooks: { after: [ { matcher: ->(_ctx) { true }, handler: ->(ctx) { mcp_restore_login_prompt(ctx, config) } } ] }, schema: oidc_provider_schema, options: config ) end |
.mcp_authenticate_token_client!(ctx, body, config) ⇒ Object
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
# File 'lib/better_auth/plugins/mcp.rb', line 325 def mcp_authenticate_token_client!(ctx, body, config) = ctx.headers["authorization"].to_s if .start_with?("Basic ") && body["client_id"].to_s.empty? return OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret] || "plain") end client = OAuthProtocol.find_client(ctx, "oauthApplication", body["client_id"]) raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client data = OAuthProtocol.stringify_keys(client) method = data["tokenEndpointAuthMethod"] || "client_secret_basic" if method != "none" && !OAuthProtocol.verify_client_secret(ctx, data["clientSecret"], body["client_secret"], config[:store_client_secret] || "plain") raise APIError.new("UNAUTHORIZED", message: "invalid_client") end client end |
.mcp_authorization_redirect(ctx, config, query, session) ⇒ Object
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 |
# File 'lib/better_auth/plugins/mcp.rb', line 171 def (ctx, config, query, session) query = OAuthProtocol.stringify_keys(query) query["prompt"] = mcp_prompt_without_login(query["prompt"]) if query.key?("prompt") prompts = OIDCProvider.parse_prompt(query["prompt"]) unless query["client_id"] raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") end unless query["response_type"] raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(ctx.context.base_url + "/error", error: "invalid_request", error_description: "response_type is required")) end client = OAuthProtocol.find_client(ctx, "oauthApplication", query["client_id"]) raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") unless client OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"]) client_data = OAuthProtocol.stringify_keys(client) raise ctx.redirect("#{ctx.context.base_url}/error?error=client_disabled") if client_data["disabled"] unless query["response_type"] == "code" raise ctx.redirect("#{ctx.context.base_url}/error?error=unsupported_response_type") end scopes = OAuthProtocol.parse_scopes(query["scope"] || config[:default_scope]) invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) } unless invalid_scopes.empty? redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_scope", error_description: "The following scopes are invalid: #{invalid_scopes.join(", ")}", state: query["state"]) raise ctx.redirect(redirect) end if config[:require_pkce] && (query["code_challenge"].to_s.empty? || query["code_challenge_method"].to_s.empty?) redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "pkce is required", state: query["state"]) raise ctx.redirect(redirect) end challenge_method = query["code_challenge_method"].to_s if challenge_method.empty? query["code_challenge_method"] = "plain" if query["code_challenge"] elsif !valid_code_challenge_method?(challenge_method, config) redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "invalid code_challenge method", state: query["state"]) raise ctx.redirect(redirect) end if prompts.include?("consent") = Crypto.random_string(32) config[:store][:consents][] = { query: query, session: session, client: client, scopes: scopes, expires_at: Time.now + config[:code_expires_in].to_i } raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:consent_page], consent_code: , client_id: client_data["clientId"], scope: OAuthProtocol.scope_string(scopes))) end code = Crypto.random_string(32) OAuthProtocol.store_code( config[:store], code: code, client_id: query["client_id"], redirect_uri: query["redirect_uri"], session: session, scopes: scopes, code_challenge: query["code_challenge"], code_challenge_method: query["code_challenge_method"] ) OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"]) end |
.mcp_authorize_endpoint(config) ⇒ Object
143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/better_auth/plugins/mcp.rb', line 143 def (config) Endpoint.new(path: "/mcp/authorize", method: "GET") do |ctx| query = OAuthProtocol.stringify_keys(ctx.query) session = Routes.current_session(ctx, allow_nil: true) unless session ctx.("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax") raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query)) end raise ctx.redirect((ctx, config, query, session)) end end |
.mcp_endpoints(config) ⇒ Object
74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/better_auth/plugins/mcp.rb', line 74 def mcp_endpoints(config) { get_mcp_o_auth_config: mcp_oauth_config_endpoint(config), get_mcp_protected_resource: mcp_protected_resource_endpoint(config), mcp_o_auth_authorize: (config), mcp_o_auth_token: mcp_token_endpoint(config), mcp_o_auth_user_info: mcp_userinfo_endpoint(config), mcp_register: mcp_register_endpoint(config), get_mcp_session: mcp_get_session_endpoint(config), o_auth_consent: (config), mcp_jwks: mcp_jwks_endpoint(config) } end |
.mcp_get_session_endpoint(config) ⇒ Object
293 294 295 296 297 298 299 300 301 |
# File 'lib/better_auth/plugins/mcp.rb', line 293 def mcp_get_session_endpoint(config) Endpoint.new(path: "/mcp/get-session", method: "GET") do |ctx| = ctx.headers["authorization"].to_s token = .start_with?("Bearer ") ? .delete_prefix("Bearer ").strip : "" next ctx.json(nil) if token.empty? ctx.json(OAuthProtocol.token_record(config[:store], token)) end end |
.mcp_jwks_endpoint(config) ⇒ Object
303 304 305 306 307 308 309 |
# File 'lib/better_auth/plugins/mcp.rb', line 303 def mcp_jwks_endpoint(config) Endpoint.new(path: "/mcp/jwks", method: "GET") do |ctx| jwt_config = config[:jwt] || {} create_jwk(ctx, jwt_config) if all_jwks(ctx, jwt_config).empty? ctx.json({keys: public_jwks(ctx, jwt_config).map { |key| public_jwk(key, jwt_config) }}) end end |
.mcp_normalize_config(config) ⇒ Object
311 312 313 314 315 316 |
# File 'lib/better_auth/plugins/mcp.rb', line 311 def mcp_normalize_config(config) oidc = normalize_hash(config[:oidc_config] || {}) merged = config.merge(oidc.except(:metadata)) merged[:scopes] = (Array(config[:scopes]) + Array(oidc[:scopes])).compact.map(&:to_s).uniq merged end |
.mcp_oauth_config_endpoint(config) ⇒ Object
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/better_auth/plugins/mcp.rb', line 88 def mcp_oauth_config_endpoint(config) Endpoint.new(path: "/.well-known/oauth-authorization-server", method: "GET", metadata: {hide: true}) do |ctx| base = OAuthProtocol.endpoint_base(ctx) ctx.json({ issuer: OAuthProtocol.issuer(ctx), authorization_endpoint: "#{base}/mcp/authorize", token_endpoint: "#{base}/mcp/token", userinfo_endpoint: "#{base}/mcp/userinfo", jwks_uri: "#{base}/mcp/jwks", registration_endpoint: "#{base}/mcp/register", scopes_supported: config[:scopes], response_types_supported: ["code"], response_modes_supported: ["query"], grant_types_supported: ["authorization_code", "refresh_token"], acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256", "none"], token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"], code_challenge_methods_supported: ["S256"], claims_supported: %w[sub iss aud exp nbf iat jti email email_verified name] }.merge(config[:oidc_config][:metadata] || {})) end end |
.mcp_parse_login_prompt(value) ⇒ Object
240 241 242 243 244 245 |
# File 'lib/better_auth/plugins/mcp.rb', line 240 def mcp_parse_login_prompt(value) parsed = JSON.parse(value.to_s) parsed.is_a?(Hash) ? parsed : nil rescue JSON::ParserError nil end |
.mcp_prompt_without_login(value) ⇒ Object
234 235 236 237 238 |
# File 'lib/better_auth/plugins/mcp.rb', line 234 def mcp_prompt_without_login(value) prompts = value.to_s.split(/\s+/).reject(&:empty?) prompts.delete("login") prompts.join(" ") end |
.mcp_protected_resource_endpoint(config) ⇒ Object
112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/better_auth/plugins/mcp.rb', line 112 def mcp_protected_resource_endpoint(config) Endpoint.new(path: "/.well-known/oauth-protected-resource", method: "GET", metadata: {hide: true}) do |ctx| origin = OAuthProtocol.origin_for(OAuthProtocol.endpoint_base(ctx)) ctx.json({ resource: config[:resource] || origin, authorization_servers: [origin], jwks_uri: config.dig(:oidc_config, :metadata, :jwks_uri) || "#{OAuthProtocol.endpoint_base(ctx)}/mcp/jwks", scopes_supported: config.dig(:oidc_config, :metadata, :scopes_supported) || config[:scopes], bearer_methods_supported: ["header"], resource_signing_alg_values_supported: ["RS256", "none"] }) end end |
.mcp_register_endpoint(config) ⇒ Object
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/better_auth/plugins/mcp.rb', line 126 def mcp_register_endpoint(config) Endpoint.new(path: "/mcp/register", method: "POST") do |ctx| mcp_set_cors_headers(ctx) ctx.json( OAuthProtocol.create_client( ctx, model: "oauthApplication", body: ctx.body, default_auth_method: "none", store_client_secret: config[:store_client_secret] || "plain" ), status: 201, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"} ) end end |
.mcp_restore_login_prompt(ctx, config) ⇒ Object
156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/better_auth/plugins/mcp.rb', line 156 def mcp_restore_login_prompt(ctx, config) = ctx.("oidc_login_prompt", ctx.context.secret) return unless session = ctx.context.new_session return unless session && session[:session] && ctx.response_headers["set-cookie"].to_s.include?(ctx.context.[:session_token].name) query = mcp_parse_login_prompt() return unless query ctx.("oidc_login_prompt", "", path: "/", max_age: 0) ctx.context.set_current_session(session) if ctx.context.respond_to?(:set_current_session) [302, ctx.response_headers.merge("location" => (ctx, config, query, session)), [""]] end |
.mcp_set_cors_headers(ctx) ⇒ Object
318 319 320 321 322 323 |
# File 'lib/better_auth/plugins/mcp.rb', line 318 def mcp_set_cors_headers(ctx) ctx.set_header("Access-Control-Allow-Origin", "*") ctx.set_header("Access-Control-Allow-Methods", "POST, OPTIONS") ctx.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization") ctx.set_header("Access-Control-Max-Age", "86400") end |
.mcp_token_endpoint(config) ⇒ Object
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'lib/better_auth/plugins/mcp.rb', line 247 def mcp_token_endpoint(config) Endpoint.new(path: "/mcp/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx| mcp_set_cors_headers(ctx) body = OAuthProtocol.stringify_keys(ctx.body) client = mcp_authenticate_token_client!(ctx, body, config) raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client response = case body["grant_type"] when OAuthProtocol::AUTH_CODE_GRANT client_data = OAuthProtocol.stringify_keys(client) if client_data["type"] == "public" && body["code_verifier"].to_s.empty? raise APIError.new("BAD_REQUEST", message: "invalid_request") end code = OAuthProtocol.consume_code!( config[:store], body["code"], client_id: client_data["clientId"], redirect_uri: body["redirect_uri"], code_verifier: body["code_verifier"] ) OAuthProtocol.issue_tokens( ctx, config[:store], model: "oauthAccessToken", client: client, session: code[:session], scopes: code[:scopes], include_refresh: code[:scopes].include?("offline_access"), issuer: OAuthProtocol.issuer(ctx), access_token_expires_in: config[:access_token_expires_in] ) when OAuthProtocol::REFRESH_GRANT OAuthProtocol.refresh_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, refresh_token: body["refresh_token"], scopes: body["scope"], issuer: OAuthProtocol.issuer(ctx), access_token_expires_in: config[:access_token_expires_in]) else raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type") end ctx.json(response, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"}) end end |
.mcp_userinfo_endpoint(config) ⇒ Object
287 288 289 290 291 |
# File 'lib/better_auth/plugins/mcp.rb', line 287 def mcp_userinfo_endpoint(config) Endpoint.new(path: "/mcp/userinfo", method: "GET") do |ctx| ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"])) end end |
.member_by_id(ctx, id) ⇒ Object
802 803 804 805 806 |
# File 'lib/better_auth/plugins/organization.rb', line 802 def member_by_id(ctx, id) return nil if id.to_s.empty? ctx.context.adapter.find_one(model: "member", where: [{field: "id", value: id}]) end |
.member_wire(ctx, member) ⇒ Object
870 871 872 873 874 875 |
# File 'lib/better_auth/plugins/organization.rb', line 870 def member_wire(ctx, member) data = Schema.parse_output(ctx.context., "member", member) user = ctx.context.internal_adapter.find_user_by_id(member["userId"]) data["user"] = user.slice("id", "name", "email", "image") if user data end |
.merge_hook_data!(target, response) ⇒ Object
931 932 933 934 935 936 937 |
# File 'lib/better_auth/plugins/organization.rb', line 931 def merge_hook_data!(target, response) data = if response.is_a?(Hash) normalize_hash(response)[:data] end target.merge!(normalize_hash(data)) if data.is_a?(Hash) target end |
.merge_permissions(base, extra) ⇒ Object
776 777 778 779 780 |
# File 'lib/better_auth/plugins/organization.rb', line 776 def (base, extra) (base).merge((extra)) do |_resource, base_actions, extra_actions| (Array(base_actions) + Array(extra_actions)).map(&:to_s).uniq end end |
.microsoft_entra_id(options = {}) ⇒ Object
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 147 def microsoft_entra_id( = {}) 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
262 263 264 |
# File 'lib/better_auth/plugins/username.rb', line 262 def min_username_length(config) (config[:min_username_length] || 3).to_i end |
.mirror_username_fields!(ctx) ⇒ Object
214 215 216 217 218 219 220 |
# File 'lib/better_auth/plugins/username.rb', line 214 def mirror_username_fields!(ctx) body = normalize_hash(ctx.body) body[:display_username] = body[:username] if present?(body[:username]) && !present?(body[:display_username]) body[:username] = body[:display_username] if present?(body[:display_username]) && !present?(body[:username]) ctx.body = body nil end |
.multi_cookie_names(ctx) ⇒ Object
145 146 147 |
# File 'lib/better_auth/plugins/multi_session.rb', line 145 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
149 150 151 |
# File 'lib/better_auth/plugins/multi_session.rb', line 149 def (name) name.to_s.include?("_multi-") end |
.multi_session_cookie_name(ctx, token) ⇒ Object
153 154 155 |
# File 'lib/better_auth/plugins/multi_session.rb', line 153 def (ctx, token) "#{ctx.context.[:session_token].name}_multi-#{token.to_s.downcase}" end |
.normalize_display_username(display_username, config) ⇒ Object
248 249 250 251 |
# File 'lib/better_auth/plugins/username.rb', line 248 def normalize_display_username(display_username, config) normalizer = config[:display_username_normalization] normalizer.respond_to?(:call) ? normalizer.call(display_username) : display_username end |
.normalize_field(value) ⇒ Object
29 30 31 32 33 34 |
# File 'lib/better_auth/plugins.rb', line 29 def normalize_field(value) data = normalize_hash(value || {}) data[:default_value] = data.delete(:defaultValue) if data.key?(:defaultValue) data[:field_name] = data.delete(:fieldName) if data.key?(:fieldName) data end |
.normalize_hash(value) ⇒ Object
7 8 9 10 11 12 13 |
# File 'lib/better_auth/plugins.rb', line 7 def normalize_hash(value) return {} unless value.is_a?(Hash) value.each_with_object({}) do |(key, object), result| result[normalize_key(key)] = object.is_a?(Hash) ? normalize_hash(object) : object end end |
.normalize_key(key) ⇒ Object
15 16 17 18 19 20 21 |
# File 'lib/better_auth/plugins.rb', line 15 def normalize_key(key) key.to_s .gsub(/([a-z\d])([A-Z])/, "\\1_\\2") .tr("-", "_") .downcase .to_sym end |
.normalize_remote_jwk(entry) ⇒ Object
259 260 261 262 263 264 |
# File 'lib/better_auth/plugins/jwt.rb', line 259 def normalize_remote_jwk(entry) data = stringify_payload(entry || {}) data["id"] ||= data["kid"] data["publicKey"] ||= data["pem"] data end |
.normalize_signed_bearer_token(token) ⇒ Object
73 74 75 |
# File 'lib/better_auth/plugins/bearer.rb', line 73 def normalize_signed_bearer_token(token) token.include?("%") ? safe_decode_bearer_token(token) : safe_decode_bearer_token(safe_encode_bearer_token(token)) end |
.normalize_time(value) ⇒ Object
475 476 477 478 479 480 |
# File 'lib/better_auth/plugins/jwt.rb', line 475 def normalize_time(value) return value if value.is_a?(Time) return nil if value.nil? Time.parse(value.to_s) end |
.normalize_user_code(value) ⇒ Object
206 207 208 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 206 def normalize_user_code(value) value.to_s end |
.normalize_username(username, config) ⇒ Object
240 241 242 243 244 245 246 |
# File 'lib/better_auth/plugins/username.rb', line 240 def normalize_username(username, config) normalizer = config[:username_normalization] return username if normalizer == false return normalizer.call(username) if normalizer.respond_to?(:call) username.downcase end |
.o_auth2_callback_endpoint(config) ⇒ Object
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 255 def o_auth2_callback_endpoint(config) Endpoint.new( path: "/oauth2/callback/:providerId", method: ["GET", "POST"], metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]} ) do |ctx| query = normalize_hash(ctx.query) provider_id = (fetch_value(ctx.params, "providerId") || query[:provider_id]).to_s raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["PROVIDER_ID_REQUIRED"]) if provider_id.empty? provider = generic_oauth_provider!(config, provider_id) state_data = generic_oauth_parse_state(ctx, query[:state].to_s) error_url = state_data["errorURL"] || state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error" redirect_error = ->(error) { raise ctx.redirect(generic_oauth_error_url(error_url, error)) } redirect_error.call(query[:error] || "oAuth_code_missing") if query[:error] || query[:code].to_s.empty? generic_oauth_validate_issuer!(ctx, provider, query, redirect_error) tokens = begin generic_oauth_exchange_token(ctx, provider, query[:code].to_s, state_data) rescue nil end redirect_error.call("oauth_code_verification_failed") unless tokens 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? 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 if existing && provider[:override_user_info] ctx.context.internal_adapter.update_user( existing[:user]["id"], "name" => name, "image" => fetch_value(mapped_user, "image"), "emailVerified" => !!fetch_value(mapped_user, "emailVerified") ) end session_data = Routes.( ctx, provider_id, mapped_user.merge("email" => email, "name" => name, "id" => account_id), generic_oauth_account_info(ctx, provider_id, account_id, tokens) ) (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 |
.o_auth2_link_account_endpoint(config) ⇒ Object
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 237 def o_auth2_link_account_endpoint(config) Endpoint.new(path: "/oauth2/link", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) provider_id = body[:provider_id].to_s provider = generic_oauth_provider(config, provider_id) raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider auth_url = ( ctx, provider, body, link: {user_id: session[:user]["id"], email: session[:user]["email"]} ) ctx.json({url: auth_url, redirect: true}) end end |
.oauth_provider(*args) ⇒ Object
7 8 9 10 11 12 13 14 |
# File 'lib/better_auth/plugins/oauth_provider.rb', line 7 def oauth_provider(*args) Kernel.require "better_auth/oauth_provider" BetterAuth::Plugins.oauth_provider(*args) rescue LoadError => error raise if error.path && error.path != "better_auth/oauth_provider" raise LoadError, "BetterAuth::Plugins.oauth_provider requires the better_auth-oauth-provider gem. Add `gem \"better_auth-oauth-provider\"` and `require \"better_auth/oauth_provider\"`." end |
.oauth_proxy(options = {}) ⇒ Object
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 11 def oauth_proxy( = {}) config = {max_age: 60}.merge(normalize_hash()) Plugin.new( id: "oauth-proxy", endpoints: { o_auth_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
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 146 def oauth_proxy_after_callback(ctx, config) location = ctx.response_headers["location"] return unless location.to_s.include?("/oauth-proxy-callback?callbackURL") return unless location.to_s.start_with?("http") location_uri = URI.parse(location) production = oauth_proxy_production_uri(ctx, config) if location_uri.origin == production.origin original = Rack::Utils.parse_query(location_uri.query).fetch("callbackURL", nil) oauth_proxy_set_location(ctx, original) if original return nil end = ctx.response_headers["set-cookie"] return if .to_s.empty? encrypted = Crypto.symmetric_encrypt( key: ctx.context.secret, 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
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 107 def 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: ctx.context.secret, 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
75 76 77 78 79 80 81 82 83 84 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 75 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
187 188 189 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 187 def oauth_proxy_callback_path?(path) path.to_s.start_with?("/callback", "/oauth2/callback") end |
.oauth_proxy_current_uri(ctx, config) ⇒ Object
199 200 201 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 199 def oauth_proxy_current_uri(ctx, config) URI.parse((config[:current_url] || ctx.context..base_url || ctx.context.base_url).to_s) end |
.oauth_proxy_endpoint(config) ⇒ Object
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 45 def oauth_proxy_endpoint(config) Endpoint.new(path: "/oauth-proxy-callback", method: "GET") do |ctx| query = normalize_hash(ctx.query) callback_url = query[:callback_url] || "/" oauth_proxy_validate_callback!(ctx, callback_url) decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, data: query[:cookies].to_s) raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid cookies or secret")) unless decrypted payload = JSON.parse(decrypted) = 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
218 219 220 221 222 223 224 225 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 218 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
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 235 def (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
203 204 205 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 203 def oauth_proxy_production_uri(ctx, config) URI.parse((config[:production_url] || ctx.context..base_url || ctx.context.base_url).to_s) end |
.oauth_proxy_restore_state_package(ctx, _config) ⇒ Object
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 86 def oauth_proxy_restore_state_package(ctx, _config) state = fetch_value(ctx.query, "state") || fetch_value(ctx.body, "state") return if state.to_s.empty? decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, data: state.to_s) return unless decrypted package = JSON.parse(decrypted) return unless package["isOAuthProxy"] && package["state"] && package["stateCookie"] = 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_set_location(ctx, location) ⇒ Object
227 228 229 230 231 232 233 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 227 def oauth_proxy_set_location(ctx, location) ctx.set_header("location", location) return unless ctx.returned.is_a?(APIError) headers = ctx.returned.headers.merge("location" => location) ctx.returned.instance_variable_set(:@headers, headers) end |
.oauth_proxy_sign_in_path?(path) ⇒ Boolean
183 184 185 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 183 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
191 192 193 194 195 196 197 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 191 def oauth_proxy_skip?(ctx, config) current = oauth_proxy_current_uri(ctx, config) production = oauth_proxy_production_uri(ctx, config) current.origin == production.origin rescue URI::InvalidURIError false end |
.oauth_proxy_state_cookie_value(ctx) ⇒ Object
176 177 178 179 180 181 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 176 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
207 208 209 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 207 def oauth_proxy_strip_trailing(value) value.to_s.sub(%r{/+\z}, "") end |
.oauth_proxy_validate_callback!(ctx, callback_url) ⇒ Object
211 212 213 214 215 216 |
# File 'lib/better_auth/plugins/oauth_proxy.rb', line 211 def oauth_proxy_validate_callback!(ctx, callback_url) return if callback_url.to_s.empty? return if ctx.context.trusted_origin?(callback_url.to_s, allow_relative_paths: true) raise APIError.new("FORBIDDEN", message: "Invalid callbackURL") end |
.object_schema(properties, required: []) ⇒ Object
343 344 345 346 347 348 349 |
# File 'lib/better_auth/plugins/open_api.rb', line 343 def object_schema(properties, required: []) { type: "object", properties: properties, required: required } end |
.oidc_authorize_endpoint(config) ⇒ Object
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 220 def (config) Endpoint.new(path: "/oauth2/authorize", method: "GET") do |ctx| query = OAuthProtocol.stringify_keys(ctx.query) prompts = OIDCProvider.parse_prompt(query["prompt"]) session = Routes.current_session(ctx, allow_nil: true) if !session && prompts.include?("none") redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "login_required", error_description: "Authentication required but prompt is none", state: query["state"], iss: OAuthProtocol.issuer(ctx)) raise ctx.redirect(redirect) end unless session ctx.("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax") raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query)) end client = OAuthProtocol.find_client(ctx, "oauthApplication", query["client_id"]) raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"]) scopes = OAuthProtocol.parse_scopes(query["scope"] || config[:default_scope]) invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) } unless invalid_scopes.empty? redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_scope", error_description: "The following scopes are invalid: #{invalid_scopes.join(", ")}", state: query["state"], iss: OAuthProtocol.issuer(ctx)) raise ctx.redirect(redirect) end if config[:require_pkce] && (query["code_challenge"].to_s.empty? || query["code_challenge_method"].to_s.empty?) redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "pkce is required", state: query["state"], iss: OAuthProtocol.issuer(ctx)) raise ctx.redirect(redirect) end challenge_method = query["code_challenge_method"].to_s if !challenge_method.empty? && !valid_code_challenge_method?(challenge_method, config) redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "invalid code_challenge method", state: query["state"], iss: OAuthProtocol.issuer(ctx)) raise ctx.redirect(redirect) end client_data = OAuthProtocol.stringify_keys(client) = !client_data["skipConsent"] && (prompts.include?("consent") || !(ctx, client_data["clientId"], session[:user]["id"], scopes)) if oidc_requires_login?(session, prompts, query) ctx.("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax") raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], client_id: client_data["clientId"], state: query["state"])) end if if prompts.include?("none") redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "consent_required", error_description: "Consent required but prompt is none", state: query["state"], iss: OAuthProtocol.issuer(ctx)) raise ctx.redirect(redirect) end = Crypto.random_string(32) config[:store][:consents][] = { query: query, session: session, client: client, scopes: scopes, expires_at: Time.now + config[:code_expires_in].to_i } unless config[:consent_page] renderer = config[:get_consent_html] raise APIError.new("INTERNAL_SERVER_ERROR", message: "No consent page provided") unless renderer.respond_to?(:call) ctx.set_header("content-type", "text/html") next renderer.call( scopes: scopes, clientMetadata: client_data["metadata"] || {}, clientIcon: client_data["icon"], clientId: client_data["clientId"], clientName: client_data["name"], code: ) end raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:consent_page], consent_code: , client_id: client_data["clientId"], scope: OAuthProtocol.scope_string(scopes))) end code = Crypto.random_string(32) OAuthProtocol.store_code( config[:store], code: code, client_id: query["client_id"], redirect_uri: query["redirect_uri"], session: session, scopes: scopes, code_challenge: query["code_challenge"], code_challenge_method: query["code_challenge_method"] ) redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProtocol.issuer(ctx)) raise ctx.redirect(redirect) end end |
.oidc_consent_endpoint(config) ⇒ Object
310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 310 def (config) Endpoint.new(path: "/oauth2/consent", method: "POST") do |ctx| Routes.current_session(ctx) body = OAuthProtocol.stringify_keys(ctx.body) = config[:store][:consents].delete(body["consent_code"].to_s) raise APIError.new("BAD_REQUEST", message: "invalid consent_code") unless raise APIError.new("BAD_REQUEST", message: "expired consent_code") if [:expires_at] <= Time.now query = [:query] if body["accept"] == false || body["accept"].to_s == "false" redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "access_denied", state: query["state"], iss: OAuthProtocol.issuer(ctx)) next ctx.json({redirectURI: redirect}) end (ctx, [:client], [:session], [:scopes]) code = Crypto.random_string(32) OAuthProtocol.store_code( config[:store], code: code, client_id: query["client_id"], redirect_uri: query["redirect_uri"], session: [:session], scopes: [:scopes], code_challenge: query["code_challenge"], code_challenge_method: query["code_challenge_method"] ) ctx.json({redirectURI: OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProtocol.issuer(ctx))}) end end |
.oidc_consent_granted?(ctx, client_id, user_id, scopes) ⇒ Boolean
538 539 540 541 542 543 544 545 546 547 548 549 550 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 538 def (ctx, client_id, user_id, scopes) = ctx.context.adapter.find_one( model: "oauthConsent", where: [ {field: "clientId", value: client_id}, {field: "userId", value: user_id} ] ) return false unless && ["consentGiven"] granted = OAuthProtocol.parse_scopes(["scopes"]) scopes.all? { |scope| granted.include?(scope) } end |
.oidc_delete_client_endpoint ⇒ Object
211 212 213 214 215 216 217 218 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 211 def oidc_delete_client_endpoint Endpoint.new(path: "/oauth2/client/:id", method: "DELETE") do |ctx| session = Routes.current_session(ctx) client = oidc_find_owned_client!(ctx, session) ctx.context.adapter.delete(model: "oauthApplication", where: [{field: "id", value: client.fetch("id")}]) ctx.json({success: true}) end end |
.oidc_end_session_endpoint ⇒ Object
409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 409 def oidc_end_session_endpoint Endpoint.new(path: "/oauth2/endsession", method: ["GET", "POST"], metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx| input_source = (ctx.method == "GET") ? ctx.query : ctx.body input = OAuthProtocol.stringify_keys(input_source) if input["post_logout_redirect_uri"] client = OAuthProtocol.find_client(ctx, "oauthApplication", input["client_id"]) raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client unless OAuthProtocol.client_logout_redirect_uris(client).include?(input["post_logout_redirect_uri"]) raise APIError.new("BAD_REQUEST", message: "invalid_request") end end Cookies.(ctx) redirect = input["post_logout_redirect_uri"] || "/" redirect = OAuthProtocol.redirect_uri_with_params(redirect, state: input["state"]) if input["state"] raise ctx.redirect(redirect) end end |
.oidc_find_owned_client!(ctx, session) ⇒ Object
486 487 488 489 490 491 492 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 486 def oidc_find_owned_client!(ctx, session) client = OAuthProtocol.find_client(ctx, "oauthApplication", ctx.params["id"] || ctx.params[:id]) raise APIError.new("NOT_FOUND", message: "client not found") unless client raise APIError.new("FORBIDDEN", message: "Access denied") unless client["userId"] == session[:user]["id"] client end |
.oidc_get_client_endpoint ⇒ Object
148 149 150 151 152 153 154 155 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 148 def oidc_get_client_endpoint Endpoint.new(path: "/oauth2/client/:id", method: "GET") do |ctx| client = OAuthProtocol.find_client(ctx, "oauthApplication", ctx.params["id"] || ctx.params[:id]) raise APIError.new("NOT_FOUND", message: "client not found") unless client ctx.json(OAuthProtocol.client_response(client, include_secret: false)) end end |
.oidc_id_token_signer(ctx, config) ⇒ Object
525 526 527 528 529 530 531 532 533 534 535 536 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 525 def oidc_id_token_signer(ctx, config) jwt_plugin = oidc_use_jwt_plugin?(ctx, config) return nil unless jwt_plugin lambda do |sign_ctx, payload| BetterAuth::Plugins.sign_jwt_payload( sign_ctx, OAuthProtocol.stringify_keys(payload), jwt_plugin[:options] || {} ) end end |
.oidc_introspect_endpoint(config) ⇒ Object
382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 382 def oidc_introspect_endpoint(config) Endpoint.new(path: "/oauth2/introspect", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx| OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret]) body = OAuthProtocol.stringify_keys(ctx.body) token = config[:store][:tokens][body["token"].to_s] || config[:store][:refresh_tokens][body["token"].to_s] active = token && !token["revoked"] && (!token["expiresAt"] || token["expiresAt"] > Time.now) ctx.json(active ? { active: true, client_id: token["clientId"], scope: OAuthProtocol.scope_string(token["scope"] || token["scopes"]), sub: token.dig("user", "id"), exp: token["expiresAt"]&.to_i } : {active: false}) end end |
.oidc_jwt_plugin(ctx) ⇒ Object
521 522 523 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 521 def oidc_jwt_plugin(ctx) ctx.context..plugins.find { |plugin| plugin[:id] == "jwt" } end |
.oidc_list_clients_endpoint ⇒ Object
157 158 159 160 161 162 163 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 157 def oidc_list_clients_endpoint Endpoint.new(path: "/oauth2/clients", method: "GET") do |ctx| session = Routes.current_session(ctx) clients = ctx.context.adapter.find_many(model: "oauthApplication", where: [{field: "userId", value: session[:user]["id"]}]) ctx.json(clients.map { |client| OAuthProtocol.client_response(client, include_secret: false) }) end end |
.oidc_metadata_endpoint(config) ⇒ Object
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 85 def (config) Endpoint.new(path: "/.well-known/openid-configuration", method: "GET", metadata: {hide: true}) do |ctx| base = OAuthProtocol.endpoint_base(ctx) supported_algs = oidc_use_jwt_plugin?(ctx, config) ? ["RS256", "EdDSA", "none"] : ["HS256", "none"] ctx.json({ issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)), authorization_endpoint: "#{base}/oauth2/authorize", token_endpoint: "#{base}/oauth2/token", userinfo_endpoint: "#{base}/oauth2/userinfo", jwks_uri: "#{base}/jwks", registration_endpoint: "#{base}/oauth2/register", introspection_endpoint: "#{base}/oauth2/introspect", revocation_endpoint: "#{base}/oauth2/revoke", end_session_endpoint: "#{base}/oauth2/endsession", scopes_supported: config[:scopes], response_types_supported: ["code"], response_modes_supported: ["query"], grant_types_supported: ["authorization_code", "refresh_token"], acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: supported_algs, token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"], introspection_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], revocation_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], code_challenge_methods_supported: ["S256"], claims_supported: %w[sub iss aud exp nbf iat jti email email_verified name] }.merge(config[:metadata] || {})) end end |
.oidc_provider(options = {}) ⇒ Object
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 36 def oidc_provider( = {}) config = { code_expires_in: 600, consent_page: "/oauth2/authorize", login_page: "/login", default_scope: "openid", access_token_expires_in: 3600, refresh_token_expires_in: 604_800, allow_plain_code_challenge_method: true, store_client_secret: "plain", scopes: %w[openid profile email offline_access], store: OAuthProtocol.stores }.merge(normalize_hash()) Plugin.new( id: "oidc-provider", endpoints: oidc_provider_endpoints(config), hooks: { after: [ { matcher: ->(ctx) { ctx.path.start_with?("/sign-in/", "/sign-up/") }, handler: ->(ctx) { oidc_resume_login_prompt(ctx, config) } } ] }, schema: oidc_provider_schema, options: config ) end |
.oidc_provider_endpoints(config) ⇒ Object
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 66 def oidc_provider_endpoints(config) { get_open_id_config: (config), o_auth2_authorize: (config), o_auth_consent: (config), o_auth2_token: oidc_token_endpoint(config), o_auth2_introspect: oidc_introspect_endpoint(config), o_auth2_revoke: oidc_revoke_endpoint(config), o_auth2_user_info: oidc_userinfo_endpoint(config), register_o_auth_application: oidc_register_endpoint(config), get_o_auth_client: oidc_get_client_endpoint, list_o_auth_applications: oidc_list_clients_endpoint, update_o_auth_application: oidc_update_client_endpoint, rotate_o_auth_application_secret: oidc_rotate_client_secret_endpoint(config), delete_o_auth_application: oidc_delete_client_endpoint, end_session: oidc_end_session_endpoint } end |
.oidc_provider_schema ⇒ Object
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 428 def oidc_provider_schema { oauthApplication: { modelName: "oauthApplication", fields: { name: {type: "string"}, icon: {type: "string", required: false}, uri: {type: "string", required: false}, metadata: {type: "json", required: false}, clientId: {type: "string", unique: true}, clientSecret: {type: "string", required: false}, redirectUrls: {type: "string"}, redirectUris: {type: "string[]", required: false}, postLogoutRedirectUris: {type: "string[]", required: false}, tokenEndpointAuthMethod: {type: "string", required: false}, skipConsent: {type: "boolean", required: false}, grantTypes: {type: "string[]", required: false}, responseTypes: {type: "string[]", required: false}, scopes: {type: "string[]", required: false}, type: {type: "string"}, disabled: {type: "boolean", required: false, default_value: false}, userId: {type: "string", required: false, references: {model: "users", field: "id", on_delete: "cascade"}, index: true}, createdAt: {type: "date", required: true, default_value: -> { Time.now }}, updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }} } }, oauthAccessToken: { modelName: "oauthAccessToken", fields: { accessToken: {type: "string", unique: true, required: false}, token: {type: "string", unique: true, required: false}, refreshToken: {type: "string", unique: true, required: false}, accessTokenExpiresAt: {type: "date", required: false}, expiresAt: {type: "date", required: false}, clientId: {type: "string", required: true}, userId: {type: "string", required: false}, sessionId: {type: "string", required: false}, scope: {type: "string", required: false}, scopes: {type: "string[]", required: false}, revoked: {type: "date", required: false}, createdAt: {type: "date", required: true, default_value: -> { Time.now }}, updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }} } }, oauthConsent: { modelName: "oauthConsent", fields: { clientId: {type: "string", required: true}, userId: {type: "string", required: true}, scopes: {type: "string[]", required: false}, consentGiven: {type: "boolean", required: false}, createdAt: {type: "date", required: true, default_value: -> { Time.now }}, updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }} } } } end |
.oidc_register_endpoint(config) ⇒ Object
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 115 def oidc_register_endpoint(config) Endpoint.new(path: "/oauth2/register", method: "POST") do |ctx| session = Routes.current_session(ctx, allow_nil: true) unless session || config[:allow_dynamic_client_registration] raise APIError.new("UNAUTHORIZED", message: "invalid_token") end body = OAuthProtocol.stringify_keys(ctx.body) grant_types = Array(body["grant_types"] || [OAuthProtocol::AUTH_CODE_GRANT]) response_types = Array(body["response_types"] || ["code"]) redirects = Array(body["redirect_uris"]).map(&:to_s) if (grant_types.empty? || grant_types.include?(OAuthProtocol::AUTH_CODE_GRANT) || grant_types.include?("implicit")) && redirects.empty? raise APIError.new("BAD_REQUEST", message: "invalid_redirect_uri") end if grant_types.include?(OAuthProtocol::AUTH_CODE_GRANT) && !response_types.include?("code") raise APIError.new("BAD_REQUEST", message: "invalid_client_metadata") end if grant_types.include?("implicit") && !response_types.include?("token") raise APIError.new("BAD_REQUEST", message: "invalid_client_metadata") end client = OAuthProtocol.create_client( ctx, model: "oauthApplication", body: body, owner_session: session, default_auth_method: "client_secret_basic", store_client_secret: config[:store_client_secret] ) ctx.json(client, status: 201, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"}) end end |
.oidc_requires_login?(session, prompts, query) ⇒ Boolean
501 502 503 504 505 506 507 508 509 510 511 512 513 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 501 def oidc_requires_login?(session, prompts, query) return true if prompts.include?("login") return false unless query.key?("max_age") max_age = Integer(query["max_age"]) return false if max_age.negative? created_at = session.dig(:session, "createdAt") || session.dig(:session, :createdAt) created_at = Time.parse(created_at.to_s) unless created_at.is_a?(Time) (Time.now - created_at) > max_age rescue ArgumentError, TypeError false end |
.oidc_resume_login_prompt(ctx, config) ⇒ Object
570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 570 def oidc_resume_login_prompt(ctx, config) prompt = ctx.("oidc_login_prompt", ctx.context.secret) return unless prompt return unless ctx.response_headers["set-cookie"].to_s.include?(ctx.context.[:session_token].name) ctx.("oidc_login_prompt", "", path: "/", max_age: 0) query = JSON.parse(prompt) prompts = OIDCProvider.parse_prompt(query["prompt"]) if prompts.include?("login") prompts.delete("login") query["prompt"] = prompts.to_a.join(" ") end ctx.query = query ctx.context.set_current_session(ctx.context.new_session) if ctx.context.respond_to?(:set_current_session) && ctx.context.new_session (config).call(ctx) rescue APIError => error raise APIError.new( error.status, message: error., headers: Endpoint::Result.merge_headers(ctx.response_headers, error.headers), code: error.code, body: error.body ) rescue JSON::ParserError nil end |
.oidc_revoke_endpoint(config) ⇒ Object
398 399 400 401 402 403 404 405 406 407 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 398 def oidc_revoke_endpoint(config) Endpoint.new(path: "/oauth2/revoke", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx| OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret]) body = OAuthProtocol.stringify_keys(ctx.body) if (token = config[:store][:tokens][body["token"].to_s] || config[:store][:refresh_tokens][body["token"].to_s]) token["revoked"] = Time.now end ctx.json({revoked: true}) end end |
.oidc_rotate_client_secret_endpoint(config) ⇒ Object
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 193 def oidc_rotate_client_secret_endpoint(config) Endpoint.new(path: "/oauth2/client/:id/rotate-secret", method: "POST") do |ctx| session = Routes.current_session(ctx) client = oidc_find_owned_client!(ctx, session) if OAuthProtocol.stringify_keys(client)["tokenEndpointAuthMethod"] == "none" raise APIError.new("BAD_REQUEST", message: "invalid_client") end client_secret = Crypto.random_string(32) updated = ctx.context.adapter.update( model: "oauthApplication", where: [{field: "id", value: client.fetch("id")}], update: {clientSecret: OAuthProtocol.store_client_secret_value(ctx, client_secret, config[:store_client_secret]), updatedAt: Time.now} ) ctx.json(OAuthProtocol.client_response(updated, include_secret: false).merge(client_secret: client_secret)) end end |
.oidc_store_consent(ctx, client, session, scopes) ⇒ Object
552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 552 def (ctx, client, session, scopes) client_id = OAuthProtocol.stringify_keys(client)["clientId"] user_id = session[:user]["id"] existing = ctx.context.adapter.find_one( model: "oauthConsent", where: [ {field: "clientId", value: client_id}, {field: "userId", value: user_id} ] ) data = {clientId: client_id, userId: user_id, scopes: scopes, consentGiven: true} if existing ctx.context.adapter.update(model: "oauthConsent", where: [{field: "id", value: existing.fetch("id")}], update: data) else ctx.context.adapter.create(model: "oauthConsent", data: data) end end |
.oidc_token_endpoint(config) ⇒ Object
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 340 def oidc_token_endpoint(config) Endpoint.new(path: "/oauth2/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx| body = OAuthProtocol.stringify_keys(ctx.body) client = OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret]) raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client response = case body["grant_type"] when OAuthProtocol::AUTH_CODE_GRANT code = OAuthProtocol.consume_code!( config[:store], body["code"], client_id: body["client_id"], redirect_uri: body["redirect_uri"], code_verifier: body["code_verifier"] ) OAuthProtocol.issue_tokens( ctx, config[:store], model: "oauthAccessToken", client: client, session: code[:session], scopes: code[:scopes], include_refresh: code[:scopes].include?("offline_access"), issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)), access_token_expires_in: config[:access_token_expires_in], id_token_signer: oidc_id_token_signer(ctx, config) ) when OAuthProtocol::REFRESH_GRANT OAuthProtocol.refresh_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, refresh_token: body["refresh_token"], scopes: body["scope"], issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)), access_token_expires_in: config[:access_token_expires_in], id_token_signer: oidc_id_token_signer(ctx, config)) else raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type") end ctx.json(response) end end |
.oidc_update_client_endpoint ⇒ Object
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 165 def oidc_update_client_endpoint Endpoint.new(path: "/oauth2/client/:id", method: "PATCH") do |ctx| session = Routes.current_session(ctx) client = oidc_find_owned_client!(ctx, session) body = OAuthProtocol.stringify_keys(ctx.body) update_source = OAuthProtocol.stringify_keys(body["update"] || body) update = {} if update_source.key?("client_name") || update_source.key?("name") update["name"] = update_source["client_name"] || update_source["name"] end update["uri"] = update_source["client_uri"] if update_source.key?("client_uri") update["icon"] = update_source["logo_uri"] if update_source.key?("logo_uri") if update_source.key?("redirect_uris") redirects = Array(update_source["redirect_uris"]).map(&:to_s) update["redirectUris"] = redirects update["redirectUrls"] = redirects.join(",") end update["postLogoutRedirectUris"] = Array(update_source["post_logout_redirect_uris"]).map(&:to_s) if update_source.key?("post_logout_redirect_uris") update["grantTypes"] = Array(update_source["grant_types"]).map(&:to_s) if update_source.key?("grant_types") update["responseTypes"] = Array(update_source["response_types"]).map(&:to_s) if update_source.key?("response_types") update["scopes"] = OAuthProtocol.parse_scopes(update_source["scope"] || update_source["scopes"]) if update_source.key?("scope") || update_source.key?("scopes") update["metadata"] = update_source["metadata"] if update_source.key?("metadata") update["updatedAt"] = Time.now updated = update.empty? ? client : ctx.context.adapter.update(model: "oauthApplication", where: [{field: "id", value: client.fetch("id")}], update: update) ctx.json(OAuthProtocol.client_response(updated, include_secret: false)) end end |
.oidc_use_jwt_plugin?(ctx, config) ⇒ Boolean
515 516 517 518 519 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 515 def oidc_use_jwt_plugin?(ctx, config) return false unless config[:use_jwt_plugin] oidc_jwt_plugin(ctx) end |
.oidc_userinfo_endpoint(config) ⇒ Object
376 377 378 379 380 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 376 def oidc_userinfo_endpoint(config) Endpoint.new(path: "/oauth2/userinfo", method: "GET") do |ctx| ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"], additional_claim: config[:get_additional_user_info_claim])) end end |
.okta(options = {}) ⇒ Object
172 173 174 175 176 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 172 def okta( = {}) 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_boolean_value(value) ⇒ Object
150 151 152 |
# File 'lib/better_auth/plugins/one_tap.rb', line 150 def one_tap_boolean_value(value) value == true || value.to_s.downcase == "true" end |
.one_tap_callback_endpoint(config) ⇒ Object
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/better_auth/plugins/one_tap.rb', line 22 def one_tap_callback_endpoint(config) Endpoint.new( path: "/one-tap/callback", method: "POST", body_schema: ->(body) { data = normalize_hash(body) data[:id_token].to_s.empty? ? false : data }, metadata: { openapi: { summary: "One tap callback", description: "Use this endpoint to authenticate with Google One Tap" } } ) do |ctx| body = normalize_hash(ctx.body) id_token = body[:id_token].to_s payload = one_tap_verify_id_token(ctx, config, id_token) email = fetch_value(payload, "email").to_s.downcase if email.empty? next ctx.json({error: "Email not available in token"}) end user = ctx.context.internal_adapter.find_user_by_email(email) if user one_tap_link_account_unless_present!(ctx, config, user, payload, id_token) session_data = one_tap_create_session(ctx, user[:user]) else raise APIError.new("BAD_GATEWAY", message: "User not found") if config[:disable_signup] created = ctx.context.internal_adapter.create_oauth_user( { email: email, emailVerified: one_tap_boolean_value(fetch_value(payload, "email_verified")), name: fetch_value(payload, "name").to_s, image: fetch_value(payload, "picture") }, { providerId: "google", accountId: fetch_value(payload, "sub").to_s, idToken: id_token } ) raise APIError.new("INTERNAL_SERVER_ERROR", message: "Could not create user") unless created session_data = one_tap_create_session(ctx, created[:user]) end Cookies.(ctx, session_data) ctx.json({ token: session_data[:session]["token"], user: Schema.parse_output(ctx.context., "user", session_data[:user]) }) end end |
.one_tap_create_session(ctx, user) ⇒ Object
137 138 139 140 |
# File 'lib/better_auth/plugins/one_tap.rb', line 137 def one_tap_create_session(ctx, user) session = ctx.context.internal_adapter.create_session(user["id"]) {session: session, user: user} end |
.one_tap_google_jwks ⇒ Object
107 108 109 110 111 112 113 |
# File 'lib/better_auth/plugins/one_tap.rb', line 107 def one_tap_google_jwks uri = URI("https://www.googleapis.com/oauth2/v3/certs") response = Net::HTTP.get_response(uri) raise "Unable to fetch Google JWKS" unless response.is_a?(Net::HTTPSuccess) JWT::JWK::Set.new(JSON.parse(response.body)) end |
.one_tap_link_account_unless_present!(ctx, _config, user, payload, id_token) ⇒ Object
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/better_auth/plugins/one_tap.rb', line 115 def one_tap_link_account_unless_present!(ctx, _config, user, payload, id_token) sub = fetch_value(payload, "sub").to_s account = ctx.context.internal_adapter.find_account(sub) return if account account_linking = ctx.context..account[:account_linking] || {} trusted = Array(account_linking[:trusted_providers]).map(&:to_s).include?("google") enabled = account_linking.fetch(:enabled, true) should_link_account = enabled != false && (trusted || one_tap_boolean_value(fetch_value(payload, "email_verified"))) unless should_link_account raise APIError.new("UNAUTHORIZED", message: "Google sub doesn't match") end ctx.context.internal_adapter.link_account( userId: user[:user]["id"], providerId: "google", accountId: sub, scope: "openid,profile,email", idToken: id_token ) end |
.one_tap_stringify_payload(payload) ⇒ Object
142 143 144 145 146 147 148 |
# File 'lib/better_auth/plugins/one_tap.rb', line 142 def one_tap_stringify_payload(payload) raise "Invalid payload" unless payload.is_a?(Hash) payload.each_with_object({}) do |(key, value), result| result[key.to_s] = value end end |
.one_tap_verify_google_id_token(id_token, audience) ⇒ Object
92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/better_auth/plugins/one_tap.rb', line 92 def one_tap_verify_google_id_token(id_token, audience) jwks = one_tap_google_jwks = { algorithms: ["RS256"], iss: ["https://accounts.google.com", "accounts.google.com"], verify_iss: true } if audience [:aud] = audience [:verify_aud] = true end payload, = JWT.decode(id_token, nil, true, .merge(jwks: jwks)) payload end |
.one_tap_verify_id_token(ctx, config, id_token) ⇒ Object
79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/better_auth/plugins/one_tap.rb', line 79 def one_tap_verify_id_token(ctx, config, id_token) verifier = config[:verify_id_token] audience = config[:client_id] || ctx.context...dig(:google, :client_id) payload = if verifier.respond_to?(:call) verifier.call(id_token, ctx, audience: audience) else one_tap_verify_google_id_token(id_token, audience) end one_tap_stringify_payload(payload) rescue raise APIError.new("BAD_REQUEST", message: "invalid id token") end |
.one_time_token(options = {}) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 7 def one_time_token( = {}) 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
63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 63 def one_time_token_after_response(ctx, config) return unless config[:set_ott_header_on_new_session] session = ctx.context.new_session return unless session && session[:session] && session[:user] token = one_time_token_create(ctx, config, session) existing = ctx.response_headers["access-control-expose-headers"].to_s exposed = existing.split(",").map(&:strip).reject(&:empty?) exposed << "set-ott" ctx.set_header("set-ott", token) ctx.set_header("access-control-expose-headers", exposed.uniq.join(", ")) nil end |
.one_time_token_create(ctx, config, session) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 78 def one_time_token_create(ctx, config, session) generator = config[:generate_token] token = if generator.respond_to?(:call) generator.call(session, ctx) else Crypto.random_string(32) end.to_s stored_token = one_time_token_stored_value(config, token) ctx.context.internal_adapter.create_verification_value( identifier: "one-time-token:#{stored_token}", value: session[:session]["token"], expiresAt: Time.now + config[:expires_in].to_i * 60 ) token end |
.one_time_token_stored_value(config, token) ⇒ Object
94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 94 def one_time_token_stored_value(config, token) storage = config[:store_token] return Crypto.sha256(token, encoding: :base64url) if storage.to_s == "hashed" if storage.is_a?(Hash) && storage[:type].to_s.tr("_", "-") == "custom-hasher" hasher = storage[:hash] return hasher.call(token) if hasher.respond_to?(:call) end token end |
.open_api(options = {}) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
# File 'lib/better_auth/plugins/open_api.rb', line 7 def open_api( = {}) config = {path: "/reference", theme: "default"}.merge(normalize_hash()) Plugin.new( id: "open-api", endpoints: { generate_open_api_schema: Endpoint.new(path: "/open-api/generate-schema", method: "GET") do |ctx| ctx.json(open_api_schema(ctx.context)) end, open_api_reference: Endpoint.new(path: config[:path], method: "GET", metadata: {hide: true}) do |ctx| raise APIError.new("NOT_FOUND") if config[:disable_default_reference] [200, {"content-type" => "text/html"}, [open_api_html(open_api_schema(ctx.context), config)]] end }, options: config ) end |
.open_api_components(options) ⇒ Object
440 441 442 443 444 445 |
# File 'lib/better_auth/plugins/open_api.rb', line 440 def open_api_components() 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_endpoints(options) ⇒ Object
59 60 61 62 63 64 65 66 67 |
# File 'lib/better_auth/plugins/open_api.rb', line 59 def open_api_endpoints() 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
470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 |
# File 'lib/better_auth/plugins/open_api.rb', line 470 def open_api_html(schema, config) nonce = config[:nonce] ? " nonce=\"#{config[:nonce]}\"" : "" <<~HTML <!doctype html> <html> <head> <title>Scalar API Reference</title> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> </head> <body> <script id="api-reference" type="application/json">#{JSON.generate(schema)}</script> <script#{nonce}>window.scalarTheme = "#{config[:theme]}";</script> <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"#{nonce}></script> </body> </html> HTML end |
.open_api_operation(endpoint, method, tag) ⇒ Object
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/better_auth/plugins/open_api.rb', line 104 def open_api_operation(endpoint, method, tag) = endpoint.[:openapi] || {} operation = { tags: Array([:tags] || [tag]), description: [:description] || route_description(endpoint.path, method), operationId: .key?(:operationId) ? [:operationId] : route_operation_id(endpoint.path, method), security: [ { bearerAuth: [] } ], parameters: [:parameters] || [], responses: open_api_responses([:responses] || route_responses(endpoint.path, method)) } if %w[POST PATCH PUT].include?(method) operation[:requestBody] = [:requestBody] || route_request_body(endpoint.path, method) || empty_request_body end operation end |
.open_api_path(path) ⇒ Object
100 101 102 |
# File 'lib/better_auth/plugins/open_api.rb', line 100 def open_api_path(path) path.split("/").map { |part| part.start_with?(":") ? "{#{part.delete_prefix(":")}}" : part }.join("/") end |
.open_api_paths(endpoints, options) ⇒ Object
85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/better_auth/plugins/open_api.rb', line 85 def open_api_paths(endpoints, ) disabled_paths = Array(.disabled_paths).map(&:to_s) endpoints.each_with_object({}) do |(_key, endpoint, tag), paths| next unless endpoint.path next if endpoint.[:hide] || endpoint.[:SERVER_ONLY] || endpoint.[:server_only] next if disabled_paths.include?(endpoint.path) path = open_api_path(endpoint.path) paths[path] ||= {} endpoint.methods.reject { |method| method == "*" }.each do |method| paths[path][method.downcase.to_sym] = open_api_operation(endpoint, method, tag) end end end |
.open_api_responses(responses = nil) ⇒ Object
391 392 393 |
# File 'lib/better_auth/plugins/open_api.rb', line 391 def open_api_responses(responses = nil) {"200" => success_response}.merge(default_error_responses).merge(responses || {}) end |
.open_api_schema(context) ⇒ Object
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
# File 'lib/better_auth/plugins/open_api.rb', line 26 def open_api_schema(context) { openapi: "3.1.1", info: { title: "Better Auth", description: "API Reference for your Better Auth Instance", version: "1.1.0" }, components: { schemas: open_api_components(context.), securitySchemes: open_api_security_schemes }, security: [ { apiKeyCookie: [], bearerAuth: [] } ], servers: [ { url: context.base_url } ], tags: [ { name: "Default", description: "Default endpoints that are included with Better Auth by default. These endpoints are not part of any plugin." } ], paths: open_api_paths(open_api_endpoints(context.), context.) } end |
.open_api_security_schemes ⇒ Object
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/better_auth/plugins/open_api.rb', line 69 def open_api_security_schemes { apiKeyCookie: { type: "apiKey", in: "cookie", name: "apiKeyCookie", description: "API Key authentication via cookie" }, bearerAuth: { type: "http", scheme: "bearer", description: "Bearer token authentication" } } end |
.open_api_user_response_schema ⇒ Object
363 364 365 366 367 368 369 370 371 372 373 374 375 376 |
# File 'lib/better_auth/plugins/open_api.rb', line 363 def open_api_user_response_schema object_schema( { id: {type: "string", description: "The unique identifier of the user"}, email: {type: "string", format: "email", description: "The email address of the user"}, name: {type: "string", description: "The name of the user"}, image: {type: "string", format: "uri", nullable: true, description: "The profile image URL of the user"}, emailVerified: {type: "boolean", description: "Whether the email has been verified"}, createdAt: {type: "string", format: "date-time", description: "When the user was created"}, updatedAt: {type: "string", format: "date-time", description: "When the user was last updated"} }, required: ["id", "email", "name", "emailVerified", "createdAt", "updatedAt"] ) end |
.org_truthy?(value) ⇒ Boolean
986 987 988 |
# File 'lib/better_auth/plugins/organization.rb', line 986 def org_truthy?(value) value == true || value.to_s == "true" end |
.organization(options = {}) ⇒ Object
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/better_auth/plugins/organization.rb', line 77 def organization( = {}) 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, 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
356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 |
# File 'lib/better_auth/plugins/organization.rb', line 356 def organization_accept_invitation_endpoint(config) Endpoint.new(path: "/organization/accept-invitation", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) invitation = invitation_by_id(ctx, body[:invitation_id] || body[:id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION")) unless invitation["email"].to_s.downcase == session[:user]["email"].to_s.downcase raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation["status"] == "pending" raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) if invitation["expiresAt"] && Time.parse(invitation["expiresAt"].to_s) < Time.now if config[:require_email_verification_on_invitation] && !session[:user]["emailVerified"] raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION")) end member = ctx.context.adapter.create(model: "member", data: {organizationId: invitation["organizationId"], userId: session[:user]["id"], role: invitation["role"], createdAt: Time.now}) organization_team_ids(invitation["teamId"]).each do |team_id| ctx.context.adapter.create(model: "teamMember", data: {teamId: team_id, userId: session[:user]["id"], createdAt: Time.now}) end updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "accepted"}) ctx.json({invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member)}) end end |
.organization_add_member_endpoint(config) ⇒ Object
426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 |
# File 'lib/better_auth/plugins/organization.rb', line 426 def organization_add_member_endpoint(config) Endpoint.new(path: "/organization/add-member", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] (ctx, config, session, organization_id, {member: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_MEMBERSHIP_LIMIT_REACHED")) user_id = body[:user_id].to_s raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty? if require_member(ctx, user_id, organization_id) raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION")) end organization = organization_by_id(ctx, organization_id) user = ctx.context.internal_adapter.find_user_by_id(user_id) member_data = {organizationId: organization_id, userId: user_id, role: parse_roles(body[:role] || "member"), createdAt: Time.now}.merge(additional_input(body, :organization_id, :user_id, :role)) merge_hook_data!(member_data, run_org_hook(config, :before_add_member, {member: member_data, user: user, organization: organization_wire(ctx, organization)}, ctx)) member = ctx.context.adapter.create(model: "member", data: member_data) run_org_hook(config, :after_add_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx) ctx.json(member_wire(ctx, member)) end end |
.organization_add_team_member_endpoint(config) ⇒ Object
624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 |
# File 'lib/better_auth/plugins/organization.rb', line 624 def organization_add_team_member_endpoint(config) Endpoint.new(path: "/organization/add-team-member", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) team = team_by_id(ctx, body[:team_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team (ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER")) user_id = body[:user_id].to_s require_member!(ctx, user_id, team["organizationId"]) max_members = config.dig(:teams, :maximum_members_per_team) if max_members && ctx.context.adapter.count(model: "teamMember", where: [{field: "teamId", value: team["id"]}]) >= max_members.to_i raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_MEMBER_LIMIT_REACHED")) end existing = ctx.context.adapter.find_one(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: user_id}]) next ctx.json(team_member_wire(ctx, existing)) if existing member = ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: user_id, createdAt: Time.now}) ctx.json(team_member_wire(ctx, member)) end end |
.organization_by_id(ctx, id) ⇒ Object
813 814 815 816 817 |
# File 'lib/better_auth/plugins/organization.rb', line 813 def organization_by_id(ctx, id) return nil if id.to_s.empty? ctx.context.adapter.find_one(model: "organization", where: [{field: "id", value: id}]) end |
.organization_by_slug(ctx, slug) ⇒ Object
819 820 821 822 823 |
# File 'lib/better_auth/plugins/organization.rb', line 819 def organization_by_slug(ctx, slug) return nil if slug.to_s.empty? ctx.context.adapter.find_one(model: "organization", where: [{field: "slug", value: slug}]) end |
.organization_cancel_invitation_endpoint(config) ⇒ Object
388 389 390 391 392 393 394 395 396 397 |
# File 'lib/better_auth/plugins/organization.rb', line 388 def organization_cancel_invitation_endpoint(config) Endpoint.new(path: "/organization/cancel-invitation", method: "POST") do |ctx| session = Routes.current_session(ctx) invitation = invitation_by_id(ctx, normalize_hash(ctx.body)[:invitation_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation (ctx, config, session, invitation["organizationId"], {invitation: ["cancel"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION")) updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "canceled"}) ctx.json(invitation_wire(ctx, updated)) end end |
.organization_check_slug_endpoint ⇒ Object
206 207 208 209 210 211 212 213 214 |
# File 'lib/better_auth/plugins/organization.rb', line 206 def organization_check_slug_endpoint Endpoint.new(path: "/organization/check-slug", method: "POST") do |ctx| slug = normalize_hash(ctx.body)[:slug].to_s if slug.empty? || organization_by_slug(ctx, slug) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_SLUG_ALREADY_TAKEN")) end ctx.json({status: true}) end end |
.organization_config(options) ⇒ Object
137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/better_auth/plugins/organization.rb', line 137 def organization_config() 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
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/better_auth/plugins/organization.rb', line 158 def organization_create_endpoint(config) Endpoint.new(path: "/organization/create", method: "POST") do |ctx| body = normalize_hash(ctx.body) session = Routes.current_session(ctx, allow_nil: true) user = session ? session[:user] : ctx.context.internal_adapter.find_user_by_id(body[:user_id]) raise APIError.new("UNAUTHORIZED") unless user name = body[:name].to_s slug = body[:slug].to_s raise APIError.new("BAD_REQUEST", message: "name is required") if name.empty? raise APIError.new("BAD_REQUEST", message: "slug is required") if slug.empty? allowed = config[:allow_user_to_create_organization] allowed = allowed.call(user) if allowed.respond_to?(:call) raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION")) unless allowed if config[:organization_limit] limit_reached = config[:organization_limit].respond_to?(:call) ? config[:organization_limit].call(user) : organization_created_count(ctx, user["id"]) >= config[:organization_limit].to_i raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS")) if limit_reached end if organization_by_slug(ctx, slug) raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_ALREADY_EXISTS")) end data = { name: name, slug: slug, logo: body[:logo], metadata: (body[:metadata]), createdAt: Time.now }.merge(additional_input(body, :name, :slug, :logo, :metadata, :keep_current_active_organization, :user_id)) merge_hook_data!(data, run_org_hook(config, :before_create_organization, {organization: data, user: user}, ctx)) organization = ctx.context.adapter.create(model: "organization", data: data, force_allow_id: true) member_data = {organizationId: organization["id"], userId: user["id"], role: config[:creator_role], createdAt: Time.now} merge_hook_data!(member_data, run_org_hook(config, :before_add_member, {member: member_data, user: user, organization: organization_wire(ctx, organization)}, ctx)) member = ctx.context.adapter.create(model: "member", data: member_data) run_org_hook(config, :after_add_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx) default_team = create_default_team(ctx, config, organization, {user: user}) if org_truthy?(config.dig(:teams, :enabled)) && config.dig(:teams, :default_team, :enabled) != false run_org_hook(config, :after_create_organization, {organization: organization_wire(ctx, organization), member: member, user: user}, ctx) if session && !org_truthy?(body[:keep_current_active_organization]) update = {activeOrganizationId: organization["id"], activeTeamId: default_team && default_team["id"]} updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], update) Cookies.(ctx, {session: updated_session || session[:session].merge(update.transform_keys(&:to_s)), user: user}) end ctx.json(organization_wire(ctx, organization).merge(members: [member_wire(ctx, member)])) end end |
.organization_create_role_endpoint(config) ⇒ Object
657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 |
# File 'lib/better_auth/plugins/organization.rb', line 657 def organization_create_role_endpoint(config) Endpoint.new(path: "/organization/create-role", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] (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
528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 |
# File 'lib/better_auth/plugins/organization.rb', line 528 def organization_create_team_endpoint(config) Endpoint.new(path: "/organization/create-team", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] (ctx, config, session, organization_id, {team: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION")) organization = organization_by_id(ctx, organization_id) max_teams = config.dig(:teams, :maximum_teams) if max_teams && ctx.context.adapter.count(model: "team", where: [{field: "organizationId", value: organization_id}]) >= max_teams.to_i raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS")) end team_data = {organizationId: organization_id, name: body[:name].to_s, createdAt: Time.now}.merge(additional_input(body, :organization_id, :name)) merge_hook_data!(team_data, run_org_hook(config, :before_create_team, {team: team_data, user: session[:user], organization: organization_wire(ctx, organization)}, ctx)) team = ctx.context.adapter.create(model: "team", data: team_data) ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now}) run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx) ctx.json(team_wire(ctx, team)) end end |
.organization_created_count(ctx, user_id) ⇒ Object
920 921 922 923 |
# File 'lib/better_auth/plugins/organization.rb', line 920 def organization_created_count(ctx, user_id) members = ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: user_id}]) members.count { |member| member["role"].to_s.split(",").include?("owner") } end |
.organization_default_roles(config = {}) ⇒ Object
149 150 151 152 153 154 155 156 |
# File 'lib/better_auth/plugins/organization.rb', line 149 def organization_default_roles(config = {}) ac = config[:ac] || create_access_control(ORGANIZATION_DEFAULT_STATEMENTS) { "admin" => ac.new_role(organization: ["update"], invitation: ["create", "cancel"], member: ["create", "update", "delete"], team: ["create", "update", "delete"], ac: ["create", "read", "update", "delete"]), "owner" => ac.new_role(organization: ["update", "delete"], member: ["create", "update", "delete"], invitation: ["create", "cancel"], team: ["create", "update", "delete"], ac: ["create", "read", "update", "delete"]), "member" => ac.new_role(organization: [], member: [], invitation: [], team: [], ac: ["read"]) } end |
.organization_delete_endpoint(config) ⇒ Object
250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 |
# File 'lib/better_auth/plugins/organization.rb', line 250 def organization_delete_endpoint(config) Endpoint.new(path: "/organization/delete", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization = organization_by_id(ctx, body[:organization_id]) || organization_by_slug(ctx, body[:organization_slug]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization (ctx, config, session, organization["id"], {organization: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION")) run_org_hook(config, :before_delete_organization, {organization: organization_wire(ctx, organization), user: session[:user]}, ctx) if org_truthy?(config.dig(:teams, :enabled)) team_ids = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: organization["id"]}]).map { |team| team["id"] } ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team_ids, operator: "in"}]) if team_ids.any? ctx.context.adapter.delete_many(model: "team", where: [{field: "organizationId", value: organization["id"]}]) end ctx.context.adapter.delete_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}]) ctx.context.adapter.delete_many(model: "member", where: [{field: "organizationId", value: organization["id"]}]) ctx.context.adapter.delete_many(model: "organizationRole", where: [{field: "organizationId", value: organization["id"]}]) if org_truthy?(config.dig(:dynamic_access_control, :enabled)) ctx.context.adapter.delete(model: "organization", where: [{field: "id", value: organization["id"]}]) ctx.json({status: true}) end end |
.organization_delete_role_endpoint(config) ⇒ Object
724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 |
# File 'lib/better_auth/plugins/organization.rb', line 724 def organization_delete_role_endpoint(config) Endpoint.new(path: "/organization/delete-role", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] (ctx, config, session, organization_id, {ac: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE")) role_name = body[:role] || body[:role_name] if role_name && organization_roles(config).key?(role_name.to_s) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("CANNOT_DELETE_A_PRE_DEFINED_ROLE")) end role = organization_role_by_id(ctx, body[:role_id]) || organization_role_by_name(ctx, organization_id, role_name) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND")) unless role assigned = ctx.context.adapter.find_many(model: "member", where: [{field: "organizationId", value: organization_id}]).any? do |member| member["role"].to_s.split(",").map(&:strip).include?(role["role"]) end raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_IS_ASSIGNED_TO_MEMBERS")) if assigned ctx.context.adapter.delete(model: "organizationRole", where: [{field: "id", value: role["id"]}]) ctx.json({success: true}) end end |
.organization_get_active_member_endpoint(_config) ⇒ Object
473 474 475 476 477 478 479 480 481 |
# File 'lib/better_auth/plugins/organization.rb', line 473 def organization_get_active_member_endpoint(_config) Endpoint.new(path: "/organization/get-active-member", method: "GET") do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id member = require_member!(ctx, session[:user]["id"], organization_id) ctx.json(member_wire(ctx, member)) end end |
.organization_get_active_member_role_endpoint(_config) ⇒ Object
483 484 485 486 487 488 489 490 491 |
# File 'lib/better_auth/plugins/organization.rb', line 483 def organization_get_active_member_role_endpoint(_config) Endpoint.new(path: "/organization/get-active-member-role", method: "GET") do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id member = require_member!(ctx, session[:user]["id"], organization_id) ctx.json({role: member["role"]}) end end |
.organization_get_full_endpoint(config) ⇒ Object
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 |
# File 'lib/better_auth/plugins/organization.rb', line 284 def organization_get_full_endpoint(config) Endpoint.new(path: "/organization/get-full-organization", method: "GET") do |ctx| session = Routes.current_session(ctx) query = normalize_hash(ctx.query) organization = organization_by_slug(ctx, query[:organization_slug]) || organization_by_id(ctx, query[:organization_id] || session[:session]["activeOrganizationId"]) next ctx.json(nil) unless organization require_member!(ctx, session[:user]["id"], organization["id"]) members = list_members_for(ctx, organization["id"]) invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}]) result = organization_wire(ctx, organization).merge( members: members.fetch(:members), invitations: invitations.map { |entry| invitation_wire(ctx, entry) } ) if org_truthy?(config.dig(:teams, :enabled)) teams = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: organization["id"]}]) result[:teams] = teams.map { |team| team_wire(ctx, team) } end ctx.json(result) end end |
.organization_get_invitation_endpoint ⇒ Object
399 400 401 402 403 404 405 |
# File 'lib/better_auth/plugins/organization.rb', line 399 def organization_get_invitation_endpoint Endpoint.new(path: "/organization/get-invitation", method: "GET") do |ctx| invitation = invitation_by_id(ctx, normalize_hash(ctx.query)[:id] || normalize_hash(ctx.query)[:invitation_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation ctx.json(invitation_wire(ctx, invitation)) end end |
.organization_get_role_endpoint(config) ⇒ Object
687 688 689 690 691 692 693 694 695 696 697 |
# File 'lib/better_auth/plugins/organization.rb', line 687 def organization_get_role_endpoint(config) Endpoint.new(path: "/organization/get-role", method: "GET") do |ctx| session = Routes.current_session(ctx) query = normalize_hash(ctx.query) organization_id = query[:organization_id] || session[:session]["activeOrganizationId"] (ctx, config, session, organization_id, {ac: ["read"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE")) role = organization_role_by_id(ctx, query[:role_id]) || organization_role_by_name(ctx, organization_id, query[:role]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND")) unless role ctx.json(organization_role_wire(role)) end end |
.organization_has_permission_endpoint(config) ⇒ Object
516 517 518 519 520 521 522 523 524 525 526 |
# File 'lib/better_auth/plugins/organization.rb', line 516 def (config) Endpoint.new(path: "/organization/has-permission", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id member = require_member!(ctx, session[:user]["id"], organization_id) = body[:permissions] || body[:permission] ctx.json({error: nil, success: (ctx, config, member["role"], , organization_id)}) end end |
.organization_invite_endpoint(config) ⇒ Object
306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'lib/better_auth/plugins/organization.rb', line 306 def organization_invite_endpoint(config) Endpoint.new(path: "/organization/invite-member", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization = organization_by_id(ctx, body[:organization_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization (ctx, config, session, organization["id"], {invitation: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION")) email = body[:email].to_s.downcase raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email) role = parse_roles(body[:role] || "member") role.split(",").each do |entry| unless organization_roles(config).key?(entry) || organization_role_by_name(ctx, organization["id"], entry) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ROLE_NOT_FOUND")) end end existing_member = find_member_by_email(ctx, organization["id"], email) raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION")) if existing_member pending = ctx.context.adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}, {field: "email", value: email}, {field: "status", value: "pending"}]) if pending.any? if config[:cancel_pending_invitations_on_re_invite] pending.each { |entry| ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: entry["id"]}], update: {status: "canceled"}) } else raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION")) end end pending_count = ctx.context.adapter.count(model: "invitation", where: [{field: "organizationId", value: organization["id"]}, {field: "status", value: "pending"}]) limit = config[:invitation_limit] if limit && pending_count >= limit.to_i raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_LIMIT_REACHED")) end team_ids = organization_team_ids(body[:team_id] || body[:team_ids]) invitation = ctx.context.adapter.create( model: "invitation", data: { organizationId: organization["id"], email: email, role: role, status: "pending", expiresAt: Time.now + config[:invitation_expires_in].to_i, inviterId: session[:user]["id"], teamId: team_ids.any? ? team_ids.join(",") : nil, createdAt: Time.now } ) sender = config[:send_invitation_email] sender.call({id: invitation["id"], role: role, email: email, organization: organization_wire(ctx, organization), invitation: invitation_wire(ctx, invitation), inviter: require_member!(ctx, session[:user]["id"], organization["id"])}, ctx.request) if sender.respond_to?(:call) ctx.json(invitation_wire(ctx, invitation)) end end |
.organization_leave_endpoint(config) ⇒ Object
493 494 495 496 497 498 499 500 501 502 503 |
# File 'lib/better_auth/plugins/organization.rb', line 493 def organization_leave_endpoint(config) Endpoint.new(path: "/organization/leave", method: "POST") do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.body)[:organization_id] member = require_member!(ctx, session[:user]["id"], organization_id) ensure_not_last_owner!(ctx, member) ctx.context.adapter.delete(model: "member", where: [{field: "id", value: member["id"]}]) ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "userId", value: session[:user]["id"]}]) if org_truthy?(config.dig(:teams, :enabled)) ctx.json({status: true}) end end |
.organization_list_endpoint ⇒ Object
216 217 218 219 220 221 222 223 |
# File 'lib/better_auth/plugins/organization.rb', line 216 def organization_list_endpoint Endpoint.new(path: "/organization/list", method: "GET") do |ctx| session = Routes.current_session(ctx) members = ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: session[:user]["id"]}]) organizations = members.filter_map { |member| organization_by_id(ctx, member["organizationId"]) } ctx.json(organizations.map { |entry| organization_wire(ctx, entry) }) end end |
.organization_list_invitations_endpoint(config) ⇒ Object
407 408 409 410 411 412 413 414 415 416 |
# File 'lib/better_auth/plugins/organization.rb', line 407 def organization_list_invitations_endpoint(config) Endpoint.new(path: "/organization/list-invitations", method: "GET") do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id (ctx, config, session, organization_id, {invitation: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION")) invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization_id}]) ctx.json(invitations.map { |entry| invitation_wire(ctx, entry) }) end end |
.organization_list_members_endpoint(_config) ⇒ Object
505 506 507 508 509 510 511 512 513 514 |
# File 'lib/better_auth/plugins/organization.rb', line 505 def organization_list_members_endpoint(_config) Endpoint.new(path: "/organization/list-members", method: "GET") do |ctx| session = Routes.current_session(ctx) query = normalize_hash(ctx.query) organization_id = query[:organization_id] || organization_by_slug(ctx, query[:organization_slug])&.fetch("id") raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id require_member!(ctx, session[:user]["id"], organization_id) ctx.json(list_members_for(ctx, organization_id, query)) end end |
.organization_list_roles_endpoint(config) ⇒ Object
676 677 678 679 680 681 682 683 684 685 |
# File 'lib/better_auth/plugins/organization.rb', line 676 def organization_list_roles_endpoint(config) Endpoint.new(path: "/organization/list-roles", method: "GET") do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"] (ctx, config, session, organization_id, {ac: ["read"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE")) defaults = organization_roles(config).keys.map { |role| {"role" => role, "permission" => {}} } dynamic = ctx.context.adapter.find_many(model: "organizationRole", where: [{field: "organizationId", value: organization_id}]).map { |role| organization_role_wire(role) } ctx.json(defaults + dynamic) end end |
.organization_list_team_members_endpoint(_config) ⇒ Object
612 613 614 615 616 617 618 619 620 621 622 |
# File 'lib/better_auth/plugins/organization.rb', line 612 def organization_list_team_members_endpoint(_config) Endpoint.new(path: "/organization/list-team-members", method: "GET") do |ctx| session = Routes.current_session(ctx) team_id = normalize_hash(ctx.query)[:team_id] || session[:session]["activeTeamId"] raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM")) unless team_id team = team_by_id(ctx, team_id) require_member!(ctx, session[:user]["id"], team["organizationId"]) members = ctx.context.adapter.find_many(model: "teamMember", where: [{field: "teamId", value: team_id}]) ctx.json(members.map { |entry| team_member_wire(ctx, entry) }) end end |
.organization_list_teams_endpoint(_config) ⇒ Object
548 549 550 551 552 553 554 555 556 |
# File 'lib/better_auth/plugins/organization.rb', line 548 def organization_list_teams_endpoint(_config) Endpoint.new(path: "/organization/list-teams", method: "GET") do |ctx| session = Routes.current_session(ctx) organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"] require_member!(ctx, session[:user]["id"], organization_id) teams = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: organization_id}]) ctx.json(teams.map { |team| team_wire(ctx, team) }) end end |
.organization_list_user_invitations_endpoint ⇒ Object
418 419 420 421 422 423 424 |
# File 'lib/better_auth/plugins/organization.rb', line 418 def organization_list_user_invitations_endpoint Endpoint.new(path: "/organization/list-user-invitations", method: "GET") do |ctx| session = Routes.current_session(ctx) invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "email", value: session[:user]["email"].to_s.downcase}, {field: "status", value: "pending"}]) ctx.json(invitations.map { |entry| invitation_wire(ctx, entry) }) end end |
.organization_list_user_teams_endpoint ⇒ Object
604 605 606 607 608 609 610 |
# File 'lib/better_auth/plugins/organization.rb', line 604 def organization_list_user_teams_endpoint Endpoint.new(path: "/organization/list-user-teams", method: "GET") do |ctx| session = Routes.current_session(ctx) memberships = ctx.context.adapter.find_many(model: "teamMember", where: [{field: "userId", value: session[:user]["id"]}]) ctx.json(memberships.filter_map { |entry| team_by_id(ctx, entry["teamId"]) }.map { |team| team_wire(ctx, team) }) end end |
.organization_permission?(ctx, config, role_string, permissions, organization_id) ⇒ Boolean
753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 |
# File 'lib/better_auth/plugins/organization.rb', line 753 def (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_reject_invitation_endpoint(_config) ⇒ Object
377 378 379 380 381 382 383 384 385 386 |
# File 'lib/better_auth/plugins/organization.rb', line 377 def organization_reject_invitation_endpoint(_config) Endpoint.new(path: "/organization/reject-invitation", method: "POST") do |ctx| session = Routes.current_session(ctx) invitation = invitation_by_id(ctx, normalize_hash(ctx.body)[:invitation_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_NOT_FOUND")) unless invitation raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION")) unless invitation["email"].to_s.downcase == session[:user]["email"].to_s.downcase updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "rejected"}) ctx.json(invitation_wire(ctx, updated)) end end |
.organization_remove_member_endpoint(config) ⇒ Object
447 448 449 450 451 452 453 454 455 456 457 458 459 |
# File 'lib/better_auth/plugins/organization.rb', line 447 def organization_remove_member_endpoint(config) Endpoint.new(path: "/organization/remove-member", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) member = member_by_id(ctx, body[:member_id]) || require_member(ctx, body[:user_id], body[:organization_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless member (ctx, config, session, member["organizationId"], {member: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER")) ensure_not_last_owner!(ctx, member) ctx.context.adapter.delete(model: "member", where: [{field: "id", value: member["id"]}]) ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "userId", value: member["userId"]}]) if org_truthy?(config.dig(:teams, :enabled)) ctx.json({status: true}) end end |
.organization_remove_team_endpoint(config) ⇒ Object
570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 |
# File 'lib/better_auth/plugins/organization.rb', line 570 def organization_remove_team_endpoint(config) Endpoint.new(path: "/organization/remove-team", method: "POST") do |ctx| session = Routes.current_session(ctx) team = team_by_id(ctx, normalize_hash(ctx.body)[:team_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team (ctx, config, session, team["organizationId"], {team: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM")) teams = ctx.context.adapter.find_many(model: "team", where: [{field: "organizationId", value: team["organizationId"]}]) if teams.length <= 1 && config.dig(:teams, :allow_removing_all_teams) != true raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("UNABLE_TO_REMOVE_LAST_TEAM")) end ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}]) ctx.context.adapter.delete(model: "team", where: [{field: "id", value: team["id"]}]) ctx.json({status: true}) end end |
.organization_remove_team_member_endpoint(config) ⇒ Object
645 646 647 648 649 650 651 652 653 654 655 |
# File 'lib/better_auth/plugins/organization.rb', line 645 def organization_remove_team_member_endpoint(config) Endpoint.new(path: "/organization/remove-team-member", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) team = team_by_id(ctx, body[:team_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team (ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER")) ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "teamId", value: team["id"]}, {field: "userId", value: body[:user_id]}]) ctx.json({status: true}) end end |
.organization_role_by_id(ctx, id) ⇒ Object
837 838 839 840 841 |
# File 'lib/better_auth/plugins/organization.rb', line 837 def organization_role_by_id(ctx, id) return nil if id.to_s.empty? ctx.context.adapter.find_one(model: "organizationRole", where: [{field: "id", value: id}]) end |
.organization_role_by_name(ctx, organization_id, role) ⇒ Object
843 844 845 846 847 |
# File 'lib/better_auth/plugins/organization.rb', line 843 def organization_role_by_name(ctx, organization_id, role) return nil if role.to_s.empty? ctx.context.adapter.find_one(model: "organizationRole", where: [{field: "organizationId", value: organization_id}, {field: "role", value: role}]) end |
.organization_role_wire(role) ⇒ Object
895 896 897 |
# File 'lib/better_auth/plugins/organization.rb', line 895 def organization_role_wire(role) role.merge("permission" => (role["permission"])) end |
.organization_roles(config) ⇒ Object
749 750 751 |
# File 'lib/better_auth/plugins/organization.rb', line 749 def organization_roles(config) (config[:roles] || organization_default_roles(config)).transform_keys(&:to_s) end |
.organization_set_active_endpoint ⇒ Object
271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/better_auth/plugins/organization.rb', line 271 def organization_set_active_endpoint Endpoint.new(path: "/organization/set-active", method: "POST") do |ctx| session = Routes.current_session(ctx, sensitive: true) body = normalize_hash(ctx.body) organization = organization_by_id(ctx, body[:organization_id]) || organization_by_slug(ctx, body[:organization_slug]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization require_member!(ctx, session[:user]["id"], organization["id"]) updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeOrganizationId: organization["id"], activeTeamId: nil}) Cookies.(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => organization["id"], "activeTeamId" => nil), user: session[:user]}) ctx.json(organization_wire(ctx, organization)) end end |
.organization_set_active_team_endpoint(_config) ⇒ Object
586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 |
# File 'lib/better_auth/plugins/organization.rb', line 586 def organization_set_active_team_endpoint(_config) Endpoint.new(path: "/organization/set-active-team", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) if body.key?(:team_id) && body[:team_id].nil? updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeTeamId: nil}) Cookies.(ctx, {session: updated_session || session[:session].merge("activeTeamId" => nil), user: session[:user]}) next ctx.json({status: true}) end team = team_by_id(ctx, body[:team_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team require_team_member!(ctx, session[:user]["id"], team["id"]) updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeOrganizationId: team["organizationId"], activeTeamId: team["id"]}) Cookies.(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => team["organizationId"], "activeTeamId" => team["id"]), user: session[:user]}) ctx.json(team_wire(ctx, team)) end end |
.organization_team_ids(value) ⇒ Object
972 973 974 |
# File 'lib/better_auth/plugins/organization.rb', line 972 def organization_team_ids(value) Array(value).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?) end |
.organization_update_endpoint(config) ⇒ Object
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
# File 'lib/better_auth/plugins/organization.rb', line 225 def organization_update_endpoint(config) Endpoint.new(path: "/organization/update", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) id = body[:organization_id] || body[:organizationId] data = normalize_hash(body[:data] || body) organization = organization_by_id(ctx, id) || organization_by_slug(ctx, body[:organization_slug]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization (ctx, config, session, organization["id"], {organization: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION")) if data[:slug] && data[:slug].to_s.empty? raise APIError.new("BAD_REQUEST", message: "slug is required") end if data[:name] && data[:name].to_s.empty? raise APIError.new("BAD_REQUEST", message: "name is required") end existing = data[:slug] ? organization_by_slug(ctx, data[:slug]) : nil raise APIError.new("CONFLICT", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_SLUG_ALREADY_TAKEN")) if existing && existing["id"] != organization["id"] update = additional_input(data, :organization_id, :organizationId, :organization_slug, :data) update[:metadata] = (update[:metadata]) if update.key?(:metadata) updated = ctx.context.adapter.update(model: "organization", where: [{field: "id", value: organization["id"]}], update: update) run_org_hook(config, :after_update_organization, {organization: organization_wire(ctx, updated), user: session[:user]}, ctx) ctx.json(organization_wire(ctx, updated)) end end |
.organization_update_member_role_endpoint(config) ⇒ Object
461 462 463 464 465 466 467 468 469 470 471 |
# File 'lib/better_auth/plugins/organization.rb', line 461 def organization_update_member_role_endpoint(config) Endpoint.new(path: "/organization/update-member-role", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) member = member_by_id(ctx, body[:member_id]) || require_member(ctx, body[:user_id], body[:organization_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless member (ctx, config, session, member["organizationId"], {member: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER")) updated = ctx.context.adapter.update(model: "member", where: [{field: "id", value: member["id"]}], update: {role: parse_roles(body[:role])}) ctx.json(member_wire(ctx, updated)) end end |
.organization_update_role_endpoint(config) ⇒ Object
699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 |
# File 'lib/better_auth/plugins/organization.rb', line 699 def organization_update_role_endpoint(config) Endpoint.new(path: "/organization/update-role", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) organization_id = body[:organization_id] || session[:session]["activeOrganizationId"] (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
558 559 560 561 562 563 564 565 566 567 568 |
# File 'lib/better_auth/plugins/organization.rb', line 558 def organization_update_team_endpoint(config) Endpoint.new(path: "/organization/update-team", method: "POST") do |ctx| session = Routes.current_session(ctx) body = normalize_hash(ctx.body) team = team_by_id(ctx, body[:team_id]) raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_NOT_FOUND")) unless team (ctx, config, session, team["organizationId"], {team: ["update"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM")) updated = ctx.context.adapter.update(model: "team", where: [{field: "id", value: team["id"]}], update: additional_input(body, :team_id, :organization_id)) ctx.json(team_wire(ctx, updated)) end end |
.organization_wire(ctx, organization) ⇒ Object
877 878 879 880 881 |
# File 'lib/better_auth/plugins/organization.rb', line 877 def organization_wire(ctx, organization) data = Schema.parse_output(ctx.context., "organization", organization) data["metadata"] = (data["metadata"]) if data&.key?("metadata") data end |
.parse_duration(value) ⇒ Object
434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 |
# File 'lib/better_auth/plugins/jwt.rb', line 434 def parse_duration(value) match = value.strip.match(/\A(-?\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|y|yr|yrs|year|years)(?:\s+from now|\s+ago)?\z/i) raise TypeError, "Invalid time string" unless match amount = match[1].to_i amount = -amount if value.include?("ago") unit = match[2].downcase multiplier = case unit when "s", "sec", "secs", "second", "seconds" then 1 when "m", "min", "mins", "minute", "minutes" then 60 when "h", "hr", "hrs", "hour", "hours" then 3600 when "d", "day", "days" then 86_400 when "w", "week", "weeks" then 604_800 else 31_557_600 end amount * multiplier end |
.parse_metadata(value) ⇒ Object
939 940 941 942 943 944 945 |
# File 'lib/better_auth/plugins/organization.rb', line 939 def (value) return value if value.nil? || value.is_a?(Hash) JSON.parse(value) rescue JSON::ParserError value end |
.parse_permission(value) ⇒ Object
951 952 953 954 955 956 957 958 |
# File 'lib/better_auth/plugins/organization.rb', line 951 def (value) return value if value.is_a?(Hash) return {} if value.nil? || value.to_s.empty? JSON.parse(value) rescue JSON::ParserError {} end |
.parse_roles(roles) ⇒ Object
745 746 747 |
# File 'lib/better_auth/plugins/organization.rb', line 745 def parse_roles(roles) Array(roles).join(",") end |
.parsed_session(ctx, entry) ⇒ Object
157 158 159 160 161 162 |
# File 'lib/better_auth/plugins/multi_session.rb', line 157 def parsed_session(ctx, entry) { session: Schema.parse_output(ctx.context., "session", entry[:session]), user: Schema.parse_output(ctx.context., "user", entry[:user]) } end |
.passkey(*args) ⇒ Object
7 8 9 10 11 12 13 14 |
# File 'lib/better_auth/plugins/passkey.rb', line 7 def passkey(*args) Kernel.require "better_auth/passkey" BetterAuth::Plugins.passkey(*args) rescue LoadError => error raise if error.path && error.path != "better_auth/passkey" raise LoadError, "BetterAuth::Plugins.passkey requires the better_auth-passkey gem. Add `gem \"better_auth-passkey\"` and `require \"better_auth/passkey\"`." end |
.patreon(options = {}) ⇒ Object
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 178 def patreon( = {}) 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_000, max: 10 } ], error_codes: PHONE_NUMBER_ERROR_CODES, options: config ) end |
.phone_number_create_user(ctx, config, body, phone_number) ⇒ Object
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'lib/better_auth/plugins/phone_number.rb', line 212 def phone_number_create_user(ctx, config, body, phone_number) 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 ) ) end |
.phone_number_deliver_otp(config, data, ctx) ⇒ Object
275 276 277 278 |
# File 'lib/better_auth/plugins/phone_number.rb', line 275 def phone_number_deliver_otp(config, data, ctx) sender = config[:send_otp] sender.call(data, ctx) if sender.respond_to?(:call) end |
.phone_number_generate_code(config) ⇒ Object
295 296 297 |
# File 'lib/better_auth/plugins/phone_number.rb', line 295 def phone_number_generate_code(config) Array.new(config[:otp_length].to_i) { SecureRandom.random_number(10).to_s }.join end |
.phone_number_schema(custom_schema) ⇒ Object
200 201 202 203 204 205 206 207 208 209 210 |
# File 'lib/better_auth/plugins/phone_number.rb', line 200 def phone_number_schema(custom_schema) base = { user: { fields: { phoneNumber: {type: "string", required: false, unique: true, sortable: true, returned: true}, phoneNumberVerified: {type: "boolean", required: false, returned: true, input: false} } } } deep_merge_hashes(base, normalize_hash(custom_schema || {})) end |
.phone_number_split_code(value) ⇒ Object
299 300 301 302 303 304 305 |
# File 'lib/better_auth/plugins/phone_number.rb', line 299 def phone_number_split_code(value) string = value.to_s index = string.rindex(":") return [string, ""] unless index [string[0...index], string[(index + 1)..]] end |
.phone_number_store_code(ctx, config, identifier, code) ⇒ Object
266 267 268 269 270 271 272 273 |
# File 'lib/better_auth/plugins/phone_number.rb', line 266 def phone_number_store_code(ctx, config, identifier, code) ctx.context.internal_adapter.delete_verification_by_identifier(identifier) ctx.context.internal_adapter.create_verification_value( identifier: identifier, value: "#{code}:0", expiresAt: Time.now + config[:expires_in].to_i ) end |
.phone_number_verify_code!(ctx, config, identifier, code, consume: true) ⇒ Object
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 |
# File 'lib/better_auth/plugins/phone_number.rb', line 232 def phone_number_verify_code!(ctx, config, identifier, code, consume: true) verifier = config[:verify_otp] if verifier.respond_to?(:call) valid = verifier.call({phone_number: identifier.delete_suffix("-request-password-reset"), code: code}, ctx) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"]) unless valid verification = ctx.context.internal_adapter.find_verification_value(identifier) ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume && verification return verification || true end verification = ctx.context.internal_adapter.find_verification_value(identifier) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_NOT_FOUND"]) unless verification if Routes.expired_time?(verification["expiresAt"]) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_EXPIRED"]) end stored_code, attempts = phone_number_split_code(verification["value"]) attempts_count = attempts.to_i if attempts_count >= config[:allowed_attempts].to_i ctx.context.internal_adapter.delete_verification_value(verification["id"]) raise APIError.new("FORBIDDEN", message: PHONE_NUMBER_ERROR_CODES["TOO_MANY_ATTEMPTS"]) end unless stored_code == code ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{stored_code}:#{attempts_count + 1}") raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"]) end ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume verification end |
.positive_integer?(value) ⇒ Boolean
258 259 260 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 258 def positive_integer?(value) value.is_a?(Integer) && value.positive? end |
.present?(value) ⇒ Boolean
274 275 276 |
# File 'lib/better_auth/plugins/username.rb', line 274 def present?(value) !value.nil? && value != false && !value.to_s.empty? end |
.present_string?(value) ⇒ Boolean
194 195 196 |
# File 'lib/better_auth/plugins/anonymous.rb', line 194 def present_string?(value) value.is_a?(String) && !value.empty? end |
.private_key_pem(pair) ⇒ Object
346 347 348 |
# File 'lib/better_auth/plugins/jwt.rb', line 346 def private_key_pem(pair) pair.respond_to?(:private_to_pem) ? pair.private_to_pem : pair.to_pem end |
.process_device_decision(ctx, session, status) ⇒ Object
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 173 def process_device_decision(ctx, session, status) body = OAuthProtocol.stringify_keys(ctx.body) code = normalize_user_code(body["userCode"] || body["user_code"]) record = find_device_user_code(ctx, code) action = (status == "approved") ? "approve" : "deny" raise ("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" if record["userId"] && record["userId"] != session[:user]["id"] raise ("FORBIDDEN", "access_denied", "You are not authorized to #{action} this device authorization") end ctx.context.adapter.update( model: "deviceCode", where: [{field: "id", value: record["id"]}], update: {"status" => status, "userId" => record["userId"] || session[:user]["id"]} ) ctx.json({success: true}) end |
.public_jwk(key, _config) ⇒ Object
290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 |
# File 'lib/better_auth/plugins/jwt.rb', line 290 def public_jwk(key, _config) data = { kid: key["id"], kty: key["kty"] || key_type_for_alg(key["alg"] || "RS256"), alg: key["alg"] || "EdDSA", use: "sig", pem: key["pem"] || key["publicKey"] } data[:n] = key["n"] if key["n"] data[:e] = key["e"] if key["e"] data[:crv] = key["crv"] if key["crv"] data[:x] = key["x"] if key["x"] data[:y] = key["y"] if key["y"] data end |
.public_jwks(ctx, config) ⇒ Object
218 219 220 221 222 223 224 225 |
# File 'lib/better_auth/plugins/jwt.rb', line 218 def public_jwks(ctx, config) now = Time.now grace_period = config.dig(:jwks, :grace_period) || 60 * 60 * 24 * 30 all_jwks(ctx, config).select do |key| expires_at = normalize_time(key["expiresAt"]) !expires_at || expires_at + grace_period.to_i > now end end |
.public_key_for(pair) ⇒ Object
342 343 344 |
# File 'lib/better_auth/plugins/jwt.rb', line 342 def public_key_for(pair) OpenSSL::PKey.read(pair.public_to_pem) end |
.public_key_jwk_fields(public_key, alg) ⇒ Object
354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 |
# File 'lib/better_auth/plugins/jwt.rb', line 354 def public_key_jwk_fields(public_key, alg) if public_key.is_a?(OpenSSL::PKey::RSA) { "kty" => "RSA", "n" => base64url_bn(public_key.n), "e" => base64url_bn(public_key.e) } elsif alg == "EdDSA" { "kty" => "OKP", "crv" => "Ed25519", "x" => Crypto.base64url_encode(public_key.raw_public_key) } else point = public_key.public_key.to_octet_string(:uncompressed).bytes length = (point.length - 1) / 2 { "kty" => "EC", "crv" => ec_curve_for_alg(alg), "x" => Crypto.base64url_encode(point[1, length].pack("C*")), "y" => Crypto.base64url_encode(point[(1 + length), length].pack("C*")) } end end |
.public_key_pem(pair) ⇒ Object
350 351 352 |
# File 'lib/better_auth/plugins/jwt.rb', line 350 def public_key_pem(pair) pair.respond_to?(:public_to_pem) ? pair.public_to_pem : pair.to_pem end |
.remote_jwks(ctx, config) ⇒ Object
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 |
# File 'lib/better_auth/plugins/jwt.rb', line 243 def remote_jwks(ctx, config) url = config.dig(:jwks, :remote_url) fetcher = config.dig(:jwks, :fetch) || config.dig(:jwks, :fetcher) payload = if fetcher.respond_to?(:call) fetcher.call(url) else uri = URI.parse(url.to_s) response = Net::HTTP.get_response(uri) response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil end keys = fetch_value(payload, "keys") Array(keys).map { |entry| normalize_remote_jwk(entry) } rescue JSON::ParserError, URI::InvalidURIError, SocketError, SystemCallError [] end |
.request_email_change_email_otp_endpoint(config) ⇒ Object
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 |
# File 'lib/better_auth/plugins/email_otp.rb', line 211 def request_email_change_email_otp_endpoint(config) Endpoint.new(path: "/email-otp/request-email-change", method: "POST") do |ctx| email_otp_change_email_enabled!(config) session = Routes.current_session(ctx) body = normalize_hash(ctx.body) current_email = session[:user]["email"].to_s.downcase new_email = body[:new_email].to_s.downcase validate_email_otp_email!(new_email) raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == current_email if config.dig(:change_email, :verify_current_email) raise APIError.new("BAD_REQUEST", message: "OTP is required to verify current email") if body[:otp].to_s.empty? email_otp_verify!(ctx, config, email: current_email, type: "email-verification", otp: body[:otp]) end otp = email_otp_resolve(ctx, config, email: new_email, type: "change-email", identifier_email: "#{current_email}-#{new_email}") if ctx.context.internal_adapter.find_user_by_email(new_email) ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier("#{current_email}-#{new_email}", "change-email")) next ctx.json({success: true}) end email_otp_deliver(config, {email: new_email, otp: otp, type: "change-email"}, ctx) ctx.json({success: true}) end end |
.request_password_reset_email_otp_endpoint(config) ⇒ Object
261 262 263 264 265 |
# File 'lib/better_auth/plugins/email_otp.rb', line 261 def request_password_reset_email_otp_endpoint(config) Endpoint.new(path: "/email-otp/request-password-reset", method: "POST") do |ctx| email_otp_password_reset_request(ctx, config) end end |
.request_password_reset_phone_number_endpoint(config) ⇒ Object
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
# File 'lib/better_auth/plugins/phone_number.rb', line 165 def request_password_reset_phone_number_endpoint(config) Endpoint.new(path: "/phone-number/request-password-reset", method: "POST") do |ctx| body = normalize_hash(ctx.body) phone_number = body[:phone_number].to_s user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}]) code = phone_number_generate_code(config) phone_number_store_code(ctx, config, "#{phone_number}-request-password-reset", code) if user && config[:send_password_reset_otp].respond_to?(:call) config[:send_password_reset_otp].call({phone_number: phone_number, code: code}, ctx) end ctx.json({status: true}) end end |
.require_member(ctx, user_id, organization_id) ⇒ Object
789 790 791 792 793 |
# File 'lib/better_auth/plugins/organization.rb', line 789 def require_member(ctx, user_id, organization_id) return nil if user_id.to_s.empty? || organization_id.to_s.empty? ctx.context.adapter.find_one(model: "member", where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]) end |
.require_member!(ctx, user_id, organization_id) ⇒ Object
782 783 784 785 786 787 |
# File 'lib/better_auth/plugins/organization.rb', line 782 def require_member!(ctx, user_id, organization_id) member = require_member(ctx, user_id, organization_id) raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION")) unless member member end |
.require_org_permission!(ctx, config, session, organization_id, permissions, message) ⇒ Object
769 770 771 772 773 774 |
# File 'lib/better_auth/plugins/organization.rb', line 769 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_team_member!(ctx, user_id, team_id) ⇒ Object
795 796 797 798 799 800 |
# File 'lib/better_auth/plugins/organization.rb', line 795 def require_team_member!(ctx, user_id, team_id) member = ctx.context.adapter.find_one(model: "teamMember", where: [{field: "userId", value: user_id}, {field: "teamId", value: team_id}]) raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("USER_IS_NOT_A_MEMBER_OF_THE_TEAM")) unless member member end |
.reset_password_email_otp_endpoint(config) ⇒ Object
273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 |
# File 'lib/better_auth/plugins/email_otp.rb', line 273 def reset_password_email_otp_endpoint(config) Endpoint.new(path: "/email-otp/reset-password", method: "POST") do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase otp = body[:otp].to_s password = body[:password].to_s email_otp_verify!(ctx, config, email: email, type: "forget-password", otp: otp) found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless found Routes.validate_password_length!(password, ctx.context..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_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
181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/better_auth/plugins/phone_number.rb', line 181 def reset_password_phone_number_endpoint(config) Endpoint.new(path: "/phone-number/reset-password", method: "POST") do |ctx| body = normalize_hash(ctx.body) phone_number = body[:phone_number].to_s otp = body[:otp].to_s new_password = body[:new_password] verification = phone_number_verify_code!(ctx, config, "#{phone_number}-request-password-reset", otp, consume: false) user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}]) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["UNEXPECTED_ERROR"]) unless user Routes.validate_password_length!(new_password, ctx.context..email_and_password) ctx.context.internal_adapter.update_password(user["id"], Routes.hash_password(ctx, new_password)) ctx.context.internal_adapter.delete_verification_value(verification["id"]) ctx.context.internal_adapter.delete_sessions(user["id"]) if ctx.context..email_and_password[:revoke_sessions_on_password_reset] ctx.json({status: true}) end end |
.resolve_login_method(ctx, config) ⇒ Object
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/better_auth/plugins/last_login_method.rb', line 63 def 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/:providerId" fetch_value(ctx.params, "providerId") when "/oauth2/callback/:providerId" fetch_value(ctx.params, "providerId") else return Regexp.last_match(1) if path =~ %r{\A/callback/([^/]+)\z} return Regexp.last_match(1) if path =~ %r{\A/oauth2/callback/([^/]+)\z} return "siwe" if path.include?("siwe") return "passkey" if path.include?("/passkey/verify-authentication") return "magic-link" if path.start_with?("/magic-link/verify") nil end end |
.revoke_device_session_endpoint ⇒ Object
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/better_auth/plugins/multi_session.rb', line 68 def revoke_device_session_endpoint Endpoint.new(path: "/multi-session/revoke", method: "POST") do |ctx| current = Routes.current_session(ctx) token = fetch_value(ctx.body, "sessionToken").to_s = (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 |
.route_description(path, method) ⇒ Object
126 127 128 |
# File 'lib/better_auth/plugins/open_api.rb', line 126 def route_description(path, method) (path, method)[:description] end |
.route_open_api_metadata(path, method) ⇒ Object
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 |
# File 'lib/better_auth/plugins/open_api.rb', line 142 def (path, method) case [path, method.to_s.upcase] when ["/change-email", "POST"] { operationId: "changeEmail", requestBody: { required: true, content: { "application/json" => { schema: object_schema( { callbackURL: {type: ["string", "null"], description: "The URL to redirect to after email verification"}, newEmail: {type: "string", description: "The new email address to set must be a valid email address"} }, required: ["newEmail"] ) } } }, responses: { "200" => { description: "Email change request processed successfully", content: { "application/json" => { schema: object_schema( { message: { type: "string", nullable: true, enum: ["Email updated", "Verification email sent"], description: "Status message of the email change process" }, status: {type: "boolean", description: "Indicates if the request was successful"}, user: {type: "object", "$ref": "#/components/schemas/User"} }, required: ["status"] ) } } }, "422" => error_response("Unprocessable Entity. Email already exists") } } when ["/change-password", "POST"] { description: "Change the password of the user", operationId: "changePassword", requestBody: { required: true, content: { "application/json" => { schema: object_schema( { newPassword: {type: "string", description: "The new password to set"}, currentPassword: {type: "string", description: "The current password is required"}, revokeOtherSessions: {type: ["boolean", "null"], description: "Must be a boolean value"} }, required: ["newPassword", "currentPassword"] ) } } }, responses: { "200" => { description: "Password successfully changed", content: { "application/json" => { schema: object_schema( { token: {type: "string", nullable: true, description: "New session token if other sessions were revoked"}, user: open_api_user_response_schema }, required: ["user"] ) } } } } } when ["/sign-in/email", "POST"] { description: "Sign in with email and password", operationId: "signInEmail", requestBody: { required: true, content: { "application/json" => { schema: object_schema( { email: {type: "string", description: "Email of the user"}, password: {type: "string", description: "Password of the user"}, callbackURL: {type: ["string", "null"], description: "Callback URL to use as a redirect for email verification"}, rememberMe: {type: ["boolean", "null"], default: true, description: "If this is false, the session will not be remembered. Default is `true`."} }, required: ["email", "password"] ) } } }, responses: { "200" => { description: "Success - Returns either session details or redirect URL", content: { "application/json" => { schema: session_response_schema(description: "Session response when idToken is provided", nullable_url: true) } } } } } when ["/sign-in/social", "POST"] { description: "Sign in with a social provider", operationId: "socialSignIn", requestBody: { required: true, content: { "application/json" => { schema: object_schema( { provider: {type: "string"}, callbackURL: {type: ["string", "null"], description: "Callback URL to redirect to after the user has signed in"}, errorCallbackURL: {type: ["string", "null"], description: "Callback URL to redirect to if an error happens"}, newUserCallbackURL: {type: ["string", "null"]}, disableRedirect: {type: ["boolean", "null"], description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself"}, requestSignUp: {type: ["boolean", "null"], description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"}, loginHint: {type: ["string", "null"], description: "The login hint to use for the authorization code request"}, additionalData: {type: ["string", "null"]}, scopes: {type: ["array", "null"], description: "Array of scopes to request from the provider. This will override the default scopes passed."}, idToken: { type: ["object", "null"], properties: { token: {type: "string", description: "ID token from the provider"}, accessToken: {type: ["string", "null"], description: "Access token from the provider"}, refreshToken: {type: ["string", "null"], description: "Refresh token from the provider"}, expiresAt: {type: ["number", "null"], description: "Expiry date of the token"}, nonce: {type: ["string", "null"], description: "Nonce used to generate the token"} }, required: ["token"] } }, required: ["provider"] ) } } }, responses: { "200" => { description: "Success - Returns either session details or redirect URL", content: { "application/json" => { schema: session_response_schema(description: "Session response when idToken is provided") } } } } } when ["/sign-up/email", "POST"] { description: "Sign up a user using email and password", operationId: "signUpWithEmailAndPassword", requestBody: { content: { "application/json" => { schema: object_schema( { name: {type: "string", description: "The name of the user"}, email: {type: "string", description: "The email of the user"}, password: {type: "string", description: "The password of the user"}, image: {type: "string", description: "The profile image URL of the user"}, callbackURL: {type: "string", description: "The URL to use for email verification callback"}, rememberMe: {type: "boolean", description: "If this is false, the session will not be remembered. Default is `true`."} }, required: ["name", "email", "password"] ) } } }, responses: { "200" => { description: "Successfully created user", content: { "application/json" => { schema: object_schema( { token: {type: "string", nullable: true, description: "Authentication token for the session"}, user: {type: "object", "$ref": "#/components/schemas/User"} }, required: ["user"] ) } } }, "422" => error_response("Unprocessable Entity. User already exists or failed to create user.") } } else {} end end |
.route_operation_id(path, method) ⇒ Object
130 131 132 |
# File 'lib/better_auth/plugins/open_api.rb', line 130 def route_operation_id(path, method) (path, method)[:operationId] end |
.route_request_body(path, method) ⇒ Object
134 135 136 |
# File 'lib/better_auth/plugins/open_api.rb', line 134 def route_request_body(path, method) (path, method)[:requestBody] end |
.route_responses(path, method) ⇒ Object
138 139 140 |
# File 'lib/better_auth/plugins/open_api.rb', line 138 def route_responses(path, method) (path, method)[:responses] end |
.run_org_hook(config, key, data, ctx) ⇒ Object
925 926 927 928 929 |
# File 'lib/better_auth/plugins/organization.rb', line 925 def run_org_hook(config, key, data, ctx) hooks = [config.dig(:organization_hooks, key), config.dig(:hooks, key)] hooks.concat(ctx.context..plugins.filter_map { |plugin| plugin.dig(:options, :organization_hooks, key) || plugin.dig("options", "organizationHooks", key.to_s) }) 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
447 448 449 450 451 452 453 454 |
# File 'lib/better_auth/plugins/open_api.rb', line 447 def schema_for_table(table) required = [] properties = table[:fields].each_with_object({}) do |(field, attributes), result| result[field.to_sym] = field_schema(attributes) required << field if attributes[:required] && attributes[:input] != false && field != "id" end {type: "object", properties: properties, required: required} end |
.scim(*args) ⇒ Object
7 8 9 10 11 12 13 14 |
# File 'lib/better_auth/plugins/scim.rb', line 7 def scim(*args) Kernel.require "better_auth/scim" BetterAuth::Plugins.scim(*args) rescue LoadError => error raise if error.path && error.path != "better_auth/scim" raise LoadError, "BetterAuth::Plugins.scim requires the better_auth-scim gem. Add `gem \"better_auth-scim\"` and `require \"better_auth/scim\"`." end |
.send_phone_number_otp_endpoint(config) ⇒ Object
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/better_auth/plugins/phone_number.rb', line 103 def send_phone_number_otp_endpoint(config) Endpoint.new(path: "/phone-number/send-otp", method: "POST") do |ctx| sender = config[:send_otp] unless sender.respond_to?(:call) raise APIError.new("NOT_IMPLEMENTED", message: PHONE_NUMBER_ERROR_CODES["SEND_OTP_NOT_IMPLEMENTED"]) end body = normalize_hash(ctx.body) phone_number = body[:phone_number].to_s validate_phone_number!(config, phone_number) code = phone_number_generate_code(config) phone_number_store_code(ctx, config, phone_number, code) phone_number_deliver_otp(config, {phone_number: phone_number, code: code}, ctx) ctx.json({message: "code sent"}) end end |
.send_verification_otp_endpoint(config) ⇒ Object
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/better_auth/plugins/email_otp.rb', line 79 def send_verification_otp_endpoint(config) Endpoint.new(path: "/email-otp/send-verification-otp", method: "POST") do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase type = body[:type].to_s validate_email_otp_type!(type) validate_email_otp_email!(email) if type == "change-email" raise APIError.new("BAD_REQUEST", message: "Invalid OTP type") end sender = config[:send_verification_otp] unless sender.respond_to?(:call) raise APIError.new("BAD_REQUEST", message: "send email verification is not implemented") end email_otp_send_verification(ctx, config, email: email, type: type) ctx.json({success: true}) end end |
.serialize_metadata(value) ⇒ Object
947 948 949 |
# File 'lib/better_auth/plugins/organization.rb', line 947 def (value) value.is_a?(Hash) ? JSON.generate(value) : value end |
.session_response_schema(description:, nullable_url: false) ⇒ Object
351 352 353 354 355 356 357 358 359 360 361 |
# File 'lib/better_auth/plugins/open_api.rb', line 351 def session_response_schema(description:, nullable_url: false) object_schema( { redirect: {type: "boolean", enum: [false]}, token: {type: "string", description: "Session token"}, url: nullable_url ? {type: "string", nullable: true} : {type: "string"}, user: {type: "object", "$ref": "#/components/schemas/User"} }, required: ["redirect", "token", "user"] ).merge(description: description) end |
.set_active_session_endpoint ⇒ Object
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/better_auth/plugins/multi_session.rb', line 49 def set_active_session_endpoint Endpoint.new(path: "/multi-session/set-active", method: "POST") do |ctx| token = fetch_value(ctx.body, "sessionToken").to_s = (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
161 162 163 164 165 166 167 168 169 |
# File 'lib/better_auth/plugins/anonymous.rb', line 161 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
140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/better_auth/plugins/jwt.rb', line 140 def set_jwt_header(ctx, config) return if config[:disable_setting_jwt_header] session = ctx.context.current_session || ctx.context.new_session return unless session && session[:session] token = jwt_token(ctx, session, config) exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?) exposed << "set-auth-jwt" ctx.set_header("set-auth-jwt", token) ctx.set_header("access-control-expose-headers", exposed.uniq.join(", ")) nil end |
.set_multi_session_cookie(ctx, config) ⇒ Object
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/better_auth/plugins/multi_session.rb', line 95 def (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 |
# File 'lib/better_auth/plugins/anonymous.rb', line 42 def sign_in_anonymous_endpoint(config) Endpoint.new(path: "/sign-in/anonymous", method: "POST") do |ctx| existing_session = Session.find_current(ctx, disable_refresh: true) if existing_session&.dig(:user, "isAnonymous") raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY"]) end email = anonymous_email(config) name = anonymous_name(ctx, config) user = ctx.context.internal_adapter.create_user( email: email, emailVerified: false, isAnonymous: true, name: name, createdAt: Time.now, updatedAt: Time.now ) raise APIError.new("INTERNAL_SERVER_ERROR", message: ANONYMOUS_ERROR_CODES["FAILED_TO_CREATE_USER"]) unless user session = ctx.context.internal_adapter.create_session(user["id"]) raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["COULD_NOT_CREATE_SESSION"]) unless session Cookies.(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
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/better_auth/plugins/email_otp.rb', line 185 def sign_in_email_otp_endpoint(config) Endpoint.new(path: "/sign-in/email-otp", method: "POST") do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase otp = body[:otp].to_s email_otp_verify!(ctx, config, email: email, type: "sign-in", otp: otp) found = ctx.context.internal_adapter.find_user_by_email(email) user = if found found[:user] else raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) if config[:disable_sign_up] ctx.context.internal_adapter.create_user(email_otp_sign_up_user_data(body, email)) end unless user["emailVerified"] user = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true) end session = ctx.context.internal_adapter.create_session(user["id"]) Cookies.(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
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
# File 'lib/better_auth/plugins/magic_link.rb', line 30 def sign_in_magic_link_endpoint(config) Endpoint.new(path: "/sign-in/magic-link", method: "POST") do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email) token = magic_link_token(email, config) stored_token = store_magic_link_token(token, config) ctx.context.internal_adapter.create_verification_value( identifier: stored_token, value: JSON.generate({"email" => email, "name" => body[:name], "attempt" => 0}), expiresAt: Time.now + (config[:expires_in] || 60 * 5).to_i ) link = magic_link_url(ctx, token, body) sender = config[:send_magic_link] data = {email: email, url: link, token: token} data[:metadata] = body[:metadata] if body.key?(:metadata) sender.call(data, ctx) if sender.respond_to?(:call) ctx.json({status: true}) end end |
.sign_in_phone_number_endpoint(config) ⇒ Object
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/better_auth/plugins/phone_number.rb', line 67 def sign_in_phone_number_endpoint(config) Endpoint.new(path: "/sign-in/phone-number", method: "POST") do |ctx| body = normalize_hash(ctx.body) phone_number = body[:phone_number].to_s password = body[:password].to_s validate_phone_number!(config, phone_number) found = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}]) unless found Routes.hash_password(ctx, password) raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER_OR_PASSWORD"]) end if config[:require_verification] && !found["phoneNumberVerified"] code = phone_number_generate_code(config) phone_number_store_code(ctx, config, phone_number, code) phone_number_deliver_otp(config, {phone_number: phone_number, code: code}, ctx) raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_NOT_VERIFIED"]) end credential = ctx.context.internal_adapter.find_accounts(found["id"]).find { |entry| entry["providerId"] == "credential" } current_password = credential && credential["password"] unless current_password && Routes.verify_password_value(ctx, password, current_password) Routes.hash_password(ctx, password) unless current_password raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER_OR_PASSWORD"]) end dont_remember_me = body.key?(:remember_me) && (body[:remember_me] == false || body[:remember_me].to_s == "false") session = ctx.context.internal_adapter.create_session(found["id"], dont_remember_me) raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session Cookies.(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 |
# 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" ] } ) do |ctx| body = normalize_hash(ctx.body) raw_username = body[:username].to_s password = body[:password].to_s callback_url = body[:callback_url] || body[:callbackURL] remember_me = body.key?(:remember_me) ? body[:remember_me] : body[:rememberMe] if raw_username.empty? || password.empty? raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"]) end username = username_for_validation(raw_username, config) validate_username!(username, config, status: "UNPROCESSABLE_ENTITY") user = ctx.context.adapter.find_one( model: "user", where: [{field: "username", value: normalize_username(username, config)}] ) unless user Routes.hash_password(ctx, password) raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"]) end 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.json({ token: session["token"], user: Schema.parse_output(ctx.context., "user", user) }) end end |
.sign_in_with_oauth2_endpoint(config) ⇒ Object
227 228 229 230 231 232 233 234 235 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 227 def sign_in_with_oauth2_endpoint(config) Endpoint.new(path: "/sign-in/oauth2", method: "POST") do |ctx| body = normalize_hash(ctx.body) provider_id = body[:provider_id].to_s provider = generic_oauth_provider!(config, provider_id) auth_url = (ctx, provider, body, link: nil) ctx.json({url: auth_url, redirect: !body[:disable_redirect]}) end end |
.sign_jwt_endpoint(config) ⇒ Object
123 124 125 126 127 128 129 |
# File 'lib/better_auth/plugins/jwt.rb', line 123 def sign_jwt_endpoint(config) Endpoint.new(path: nil, method: "POST") do |ctx| payload = fetch_value(ctx.body, "payload") || {} override = normalize_hash(fetch_value(ctx.body, "overrideOptions") || {}) ctx.json({token: sign_jwt_payload(ctx, stringify_payload(payload), deep_merge(config, override))}) end end |
.sign_jwt_payload(ctx, payload, config) ⇒ Object
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 |
# File 'lib/better_auth/plugins/jwt.rb', line 169 def sign_jwt_payload(ctx, payload, config) jwt_config = config[:jwt] || {} now = Time.now.to_i payload = stringify_payload(payload).dup payload["iat"] ||= now payload["exp"] ||= jwt_expiration(jwt_config[:expiration_time] || "15m", payload["iat"]) payload["iss"] ||= jwt_config[:issuer] || ctx.context.base_url payload["aud"] ||= jwt_config[:audience] || ctx.context.base_url return jwt_config[:sign].call(payload) if jwt_config[:sign].respond_to?(:call) key = signing_jwk(ctx, config) private_key = OpenSSL::PKey.read(jwk_private_key_value(ctx, key, config)) alg = key["alg"] || "RS256" return encode_eddsa_jwt(payload, private_key, key["id"]) if alg == "EdDSA" ::JWT.encode(payload, private_key, alg, kid: key["id"]) end |
.signing_jwk(ctx, config) ⇒ Object
211 212 213 214 215 216 |
# File 'lib/better_auth/plugins/jwt.rb', line 211 def signing_jwk(ctx, config) key = latest_jwk(ctx, config) return key if key && !jwk_expired?(key) create_jwk(ctx, config) end |
.siwe(options = {}) ⇒ Object
12 13 14 15 16 17 18 19 20 21 22 23 24 |
# File 'lib/better_auth/plugins/siwe.rb', line 12 def siwe( = {}) config = normalize_hash() Plugin.new( id: "siwe", schema: siwe_schema(config[:schema]), endpoints: { get_siwe_nonce: get_siwe_nonce_endpoint(config), verify_siwe_message: (config) }, options: config ) end |
.siwe_chain_id(value) ⇒ Object
153 154 155 156 157 158 |
# File 'lib/better_auth/plugins/siwe.rb', line 153 def siwe_chain_id(value) chain_id = (value.nil? || value.to_s.empty?) ? 1 : value.to_i raise APIError.new("BAD_REQUEST", message: "Invalid chainId") unless chain_id.positive? && chain_id <= 2_147_483_647 chain_id end |
.siwe_create_user(ctx, config, wallet_address, _chain_id, email, anonymous) ⇒ Object
199 200 201 202 203 204 205 206 207 208 |
# File 'lib/better_auth/plugins/siwe.rb', line 199 def siwe_create_user(ctx, config, wallet_address, _chain_id, email, anonymous) domain = config[:email_domain_name] || URI.parse(ctx.context.base_url).host || ctx.context.base_url lookup = config[:ens_lookup] ens = lookup.respond_to?(:call) ? normalize_hash(lookup.call(wallet_address: wallet_address) || {}) : {} ctx.context.internal_adapter.create_user( name: ens[:name] || wallet_address, email: (anonymous == false && !email.empty?) ? email : "#{wallet_address}@#{domain}", image: ens[:avatar] || "" ) end |
.siwe_ensure_wallet_and_account(ctx, user, wallet_address, chain_id) ⇒ Object
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/better_auth/plugins/siwe.rb', line 210 def 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
238 239 240 |
# File 'lib/better_auth/plugins/siwe.rb', line 238 def siwe_expired_time?(value) value && value < Time.now end |
.siwe_find_user(ctx, wallet_address, chain_id) ⇒ Object
187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/better_auth/plugins/siwe.rb', line 187 def siwe_find_user(ctx, wallet_address, chain_id) existing = ctx.context.adapter.find_one( model: "walletAddress", where: [ {field: "address", value: wallet_address}, {field: "chainId", value: chain_id} ] ) existing ||= ctx.context.adapter.find_one(model: "walletAddress", where: [{field: "address", value: wallet_address}]) existing && ctx.context.internal_adapter.find_user_by_id(existing["userId"]) end |
.siwe_identifier(wallet_address, chain_id) ⇒ Object
160 161 162 |
# File 'lib/better_auth/plugins/siwe.rb', line 160 def siwe_identifier(wallet_address, chain_id) "siwe:#{wallet_address}:#{chain_id}" end |
.siwe_merge_schema_fields(base_fields, custom_fields) ⇒ Object
110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/better_auth/plugins/siwe.rb', line 110 def siwe_merge_schema_fields(base_fields, custom_fields) fields = base_fields.each_with_object({}) do |(raw_field, attributes), result| result[Schema.storage_key(raw_field)] = normalize_hash(attributes) end normalize_hash(custom_fields).each do |raw_field, value| field = Schema.storage_key(raw_field) custom_attributes = (value.is_a?(String) || value.is_a?(Symbol)) ? {field_name: value.to_s} : normalize_hash(value) fields[field] = (fields[field] || {}).merge(custom_attributes) end fields end |
.siwe_nonce_body(body) ⇒ Object
124 125 126 127 128 129 |
# File 'lib/better_auth/plugins/siwe.rb', line 124 def siwe_nonce_body(body) data = normalize_hash(body) siwe_normalize_wallet!(data[:wallet_address]) data[:chain_id] = siwe_chain_id(data[:chain_id]) data end |
.siwe_normalize_wallet!(value) ⇒ Object
146 147 148 149 150 151 |
# File 'lib/better_auth/plugins/siwe.rb', line 146 def siwe_normalize_wallet!(value) wallet = value.to_s raise APIError.new("BAD_REQUEST", message: "Invalid walletAddress") unless SIWE_WALLET_PATTERN.match?(wallet) Crypto.to_checksum_address(wallet) end |
.siwe_schema(custom_schema = nil) ⇒ Object
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/better_auth/plugins/siwe.rb', line 87 def siwe_schema(custom_schema = nil) base = { "walletAddress" => { fields: { userId: {type: "string", references: {model: "user", field: "id"}, required: true, index: true}, address: {type: "string", required: true}, chainId: {type: "number", required: true}, isPrimary: {type: "boolean", default_value: false}, createdAt: {type: "date", required: true} } } } return base unless custom_schema.is_a?(Hash) normalize_hash(custom_schema).each_with_object(base) do |(raw_model, table), result| model = Schema.storage_key(raw_model) current = result[model] || {} custom_table = normalize_hash(table) fields = siwe_merge_schema_fields(current[:fields] || current["fields"] || {}, custom_table.delete(:fields) || {}) result[model] = current.merge(custom_table).merge(fields: fields) end end |
.siwe_verify_body(body, config) ⇒ Object
131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/better_auth/plugins/siwe.rb', line 131 def siwe_verify_body(body, config) data = normalize_hash(body) raise APIError.new("BAD_REQUEST", message: "message is required") if data[:message].to_s.empty? raise APIError.new("BAD_REQUEST", message: "signature is required") if data[:signature].to_s.empty? siwe_normalize_wallet!(data[:wallet_address]) data[:chain_id] = siwe_chain_id(data[:chain_id]) anonymous = config.key?(:anonymous) ? config[:anonymous] : true email = data[:email].to_s raise APIError.new("BAD_REQUEST", message: "Email is required when anonymous is disabled.") if anonymous == false && email.empty? raise APIError.new("BAD_REQUEST", message: "Invalid email address") if !email.empty? && !SIWE_EMAIL_PATTERN.match?(email) data end |
.siwe_verify_message(config, body, wallet_address, chain_id, nonce, ctx) ⇒ Object
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/better_auth/plugins/siwe.rb', line 164 def (config, body, wallet_address, chain_id, nonce, ctx) verifier = config[:verify_message] raise APIError.new("INTERNAL_SERVER_ERROR", message: "SIWE verify_message callback is required") unless verifier.respond_to?(:call) verifier.call( message: body[:message].to_s, signature: body[:signature].to_s, address: wallet_address, chain_id: chain_id, cacao: { h: {t: "caip122"}, p: { domain: config[:domain], aud: config[:domain], nonce: nonce, iss: config[:domain], version: "1" }, s: {t: "eip191", s: body[:signature].to_s} } ) end |
.slack(options = {}) ⇒ Object
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/better_auth/plugins/generic_oauth.rb', line 203 def slack( = {}) 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) ⇒ Object
7 8 9 10 11 12 13 14 |
# File 'lib/better_auth/plugins/sso.rb', line 7 def sso(*args) Kernel.require "better_auth/sso" BetterAuth::Plugins.sso(*args) rescue LoadError => error raise if error.path && error.path != "better_auth/sso" raise LoadError, "BetterAuth::Plugins.sso requires the better_auth-sso gem. Add `gem \"better_auth-sso\"` and `require \"better_auth/sso\"`." end |
.storage_fields(fields) ⇒ Object
23 24 25 26 27 |
# File 'lib/better_auth/plugins.rb', line 23 def storage_fields(fields) normalize_hash(fields).each_with_object({}) do |(key, value), result| result[Schema.storage_key(key)] = normalize_field(value) end end |
.store_magic_link_token(token, config) ⇒ Object
142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/better_auth/plugins/magic_link.rb', line 142 def store_magic_link_token(token, config) storage = config[:store_token] return Crypto.sha256(token, encoding: :base64url) if storage.to_s == "hashed" if storage.is_a?(Hash) && %w[custom-hasher custom_hasher].include?(storage[:type].to_s) hasher = storage[:hash] return hasher.call(token) if hasher.respond_to?(:call) end token end |
.stringify_keys(value) ⇒ Object
77 78 79 80 81 82 |
# File 'lib/better_auth/plugins/custom_session.rb', line 77 def stringify_keys(value) return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash) return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array) value end |
.stringify_payload(value) ⇒ Object
468 469 470 471 472 473 |
# File 'lib/better_auth/plugins/jwt.rb', line 468 def stringify_payload(value) return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_payload(object_value) } if value.is_a?(Hash) return value.map { |entry| stringify_payload(entry) } if value.is_a?(Array) value end |
.stringify_permission(value) ⇒ Object
960 961 962 963 964 |
# File 'lib/better_auth/plugins/organization.rb', line 960 def (value) normalize_hash(value || {}).each_with_object({}) do |(resource, actions), result| result[resource.to_s] = Array(actions).map(&:to_s) end end |
.stripe(*args) ⇒ Object
7 8 9 10 11 12 13 14 |
# File 'lib/better_auth/plugins/stripe.rb', line 7 def stripe(*args) Kernel.require "better_auth/stripe" BetterAuth::Plugins.stripe(*args) rescue LoadError => error raise if error.path && error.path != "better_auth/stripe" raise LoadError, "BetterAuth::Plugins.stripe requires the better_auth-stripe gem. Add `gem \"better_auth-stripe\"` and `require \"better_auth/stripe\"`." end |
.success_response ⇒ Object
395 396 397 398 399 400 401 402 403 404 405 406 407 |
# File 'lib/better_auth/plugins/open_api.rb', line 395 def success_response { description: "Success", content: { "application/json" => { schema: { type: "object", properties: {} } } } } end |
.symbolize_session(entry) ⇒ Object
69 70 71 72 73 74 75 |
# File 'lib/better_auth/plugins/custom_session.rb', line 69 def symbolize_session(entry) data = stringify_keys(entry) { session: data["session"], user: data["user"] } end |
.team_by_id(ctx, id) ⇒ Object
831 832 833 834 835 |
# File 'lib/better_auth/plugins/organization.rb', line 831 def team_by_id(ctx, id) return nil if id.to_s.empty? ctx.context.adapter.find_one(model: "team", where: [{field: "id", value: id}]) end |
.team_member_wire(ctx, member) ⇒ Object
891 892 893 |
# File 'lib/better_auth/plugins/organization.rb', line 891 def team_member_wire(ctx, member) Schema.parse_output(ctx.context., "teamMember", member) end |
.team_wire(ctx, team) ⇒ Object
887 888 889 |
# File 'lib/better_auth/plugins/organization.rb', line 887 def team_wire(ctx, team) Schema.parse_output(ctx.context., "team", team) end |
.truthy?(value) ⇒ Boolean
307 308 309 |
# File 'lib/better_auth/plugins/phone_number.rb', line 307 def truthy?(value) value == true || value.to_s == "true" end |
.truthy_value?(value) ⇒ Boolean
65 66 67 |
# File 'lib/better_auth/plugins/custom_session.rb', line 65 def truthy_value?(value) value == true || value.to_s == "true" end |
.two_factor(options = {}) ⇒ Object
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/better_auth/plugins/two_factor.rb', line 30 def two_factor( = {}) config = { two_factor_table: "twoFactor", trust_device_max_age: TRUST_DEVICE_COOKIE_MAX_AGE, two_factor_cookie_max_age: TWO_FACTOR_COOKIE_MAX_AGE, backup_code_options: {store_backup_codes: "encrypted"}, otp_options: {}, totp_options: {} }.merge(normalize_hash()) config[:backup_code_options] = {store_backup_codes: "encrypted"}.merge(normalize_hash(config[:backup_code_options])) config[:otp_options] = normalize_hash(config[:otp_options]) config[:totp_options] = normalize_hash(config[:totp_options]) Plugin.new( id: "two-factor", endpoints: { enable_two_factor: two_factor_enable_endpoint(config), disable_two_factor: two_factor_disable_endpoint(config), generate_totp: two_factor_generate_totp_endpoint(config), get_totp_uri: two_factor_get_totp_uri_endpoint(config), verify_totp: two_factor_verify_totp_endpoint(config), send_two_factor_otp: two_factor_send_otp_endpoint(config), verify_two_factor_otp: two_factor_verify_otp_endpoint(config), verify_backup_code: two_factor_verify_backup_code_endpoint(config), generate_backup_codes: two_factor_generate_backup_codes_endpoint(config), view_backup_codes: two_factor_view_backup_codes_endpoint(config) }, hooks: { after: [ { matcher: ->(ctx) { ["/sign-in/email", "/sign-in/username", "/sign-in/phone-number"].include?(ctx.path) }, handler: ->(ctx) { two_factor_after_sign_in(ctx, config) } } ] }, schema: two_factor_schema(config[:schema]), rate_limit: [ { path_matcher: ->(path) { path.start_with?("/two-factor/") }, window: 10, max: 3 } ], error_codes: TWO_FACTOR_ERROR_CODES, options: config ) end |
.two_factor_after_sign_in(ctx, config) ⇒ Object
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 |
# File 'lib/better_auth/plugins/two_factor.rb', line 298 def 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)}" ctx.context.internal_adapter.create_verification_value( identifier: identifier, value: data[:user]["id"], expiresAt: Time.now + config[:two_factor_cookie_max_age].to_i ) ctx.(.name, identifier, ctx.context.secret, .attributes) ctx.json({twoFactorRedirect: true}) end |
.two_factor_check_password!(ctx, user_id, password) ⇒ Object
383 384 385 386 387 388 |
# File 'lib/better_auth/plugins/two_factor.rb', line 383 def two_factor_check_password!(ctx, user_id, password) account = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" } 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
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/better_auth/plugins/two_factor.rb', line 110 def two_factor_disable_endpoint(config) Endpoint.new(path: "/two-factor/disable", method: "POST") do |ctx| session = Routes.current_session(ctx, sensitive: true) body = normalize_hash(ctx.body) two_factor_check_password!(ctx, session[:user]["id"], body[:password]) updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: false) ctx.context.adapter.delete(model: config[:two_factor_table], where: [{field: "userId", value: updated_user["id"]}]) new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false) Cookies.(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
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/better_auth/plugins/two_factor.rb', line 78 def two_factor_enable_endpoint(config) Endpoint.new(path: "/two-factor/enable", method: "POST") do |ctx| session = Routes.current_session(ctx, sensitive: true) body = normalize_hash(ctx.body) two_factor_check_password!(ctx, session[:user]["id"], body[:password]) secret = two_factor_generate_secret backup = two_factor_generate_backup_codes(ctx.context.secret, config[:backup_code_options]) if config[:skip_verification_on_enable] updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: true) new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false) Cookies.(ctx, {session: new_session, user: updated_user}) ctx.context.internal_adapter.delete_session(session[:session]["token"]) end ctx.context.adapter.delete_many(model: config[:two_factor_table], where: [{field: "userId", value: session[:user]["id"]}]) ctx.context.adapter.create( model: config[:two_factor_table], data: { secret: Crypto.symmetric_encrypt(key: ctx.context.secret, data: secret), backupCodes: backup[:stored], userId: session[:user]["id"] } ) ctx.json({ totpURI: two_factor_totp_uri(secret, issuer: body[:issuer] || config[:issuer] || ctx.context.app_name, account: session[:user]["email"], options: config[:totp_options]), backupCodes: backup[:codes] }) end end |
.two_factor_generate_backup_codes(secret, options) ⇒ Object
426 427 428 429 430 431 432 433 434 435 436 437 438 |
# File 'lib/better_auth/plugins/two_factor.rb', line 426 def two_factor_generate_backup_codes(secret, ) 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
255 256 257 258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/better_auth/plugins/two_factor.rb', line 255 def two_factor_generate_backup_codes_endpoint(config) Endpoint.new(path: "/two-factor/generate-backup-codes", method: "POST") do |ctx| session = Routes.current_session(ctx, sensitive: true) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless session[:user]["twoFactorEnabled"] two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password]) record = two_factor_record(ctx, config, session[:user]["id"]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless record backup = two_factor_generate_backup_codes(ctx.context.secret, config[:backup_code_options]) ctx.context.adapter.update(model: config[:two_factor_table], where: [{field: "id", value: record["id"]}], update: {backupCodes: backup[:stored]}) ctx.json({status: true, backupCodes: backup[:codes]}) end end |
.two_factor_generate_secret ⇒ Object
396 397 398 399 |
# File 'lib/better_auth/plugins/two_factor.rb', line 396 def two_factor_generate_secret raw = SecureRandom.random_bytes(20) base32_encode(raw) end |
.two_factor_generate_totp_endpoint(config) ⇒ Object
133 134 135 136 137 138 139 |
# File 'lib/better_auth/plugins/two_factor.rb', line 133 def two_factor_generate_totp_endpoint(config) Endpoint.new(path: "/totp/generate", method: "POST") do |ctx| two_factor_totp_enabled!(config) body = normalize_hash(ctx.body) ctx.json({code: two_factor_totp(body[:secret], options: config[:totp_options])}) end end |
.two_factor_get_totp_uri_endpoint(config) ⇒ Object
141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/better_auth/plugins/two_factor.rb', line 141 def two_factor_get_totp_uri_endpoint(config) Endpoint.new(path: "/two-factor/get-totp-uri", method: "POST") do |ctx| two_factor_totp_enabled!(config) session = Routes.current_session(ctx, sensitive: true) two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password]) record = two_factor_record(ctx, config, session[:user]["id"]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record secret = Crypto.symmetric_decrypt(key: ctx.context.secret, data: record["secret"]) ctx.json({totpURI: two_factor_totp_uri(secret, issuer: config[:issuer] || ctx.context.app_name, account: session[:user]["email"], options: config[:totp_options])}) end end |
.two_factor_otp_matches?(ctx, stored, input, options) ⇒ Boolean
485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 |
# File 'lib/better_auth/plugins/two_factor.rb', line 485 def two_factor_otp_matches?(ctx, stored, input, ) storage = [:store_otp] expected, actual = if storage == "hashed" [stored, Crypto.sha256(input, encoding: :base64url)] elsif storage == "encrypted" [Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored), input] elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call) [stored, storage[:hash].call(input)] elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call) [storage[:decrypt].call(stored), input] else [stored, input] end expected && actual && Crypto.constant_time_compare(expected.to_s, actual.to_s) end |
.two_factor_random_digits(length) ⇒ Object
466 467 468 |
# File 'lib/better_auth/plugins/two_factor.rb', line 466 def two_factor_random_digits(length) Array.new(length) { SecureRandom.random_number(10) }.join end |
.two_factor_read_backup_codes(secret, stored, options) ⇒ Object
452 453 454 455 456 457 458 459 460 461 462 463 464 |
# File 'lib/better_auth/plugins/two_factor.rb', line 452 def two_factor_read_backup_codes(secret, stored, ) 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) rescue JSON::ParserError [] end |
.two_factor_record(ctx, config, user_id) ⇒ Object
379 380 381 |
# File 'lib/better_auth/plugins/two_factor.rb', line 379 def two_factor_record(ctx, config, user_id) ctx.context.adapter.find_one(model: config[:two_factor_table], where: [{field: "userId", value: user_id}]) end |
.two_factor_schema(custom_schema = nil) ⇒ Object
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 |
# File 'lib/better_auth/plugins/two_factor.rb', line 280 def two_factor_schema(custom_schema = nil) base = { user: { fields: { twoFactorEnabled: {type: "boolean", required: false, default_value: false, returned: true} } }, twoFactor: { fields: { secret: {type: "string", required: true, returned: false, index: true}, backupCodes: {type: "string", required: true, returned: false}, userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}} } } } deep_merge_hashes(base, normalize_hash(custom_schema || {})) end |
.two_factor_send_otp_endpoint(config) ⇒ Object
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/better_auth/plugins/two_factor.rb', line 175 def two_factor_send_otp_endpoint(config) Endpoint.new(path: "/two-factor/send-otp", method: "POST") do |ctx| otp_config = config[:otp_options] sender = otp_config[:send_otp] unless sender.respond_to?(:call) raise APIError.new("BAD_REQUEST", message: "otp isn't configured") end data = two_factor_verification_context(ctx, config) code = two_factor_random_digits((otp_config[:digits] || 6).to_i) stored = two_factor_store_otp_value(ctx, code, otp_config) ctx.context.internal_adapter.create_verification_value( identifier: "2fa-otp-#{data[:key]}", value: "#{stored}:0", expiresAt: Time.now + ((otp_config[:period] || 3).to_i * 60) ) sender.call({user: data[:session][:user], otp: code}, ctx) ctx.json({status: true}) end end |
.two_factor_set_trusted_device(ctx, config, user_id) ⇒ Object
352 353 354 355 356 357 358 359 |
# File 'lib/better_auth/plugins/two_factor.rb', line 352 def two_factor_set_trusted_device(ctx, config, user_id) max_age = config[:trust_device_max_age].to_i identifier = "trust-device-#{Crypto.random_string(32)}" token = Crypto.hmac_signature("#{user_id}!#{identifier}", ctx.context.secret, encoding: :base64url) ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: user_id, expiresAt: Time.now + max_age) = 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
440 441 442 443 444 445 446 447 448 449 450 |
# File 'lib/better_auth/plugins/two_factor.rb', line 440 def two_factor_store_backup_codes(secret, codes, ) 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
470 471 472 473 474 475 476 477 478 479 480 481 482 483 |
# File 'lib/better_auth/plugins/two_factor.rb', line 470 def two_factor_store_otp_value(ctx, code, ) storage = [:store_otp] if storage == "hashed" Crypto.sha256(code, encoding: :base64url) elsif storage == "encrypted" Crypto.symmetric_encrypt(key: ctx.context.secret, data: code) elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call) storage[:hash].call(code) elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call) storage[:encrypt].call(code) else code end end |
.two_factor_totp(secret, options: {}) ⇒ Object
401 402 403 404 |
# File 'lib/better_auth/plugins/two_factor.rb', line 401 def two_factor_totp(secret, options: {}) interval = Time.now.to_i / ([:period] || 30).to_i two_factor_totp_at(secret, interval, digits: ([:digits] || 6).to_i) end |
.two_factor_totp_at(secret, counter, digits:) ⇒ Object
412 413 414 415 416 417 418 |
# File 'lib/better_auth/plugins/two_factor.rb', line 412 def two_factor_totp_at(secret, counter, digits:) key = base32_decode(secret) digest = OpenSSL::HMAC.digest("SHA1", key, [counter].pack("Q>")) offset = digest.bytes.last & 0x0f binary = digest.byteslice(offset, 4).unpack1("N") & 0x7fffffff (binary % (10**digits)).to_s.rjust(digits, "0") end |
.two_factor_totp_enabled!(config) ⇒ Object
390 391 392 393 394 |
# File 'lib/better_auth/plugins/two_factor.rb', line 390 def two_factor_totp_enabled!(config) if config[:totp_options][:disable] raise APIError.new("BAD_REQUEST", message: "totp isn't configured") end end |
.two_factor_totp_uri(secret, issuer:, account:, options: {}) ⇒ Object
420 421 422 423 424 |
# File 'lib/better_auth/plugins/two_factor.rb', line 420 def two_factor_totp_uri(secret, issuer:, account:, options: {}) label = "#{issuer}:#{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
406 407 408 409 410 |
# File 'lib/better_auth/plugins/two_factor.rb', line 406 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
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 |
# File 'lib/better_auth/plugins/two_factor.rb', line 361 def two_factor_trusted_device_valid?(ctx, config, user_id) = 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 = identifier && ctx.context.internal_adapter.find_verification_value(identifier) if token && identifier && Crypto.constant_time_compare(token, expected) && verification && verification["value"] == user_id && verification["expiresAt"] > Time.now ctx.context.internal_adapter.delete_verification_value(verification["id"]) two_factor_set_trusted_device(ctx, config, user_id) true else Cookies.(ctx, ) false end end |
.two_factor_verification_context(ctx, config) ⇒ Object
317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 |
# File 'lib/better_auth/plugins/two_factor.rb', line 317 def two_factor_verification_context(ctx, config) session = Routes.current_session(ctx, allow_nil: true) if session key = "#{session[:user]["id"]}!#{session[:session]["id"]}" return {session: session, key: key, valid: -> { ctx.json({token: session[:session]["token"], user: Schema.parse_output(ctx.context., "user", session[:user])}) }} 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 dont_remember_me = Cookies.dont_remember?(ctx) new_session = ctx.context.internal_adapter.create_session(user["id"], dont_remember_me) raise APIError.new("INTERNAL_SERVER_ERROR", message: "failed to create session") unless new_session ctx.context.internal_adapter.delete_verification_value(verification["id"]) Cookies.(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 {session: {session: nil, user: user}, key: identifier, valid: valid} end |
.two_factor_verify_backup_code_endpoint(config) ⇒ Object
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'lib/better_auth/plugins/two_factor.rb', line 230 def two_factor_verify_backup_code_endpoint(config) Endpoint.new(path: "/two-factor/verify-backup-code", method: "POST") do |ctx| body = normalize_hash(ctx.body) data = two_factor_verification_context(ctx, config) record = two_factor_record(ctx, config, data[:session][:user]["id"]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record codes = two_factor_read_backup_codes(ctx.context.secret, record["backupCodes"], config[:backup_code_options]) unless codes.include?(body[:code].to_s) raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_BACKUP_CODE"]) end remaining = codes.reject { |code| code == body[:code].to_s } stored = two_factor_store_backup_codes(ctx.context.secret, remaining, config[:backup_code_options]) updated = ctx.context.adapter.update( model: config[:two_factor_table], where: [{field: "id", value: record["id"]}, {field: "backupCodes", value: record["backupCodes"]}], update: {backupCodes: stored} ) raise APIError.new("CONFLICT", message: "Failed to verify backup code. Please try again.") unless updated body[:disable_session] ? ctx.json({token: data[:session][:session]&.fetch("token", nil), user: Schema.parse_output(ctx.context., "user", data[:session][:user])}) : data[:valid].call end end |
.two_factor_verify_otp_endpoint(config) ⇒ Object
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/better_auth/plugins/two_factor.rb', line 196 def two_factor_verify_otp_endpoint(config) Endpoint.new(path: "/two-factor/verify-otp", method: "POST") do |ctx| body = normalize_hash(ctx.body) data = two_factor_verification_context(ctx, config) verification = ctx.context.internal_adapter.find_verification_value("2fa-otp-#{data[:key]}") stored, counter = verification&.fetch("value", nil).to_s.split(":", 2) if !verification || verification["expiresAt"] < Time.now ctx.context.internal_adapter.delete_verification_value(verification["id"]) if verification raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["OTP_HAS_EXPIRED"]) end allowed = (config[:otp_options][:allowed_attempts] || 5).to_i if counter.to_i >= allowed ctx.context.internal_adapter.delete_verification_value(verification["id"]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE"]) end unless two_factor_otp_matches?(ctx, stored, body[:code].to_s, config[:otp_options]) ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{stored}:#{counter.to_i + 1}") raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"]) end if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session] updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true) new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false) ctx.context.internal_adapter.delete_session(data[:session][:session]["token"]) Cookies.(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
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/better_auth/plugins/two_factor.rb', line 154 def two_factor_verify_totp_endpoint(config) Endpoint.new(path: "/two-factor/verify-totp", method: "POST") do |ctx| two_factor_totp_enabled!(config) body = normalize_hash(ctx.body) data = two_factor_verification_context(ctx, config) record = two_factor_record(ctx, config, data[:session][:user]["id"]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record secret = Crypto.symmetric_decrypt(key: ctx.context.secret, data: record["secret"]) raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"]) unless two_factor_totp_valid?(secret, body[:code], options: config[:totp_options]) if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session] updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true) new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false) ctx.context.internal_adapter.delete_session(data[:session][:session]["token"]) Cookies.(ctx, {session: new_session, user: updated_user}) end data[:valid].call end end |
.two_factor_view_backup_codes_endpoint(config) ⇒ Object
270 271 272 273 274 275 276 277 278 |
# File 'lib/better_auth/plugins/two_factor.rb', line 270 def two_factor_view_backup_codes_endpoint(config) Endpoint.new(method: "POST") do |ctx| body = normalize_hash(ctx.body) record = two_factor_record(ctx, config, body[:user_id]) raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record ctx.json({status: true, backupCodes: two_factor_read_backup_codes(ctx.context.secret, record["backupCodes"], config[:backup_code_options])}) end end |
.username(options = {}) ⇒ Object
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
# File 'lib/better_auth/plugins/username.rb', line 20 def username( = {}) 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
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/better_auth/plugins/username.rb', line 154 def username_database_hooks(config) before_hook = lambda do |user, _context| data = user.dup if data["username"].is_a?(String) && !data["username"].empty? data["username"] = normalize_username(data["username"], config) end if data["displayUsername"].is_a?(String) && !data["displayUsername"].empty? data["displayUsername"] = normalize_display_username(data["displayUsername"], config) end {data: data} end { user: { create: {before: before_hook}, update: {before: before_hook} } } end |
.username_for_validation(username, config) ⇒ Object
236 237 238 |
# File 'lib/better_auth/plugins/username.rb', line 236 def username_for_validation(username, config) (validation_order(config, :username) == "pre-normalization") ? normalize_username(username, config) : username end |
.username_mutation_path?(path) ⇒ Boolean
258 259 260 |
# File 'lib/better_auth/plugins/username.rb', line 258 def username_mutation_path?(path) path == "/sign-up/email" || path == "/update-user" end |
.username_schema(config) ⇒ Object
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/better_auth/plugins/username.rb', line 132 def username_schema(config) { user: { fields: { username: { type: "string", required: false, sortable: true, unique: true, returned: true, field_name: "username" }, displayUsername: { type: "string", required: false, field_name: "display_username" } } } } end |
.valid_code_challenge_method?(method, config) ⇒ Boolean
494 495 496 497 498 499 |
# File 'lib/better_auth/plugins/oidc_provider.rb', line 494 def valid_code_challenge_method?(method, config) normalized = method.to_s.downcase return true if normalized == "s256" normalized == "plain" && config[:allow_plain_code_challenge_method] end |
.valid_signed_token?(ctx, signed_token) ⇒ Boolean
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
246 247 248 249 250 251 252 253 254 255 256 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 246 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
492 493 494 |
# File 'lib/better_auth/plugins/email_otp.rb', line 492 def validate_email_otp_email!(email) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email) end |
.validate_email_otp_type!(type) ⇒ Object
496 497 498 499 500 |
# File 'lib/better_auth/plugins/email_otp.rb', line 496 def validate_email_otp_type!(type) return if %w[email-verification sign-in forget-password change-email].include?(type) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) end |
.validate_jwt_options!(config) ⇒ Object
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/better_auth/plugins/jwt.rb', line 85 def (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
164 165 166 167 168 169 |
# File 'lib/better_auth/plugins/magic_link.rb', line 164 def validate_magic_link_callback!(ctx, value, label) return if value.nil? || value.to_s.empty? return if ctx.context.trusted_origin?(value.to_s, allow_relative_paths: true) raise APIError.new("FORBIDDEN", message: "Invalid #{label}") end |
.validate_permission_resources!(config, permission) ⇒ Object
966 967 968 969 970 |
# File 'lib/better_auth/plugins/organization.rb', line 966 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
287 288 289 290 291 292 293 |
# File 'lib/better_auth/plugins/phone_number.rb', line 287 def validate_phone_number!(config, phone_number) validator = config[:phone_number_validator] return unless validator.respond_to?(:call) return if validator.call(phone_number) raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER"]) end |
.validate_unique_phone_number!(ctx, phone_number) ⇒ Object
280 281 282 283 284 285 |
# File 'lib/better_auth/plugins/phone_number.rb', line 280 def validate_unique_phone_number!(ctx, phone_number) return if phone_number.to_s.empty? existing = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number.to_s}]) raise APIError.new("UNPROCESSABLE_ENTITY", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_EXIST"]) if existing end |
.validate_username!(username, config, status:) ⇒ Object
222 223 224 225 226 227 228 229 230 231 232 233 234 |
# File 'lib/better_auth/plugins/username.rb', line 222 def validate_username!(username, config, status:) if username.length < min_username_length(config) raise APIError.new(status, message: USERNAME_ERROR_CODES["USERNAME_TOO_SHORT"]) end if username.length > max_username_length(config) raise APIError.new(status, message: USERNAME_ERROR_CODES["USERNAME_TOO_LONG"]) end validator = config[:username_validator] valid = validator.respond_to?(:call) ? validator.call(username) : default_username_valid?(username) raise APIError.new(status, message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) unless valid end |
.validate_username_mutation!(ctx, config) ⇒ Object
174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/better_auth/plugins/username.rb', line 174 def validate_username_mutation!(ctx, config) body = normalize_hash(ctx.body) raw_username = body.key?(:username) ? body[:username] : nil username = if raw_username.is_a?(String) && validation_order(config, :username) == "post-normalization" normalize_username(raw_username, config) else raw_username end if username.is_a?(String) validate_username!(username, config, status: "BAD_REQUEST") existing = ctx.context.adapter.find_one(model: "user", where: [{field: "username", value: normalize_username(username, config)}]) current = (ctx.path == "/update-user") ? Routes.current_session(ctx, allow_nil: true) : nil same_user = existing && current && existing["id"] == current[:session]["userId"] if existing && ctx.path == "/sign-up/email" raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"]) end if existing && ctx.path == "/update-user" && !same_user raise APIError.new("BAD_REQUEST", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"]) end end raw_display_username = body.key?(:display_username) ? body[:display_username] : nil display_username = if raw_display_username.is_a?(String) && validation_order(config, :display_username) == "post-normalization" normalize_display_username(raw_display_username, config) else raw_display_username end if display_username.is_a?(String) validator = config[:display_username_validator] unless !validator.respond_to?(:call) || validator.call(display_username) raise APIError.new("BAD_REQUEST", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"]) end end nil end |
.validation_order(config, field) ⇒ Object
253 254 255 256 |
# File 'lib/better_auth/plugins/username.rb', line 253 def validation_order(config, field) order = config[:validation_order] || {} order[field] || "pre-normalization" end |
.verification_jwks(ctx, config) ⇒ Object
236 237 238 239 240 241 |
# File 'lib/better_auth/plugins/jwt.rb', line 236 def verification_jwks(ctx, config) local = all_jwks(ctx, config) return local unless config.dig(:jwks, :remote_url) local + remote_jwks(ctx, config) end |
.verification_uri(ctx, config) ⇒ Object
223 224 225 226 227 228 |
# File 'lib/better_auth/plugins/device_authorization.rb', line 223 def verification_uri(ctx, config) uri = config[:verification_uri] || "/device" return uri if uri.to_s.start_with?("http://", "https://") "#{OAuthProtocol.endpoint_base(ctx)}#{uri.to_s.start_with?("/") ? uri : "/#{uri}"}" end |
.verified_multi_session_tokens(ctx) ⇒ Object
141 142 143 |
# File 'lib/better_auth/plugins/multi_session.rb', line 141 def verified_multi_session_tokens(ctx) (ctx).filter_map { |name| ctx.(name, ctx.context.secret) } end |
.verify_eddsa_jwt(ctx, token, key, config) ⇒ Object
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 |
# File 'lib/better_auth/plugins/jwt.rb', line 399 def verify_eddsa_jwt(ctx, token, key, config) header_segment, payload_segment, signature_segment = token.split(".", 3) return nil unless header_segment && payload_segment && signature_segment public_key = JWT.public_key(key) signing_input = "#{header_segment}.#{payload_segment}" signature = Crypto.base64url_decode(signature_segment) return nil unless public_key.verify(nil, signature, signing_input) payload = JSON.parse(Crypto.base64url_decode(payload_segment)) now = Time.now.to_i return nil if payload["exp"] && payload["exp"].to_i <= now issuer = config.dig(:jwt, :issuer) || ctx.context.base_url audience = config.dig(:jwt, :audience) || ctx.context.base_url return nil if issuer && payload["iss"] != issuer return nil if audience && Array(payload["aud"]).map(&:to_s).none?(audience.to_s) return nil unless jwt_payload_valid?(payload) payload rescue JSON::ParserError, OpenSSL::PKey::PKeyError, ArgumentError nil end |
.verify_email_otp_endpoint(config) ⇒ Object
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/better_auth/plugins/email_otp.rb', line 156 def verify_email_otp_endpoint(config) Endpoint.new(path: "/email-otp/verify-email", method: "POST") do |ctx| body = normalize_hash(ctx.body) email = body[:email].to_s.downcase otp = body[:otp].to_s validate_email_otp_email!(email) email_otp_verify!(ctx, config, email: email, type: "email-verification", otp: otp) found = ctx.context.internal_adapter.find_user_by_email(email) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless found user = found[:user] call_email_verification_option(ctx, :before_email_verification, user) updated = ctx.context.internal_adapter.update_user(user["id"], email: email, emailVerified: true) call_email_verification_option(ctx, :on_email_verification, updated) call_email_verification_option(ctx, :after_email_verification, updated) if ctx.context..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
131 132 133 134 135 136 137 138 |
# File 'lib/better_auth/plugins/jwt.rb', line 131 def verify_jwt_endpoint(config) Endpoint.new(path: nil, method: "POST") do |ctx| token = fetch_value(ctx.body, "token") issuer = fetch_value(ctx.body, "issuer") = 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
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/better_auth/plugins/jwt.rb', line 188 def verify_jwt_token(ctx, token, config) header = ::JWT.decode(token.to_s, nil, false).last key = verification_jwks(ctx, config).find { |entry| entry["id"] == header["kid"] || entry["kid"] == header["kid"] } return nil unless key return verify_eddsa_jwt(ctx, token.to_s, key, config) if (key["alg"] || header["alg"]) == "EdDSA" = { algorithm: key["alg"] || "RS256", iss: config.dig(:jwt, :issuer) || ctx.context.base_url, verify_iss: true, aud: config.dig(:jwt, :audience) || ctx.context.base_url, verify_aud: true } decoded, = ::JWT.decode(token.to_s, JWT.public_key(key), true, ) jwt_payload_valid?(decoded) ? decoded : nil rescue ::JWT::DecodeError, OpenSSL::PKey::PKeyError nil end |
.verify_one_time_token_endpoint(config) ⇒ Object
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/better_auth/plugins/one_time_token.rb', line 43 def verify_one_time_token_endpoint(config) Endpoint.new(path: "/one-time-token/verify", method: "POST") do |ctx| body = normalize_hash(ctx.body) token = body[:token].to_s stored_token = one_time_token_stored_value(config, token) verification = ctx.context.internal_adapter.find_verification_value("one-time-token:#{stored_token}") raise APIError.new("BAD_REQUEST", message: "Invalid token") unless verification ctx.context.internal_adapter.delete_verification_value(verification["id"]) raise APIError.new("BAD_REQUEST", message: "Token expired") if Routes.expired_time?(verification["expiresAt"]) session = ctx.context.internal_adapter.find_session(verification["value"]) raise APIError.new("BAD_REQUEST", message: "Session not found") unless session raise APIError.new("BAD_REQUEST", message: "Session expired") if Routes.expired_time?(session[:session]["expiresAt"]) Cookies.(ctx, session) unless config[:disable_set_session_cookie] ctx.json(session) end end |
.verify_phone_number_endpoint(config) ⇒ Object
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/better_auth/plugins/phone_number.rb', line 120 def verify_phone_number_endpoint(config) Endpoint.new(path: "/phone-number/verify", method: "POST") do |ctx| body = normalize_hash(ctx.body) phone_number = body[:phone_number].to_s code = body[:code].to_s phone_number_verify_code!(ctx, config, phone_number, code) if truthy?(body[:update_phone_number]) session = Routes.current_session(ctx) existing = ctx.context.adapter.find_many(model: "user", where: [{field: "phoneNumber", value: phone_number}]) unless existing.empty? raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_EXIST"]) end updated = ctx.context.internal_adapter.update_user( session[:user]["id"], phoneNumber: phone_number, phoneNumberVerified: true ) next ctx.json({status: true, token: session[:session]["token"], user: Schema.parse_output(ctx.context., "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
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/better_auth/plugins/siwe.rb', line 44 def (config) Endpoint.new(path: "/siwe/verify", method: "POST", body_schema: ->(body) { siwe_verify_body(body, config) }) do |ctx| body = normalize_hash(ctx.body) wallet_address = siwe_normalize_wallet!(body[:wallet_address]) chain_id = siwe_chain_id(body[:chain_id]) email = body[:email].to_s anonymous = config.key?(:anonymous) ? config[:anonymous] : true raise APIError.new("BAD_REQUEST", message: "Email is required when anonymous is disabled.") if anonymous == false && email.empty? raise APIError.new("BAD_REQUEST", message: "Invalid email address") if !email.empty? && !SIWE_EMAIL_PATTERN.match?(email) verification = ctx.context.internal_adapter.find_verification_value(siwe_identifier(wallet_address, chain_id)) if !verification || siwe_expired_time?(verification["expiresAt"]) raise APIError.new("UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE", message: "Unauthorized: Invalid or expired nonce") end verified = (config, body, wallet_address, chain_id, verification["value"], ctx) raise APIError.new("UNAUTHORIZED", message: "Unauthorized: Invalid SIWE signature") unless verified ctx.context.internal_adapter.delete_verification_value(verification["id"]) user = siwe_find_user(ctx, wallet_address, chain_id) user ||= siwe_create_user(ctx, config, wallet_address, chain_id, email, anonymous) 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 |