Module: BetterAuth::Routes
- Defined in:
- lib/better_auth/routes/ok.rb,
lib/better_auth/routes/user.rb,
lib/better_auth/routes/error.rb,
lib/better_auth/routes/social.rb,
lib/better_auth/routes/account.rb,
lib/better_auth/routes/session.rb,
lib/better_auth/routes/sign_in.rb,
lib/better_auth/routes/sign_up.rb,
lib/better_auth/routes/password.rb,
lib/better_auth/routes/sign_out.rb,
lib/better_auth/routes/validation.rb,
lib/better_auth/routes/email_verification.rb
Constant Summary collapse
- EMAIL_PATTERN =
/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/- PASSWORD_RESET_MESSAGE =
"If this email exists in our system, check your email for the reset link"- REQUEST_EMAIL_PATTERN =
/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
Class Method Summary collapse
- .absolute_callback(context, callback_url, params) ⇒ Object
- .access_token_expired?(account) ⇒ Boolean
- .access_token_response(ctx, user_id:, provider_id:, account_id: nil, provider: nil) ⇒ Object
- .account_cookie(ctx, provider_id, account_id = nil, user_id = nil) ⇒ Object
- .account_info ⇒ Object
- .account_storage_fields(data) ⇒ Object
- .account_token_update_hash(ctx, tokens) ⇒ Object
- .append_query(url, query) ⇒ Object
- .blank_remote_id?(value) ⇒ Boolean
- .call_existing_sign_up_callback(ctx, email_config, existing) ⇒ Object
- .call_option(callback, user, request) ⇒ Object
- .call_provider(provider, key, *arguments) ⇒ Object
- .callback_oauth ⇒ Object
- .change_email ⇒ Object
- .change_email_token_identifier(token) ⇒ Object
- .change_password ⇒ Object
- .coerce_input_value(value, attributes) ⇒ Object
- .consume_change_email_token!(ctx, identifier, callback_url) ⇒ Object
- .core_model_fields(model) ⇒ Object
- .create_email_verification_token(ctx, email, update_to: nil, extra: {}) ⇒ Object
- .create_sign_up_user(ctx, additional, email, name, image) ⇒ Object
- .credential_account(ctx, user_id) ⇒ Object
- .current_session(ctx, allow_nil: false, sensitive: false, fresh: false, disable_refresh: false) ⇒ Object
- .default_error_description(code) ⇒ Object
- .delete_current_user!(ctx, session) ⇒ Object
- .delete_user ⇒ Object
- .delete_user_by_token!(ctx, session, token) ⇒ Object
- .delete_user_callback ⇒ Object
- .email_change_session_data(ctx, session, user) ⇒ Object
- .email_verification_url(ctx, token, callback_url) ⇒ Object
- .enforce_verification_email_floor(start) ⇒ Object
- .ensure_fresh_session!(ctx, session) ⇒ Object
- .error ⇒ Object
- .error_html(code, description) ⇒ Object
- .error_query_params(code, description) ⇒ Object
- .expired_time?(value) ⇒ Boolean
- .fetch_value(hash, key) ⇒ Object
- .find_provider_account(ctx, user_id, provider_id, account_id = nil) ⇒ Object
- .get_access_token ⇒ Object
- .get_session ⇒ Object
- .hash_password(ctx, password) ⇒ Object
- .hasher_arity_accepts_context?(hasher) ⇒ Boolean
- .implicit_link_allowed?(ctx, local_user) ⇒ Boolean
- .js_truthy_input?(value) ⇒ Boolean
- .link_social ⇒ Object
- .link_social_account_from_callback(ctx, provider_id, user_info, tokens, link) ⇒ Object
- .linkable_provider?(ctx, provider_id, user_info, implicit: false, local_user: nil) ⇒ Boolean
- .list_accounts ⇒ Object
- .list_sessions ⇒ Object
- .log(context, level, message) ⇒ Object
- .normalize_hash(value) ⇒ Object
- .normalize_time(value) ⇒ Object
- .oauth_error_url(base_url, error, description = nil) ⇒ Object
- .oauth_proxy_callback_url?(context, callback_url) ⇒ Boolean
- .oauth_token_for_storage(ctx, token) ⇒ Object
- .oauth_token_value(ctx, token) ⇒ Object
- .ok ⇒ Object
- .override_social_user_info(ctx, user, user_info) ⇒ Object
- .parse_declared_input(ctx, model, data, allowed_base: []) ⇒ Object
- .parse_json_hash(value) ⇒ Object
- .parse_sign_up_additional_user_fields(ctx, data) ⇒ Object
- .parse_time(value) ⇒ Object
- .parsed_session_response(ctx, session) ⇒ Object
- .persist_social_user(ctx, provider_id, user_info, account_info, callback_url: nil, disable_sign_up: false, override_user_info: false) ⇒ Object
- .preflight_follow_up_verification_token_link!(ctx, payload, update_to) ⇒ Object
- .preflight_sign_in_token_link!(ctx) ⇒ Object
- .preflight_sign_up_token_link!(ctx) ⇒ Object
- .promote_verified_email_on_link!(ctx, local_user, social_user) ⇒ Object
- .provider_callable(provider, key) ⇒ Object
- .provider_disable_implicit_sign_up?(provider) ⇒ Boolean
- .provider_disable_sign_up?(provider) ⇒ Boolean
- .provider_override_user_info_on_sign_in?(provider_id, context) ⇒ Boolean
- .redirect_or_error(ctx, callback_url, error, code: nil) ⇒ Object
- .redirect_or_json(ctx, callback_url, data) ⇒ Object
- .refresh_token ⇒ Object
-
.register_change_email_token!(ctx, token) ⇒ Object
Ruby hardening adaptation of upstream PR #9519.
- .request_body_schema(required_strings: [], required_nonempty_strings: [], email_strings: [], optional_strings: []) ⇒ Object
- .request_only_session(ctx) ⇒ Object
- .request_password_reset ⇒ Object
- .request_password_reset_callback ⇒ Object
- .request_query_schema(required_strings: [], optional_strings: []) ⇒ Object
- .request_storage_key(key) ⇒ Object
- .request_string?(data, key) ⇒ Boolean
- .request_validation_hash(value) ⇒ Object
- .require_fresh_session!(ctx, session) ⇒ Object
- .reset_password ⇒ Object
- .resolve_default(value) ⇒ Object
- .revoke_other_sessions ⇒ Object
- .revoke_session ⇒ Object
- .revoke_sessions ⇒ Object
- .safe_additional_state(body) ⇒ Object
- .sanitize_html(value) ⇒ Object
- .send_change_email_verification(ctx, sender, user, current_email, new_email, callback_url) ⇒ Object
- .send_change_email_verification_payload(ctx, user, update_to, callback_url) ⇒ Object
- .send_sign_in_verification_email(ctx, user, callback_url) ⇒ Object
- .send_sign_up_verification_email(ctx, user, callback_url) ⇒ Object
- .send_verification_email ⇒ Object
- .send_verification_email_payload(ctx, user, callback_url) ⇒ Object
- .session_needs_refresh?(ctx, session) ⇒ Boolean
- .session_overrides(ctx) ⇒ Object
- .set_password ⇒ Object
- .set_verified_session_cookie(ctx, user) ⇒ Object
- .sign_in_email ⇒ Object
- .sign_in_social ⇒ Object
- .sign_out ⇒ Object
- .sign_up_email ⇒ Object
- .social_log(context, level, message) ⇒ Object
- .social_provider(context, provider_id) ⇒ Object
- .social_user_from_id_token!(ctx, provider, id_token) ⇒ Object
- .store_oauth_state_cookie(ctx, state) ⇒ Object
- .stringify_keys(value) ⇒ Object
- .stringify_synthetic_user(value) ⇒ Object
- .synthetic_sign_up_user(ctx, additional, email, name, image) ⇒ Object
- .token_hash(tokens) ⇒ Object
- .token_hash_for_storage(ctx, tokens) ⇒ Object
- .truthy_query?(query, key) ⇒ Boolean
- .unlink_account ⇒ Object
- .update_account_tokens(ctx, account, tokens) ⇒ Object
- .update_session ⇒ Object
- .update_social_user_info_on_link(ctx, user_id, user_info) ⇒ Object
- .update_user ⇒ Object
- .update_verified_email_on_link(ctx, user_id, current_email, social_user) ⇒ Object
- .valid_error_code?(value) ⇒ Boolean
- .valid_oauth_state_cookie?(ctx, state) ⇒ Boolean
- .validate_auth_callback_url!(context, value, label) ⇒ Object
- .validate_callback_url!(context, callback_url) ⇒ Object
- .validate_change_email_token!(ctx, token, payload, callback_url) ⇒ Object
- .validate_password_length!(password, email_config) ⇒ Object
- .validate_redirect_url!(context, redirect_url) ⇒ Object
- .validate_sign_up_input!(email, password, email_config) ⇒ Object
- .validate_social_callback_url!(context, callback_url, error_code) ⇒ Object
- .verification_email_monotonic_clock ⇒ Object
- .verification_email_sleep(seconds) ⇒ Object
- .verify_email ⇒ Object
- .verify_email_token(ctx, token, callback_url) ⇒ Object
- .verify_password ⇒ Object
- .verify_password_value(ctx, password, digest) ⇒ Object
Class Method Details
.absolute_callback(context, callback_url, params) ⇒ Object
265 266 267 268 269 270 271 272 273 274 |
# File 'lib/better_auth/routes/password.rb', line 265 def self.absolute_callback(context, callback_url, params) validate_callback_url!(context, callback_url) uri = URI.parse(callback_url.to_s) origin = Configuration.origin_for(URI.parse(context.base_url)) url = uri.relative? ? URI.join("#{origin}/", callback_url.to_s.delete_prefix("/")) : uri query = URI.decode_www_form(url.query.to_s) params.each { |key, value| query << [key.to_s, value] } url.query = URI.encode_www_form(query) url.to_s end |
.access_token_expired?(account) ⇒ Boolean
377 378 379 380 |
# File 'lib/better_auth/routes/account.rb', line 377 def self.access_token_expired?(account) value = parse_time(account["accessTokenExpiresAt"]) value && value < Time.now + 5 end |
.access_token_response(ctx, user_id:, provider_id:, account_id: nil, provider: nil) ⇒ Object
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 |
# File 'lib/better_auth/routes/account.rb', line 323 def self.access_token_response(ctx, user_id:, provider_id:, account_id: nil, provider: nil) provider ||= (ctx.context, provider_id) raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider account = (ctx, provider_id, account_id, user_id) || find_provider_account(ctx, user_id, provider_id, account_id) raise APIError.new("BAD_REQUEST", message: "Account not found") unless account if account["refreshToken"] && access_token_expired?(account) && provider_callable(provider, :refresh_access_token) begin tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, account["refreshToken"])) updated = update_account_tokens(ctx, account, tokens) account = account.merge(token_hash(tokens)) Cookies.(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens))) rescue => error log(ctx.context, :error, "FAILED_TO_GET_ACCESS_TOKEN #{error.}") raise APIError.new("BAD_REQUEST", code: "FAILED_TO_GET_ACCESS_TOKEN", message: "Failed to get a valid access token") end end { accessToken: oauth_token_value(ctx, account["accessToken"]), accessTokenExpiresAt: account["accessTokenExpiresAt"], scopes: account["scopes"] || (account["scope"].to_s.empty? ? [] : account["scope"].to_s.split(",")), idToken: account["idToken"] } end |
.account_cookie(ctx, provider_id, account_id = nil, user_id = nil) ⇒ Object
365 366 367 368 369 370 371 372 373 374 375 |
# File 'lib/better_auth/routes/account.rb', line 365 def self.(ctx, provider_id, account_id = nil, user_id = nil) return nil unless ctx.context..account[:store_account_cookie] account = Cookies.(ctx) return nil unless account return nil if provider_id && account["providerId"] != provider_id return nil unless account_id.to_s.empty? || account["id"] == account_id || account["accountId"] == account_id return nil unless user_id.to_s.empty? || account["userId"].to_s.empty? || account["userId"] == user_id account end |
.account_info ⇒ Object
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 |
# File 'lib/better_auth/routes/account.rb', line 219 def self.account_info Endpoint.new( path: "/account-info", method: "GET", metadata: { openapi: { operationId: "accountInfo", description: "Get user info from a linked provider account", parameters: [ { name: "accountId", in: "query", required: false, schema: {type: "string"} }, { name: "providerId", in: "query", required: false, schema: {type: "string"} }, { name: "userId", in: "query", required: false, schema: {type: "string"} } ], responses: { "200" => OpenAPI.json_response( "Success", OpenAPI.object_schema( { user: OpenAPI.object_schema( { id: {type: "string"}, name: {type: "string"}, email: {type: "string"}, image: {type: "string"}, emailVerified: {type: "boolean"} }, required: ["id", "emailVerified"] ), data: { type: "object", properties: {}, additionalProperties: true } }, required: ["user", "data"] ).merge(additionalProperties: false) ) } } } ) do |ctx| session = current_session(ctx, allow_nil: true) raise APIError.new("UNAUTHORIZED") if !session && (ctx.request || !ctx.headers.empty?) user_id = session&.dig(:user, "id") || fetch_value(ctx.query, "userId") raise APIError.new("BAD_REQUEST", code: "USER_ID_OR_SESSION_REQUIRED", message: "Either userId or session is required") if user_id.to_s.empty? account_id = fetch_value(ctx.query, "accountId") provider_id = fetch_value(ctx.query, "providerId") account = if !account_id.to_s.empty? matching_accounts = ctx.context.internal_adapter.find_accounts(user_id).select do |entry| entry["accountId"] == account_id && (provider_id.to_s.empty? || entry["providerId"] == provider_id) end if matching_accounts.length > 1 raise APIError.new( "BAD_REQUEST", code: "AMBIGUOUS_ACCOUNT", message: "Multiple accounts share this account ID. Pass a providerId to disambiguate." ) end matching_accounts.first else (ctx, provider_id, nil, user_id) end raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["ACCOUNT_NOT_FOUND"]) unless account && account["userId"] == user_id provider = (ctx.context, account["providerId"]) unless provider && provider_callable(provider, :get_user_info) raise APIError.new( "BAD_REQUEST", code: "PROVIDER_NOT_CONFIGURED", message: "Account is not associated with a configured social provider." ) end tokens = access_token_response( ctx, user_id: user_id, provider_id: account["providerId"], account_id: account["accountId"], provider: provider ) raise APIError.new("BAD_REQUEST", message: "Access token not found") if tokens[:accessToken].to_s.empty? info = call_provider(provider, :get_user_info, tokens.merge(access_token: tokens[:accessToken])) ctx.json(info) end end |
.account_storage_fields(data) ⇒ Object
417 418 419 420 |
# File 'lib/better_auth/routes/account.rb', line 417 def self.account_storage_fields(data) allowed = %w[accessToken refreshToken idToken accessTokenExpiresAt refreshTokenExpiresAt scope] token_hash(data).select { |key, value| allowed.include?(key) && !value.nil? } end |
.account_token_update_hash(ctx, tokens) ⇒ Object
413 414 415 |
# File 'lib/better_auth/routes/account.rb', line 413 def self.account_token_update_hash(ctx, tokens) account_storage_fields(token_hash_for_storage(ctx, tokens)) end |
.append_query(url, query) ⇒ Object
66 67 68 69 |
# File 'lib/better_auth/routes/error.rb', line 66 def self.append_query(url, query) separator = url.include?("?") ? "&" : "?" "#{url}#{separator}#{query}" end |
.blank_remote_id?(value) ⇒ Boolean
486 487 488 |
# File 'lib/better_auth/routes/social.rb', line 486 def self.blank_remote_id?(value) value.nil? || value.to_s.strip.empty? end |
.call_existing_sign_up_callback(ctx, email_config, existing) ⇒ Object
166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'lib/better_auth/routes/sign_up.rb', line 166 def self.call_existing_sign_up_callback(ctx, email_config, existing) callback = email_config[:on_existing_user_sign_up] return unless callback.respond_to?(:call) user = existing[:user] || existing["user"] || existing data = {user: user} if callback.arity == 1 callback.call(data) else callback.call(data, ctx.request) end end |
.call_option(callback, user, request) ⇒ Object
278 279 280 |
# File 'lib/better_auth/routes/email_verification.rb', line 278 def self.call_option(callback, user, request) callback.call(user, request) if callback.respond_to?(:call) end |
.call_provider(provider, key, *arguments) ⇒ Object
440 441 442 443 444 445 |
# File 'lib/better_auth/routes/account.rb', line 440 def self.call_provider(provider, key, *arguments) return provider.public_send(key, *arguments) if provider.respond_to?(key) callable = provider[key] || provider[key.to_s] callable.respond_to?(:call) ? callable.call(*arguments) : callable end |
.callback_oauth ⇒ Object
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 |
# File 'lib/better_auth/routes/social.rb', line 111 def self.callback_oauth Endpoint.new( path: "/callback/:id", method: ["GET", "POST"], metadata: { allowed_media_types: ["application/x-www-form-urlencoded", "application/json"], openapi: { operationId: "callbackOAuth", description: "Handle an OAuth provider callback", parameters: [ { name: "id", in: "path", required: true, schema: {type: "string"} } ], responses: { "302" => {description: "Redirects to the configured callback URL"} } } } ) do |ctx| provider_id = (fetch_value(ctx.params, "id") || fetch_value(ctx.params, "providerId")).to_s if ctx.method == "POST" merged = normalize_hash(ctx.query).merge(normalize_hash(ctx.body)) query = URI.encode_www_form(merged.reject { |_key, value| value.nil? || value.to_s.empty? }) target = "#{ctx.context.canonical_base_url}/callback/#{provider_id}" target = "#{target}?#{query}" unless query.empty? raise ctx.redirect(target) end source = ctx.query data = normalize_hash(source) state = data["state"].to_s default_error_url = ctx.context..on_api_error[:error_url] || "#{ctx.context.base_url}/error" state_data = begin OAuthState.parse(ctx, state) rescue OAuthState::Error => error raise ctx.redirect(oauth_error_url(error.error_url || default_error_url, error.code)) end error_url = state_data["errorURL"] || state_data["errorCallbackURL"] || default_error_url raise ctx.redirect(oauth_error_url(error_url, data["error"], data["errorDescription"] || data["error_description"])) if data["error"] raise ctx.redirect(oauth_error_url(error_url, "no_code")) if data["code"].to_s.empty? provider = (ctx.context, provider_id) raise ctx.redirect(oauth_error_url(error_url, "oauth_provider_not_found")) unless provider tokens = begin call_provider(provider, :validate_authorization_code, { code: data["code"], codeVerifier: state_data["codeVerifier"], code_verifier: state_data["codeVerifier"], redirectURI: "#{ctx.context.canonical_base_url}/callback/#{provider_id}", redirect_uri: "#{ctx.context.canonical_base_url}/callback/#{provider_id}" }) rescue APIError => error raise if error.status == "FOUND" nil rescue => error (ctx.context, :error, "OAuth code validation failed: #{error.}") nil end raise ctx.redirect(oauth_error_url(error_url, "invalid_code")) unless tokens token_data = token_hash(tokens) token_data["user"] = parse_json_hash(data["user"]) if data["user"] user_info = begin call_provider(provider, :get_user_info, token_data) rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError nil end user = user_info[:user] || user_info["user"] if user_info raise ctx.redirect(oauth_error_url(error_url, "unable_to_get_user_info")) unless user raise ctx.redirect(oauth_error_url(error_url, "email_not_found")) if fetch_value(user, "email").to_s.empty? raise ctx.redirect(oauth_error_url(error_url, "unable_to_get_user_info")) if blank_remote_id?(fetch_value(user, "id")) link = state_data["link"] || state_data[:link] if link linked = (ctx, provider_id, user, tokens, link) raise ctx.redirect(oauth_error_url(error_url, linked[:error])) if linked[:error] raise ctx.redirect(state_data["callbackURL"] || "/") end account_info = token_hash_for_storage(ctx, tokens).merge("accountId" => fetch_value(user, "id").to_s) session_data = ( ctx, provider_id, user, account_info, callback_url: state_data["callbackURL"], disable_sign_up: provider_disable_sign_up?(provider) || (provider_disable_implicit_sign_up?(provider) && !state_data["requestSignUp"]) ) raise ctx.redirect(oauth_error_url(error_url, session_data[:error].tr(" ", "_"))) if session_data[:error] Cookies.(ctx, session_data) callback_url = session_data[:new_user] ? (state_data["newUserURL"] || state_data["newUserCallbackURL"] || state_data["callbackURL"] || "/") : (state_data["callbackURL"] || "/") raise ctx.redirect(callback_url) end end |
.change_email ⇒ Object
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 |
# File 'lib/better_auth/routes/user.rb', line 263 def self.change_email Endpoint.new( path: "/change-email", method: "POST", metadata: { openapi: { operationId: "changeEmail", requestBody: OpenAPI.json_request_body( OpenAPI.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" => OpenAPI.json_response( "Email change request processed successfully", OpenAPI.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" => OpenAPI.error_response("Unprocessable Entity. Email already exists") } } } ) do |ctx| enabled = ctx.context..user.dig(:change_email, :enabled) raise APIError.new("BAD_REQUEST", code: "CHANGE_EMAIL_DISABLED", message: BASE_ERROR_CODES["CHANGE_EMAIL_DISABLED"]) unless enabled session = current_session(ctx, sensitive: true) body = normalize_hash(ctx.body) new_email = (body["newEmail"] || body["new_email"]).to_s.downcase raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless EMAIL_PATTERN.match?(new_email) raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == session[:user]["email"] sender = ctx.context..email_verification[:send_verification_email] confirmation_sender = ctx.context..user.dig(:change_email, :send_change_email_confirmation) can_update_without_verification = !session[:user]["emailVerified"] && ctx.context..user.dig(:change_email, :update_email_without_verification) can_send_confirmation = session[:user]["emailVerified"] && confirmation_sender.respond_to?(:call) can_send_verification = sender.respond_to?(:call) unless can_update_without_verification || can_send_confirmation || can_send_verification raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) end ctx.context.token_link_base_url if can_send_confirmation || can_send_verification existing_target = ctx.context.internal_adapter.find_user_by_email(new_email) next ctx.json({status: true}) if existing_target if can_update_without_verification updated = ctx.context.internal_adapter.update_user_by_email(session[:user]["email"], email: new_email) Cookies.(ctx, {session: session[:session], user: updated}) send_verification_email_payload(ctx, updated, body["callbackURL"] || body["callbackUrl"] || body["callback_url"]) if can_send_verification next ctx.json({status: true}) end if can_send_confirmation callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"] token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-confirmation"}) register_change_email_token!(ctx, token) url = email_verification_url(ctx, token, callback_url) confirmation_sender.call({user: session[:user], new_email: new_email, url: url, token: token}, ctx.request) next ctx.json({status: true}) end send_change_email_verification(ctx, sender, session[:user], session[:user]["email"], new_email, body["callbackURL"] || body["callbackUrl"] || body["callback_url"]) ctx.json({status: true}) end end |
.change_email_token_identifier(token) ⇒ Object
240 241 242 |
# File 'lib/better_auth/routes/email_verification.rb', line 240 def self.change_email_token_identifier(token) "change-email:#{Crypto.sha256(token, encoding: :base64url)}" end |
.change_password ⇒ Object
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/better_auth/routes/user.rb', line 44 def self.change_password Endpoint.new( path: "/change-password", method: "POST", metadata: { openapi: { description: "Change the password of the user", operationId: "changePassword", requestBody: OpenAPI.json_request_body( OpenAPI.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" => OpenAPI.json_response( "Password successfully changed", OpenAPI.object_schema( { token: {type: "string", nullable: true, description: "New session token if other sessions were revoked"}, user: OpenAPI.user_response_schema }, required: ["user"] ) ) } } } ) do |ctx| session = current_session(ctx, sensitive: true) body = normalize_hash(ctx.body) new_password = body["newPassword"] || body["new_password"] current_password = body["currentPassword"] || body["current_password"] validate_password_length!(new_password, ctx.context..email_and_password) account = credential_account(ctx, session[:user]["id"]) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless account && account["password"] unless verify_password_value(ctx, current_password.to_s, account["password"]) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"]) end ctx.context.internal_adapter.update_account(account["id"], password: hash_password(ctx, new_password)) token = nil if body["revokeOtherSessions"] || body["revoke_other_sessions"] ctx.context.internal_adapter.delete_user_sessions(session[:user]["id"]) new_session = ctx.context.internal_adapter.create_session(session[:user]["id"]) Cookies.(ctx, {session: new_session, user: session[:user]}) token = new_session["token"] end ctx.json({token: token, user: Schema.parse_output(ctx.context., "user", session[:user])}) end end |
.coerce_input_value(value, attributes) ⇒ Object
403 404 405 406 407 408 |
# File 'lib/better_auth/routes/user.rb', line 403 def self.coerce_input_value(value, attributes) return value if value.nil? return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String) value end |
.consume_change_email_token!(ctx, identifier, callback_url) ⇒ Object
231 232 233 234 235 236 237 238 |
# File 'lib/better_auth/routes/email_verification.rb', line 231 def self.consume_change_email_token!(ctx, identifier, callback_url) return unless identifier verification = ctx.context.internal_adapter.consume_verification_value(identifier) unless verification redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["TOKEN_ALREADY_USED"], code: "TOKEN_ALREADY_USED") end end |
.core_model_fields(model) ⇒ Object
410 411 412 413 414 415 416 417 418 419 |
# File 'lib/better_auth/routes/user.rb', line 410 def self.core_model_fields(model) case model.to_s when "user" %w[id name email emailVerified image createdAt updatedAt] when "session" %w[id expiresAt token ipAddress userAgent userId createdAt updatedAt] else %w[id createdAt updatedAt] end end |
.create_email_verification_token(ctx, email, update_to: nil, extra: {}) ⇒ Object
192 193 194 195 196 |
# File 'lib/better_auth/routes/email_verification.rb', line 192 def self.create_email_verification_token(ctx, email, update_to: nil, extra: {}) payload = {"email" => email.to_s.downcase}.merge(extra) payload["updateTo"] = update_to if update_to Crypto.sign_jwt(payload, ctx.context.secret, expires_in: ctx.context..email_verification[:expires_in] || 3600) end |
.create_sign_up_user(ctx, additional, email, name, image) ⇒ Object
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/better_auth/routes/sign_up.rb', line 150 def self.create_sign_up_user(ctx, additional, email, name, image) ctx.context.internal_adapter.create_user( additional.merge( "email" => email.downcase, "name" => name, "image" => image, "emailVerified" => false ), context: ctx ) rescue APIError raise rescue raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"]) end |
.credential_account(ctx, user_id) ⇒ Object
243 244 245 |
# File 'lib/better_auth/routes/password.rb', line 243 def self.credential_account(ctx, user_id) ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" } end |
.current_session(ctx, allow_nil: false, sensitive: false, fresh: false, disable_refresh: false) ⇒ Object
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/better_auth/routes/session.rb', line 185 def self.current_session(ctx, allow_nil: false, sensitive: false, fresh: false, disable_refresh: false) data = Session.find_current( ctx, disable_cookie_cache: truthy_query?(ctx.query, "disableCookieCache"), disable_refresh: disable_refresh || truthy_query?(ctx.query, "disableRefresh"), sensitive: sensitive ) return nil if allow_nil && data.nil? raise APIError.new("UNAUTHORIZED") unless data session = stringify_keys(data[:session] || data["session"]) ensure_fresh_session!(ctx, session) if fresh { session: session, user: stringify_keys(data[:user] || data["user"]) } end |
.default_error_description(code) ⇒ Object
107 108 109 110 |
# File 'lib/better_auth/routes/error.rb', line 107 def self.default_error_description(code) "We encountered an unexpected error. You can find more information about this error at " \ "<a href=\"https://better-auth.com/docs/reference/errors/#{URI.encode_www_form_component(code)}\">Better Auth docs</a>." end |
.delete_current_user!(ctx, session) ⇒ Object
360 361 362 363 364 365 366 367 368 369 |
# File 'lib/better_auth/routes/user.rb', line 360 def self.delete_current_user!(ctx, session) config = ctx.context..user[:delete_user] || {} call_option(config[:before_delete], session[:user], ctx.request) deleted = ctx.context.internal_adapter.delete_user(session[:user]["id"]) raise APIError.new("BAD_REQUEST", message: "User delete aborted") if deleted == false ctx.context.internal_adapter.delete_user_sessions(session[:user]["id"]) Cookies.(ctx) call_option(config[:after_delete], session[:user], ctx.request) end |
.delete_user ⇒ Object
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
# File 'lib/better_auth/routes/user.rb', line 141 def self.delete_user Endpoint.new( path: "/delete-user", method: "POST", metadata: { openapi: { operationId: "deleteUser", description: "Delete the current user", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { password: {type: ["string", "null"], description: "The user's password"}, token: {type: ["string", "null"], description: "Delete account verification token"}, callbackURL: {type: ["string", "null"], description: "The URL to redirect to after deletion"} } ) ), responses: { "200" => OpenAPI.json_response( "User deleted or verification email sent", OpenAPI.object_schema( { success: {type: "boolean"}, message: {type: "string"} }, required: ["success", "message"] ) ) } } } ) do |ctx| enabled = ctx.context..user.dig(:delete_user, :enabled) raise APIError.new("NOT_FOUND") unless enabled session = current_session(ctx, sensitive: true) body = normalize_hash(ctx.body) sender = ctx.context..user.dig(:delete_user, :send_delete_account_verification) token_link_base_url = ctx.context.token_link_base_url if !body["token"] && sender.respond_to?(:call) if body["password"] account = credential_account(ctx, session[:user]["id"]) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless account && account["password"] unless verify_password_value(ctx, body["password"], account["password"]) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"]) end end if body["token"] delete_user_by_token!(ctx, session, body["token"]) elsif sender token = SecureRandom.hex(16) expires_in = ctx.context..user.dig(:delete_user, :delete_token_expires_in) || 3600 callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || "/" url = "#{token_link_base_url}/delete-user/callback?token=#{URI.encode_www_form_component(token)}&callbackURL=#{URI.encode_www_form_component(callback_url)}" ctx.context.internal_adapter.create_verification_value( identifier: "delete-account-#{token}", value: session[:user]["id"], expiresAt: Time.now + expires_in.to_i ) sender.call({user: session[:user], url: url, token: token}, ctx.request) next ctx.json({success: true, message: "Verification email sent"}) elsif !body["password"] require_fresh_session!(ctx, session) end delete_current_user!(ctx, session) ctx.json({success: true, message: "User deleted"}) end end |
.delete_user_by_token!(ctx, session, token) ⇒ Object
353 354 355 356 357 358 |
# File 'lib/better_auth/routes/user.rb', line 353 def self.delete_user_by_token!(ctx, session, token) verification = ctx.context.internal_adapter.consume_verification_value("delete-account-#{token}") unless verification && verification["value"] == session[:user]["id"] raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["INVALID_TOKEN"]) end end |
.delete_user_callback ⇒ Object
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 |
# File 'lib/better_auth/routes/user.rb', line 212 def self.delete_user_callback Endpoint.new( path: "/delete-user/callback", method: "GET", metadata: { openapi: { operationId: "deleteUserCallback", description: "Delete the current user using a verification token", parameters: [ { name: "token", in: "query", required: true, schema: {type: "string"} }, { name: "callbackURL", in: "query", required: false, schema: {type: "string"} } ], responses: { "200" => OpenAPI.json_response( "User deleted", OpenAPI.object_schema( { success: {type: "boolean"}, message: {type: "string"} }, required: ["success", "message"] ) ) } } } ) do |ctx| enabled = ctx.context..user.dig(:delete_user, :enabled) raise APIError.new("NOT_FOUND") unless enabled session = current_session(ctx) token = fetch_value(ctx.query, "token") callback_url = fetch_value(ctx.query, "callbackURL") validate_callback_url!(ctx.context, callback_url) delete_user_by_token!(ctx, session, token) delete_current_user!(ctx, session) raise ctx.redirect(callback_url) if callback_url ctx.json({success: true, message: "User deleted"}) end end |
.email_change_session_data(ctx, session, user) ⇒ Object
268 269 270 271 272 273 274 275 276 |
# File 'lib/better_auth/routes/email_verification.rb', line 268 def self.email_change_session_data(ctx, session, user) return session[:session] if session && session[:user]["id"] == user["id"] session_data = ctx.context.internal_adapter.create_session(user["id"]) unless session_data raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) end session_data end |
.email_verification_url(ctx, token, callback_url) ⇒ Object
348 349 350 351 |
# File 'lib/better_auth/routes/user.rb', line 348 def self.email_verification_url(ctx, token, callback_url) callback = URI.encode_www_form_component(callback_url || "/") "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}" end |
.enforce_verification_email_floor(start) ⇒ Object
72 73 74 75 |
# File 'lib/better_auth/routes/email_verification.rb', line 72 def self.enforce_verification_email_floor(start) remaining = 0.5 - (verification_email_monotonic_clock - start) verification_email_sleep(remaining) if remaining.positive? end |
.ensure_fresh_session!(ctx, session) ⇒ Object
214 215 216 217 218 219 220 221 222 |
# File 'lib/better_auth/routes/session.rb', line 214 def self.ensure_fresh_session!(ctx, session) fresh_age = ctx.context.session_config[:fresh_age].to_i return if fresh_age.zero? created_at = normalize_time(session["createdAt"]) return unless created_at && Time.now - created_at >= fresh_age raise APIError.new("FORBIDDEN", code: "SESSION_NOT_FRESH", message: BASE_ERROR_CODES.fetch("SESSION_NOT_FRESH")) end |
.error ⇒ 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 |
# File 'lib/better_auth/routes/error.rb', line 7 def self.error Endpoint.new( path: "/error", method: "GET", metadata: { hide: true, openapi: { description: "Displays an error page", responses: { "200" => OpenAPI.json_response( "Success", OpenAPI.object_schema( { html: { type: "string", description: "The HTML content of the error page" } }, required: ["html"] ) ) } } } ) do |ctx| query = ctx.query || {} raw_code = query["error"] || query[:error] || "UNKNOWN" raw_description = query["error_description"] || query[:error_description] safe_code = valid_error_code?(raw_code) ? raw_code.to_s : "UNKNOWN" query_params = error_query_params(safe_code, raw_description) error_url = ctx.context..on_api_error[:error_url] if error_url location = append_query(error_url, query_params) next [302, {"location" => location}, [""]] end if ctx.context..production? && !ctx.context..on_api_error[:customize_default_error_page] next [302, {"location" => "/?#{query_params}"}, [""]] end [ 200, {"content-type" => "text/html"}, [error_html(safe_code, raw_description)] ] end end |
.error_html(code, description) ⇒ Object
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 |
# File 'lib/better_auth/routes/error.rb', line 71 def self.error_html(code, description) safe_code = sanitize_html(code) safe_description = description ? sanitize_html(description) : default_error_description(safe_code) <<~HTML <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Error</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #fff; color: #171717; } main { width: min(42rem, calc(100% - 2rem)); border: 2px solid #d4d4d4; padding: 1.5rem; text-align: center; } h1 { margin: 0 0 1rem; font-size: 3rem; line-height: 1; } code { display: inline-block; border: 1px solid #d4d4d4; padding: 0.375rem 0.75rem; margin-bottom: 1rem; word-break: break-all; } p { color: #525252; line-height: 1.5; } a { color: inherit; } @media (prefers-color-scheme: dark) { body { background: #171717; color: #fafafa; } main, code { border-color: #404040; } p { color: #d4d4d4; } } </style> </head> <body> <main> <h1>ERROR</h1> <code>#{safe_code}</code> <p>#{safe_description}</p> </main> </body> </html> HTML end |
.error_query_params(code, description) ⇒ Object
60 61 62 63 64 |
# File 'lib/better_auth/routes/error.rb', line 60 def self.error_query_params(code, description) params = {error: code} params[:error_description] = description if description URI.encode_www_form(params) end |
.expired_time?(value) ⇒ Boolean
247 248 249 |
# File 'lib/better_auth/routes/password.rb', line 247 def self.expired_time?(value) value && value < Time.now end |
.fetch_value(hash, key) ⇒ Object
251 252 253 254 255 256 257 258 259 260 261 262 263 |
# File 'lib/better_auth/routes/password.rb', line 251 def self.fetch_value(hash, key) snake_key = key.to_s .gsub(/([a-z\d])([A-Z])/, "\\1_\\2") .tr("-", "_") .downcase hash[key] || hash[key.to_s] || hash[key.to_sym] || hash[Schema.storage_key(key)] || hash[Schema.storage_key(key).to_sym] || hash[snake_key] || hash[snake_key.to_sym] end |
.find_provider_account(ctx, user_id, provider_id, account_id = nil) ⇒ Object
359 360 361 362 363 |
# File 'lib/better_auth/routes/account.rb', line 359 def self.find_provider_account(ctx, user_id, provider_id, account_id = nil) ctx.context.internal_adapter.find_accounts(user_id).find do |account| account["providerId"] == provider_id && (account_id.to_s.empty? || account["id"] == account_id || account["accountId"] == account_id) end end |
.get_access_token ⇒ Object
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/better_auth/routes/account.rb', line 80 def self.get_access_token Endpoint.new( path: "/get-access-token", method: "POST", body_schema: request_body_schema( required_strings: %w[providerId], optional_strings: %w[accountId userId] ), metadata: { openapi: { operationId: "getAccessToken", description: "Get an access token for a linked provider account", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { providerId: {type: "string"}, accountId: {type: ["string", "null"]}, userId: {type: ["string", "null"]} }, required: ["providerId"] ) ), responses: { "200" => OpenAPI.json_response( "Provider access token", OpenAPI.object_schema( { accessToken: {type: ["string", "null"]}, accessTokenExpiresAt: {type: ["string", "null"], format: "date-time"}, scopes: {type: "array", items: {type: "string"}}, idToken: {type: ["string", "null"]} }, required: ["scopes"] ) ) } } } ) do |ctx| session = current_session(ctx, allow_nil: true) body = normalize_hash(ctx.body) raise APIError.new("UNAUTHORIZED") if ctx.request && !session user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"] raise APIError.new("UNAUTHORIZED") if user_id.to_s.empty? provider_id = body["providerId"] || body["provider_id"] raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty? account_id = body["accountId"] || body["account_id"] ctx.json(access_token_response(ctx, user_id: user_id, provider_id: provider_id, account_id: account_id)) end end |
.get_session ⇒ Object
5 6 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 |
# File 'lib/better_auth/routes/session.rb', line 5 def self.get_session Endpoint.new( path: "/get-session", method: ["GET", "POST"], metadata: { openapi: { operationId: "getSession", description: "Get the current session", responses: { "200" => OpenAPI.json_response("Current session or null", OpenAPI.session_response_schema_pair.merge(type: ["object", "null"])) } } } ) do |ctx| defer_refresh = ctx.context.session_config[:defer_session_refresh] if ctx.method == "POST" && !defer_refresh raise APIError.new( "METHOD_NOT_ALLOWED", code: "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED", message: BASE_ERROR_CODES["METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED"] ) end session = current_session(ctx, allow_nil: true, disable_refresh: defer_refresh && ctx.method == "GET") next ctx.json(nil) unless session response = parsed_session_response(ctx, session) response[:needsRefresh] = session_needs_refresh?(ctx, session[:session]) if defer_refresh && ctx.method == "GET" ctx.json(response) rescue APIError raise rescue => error log(ctx.context, :error, "FAILED_TO_GET_SESSION #{error.}") raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"]) end end |
.hash_password(ctx, password) ⇒ Object
217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/better_auth/routes/password.rb', line 217 def self.hash_password(ctx, password) hasher = ctx.context..email_and_password.dig(:password, :hash) if hasher.respond_to?(:call) return hasher_arity_accepts_context?(hasher) ? hasher.call(password, ctx) : hasher.call(password) end Password.hash( password, algorithm: ctx.context..password_hasher ) end |
.hasher_arity_accepts_context?(hasher) ⇒ Boolean
229 230 231 232 |
# File 'lib/better_auth/routes/password.rb', line 229 def self.hasher_arity_accepts_context?(hasher) arity = hasher.arity arity != 1 && arity != -1 end |
.implicit_link_allowed?(ctx, local_user) ⇒ Boolean
479 480 481 482 483 484 |
# File 'lib/better_auth/routes/social.rb', line 479 def self.implicit_link_allowed?(ctx, local_user) linking = ctx.context..account[:account_linking] || {} return false if linking[:enabled] == false || linking[:disable_implicit_linking] == true linking[:require_local_email_verified] == false || !!local_user["emailVerified"] end |
.js_truthy_input?(value) ⇒ Boolean
228 229 230 231 232 233 234 |
# File 'lib/better_auth/routes/sign_up.rb', line 228 def self.js_truthy_input?(value) return false if value.nil? || value == false return false if value.is_a?(Numeric) && (value.zero? || (value.respond_to?(:nan?) && value.nan?)) return false if value.is_a?(String) && value.empty? true end |
.link_social ⇒ Object
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 |
# File 'lib/better_auth/routes/social.rb', line 213 def self. Endpoint.new( path: "/link-social", method: "POST", metadata: { openapi: { operationId: "linkSocialAccount", description: "Link a social account to the current user", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { provider: {type: "string"}, callbackURL: {type: ["string", "null"]}, errorCallbackURL: {type: ["string", "null"]}, disableRedirect: {type: ["boolean", "null"]}, scopes: {type: ["array", "null"], items: {type: "string"}}, idToken: {type: ["object", "null"]} }, required: ["provider"] ) ), responses: { "200" => OpenAPI.json_response( "Social account link started or completed", OpenAPI.object_schema( { url: {type: "string"}, redirect: {type: "boolean"}, status: {type: ["boolean", "null"]} }, required: ["url", "redirect"] ) ) } } } ) do |ctx| session = current_session(ctx) body = normalize_hash(ctx.body) provider_id = body["provider"].to_s provider = (ctx.context, provider_id) raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider (ctx.context, body["callbackURL"] || body["callbackUrl"] || body["callback_url"], "INVALID_CALLBACK_URL") (ctx.context, body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"], "INVALID_ERROR_CALLBACK_URL") id_token = fetch_value(body, "idToken") if id_token data = (ctx, provider, id_token) email = fetch_value(data[:user], "email").to_s.downcase unless linkable_provider?(ctx, provider_id, data[:user]) raise APIError.new("UNAUTHORIZED", message: "Account not linked - untrusted provider") end unless email == session[:user]["email"].to_s.downcase || ctx.context..account.dig(:account_linking, :allow_different_emails) raise APIError.new("UNAUTHORIZED", message: "Account not linked - different emails not allowed") end account_id = fetch_value(data[:user], "id").to_s existing = ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |account| account["providerId"] == provider_id && account["accountId"] == account_id end unless existing begin account = ctx.context.internal_adapter.create_account(token_hash_for_storage(ctx, data[:account]).merge("userId" => session[:user]["id"])) raise BetterAuth::Error, "Unable to link social account" unless account rescue (ctx.context, :error, "Unable to link social account") raise APIError.new("INTERNAL_SERVER_ERROR", message: "Unable to link social account") end end (ctx, session[:user]["id"], data[:user]) update_verified_email_on_link(ctx, session[:user]["id"], session[:user]["email"], data[:user]) next ctx.json({url: "", status: true, redirect: false}) end code_verifier = SecureRandom.hex(16) state_data = { "callbackURL" => body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || ctx.context.base_url, "errorCallbackURL" => body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"], "requestSignUp" => body["requestSignUp"] || body["request_sign_up"], "codeVerifier" => code_verifier, "link" => { "userId" => session[:user]["id"], "email" => session[:user]["email"] } }.merge(safe_additional_state(body)) state = Crypto.sign_jwt(state_data, ctx.context.secret, expires_in: 600) (ctx, state) url = call_provider(provider, :create_authorization_url, { state: state, codeVerifier: code_verifier, code_verifier: code_verifier, redirectURI: "#{ctx.context.canonical_base_url}/callback/#{provider_id}", redirect_uri: "#{ctx.context.canonical_base_url}/callback/#{provider_id}", scopes: body["scopes"], loginHint: body["loginHint"] || body["login_hint"] }) ctx.set_header("location", url.to_s) unless body["disableRedirect"] || body["disable_redirect"] ctx.json({url: url.to_s, redirect: !(body["disableRedirect"] || body["disable_redirect"])}) end end |
.link_social_account_from_callback(ctx, provider_id, user_info, tokens, link) ⇒ Object
490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 |
# File 'lib/better_auth/routes/social.rb', line 490 def self.(ctx, provider_id, user_info, tokens, link) return {error: "unable_to_link_account"} unless linkable_provider?(ctx, provider_id, user_info) return {error: "unable_to_link_account"} if blank_remote_id?(fetch_value(user_info, "id")) email = fetch_value(user_info, "email").to_s.downcase link_email = fetch_value(link, "email").to_s.downcase unless email == link_email || ctx.context..account.dig(:account_linking, :allow_different_emails) return {error: "email_doesn't_match"} end account_id = fetch_value(user_info, "id").to_s user_id = fetch_value(link, "userId").to_s account_info = token_hash_for_storage(ctx, tokens).merge( "providerId" => provider_id, "accountId" => account_id, "userId" => user_id ) existing = ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider_id) begin account = if existing return {error: "account_already_linked_to_different_user"} if existing["userId"].to_s != user_id ctx.context.internal_adapter.update_account(existing["id"], account_info) else ctx.context.internal_adapter.create_account(account_info) end raise BetterAuth::Error, "Unable to link social account" unless account rescue (ctx.context, :error, "Unable to link social account") return {error: "unable_to_link_account"} end (ctx, user_id, user_info) update_verified_email_on_link(ctx, user_id, link_email, user_info) {status: true} end |
.linkable_provider?(ctx, provider_id, user_info, implicit: false, local_user: nil) ⇒ Boolean
467 468 469 470 471 472 473 474 475 476 477 |
# File 'lib/better_auth/routes/social.rb', line 467 def self.linkable_provider?(ctx, provider_id, user_info, implicit: false, local_user: nil) linking = ctx.context..account[:account_linking] || {} return false if linking[:enabled] == false return false if implicit && linking[:disable_implicit_linking] == true if implicit && linking.fetch(:require_local_email_verified, true) && !local_user&.fetch("emailVerified", false) return false end trusted = Array(linking[:trusted_providers]).map(&:to_s).include?(provider_id.to_s) trusted || !!fetch_value(user_info, "emailVerified") end |
.list_accounts ⇒ Object
5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
# File 'lib/better_auth/routes/account.rb', line 5 def self.list_accounts Endpoint.new( path: "/list-accounts", method: "GET", metadata: { openapi: { operationId: "listUserAccounts", description: "List linked accounts for the current user", responses: { "200" => OpenAPI.json_response( "Linked accounts", {type: "array", items: {type: "object", "$ref": "#/components/schemas/Account"}} ) } } } ) do |ctx| session = current_session(ctx) accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"]).map do |account| parsed = Schema.parse_output(ctx.context., "account", account) scope = parsed.delete("scope") parsed.merge("scopes" => scope.to_s.empty? ? [] : scope.to_s.split(",")) end ctx.json(accounts) end end |
.list_sessions ⇒ 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/routes/session.rb', line 42 def self.list_sessions Endpoint.new( path: "/list-sessions", method: "GET", metadata: { openapi: { operationId: "listSessions", description: "List active sessions for the current user", responses: { "200" => OpenAPI.json_response( "Active sessions", {type: "array", items: {type: "object", "$ref": "#/components/schemas/Session"}} ) } } } ) do |ctx| session = current_session(ctx, sensitive: true, fresh: true) sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"]) active = sessions .map { |entry| stringify_keys(entry) } .select { |entry| !Session.expired?(entry) } .map { |entry| Schema.parse_output(ctx.context., "session", entry) } ctx.json(active) end end |
.log(context, level, message) ⇒ Object
271 272 273 274 275 276 277 278 |
# File 'lib/better_auth/routes/session.rb', line 271 def self.log(context, level, ) logger = context.logger if logger.respond_to?(:call) logger.call(level, ) elsif logger.respond_to?(level) logger.public_send(level, ) end end |
.normalize_hash(value) ⇒ Object
279 280 281 282 283 |
# File 'lib/better_auth/routes/sign_up.rb', line 279 def self.normalize_hash(value) value.each_with_object({}) do |(key, object_value), result| result[Schema.storage_key(key)] = object_value end end |
.normalize_time(value) ⇒ Object
234 235 236 237 238 239 240 241 |
# File 'lib/better_auth/routes/session.rb', line 234 def self.normalize_time(value) return value if value.is_a?(Time) return nil if value.nil? || value.to_s.empty? Time.parse(value.to_s) rescue ArgumentError nil end |
.oauth_error_url(base_url, error, description = nil) ⇒ Object
450 451 452 453 454 455 456 457 |
# File 'lib/better_auth/routes/social.rb', line 450 def self.oauth_error_url(base_url, error, description = nil) uri = URI.parse(base_url.to_s) query = URI.decode_www_form(uri.query.to_s) query << ["error", error.to_s] query << ["error_description", description.to_s] if description uri.query = URI.encode_www_form(query) uri.to_s end |
.oauth_proxy_callback_url?(context, callback_url) ⇒ Boolean
586 587 588 589 590 591 592 593 594 595 596 |
# File 'lib/better_auth/routes/social.rb', line 586 def self.oauth_proxy_callback_url?(context, callback_url) uri = URI.parse(callback_url.to_s) proxy_path = "#{context..base_path}/oauth-proxy-callback" return false unless uri.path == proxy_path nested = URI.decode_www_form(uri.query.to_s).assoc("callbackURL")&.last validate_callback_url!(context, nested) true rescue APIError, URI::InvalidURIError false end |
.oauth_token_for_storage(ctx, token) ⇒ Object
422 423 424 425 426 427 |
# File 'lib/better_auth/routes/account.rb', line 422 def self.oauth_token_for_storage(ctx, token) return token if token.to_s.empty? return token unless ctx.context..account[:encrypt_oauth_tokens] Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: token) end |
.oauth_token_value(ctx, token) ⇒ Object
429 430 431 432 433 434 |
# File 'lib/better_auth/routes/account.rb', line 429 def self.oauth_token_value(ctx, token) return token if token.to_s.empty? return token unless ctx.context..account[:encrypt_oauth_tokens] Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: token) || token end |
.ok ⇒ Object
5 6 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 |
# File 'lib/better_auth/routes/ok.rb', line 5 def self.ok Endpoint.new( path: "/ok", method: "GET", metadata: { hide: true, openapi: { description: "Check if the API is working", responses: { "200" => OpenAPI.json_response( "API is working", OpenAPI.object_schema( { ok: { type: "boolean", description: "Indicates if the API is working" } }, required: ["ok"] ) ) } } } ) do |ctx| ctx.json({ok: true}) end end |
.override_social_user_info(ctx, user, user_info) ⇒ Object
533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 |
# File 'lib/better_auth/routes/social.rb', line 533 def self.(ctx, user, user_info) email = fetch_value(user_info, "email").to_s.downcase email_verified = if email == user["email"].to_s.downcase !!(user["emailVerified"] || fetch_value(user_info, "emailVerified")) else !!fetch_value(user_info, "emailVerified") end provider_fields = Schema.parse_provider_profile_user_input(ctx.context., user_info, action: :update) update = provider_fields.merge( "email" => email, "name" => fetch_value(user_info, "name").to_s, "image" => fetch_value(user_info, "image"), "emailVerified" => email_verified ).reject { |_key, value| value.nil? } ctx.context.internal_adapter.update_user(user["id"], update) rescue (ctx.context, :warn, "Could not override social user info") raise end |
.parse_declared_input(ctx, model, data, allowed_base: []) ⇒ Object
381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 |
# File 'lib/better_auth/routes/user.rb', line 381 def self.parse_declared_input(ctx, model, data, allowed_base: []) input = normalize_hash(data || {}) table = Schema.auth_tables(ctx.context.)[model.to_s] fields = table ? table.fetch(:fields) : {} additional = ctx.context..public_send(model.to_sym)[:additional_fields] || {} fields = fields.merge(additional.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value }) if model.to_s == "session" declared_fields = fields.keys - core_model_fields(model) allowed = (Array(allowed_base).map { |field| Schema.storage_key(field) } + declared_fields).uniq input.each_with_object({}) do |(field, value), result| next unless fields.key?(field) next unless allowed.include?(field) attributes = fields.fetch(field) if attributes[:input] == false raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set") end result[field] = coerce_input_value(value, attributes) end end |
.parse_json_hash(value) ⇒ Object
616 617 618 619 620 621 622 623 624 |
# File 'lib/better_auth/routes/social.rb', line 616 def self.parse_json_hash(value) return value if value.is_a?(Hash) return {} if value.nil? || value.to_s.empty? parsed = JSON.parse(value.to_s) parsed.is_a?(Hash) ? parsed : {} rescue JSON::ParserError {} end |
.parse_sign_up_additional_user_fields(ctx, data) ⇒ Object
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/better_auth/routes/sign_up.rb', line 203 def self.parse_sign_up_additional_user_fields(ctx, data) input = normalize_hash(data || {}) fields = Schema.auth_tables(ctx.context.).fetch("user").fetch(:fields) .except(*core_model_fields("user")) fields.each_with_object({}) do |(field, attributes), result| if input.key?(field) if attributes[:input] == false if attributes.key?(:default_value) result[field] = resolve_default(attributes[:default_value]) elsif js_truthy_input?(input[field]) raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set") end next end result[field] = coerce_input_value(input[field], attributes) elsif attributes.key?(:default_value) result[field] = resolve_default(attributes[:default_value]) elsif attributes[:required] raise APIError.new("BAD_REQUEST", message: "#{field} is required") end end end |
.parse_time(value) ⇒ Object
382 383 384 385 386 387 388 389 |
# File 'lib/better_auth/routes/account.rb', line 382 def self.parse_time(value) return value if value.is_a?(Time) return nil if value.nil? || value.to_s.empty? Time.parse(value.to_s) rescue ArgumentError nil end |
.parsed_session_response(ctx, session) ⇒ Object
243 244 245 246 247 248 |
# File 'lib/better_auth/routes/session.rb', line 243 def self.parsed_session_response(ctx, session) { session: Schema.parse_output(ctx.context., "session", session[:session]), user: Schema.parse_output(ctx.context., "user", session[:user]) } end |
.persist_social_user(ctx, provider_id, user_info, account_info, callback_url: nil, disable_sign_up: false, override_user_info: false) ⇒ Object
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 |
# File 'lib/better_auth/routes/social.rb', line 351 def self.(ctx, provider_id, user_info, account_info, callback_url: nil, disable_sign_up: false, override_user_info: false) email = fetch_value(user_info, "email").to_s.downcase account_id = (account_info["accountId"] || account_info[:accountId] || account_info[:account_id] || fetch_value(user_info, "id")).to_s return {error: "unable to get user info"} if blank_remote_id?(account_id) existing = begin ctx.context.internal_adapter.find_oauth_user(email, account_id, provider_id) rescue (ctx.context, :error, "Unable to query social user") raise APIError.new("INTERNAL_SERVER_ERROR", message: "internal server error") end if existing && existing[:linked_account] user = existing[:user] if ctx.context..account[:update_account_on_sign_in] != false update_data = account_storage_fields(account_info) unless update_data.empty? begin ctx.context.internal_adapter.update_account(existing[:linked_account]["id"], update_data) rescue (ctx.context, :error, "Unable to link social account") raise end end end verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info) user = verified_user if verified_user new_user = false elsif existing linkable = linkable_provider?(ctx, provider_id, user_info, implicit: true, local_user: existing[:user]) unless linkable return {error: existing[:user]["banned"] ? "banned" : "account not linked"} end user = existing[:user] user = ctx.context.adapter.transaction do account = begin ctx.context.internal_adapter.create_account(account_info.merge("providerId" => provider_id, "accountId" => account_id, "userId" => user["id"])) rescue (ctx.context, :error, "Unable to link social account") raise end unless account (ctx.context, :error, "Unable to link social account") raise BetterAuth::Error, "Failed to link social account" end promoted = promote_verified_email_on_link!(ctx, user, user_info) linked_user = promoted || user (ctx, linked_user["id"], user_info) || linked_user end new_user = false else return {error: "signup disabled"} if disable_sign_up begin provider_fields = Schema.parse_provider_profile_user_input(ctx.context., user_info, action: :create) created = ctx.context.internal_adapter.create_oauth_user( provider_fields.merge( email: email, name: fetch_value(user_info, "name").to_s, image: fetch_value(user_info, "image"), emailVerified: !!fetch_value(user_info, "emailVerified") ), account_info.merge("providerId" => provider_id, "accountId" => account_id), context: ctx ) raise BetterAuth::Error, "Failed to create social user" unless created rescue (ctx.context, :error, "Unable to create social user") return {error: "unable to create user"} end user = created[:user] new_user = true end if existing && (override_user_info || provider_override_user_info_on_sign_in?(provider_id, ctx.context)) user = (ctx, user, user_info) end return {error: "unable to create user"} unless user session = ctx.context.internal_adapter.create_session(user["id"], false, session_overrides(ctx), true, ctx) {session: session, user: user, new_user: new_user} end |
.preflight_follow_up_verification_token_link!(ctx, payload, update_to) ⇒ Object
184 185 186 187 188 189 190 |
# File 'lib/better_auth/routes/email_verification.rb', line 184 def self.preflight_follow_up_verification_token_link!(ctx, payload, update_to) return unless update_to return unless ctx.context..email_verification[:send_verification_email].respond_to?(:call) request_type = payload["requestType"] || payload["request_type"] ctx.context.token_link_base_url unless request_type == "change-email-verification" end |
.preflight_sign_in_token_link!(ctx) ⇒ Object
115 116 117 118 119 120 121 |
# File 'lib/better_auth/routes/sign_in.rb', line 115 def self.preflight_sign_in_token_link!(ctx) verification = ctx.context..email_verification password_config = ctx.context..email_and_password if password_config[:require_email_verification] && verification[:send_on_sign_in] && verification[:send_verification_email].respond_to?(:call) ctx.context.token_link_base_url end end |
.preflight_sign_up_token_link!(ctx) ⇒ Object
265 266 267 268 269 270 |
# File 'lib/better_auth/routes/sign_up.rb', line 265 def self.preflight_sign_up_token_link!(ctx) verification = ctx.context..email_verification password_config = ctx.context..email_and_password send_on_sign_up = verification.key?(:send_on_sign_up) ? verification[:send_on_sign_up] : password_config[:require_email_verification] ctx.context.token_link_base_url if send_on_sign_up && verification[:send_verification_email].respond_to?(:call) end |
.promote_verified_email_on_link!(ctx, local_user, social_user) ⇒ Object
605 606 607 608 609 610 611 612 613 614 |
# File 'lib/better_auth/routes/social.rb', line 605 def self.promote_verified_email_on_link!(ctx, local_user, ) return if local_user["emailVerified"] == true return unless fetch_value(, "emailVerified") return unless fetch_value(, "email").to_s.strip.downcase == local_user["email"].to_s.strip.downcase updated = ctx.context.internal_adapter.update_user(local_user.fetch("id"), {"emailVerified" => true}) raise BetterAuth::Error, "Failed to promote verified email" unless updated updated end |
.provider_callable(provider, key) ⇒ Object
436 437 438 |
# File 'lib/better_auth/routes/account.rb', line 436 def self.provider_callable(provider, key) provider.respond_to?(key) || (provider.is_a?(Hash) && (provider[key] || provider[key.to_s])) end |
.provider_disable_implicit_sign_up?(provider) ⇒ Boolean
459 460 461 |
# File 'lib/better_auth/routes/social.rb', line 459 def self.provider_disable_implicit_sign_up?(provider) !!(fetch_value(provider, "disableImplicitSignUp") || fetch_value(provider, "disableSignUp") || fetch_value(fetch_value(provider, "options") || {}, "disableSignUp")) end |
.provider_disable_sign_up?(provider) ⇒ Boolean
463 464 465 |
# File 'lib/better_auth/routes/social.rb', line 463 def self.provider_disable_sign_up?(provider) !!(fetch_value(provider, "disableSignUp") || fetch_value(fetch_value(provider, "options") || {}, "disableSignUp")) end |
.provider_override_user_info_on_sign_in?(provider_id, context) ⇒ Boolean
528 529 530 531 |
# File 'lib/better_auth/routes/social.rb', line 528 def self.provider_override_user_info_on_sign_in?(provider_id, context) provider = (context, provider_id) !!(fetch_value(provider, "overrideUserInfoOnSignIn") || fetch_value(fetch_value(provider, "options") || {}, "overrideUserInfoOnSignIn")) end |
.redirect_or_error(ctx, callback_url, error, code: nil) ⇒ Object
244 245 246 247 248 249 250 |
# File 'lib/better_auth/routes/email_verification.rb', line 244 def self.redirect_or_error(ctx, callback_url, error, code: nil) if callback_url separator = callback_url.include?("?") ? "&" : "?" raise ctx.redirect("#{callback_url}#{separator}error=#{code || error}") end raise APIError.new("UNAUTHORIZED", code: code, message: error) end |
.redirect_or_json(ctx, callback_url, data) ⇒ Object
252 253 254 255 256 |
# File 'lib/better_auth/routes/email_verification.rb', line 252 def self.redirect_or_json(ctx, callback_url, data) raise ctx.redirect(callback_url) if callback_url ctx.json(data) end |
.refresh_token ⇒ Object
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 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 |
# File 'lib/better_auth/routes/account.rb', line 134 def self.refresh_token Endpoint.new( path: "/refresh-token", method: "POST", body_schema: request_body_schema( required_strings: %w[providerId], optional_strings: %w[accountId userId] ), metadata: { openapi: { operationId: "refreshToken", description: "Refresh an OAuth provider access token", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { providerId: {type: "string"}, accountId: {type: ["string", "null"]}, userId: {type: ["string", "null"]} }, required: ["providerId"] ) ), responses: { "200" => OpenAPI.json_response( "Refreshed provider tokens", OpenAPI.object_schema( { accessToken: {type: ["string", "null"]}, refreshToken: {type: ["string", "null"]}, accessTokenExpiresAt: {type: ["string", "null"], format: "date-time"}, refreshTokenExpiresAt: {type: ["string", "null"], format: "date-time"}, scope: {type: ["string", "null"]}, idToken: {type: ["string", "null"]}, providerId: {type: "string"}, accountId: {type: "string"} }, required: ["providerId", "accountId"] ) ) } } } ) do |ctx| session = current_session(ctx, allow_nil: true) body = normalize_hash(ctx.body) raise APIError.new("UNAUTHORIZED") if ctx.request && !session user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"] raise APIError.new("BAD_REQUEST", message: "Either userId or session is required") if user_id.to_s.empty? provider_id = body["providerId"] || body["provider_id"] raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty? provider = (ctx.context, provider_id) raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} not found.") unless provider raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} does not support token refreshing.") unless provider_callable(provider, :refresh_access_token) account_id = body["accountId"] || body["account_id"] account = (ctx, provider_id, account_id, user_id) || find_provider_account(ctx, user_id, provider_id, account_id) raise APIError.new("BAD_REQUEST", message: "Account not found") unless account refresh_token = oauth_token_value(ctx, account["refreshToken"]) raise APIError.new("BAD_REQUEST", message: "Refresh token not found") if refresh_token.to_s.empty? begin tokens = call_provider(provider, :refresh_access_token, refresh_token) updated = update_account_tokens(ctx, account, tokens) values = token_hash(tokens) Cookies.(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens))) rescue => error log(ctx.context, :error, "FAILED_TO_REFRESH_ACCESS_TOKEN #{error.}") raise APIError.new("BAD_REQUEST", code: "FAILED_TO_REFRESH_ACCESS_TOKEN", message: "Failed to refresh access token") end ctx.json({ accessToken: values["accessToken"], refreshToken: values["refreshToken"] || refresh_token, accessTokenExpiresAt: values["accessTokenExpiresAt"], refreshTokenExpiresAt: values["refreshTokenExpiresAt"] || account["refreshTokenExpiresAt"], scope: values["scope"] || account["scope"], idToken: values["idToken"] || account["idToken"], providerId: account["providerId"], accountId: account["accountId"] }) end end |
.register_change_email_token!(ctx, token) ⇒ Object
Ruby hardening adaptation of upstream PR #9519. This is intentionally stricter than the pinned Better Auth v1.6.23 behavior, and hashes the JWT before storage so the verification table never contains the bearer token.
210 211 212 213 214 215 216 |
# File 'lib/better_auth/routes/email_verification.rb', line 210 def self.register_change_email_token!(ctx, token) ctx.context.internal_adapter.create_verification_value( identifier: change_email_token_identifier(token), value: "issued", expiresAt: Time.now + (ctx.context..email_verification[:expires_in] || 3600) ) end |
.request_body_schema(required_strings: [], required_nonempty_strings: [], email_strings: [], optional_strings: []) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 |
# File 'lib/better_auth/routes/validation.rb', line 7 def self.request_body_schema(required_strings: [], required_nonempty_strings: [], email_strings: [], optional_strings: []) ->(body) { data = request_validation_hash(body) return false unless required_strings.all? { |key| request_string?(data, key) } return false unless required_nonempty_strings.all? { |key| request_string?(data, key) && !data[request_storage_key(key)].empty? } return false unless email_strings.all? { |key| request_string?(data, key) && REQUEST_EMAIL_PATTERN.match?(data[request_storage_key(key)]) } return false unless optional_strings.all? { |key| !data.key?(request_storage_key(key)) || request_string?(data, key) } data } end |
.request_only_session(ctx) ⇒ Object
205 206 207 208 209 210 211 212 |
# File 'lib/better_auth/routes/session.rb', line 205 def self.request_only_session(ctx) session = current_session(ctx, allow_nil: true) if !session && (ctx.request || !ctx.headers.empty?) raise APIError.new("UNAUTHORIZED", code: "UNAUTHORIZED", message: "Unauthorized") end session end |
.request_password_reset ⇒ Object
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 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/better_auth/routes/password.rb', line 10 def self.request_password_reset Endpoint.new( path: "/request-password-reset", method: "POST", body_schema: request_body_schema(email_strings: %w[email]), metadata: { openapi: { operationId: "requestPasswordReset", description: "Request a password reset link", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { email: {type: "string", description: "The email address of the user"}, redirectTo: {type: ["string", "null"], description: "The URL to redirect to after reset"} }, required: ["email"] ) ), responses: { "200" => OpenAPI.json_response( "Password reset request processed", OpenAPI.status_response_schema( { message: {type: "string"} }, required: ["status", "message"] ) ) } } } ) do |ctx| sender = ctx.context..email_and_password[:send_reset_password] unless sender.respond_to?(:call) raise APIError.new("BAD_REQUEST", code: "RESET_PASSWORD_DISABLED", message: BASE_ERROR_CODES["RESET_PASSWORD_DISABLED"]) end token_link_base_url = ctx.context.token_link_base_url body = normalize_hash(ctx.body) email = body["email"].to_s.downcase redirect_to = body["redirectTo"] || body["redirect_to"] validate_redirect_url!(ctx.context, redirect_to) found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true) unless found SecureRandom.hex(12) ctx.context.internal_adapter.find_verification_value("dummy-verification-token") next ctx.json({status: true, message: PASSWORD_RESET_MESSAGE}) end token = SecureRandom.hex(12) expires_in = ctx.context..email_and_password[:reset_password_token_expires_in] || 3600 ctx.context.internal_adapter.create_verification_value( identifier: "reset-password:#{token}", value: found[:user]["id"], expiresAt: Time.now + expires_in.to_i ) callback = redirect_to ? URI.encode_www_form_component(redirect_to) : "" url = "#{token_link_base_url}/reset-password/#{token}?callbackURL=#{callback}" begin sender.call({user: found[:user], url: url, token: token}, ctx.request) rescue => error log(ctx.context, :error, "RESET_PASSWORD_EMAIL_ERROR #{error.}") end ctx.json({status: true, message: PASSWORD_RESET_MESSAGE}) end end |
.request_password_reset_callback ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/better_auth/routes/password.rb', line 78 def self.request_password_reset_callback Endpoint.new( path: "/reset-password/:token", method: "GET", query_schema: request_query_schema(required_strings: %w[callbackURL]), metadata: { openapi: { operationId: "requestPasswordResetCallback", description: "Validate a password reset token and redirect to the callback URL", parameters: [ { name: "token", in: "path", required: true, schema: {type: "string"} }, { name: "callbackURL", in: "query", required: true, schema: {type: "string"} } ], responses: { "302" => {description: "Redirects to callback URL with token or error"} } } } ) do |ctx| token = ctx.params[:token].to_s callback_url = fetch_value(ctx.query, "callbackURL") validate_callback_url!(ctx.context, callback_url) verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}") unless verification && !expired_time?(verification["expiresAt"]) raise ctx.redirect(absolute_callback(ctx.context, callback_url, error: "INVALID_TOKEN")) end raise ctx.redirect(absolute_callback(ctx.context, callback_url, token: token)) end end |
.request_query_schema(required_strings: [], optional_strings: []) ⇒ Object
19 20 21 22 23 24 25 26 27 |
# File 'lib/better_auth/routes/validation.rb', line 19 def self.request_query_schema(required_strings: [], optional_strings: []) ->(query) { data = request_validation_hash(query) return false unless required_strings.all? { |key| request_string?(data, key) } return false unless optional_strings.all? { |key| !data.key?(request_storage_key(key)) || request_string?(data, key) } data } end |
.request_storage_key(key) ⇒ Object
41 42 43 44 45 46 47 48 |
# File 'lib/better_auth/routes/validation.rb', line 41 def self.request_storage_key(key) key.to_s .gsub(/([a-z\d])([A-Z])/, "\\1_\\2") .tr("-", "_") .downcase .split("_") .then { |parts| ([parts.first] + parts.drop(1).map(&:capitalize)).join } end |
.request_string?(data, key) ⇒ Boolean
37 38 39 |
# File 'lib/better_auth/routes/validation.rb', line 37 def self.request_string?(data, key) data[request_storage_key(key)].is_a?(String) end |
.request_validation_hash(value) ⇒ Object
29 30 31 32 33 34 35 |
# File 'lib/better_auth/routes/validation.rb', line 29 def self.request_validation_hash(value) return {} unless value.is_a?(Hash) value.each_with_object({}) do |(key, object_value), result| result[request_storage_key(key)] = object_value end end |
.require_fresh_session!(ctx, session) ⇒ Object
371 372 373 374 375 376 377 378 379 |
# File 'lib/better_auth/routes/user.rb', line 371 def self.require_fresh_session!(ctx, session) fresh_age = ctx.context.session_config[:fresh_age].to_i return if fresh_age <= 0 created_at = Session.normalize_time(session[:session]["createdAt"] || session[:session]["created_at"]) return if created_at && created_at + fresh_age > Time.now raise APIError.new("BAD_REQUEST", code: "SESSION_EXPIRED", message: BASE_ERROR_CODES["SESSION_EXPIRED"]) end |
.reset_password ⇒ 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 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/better_auth/routes/password.rb', line 120 def self.reset_password Endpoint.new( path: "/reset-password", method: "POST", body_schema: request_body_schema( required_strings: %w[newPassword], optional_strings: %w[token] ), query_schema: request_query_schema(optional_strings: %w[token]), metadata: { openapi: { operationId: "resetPassword", description: "Reset a password using a reset token", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { token: {type: "string", description: "The password reset token"}, newPassword: {type: "string", description: "The new password to set"} }, required: ["token", "newPassword"] ) ), responses: { "200" => OpenAPI.json_response("Password reset successfully", OpenAPI.status_response_schema) } } } ) do |ctx| body = normalize_hash(ctx.body) token = body["token"] || fetch_value(ctx.query, "token") raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) if token.to_s.empty? password = body["newPassword"] || body["new_password"] validate_password_length!(password, ctx.context..email_and_password) verification = ctx.context.internal_adapter.consume_verification_value("reset-password:#{token}") raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless verification user_id = verification["value"] hashed = hash_password(ctx, password) account = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" } if account ctx.context.internal_adapter.update_password(user_id, hashed) else ctx.context.internal_adapter.create_account(userId: user_id, providerId: "credential", accountId: user_id, password: hashed) end if (callback = ctx.context..email_and_password[:on_password_reset]) user = ctx.context.internal_adapter.find_user_by_id(user_id) callback.call({user: user}, ctx.request) if user end ctx.context.internal_adapter.delete_user_sessions(user_id) if ctx.context..email_and_password[:revoke_sessions_on_password_reset] ctx.json({status: true}) end end |
.resolve_default(value) ⇒ Object
236 237 238 |
# File 'lib/better_auth/routes/sign_up.rb', line 236 def self.resolve_default(value) value.respond_to?(:call) ? value.call : value end |
.revoke_other_sessions ⇒ 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 |
# File 'lib/better_auth/routes/session.rb', line 158 def self.revoke_other_sessions Endpoint.new( path: "/revoke-other-sessions", method: "POST", metadata: { openapi: { operationId: "revokeOtherSessions", description: "Revoke all sessions except the current one", responses: { "200" => OpenAPI.json_response("Other sessions revoked", OpenAPI.status_response_schema) } } } ) do |ctx| session = current_session(ctx, sensitive: true) current_token = session[:session]["token"] sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"]) sessions.each do |entry| data = stringify_keys(entry) next if Session.expired?(data) || data["token"] == current_token ctx.context.internal_adapter.delete_session(data["token"]) end ctx.json({status: true}) end end |
.revoke_session ⇒ 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/routes/session.rb', line 102 def self.revoke_session Endpoint.new( path: "/revoke-session", method: "POST", metadata: { openapi: { operationId: "revokeSession", description: "Revoke a session by token", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { token: {type: "string", description: "The session token to revoke"} }, required: ["token"] ) ), responses: { "200" => OpenAPI.json_response("Session revoked", OpenAPI.status_response_schema) } } } ) do |ctx| session = current_session(ctx, sensitive: true) body = normalize_hash(ctx.body) token = body["token"].to_s found = ctx.context.internal_adapter.find_session(token) if found && stringify_keys(found[:session] || found["session"])["userId"] == session[:user]["id"] ctx.context.internal_adapter.delete_session(token) end ctx.json({status: true}) end end |
.revoke_sessions ⇒ Object
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/better_auth/routes/session.rb', line 137 def self.revoke_sessions Endpoint.new( path: "/revoke-sessions", method: "POST", metadata: { openapi: { operationId: "revokeSessions", description: "Revoke all sessions for the current user", responses: { "200" => OpenAPI.json_response("Sessions revoked", OpenAPI.status_response_schema) } } } ) do |ctx| session = current_session(ctx, sensitive: true) ctx.context.internal_adapter.delete_user_sessions(session[:user]["id"]) Cookies.(ctx) ctx.json({status: true}) end end |
.safe_additional_state(body) ⇒ Object
569 570 571 572 573 574 575 |
# File 'lib/better_auth/routes/social.rb', line 569 def self.safe_additional_state(body) additional = body["additionalData"] || body["additional_data"] return {} unless additional.is_a?(Hash) reserved = %w[callbackURL callbackUrl callback_url errorCallbackURL errorCallbackUrl error_callback_url errorURL error_url newUserCallbackURL newUserCallbackUrl new_user_callback_url newUserURL new_user_url requestSignUp request_sign_up codeVerifier code_verifier link expiresAt expires_at] normalize_hash(additional).reject { |key, _value| reserved.include?(key.to_s) } end |
.sanitize_html(value) ⇒ Object
112 113 114 115 116 117 118 119 |
# File 'lib/better_auth/routes/error.rb', line 112 def self.sanitize_html(value) value.to_s .gsub("<", "<") .gsub(">", ">") .gsub('"', """) .gsub("'", "'") .gsub(/&(?!(?:amp|lt|gt|quot|#39|#x[0-9a-fA-F]+|#[0-9]+);)/, "&") end |
.send_change_email_verification(ctx, sender, user, current_email, new_email, callback_url) ⇒ Object
342 343 344 345 346 |
# File 'lib/better_auth/routes/user.rb', line 342 def self.send_change_email_verification(ctx, sender, user, current_email, new_email, callback_url) token = create_email_verification_token(ctx, current_email, update_to: new_email, extra: {"requestType" => "change-email-verification"}) register_change_email_token!(ctx, token) sender.call({user: user.merge("email" => new_email), url: email_verification_url(ctx, token, callback_url), token: token}, ctx.request) end |
.send_change_email_verification_payload(ctx, user, update_to, callback_url) ⇒ Object
173 174 175 176 177 178 179 180 181 182 |
# File 'lib/better_auth/routes/email_verification.rb', line 173 def self.send_change_email_verification_payload(ctx, user, update_to, callback_url) sender = ctx.context..email_verification[:send_verification_email] return unless sender.respond_to?(:call) token = create_email_verification_token(ctx, user["email"], update_to: update_to, extra: {"requestType" => "change-email-verification"}) register_change_email_token!(ctx, token) callback = URI.encode_www_form_component(callback_url || "/") url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}" sender.call({user: user.merge("email" => update_to), url: url, token: token}, ctx.request) end |
.send_sign_in_verification_email(ctx, user, callback_url) ⇒ Object
100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/better_auth/routes/sign_in.rb', line 100 def self.send_sign_in_verification_email(ctx, user, callback_url) verification = ctx.context..email_verification sender = verification[:send_verification_email] return unless verification[:send_on_sign_in] && sender.respond_to?(:call) token = Crypto.sign_jwt( {"email" => user["email"].to_s.downcase}, ctx.context.secret, expires_in: verification[:expires_in] || 3600 ) callback = URI.encode_www_form_component(callback_url || "/") url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}" sender.call({user: user, url: url, token: token}, ctx.request) end |
.send_sign_up_verification_email(ctx, user, callback_url) ⇒ Object
246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 |
# File 'lib/better_auth/routes/sign_up.rb', line 246 def self.send_sign_up_verification_email(ctx, user, callback_url) verification = ctx.context..email_verification password_config = ctx.context..email_and_password send_on_sign_up = verification.key?(:send_on_sign_up) ? verification[:send_on_sign_up] : password_config[:require_email_verification] return unless send_on_sign_up sender = verification[:send_verification_email] return unless sender.respond_to?(:call) token = Crypto.sign_jwt( {"email" => user["email"].to_s.downcase}, ctx.context.secret, expires_in: verification[:expires_in] || 3600 ) callback = URI.encode_www_form_component(callback_url || "/") url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}" sender.call({user: user, url: url, token: token}, ctx.request) end |
.send_verification_email ⇒ 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 |
# File 'lib/better_auth/routes/email_verification.rb', line 7 def self.send_verification_email Endpoint.new( path: "/send-verification-email", method: "POST", metadata: { openapi: { operationId: "sendVerificationEmail", description: "Send an email verification link", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { email: {type: "string", description: "The email address to verify"}, callbackURL: {type: ["string", "null"], description: "The URL to redirect to after verification"} }, required: ["email"] ) ), responses: { "200" => OpenAPI.json_response("Verification email sent", OpenAPI.status_response_schema) } } } ) do |ctx| sender = ctx.context..email_verification[:send_verification_email] raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call) ctx.context.token_link_base_url body = normalize_hash(ctx.body) email = body["email"].to_s.downcase session = current_session(ctx, allow_nil: true) if session raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["EMAIL_MISMATCH"]) if session[:user]["email"] != email raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["EMAIL_ALREADY_VERIFIED"]) if session[:user]["emailVerified"] send_verification_email_payload(ctx, session[:user], body["callbackURL"] || body["callbackUrl"] || body["callback_url"]) next ctx.json({status: true}) end start = verification_email_monotonic_clock found = ctx.context.internal_adapter.find_user_by_email(email) if found && !found[:user]["emailVerified"] begin send_verification_email_payload(ctx, found[:user], body["callbackURL"] || body["callbackUrl"] || body["callback_url"]) rescue => error enforce_verification_email_floor(start) raise error end else create_email_verification_token(ctx, email) end enforce_verification_email_floor(start) ctx.json({status: true}) end end |
.send_verification_email_payload(ctx, user, callback_url) ⇒ Object
166 167 168 169 170 171 |
# File 'lib/better_auth/routes/email_verification.rb', line 166 def self.send_verification_email_payload(ctx, user, callback_url) token = create_email_verification_token(ctx, user["email"]) callback = URI.encode_www_form_component(callback_url || "/") url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}" ctx.context..email_verification[:send_verification_email].call({user: user, url: url, token: token}, ctx.request) end |
.session_needs_refresh?(ctx, session) ⇒ Boolean
224 225 226 227 228 229 230 231 232 |
# File 'lib/better_auth/routes/session.rb', line 224 def self.session_needs_refresh?(ctx, session) return false if truthy_query?(ctx.query, "disableRefresh") || ctx.context.session_config[:disable_session_refresh] update_age = ctx.context.session_config[:update_age].to_i return true if update_age.zero? updated_at = normalize_time(session["updatedAt"]) updated_at && updated_at + update_age <= Time.now end |
.session_overrides(ctx) ⇒ Object
272 273 274 275 276 277 |
# File 'lib/better_auth/routes/sign_up.rb', line 272 def self.session_overrides(ctx) { ipAddress: RequestIP.client_ip(ctx, ctx.context.).to_s, userAgent: ctx.headers["user-agent"].to_s } end |
.set_password ⇒ Object
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 |
# File 'lib/better_auth/routes/user.rb', line 101 def self.set_password Endpoint.new( path: "/set-password", method: "POST", metadata: { exclude_from_openapi: true, openapi: { operationId: "setPassword", description: "Set a password for the current user", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { newPassword: {type: "string", description: "The password to set"} }, required: ["newPassword"] ) ), responses: { "200" => OpenAPI.json_response("Password set", OpenAPI.status_response_schema) } } } ) do |ctx| session = current_session(ctx, sensitive: true) body = normalize_hash(ctx.body) new_password = body["newPassword"] || body["new_password"] validate_password_length!(new_password, ctx.context..email_and_password) account = credential_account(ctx, session[:user]["id"]) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_ALREADY_SET"]) if account && account["password"] ctx.context.internal_adapter.link_account( userId: session[:user]["id"], providerId: "credential", accountId: session[:user]["id"], password: hash_password(ctx, new_password) ) ctx.json({status: true}) end end |
.set_verified_session_cookie(ctx, user) ⇒ Object
258 259 260 261 262 263 264 265 266 |
# File 'lib/better_auth/routes/email_verification.rb', line 258 def self.(ctx, user) session = current_session(ctx, allow_nil: true) session_data = if session && session[:user]["id"] == user["id"] session[:session] else ctx.context.internal_adapter.create_session(user["id"]) end Cookies.(ctx, {session: session_data, user: user}) end |
.sign_in_email ⇒ 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 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/better_auth/routes/sign_in.rb', line 7 def self.sign_in_email Endpoint.new( path: "/sign-in/email", method: "POST", body_schema: request_body_schema(required_strings: %w[email password]), metadata: { allowed_media_types: [ "application/x-www-form-urlencoded", "application/json" ], openapi: { operationId: "signInEmail", description: "Sign in with email and password", requestBody: OpenAPI.json_request_body( OpenAPI.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" => OpenAPI.json_response( "Success - Returns either session details or redirect URL", OpenAPI.session_response_schema(description: "Session response when idToken is provided", nullable_url: true) ) } } } ) do |ctx| = ctx.context. email_config = .email_and_password if email_config[:enabled] != true raise APIError.new("BAD_REQUEST", code: "EMAIL_PASSWORD_DISABLED", message: BASE_ERROR_CODES["EMAIL_PASSWORD_DISABLED"]) end body = normalize_hash(ctx.body) email = body["email"].to_s password = body["password"].to_s callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"] remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"] validate_auth_callback_url!(ctx.context, callback_url, "callbackURL") preflight_sign_in_token_link!(ctx) unless EMAIL_PATTERN.match?(email) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) end found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true) unless found hash_password(ctx, password) raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_EMAIL_OR_PASSWORD"]) end user = found[:user] || found["user"] accounts = found[:accounts] || found["accounts"] || [] credential_account = accounts.find { |account| account["providerId"] == "credential" || account[:providerId] == "credential" } current_password = credential_account && (credential_account["password"] || credential_account[:password]) unless current_password && verify_password_value(ctx, password, current_password) hash_password(ctx, password) unless current_password raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_EMAIL_OR_PASSWORD"]) end if email_config[:require_email_verification] && !user["emailVerified"] send_sign_in_verification_email(ctx, user, callback_url) raise APIError.new("FORBIDDEN", message: BASE_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, session_overrides(ctx), true, ctx ) raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session Cookies.(ctx, {session: session, user: user}, dont_remember_me) ctx.set_header("location", callback_url) if callback_url ctx.json({ redirect: !!callback_url, token: session["token"], url: callback_url, user: Schema.parse_output(, "user", user) }) end end |
.sign_in_social ⇒ Object
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 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/better_auth/routes/social.rb', line 10 def self. Endpoint.new( path: "/sign-in/social", method: "POST", metadata: { openapi: { description: "Sign in with a social provider", operationId: "socialSignIn", requestBody: OpenAPI.json_request_body( OpenAPI.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" => OpenAPI.json_response( "Success - Returns either session details or redirect URL", OpenAPI.session_response_schema(description: "Session response when idToken is provided") ) } } } ) do |ctx| body = normalize_hash(ctx.body) provider_id = body["provider"].to_s provider = (ctx.context, provider_id) raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider (ctx.context, body["callbackURL"] || body["callbackUrl"] || body["callback_url"], "INVALID_CALLBACK_URL") (ctx.context, body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"], "INVALID_ERROR_CALLBACK_URL") (ctx.context, body["newUserCallbackURL"] || body["newUserCallbackUrl"] || body["new_user_callback_url"], "INVALID_NEW_USER_CALLBACK_URL") id_token = fetch_value(body, "idToken") if id_token data = (ctx, provider, id_token) session_data = ( ctx, provider_id, data[:user], token_hash_for_storage(ctx, data[:account]), callback_url: body["callbackURL"], disable_sign_up: provider_disable_sign_up?(provider) || (provider_disable_implicit_sign_up?(provider) && !body["requestSignUp"]) ) raise APIError.new("UNAUTHORIZED", message: session_data[:error], code: "OAUTH_LINK_ERROR") if session_data[:error] Cookies.(ctx, session_data) next ctx.json({ redirect: false, token: session_data[:session]["token"], url: nil, user: Schema.parse_output(ctx.context., "user", session_data[:user]) }) end code_verifier = SecureRandom.hex(16) state = Crypto.sign_jwt( { "callbackURL" => body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || "/", "errorCallbackURL" => body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"], "newUserCallbackURL" => body["newUserCallbackURL"] || body["newUserCallbackUrl"] || body["new_user_callback_url"], "requestSignUp" => body["requestSignUp"] || body["request_sign_up"], "codeVerifier" => code_verifier }.merge(safe_additional_state(body)), ctx.context.secret, expires_in: 600 ) (ctx, state) url = call_provider(provider, :create_authorization_url, { state: state, codeVerifier: code_verifier, code_verifier: code_verifier, redirectURI: "#{ctx.context.canonical_base_url}/callback/#{provider_id}", redirect_uri: "#{ctx.context.canonical_base_url}/callback/#{provider_id}", scopes: body["scopes"], loginHint: body["loginHint"] || body["login_hint"] }) ctx.set_header("location", url.to_s) unless body["disableRedirect"] || body["disable_redirect"] ctx.json({url: url.to_s, redirect: !(body["disableRedirect"] || body["disable_redirect"])}) end end |
.sign_out ⇒ Object
5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
# File 'lib/better_auth/routes/sign_out.rb', line 5 def self.sign_out Endpoint.new( path: "/sign-out", method: "POST", metadata: { openapi: { operationId: "signOut", description: "Sign out the current session", responses: { "200" => OpenAPI.json_response("Successfully signed out", OpenAPI.success_response_schema) } } } ) do |ctx| = ctx.context.[:session_token] token = ctx.(.name, ctx.context.secret) ctx.context.internal_adapter.delete_session(token) if token Cookies.(ctx) ctx.json({success: true}) end end |
.sign_up_email ⇒ Object
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 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 |
# File 'lib/better_auth/routes/sign_up.rb', line 10 def self.sign_up_email Endpoint.new( path: "/sign-up/email", method: "POST", body_schema: request_body_schema( required_strings: %w[name email], required_nonempty_strings: %w[password] ), metadata: { allowed_media_types: [ "application/x-www-form-urlencoded", "application/json" ], openapi: { operationId: "signUpWithEmailAndPassword", description: "Sign up a user using email and password", requestBody: OpenAPI.json_request_body( OpenAPI.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"] ), required: false ), responses: { "200" => OpenAPI.json_response( "Successfully created user", OpenAPI.object_schema( { token: {type: "string", nullable: true, description: "Authentication token for the session"}, user: {type: "object", "$ref": "#/components/schemas/User"} }, required: ["user"] ) ), "422" => OpenAPI.error_response("Unprocessable Entity. User already exists or failed to create user.") } } } ) do |ctx| = ctx.context. email_config = .email_and_password if email_config[:enabled] != true || email_config[:disable_sign_up] raise APIError.new("BAD_REQUEST", code: "EMAIL_PASSWORD_SIGN_UP_DISABLED", message: BASE_ERROR_CODES["EMAIL_PASSWORD_SIGN_UP_DISABLED"]) end preflight_sign_up_token_link!(ctx) body = normalize_hash(ctx.body) name = body["name"].to_s email = body["email"].to_s password = body["password"] image = body["image"] callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"] remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"] validate_auth_callback_url!(ctx.context, callback_url, "callbackURL") validate_sign_up_input!(email, password, email_config) reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me] additional_user_fields = parse_sign_up_additional_user_fields(ctx, body.except(*reserved)) ctx.context.adapter.transaction do existing = ctx.context.internal_adapter.find_user_by_email(email) if existing if email_config[:require_email_verification] || email_config[:auto_sign_in] == false hash_password(ctx, password) call_existing_sign_up_callback(ctx, email_config, existing) synthetic_user = synthetic_sign_up_user(ctx, additional_user_fields, email, name, image) next ctx.json({token: nil, user: Schema.parse_output(, "user", synthetic_user)}) end raise APIError.new( "UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"] ) end hashed_password = hash_password(ctx, password) created_user = create_sign_up_user(ctx, additional_user_fields, email, name, image) raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"]) unless created_user ctx.context.internal_adapter.link_account( userId: created_user["id"], providerId: "credential", accountId: created_user["id"], password: hashed_password ) send_sign_up_verification_email(ctx, created_user, callback_url) if email_config[:auto_sign_in] == false || email_config[:require_email_verification] next ctx.json({token: nil, user: Schema.parse_output(, "user", created_user)}) end dont_remember_me = remember_me == false || remember_me.to_s == "false" session = ctx.context.internal_adapter.create_session( created_user["id"], dont_remember_me, session_overrides(ctx), true, ctx ) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session Cookies.(ctx, {session: session, user: created_user}, dont_remember_me) ctx.json({token: session["token"], user: Schema.parse_output(, "user", created_user)}) end end end |
.social_log(context, level, message) ⇒ Object
626 627 628 629 630 631 632 633 |
# File 'lib/better_auth/routes/social.rb', line 626 def self.(context, level, ) logger = context.logger if logger.respond_to?(:call) logger.call(level, ) elsif logger.respond_to?(level) logger.public_send(level, ) end end |
.social_provider(context, provider_id) ⇒ Object
350 351 352 353 354 355 356 357 |
# File 'lib/better_auth/routes/account.rb', line 350 def self.(context, provider_id) return nil if provider_id.to_s.empty? provider = context.[provider_id.to_sym] || context.[provider_id.to_s] return provider.merge(id: provider_id.to_s) if provider.is_a?(Hash) && !provider.key?(:id) && !provider.key?("id") provider end |
.social_user_from_id_token!(ctx, provider, id_token) ⇒ Object
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 |
# File 'lib/better_auth/routes/social.rb', line 314 def self.(ctx, provider, id_token) token = fetch_value(id_token, "token").to_s unless provider_callable(provider, :verify_id_token) raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["ID_TOKEN_NOT_SUPPORTED"]) end valid = call_provider(provider, :verify_id_token, token, fetch_value(id_token, "nonce")) raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless valid token_user = parse_json_hash(fetch_value(id_token, "user")) user_info = call_provider(provider, :get_user_info, { "idToken" => token, "id_token" => token, "accessToken" => fetch_value(id_token, "accessToken"), "access_token" => fetch_value(id_token, "accessToken"), "refreshToken" => fetch_value(id_token, "refreshToken"), "refresh_token" => fetch_value(id_token, "refreshToken"), "user" => token_user }) user = user_info[:user] || user_info["user"] if user_info raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_USER_INFO"]) unless user raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["USER_EMAIL_NOT_FOUND"]) if fetch_value(user, "email").to_s.empty? raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_USER_INFO"]) if blank_remote_id?(fetch_value(user, "id")) { user: user, account: { "providerId" => fetch_value(provider, "id").to_s, "accountId" => fetch_value(user, "id").to_s, "accessToken" => fetch_value(id_token, "accessToken"), "refreshToken" => fetch_value(id_token, "refreshToken"), "idToken" => token, "user" => token_user } } end |
.store_oauth_state_cookie(ctx, state) ⇒ Object
434 435 436 437 438 439 |
# File 'lib/better_auth/routes/social.rb', line 434 def self.(ctx, state) return unless ctx.request = ctx.context.("state", max_age: 600) ctx.(.name, state, ctx.context.secret, .attributes) end |
.stringify_keys(value) ⇒ Object
264 265 266 267 268 269 |
# File 'lib/better_auth/routes/session.rb', line 264 def self.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_synthetic_user(value) ⇒ Object
240 241 242 243 244 |
# File 'lib/better_auth/routes/sign_up.rb', line 240 def self.stringify_synthetic_user(value) return value.each_with_object({}) { |(key, object_value), result| result[Schema.storage_key(key)] = object_value } if value.is_a?(Hash) {} end |
.synthetic_sign_up_user(ctx, additional, email, name, image) ⇒ Object
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/routes/sign_up.rb', line 179 def self.synthetic_sign_up_user(ctx, additional, email, name, image) now = Time.now core_fields = { "id" => SecureRandom.hex(16), "name" => name, "email" => email.to_s.downcase, "emailVerified" => false, "image" => image, "createdAt" => now, "updatedAt" => now } custom = ctx.context..email_and_password[:custom_synthetic_user] return core_fields.merge(additional) unless custom.respond_to?(:call) value = { core_fields: core_fields.except("id"), coreFields: core_fields.except("id"), additional_fields: additional, additionalFields: additional, id: core_fields["id"] } stringify_synthetic_user(custom.call(value)) end |
.token_hash(tokens) ⇒ Object
400 401 402 403 404 |
# File 'lib/better_auth/routes/account.rb', line 400 def self.token_hash(tokens) data = normalize_hash(tokens || {}) data["scope"] = Array(data.delete("scopes")).join(",") if data.key?("scopes") data end |
.token_hash_for_storage(ctx, tokens) ⇒ Object
406 407 408 409 410 411 |
# File 'lib/better_auth/routes/account.rb', line 406 def self.token_hash_for_storage(ctx, tokens) data = token_hash(tokens) data["accessToken"] = oauth_token_for_storage(ctx, data["accessToken"]) if data.key?("accessToken") data["refreshToken"] = oauth_token_for_storage(ctx, data["refreshToken"]) if data.key?("refreshToken") data end |
.truthy_query?(query, key) ⇒ Boolean
250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/better_auth/routes/session.rb', line 250 def self.truthy_query?(query, key) snake_key = key.to_s .gsub(/([a-z\d])([A-Z])/, "\\1_\\2") .tr("-", "_") .downcase value = query[key] || query[key.to_sym] || query[Schema.storage_key(key)] || query[Schema.storage_key(key).to_sym] || query[snake_key] || query[snake_key.to_sym] value == true || value.to_s == "true" end |
.unlink_account ⇒ Object
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
# File 'lib/better_auth/routes/account.rb', line 32 def self.unlink_account Endpoint.new( path: "/unlink-account", method: "POST", body_schema: request_body_schema( required_strings: %w[providerId], optional_strings: %w[accountId] ), metadata: { openapi: { operationId: "unlinkAccount", description: "Unlink an account from the current user", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { providerId: {type: "string"}, accountId: {type: ["string", "null"]} }, required: ["providerId"] ) ), responses: { "200" => OpenAPI.json_response("Account unlinked", OpenAPI.status_response_schema) } } } ) do |ctx| session = current_session(ctx, sensitive: true, fresh: true) body = normalize_hash(ctx.body) accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"]) if accounts.length == 1 && !ctx.context..account.dig(:account_linking, :allow_unlinking_all) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["FAILED_TO_UNLINK_LAST_ACCOUNT"]) end provider_id = body["providerId"] || body["provider_id"] raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty? account_id = body["accountId"] || body["account_id"] account = accounts.find do |entry| entry["providerId"] == provider_id && (account_id.to_s.empty? || entry["accountId"] == account_id) end raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["ACCOUNT_NOT_FOUND"]) unless account ctx.context.internal_adapter.delete_account(account["id"]) ctx.json({status: true}) end end |
.update_account_tokens(ctx, account, tokens) ⇒ Object
391 392 393 394 395 396 397 398 |
# File 'lib/better_auth/routes/account.rb', line 391 def self.update_account_tokens(ctx, account, tokens) return nil if account["id"].to_s.empty? data = account_token_update_hash(ctx, tokens) return nil if data.empty? ctx.context.internal_adapter.update_account(account["id"], data) end |
.update_session ⇒ Object
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/better_auth/routes/session.rb', line 69 def self.update_session Endpoint.new( path: "/update-session", method: "POST", metadata: { openapi: { operationId: "updateSession", description: "Update the current session", responses: { "200" => OpenAPI.json_response("Updated session", OpenAPI.session_response_schema_pair) } } } ) do |ctx| session = current_session(ctx) raise APIError.new("BAD_REQUEST", code: "BODY_MUST_BE_AN_OBJECT", message: BASE_ERROR_CODES["BODY_MUST_BE_AN_OBJECT"]) unless ctx.body.is_a?(Hash) body = Routes.parse_declared_input(ctx, "session", ctx.body, allowed_base: []) raise APIError.new("BAD_REQUEST", message: "No fields to update") if body.empty? update = body.merge("updatedAt" => Time.now) updated = ctx.context.internal_adapter.update_session(session[:session]["token"], update) if !updated && Session.stateful?(ctx) Cookies.(ctx) raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"]) end merged = session[:session].merge(updated || update) Cookies.(ctx, {session: merged, user: session[:user]}, Cookies.dont_remember?(ctx)) ctx.json({session: Schema.parse_output(ctx.context., "session", merged)}) end end |
.update_social_user_info_on_link(ctx, user_id, user_info) ⇒ Object
553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 |
# File 'lib/better_auth/routes/social.rb', line 553 def self.(ctx, user_id, user_info) return unless ctx.context..account.dig(:account_linking, :update_user_info_on_link) provider_fields = Schema.parse_provider_profile_user_input(ctx.context., user_info, action: :update) update = provider_fields.merge( "name" => fetch_value(user_info, "name"), "image" => fetch_value(user_info, "image") ).reject { |_key, value| value.nil? } return if update.empty? ctx.context.internal_adapter.update_user(user_id, update) rescue (ctx.context, :warn, "Could not update user info on account link") nil end |
.update_user ⇒ Object
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 |
# File 'lib/better_auth/routes/user.rb', line 8 def self.update_user Endpoint.new( path: "/update-user", method: "POST", metadata: { openapi: { operationId: "updateUser", description: "Update the current user's profile", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { name: {type: ["string", "null"], description: "The user's name"}, image: {type: ["string", "null"], description: "The user's profile image URL"} } ) ), responses: { "200" => OpenAPI.json_response("User updated", OpenAPI.status_response_schema) } } } ) do |ctx| session = current_session(ctx) raise APIError.new("BAD_REQUEST", code: "BODY_MUST_BE_AN_OBJECT", message: BASE_ERROR_CODES["BODY_MUST_BE_AN_OBJECT"]) unless ctx.body.is_a?(Hash) body = normalize_hash(ctx.body) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["EMAIL_CAN_NOT_BE_UPDATED"]) if body.key?("email") update = parse_declared_input(ctx, "user", body, allowed_base: ["name", "image"]) raise APIError.new("BAD_REQUEST", message: "No fields to update") if update.empty? updated = ctx.context.internal_adapter.update_user(session[:user]["id"], update) Cookies.(ctx, {session: session[:session], user: updated}, Cookies.dont_remember?(ctx)) ctx.json({status: true}) end end |
.update_verified_email_on_link(ctx, user_id, current_email, social_user) ⇒ Object
598 599 600 601 602 603 |
# File 'lib/better_auth/routes/social.rb', line 598 def self.update_verified_email_on_link(ctx, user_id, current_email, ) return unless fetch_value(, "emailVerified") return unless fetch_value(, "email").to_s.downcase == current_email.to_s.downcase ctx.context.internal_adapter.update_user(user_id, {"emailVerified" => true}) end |
.valid_error_code?(value) ⇒ Boolean
56 57 58 |
# File 'lib/better_auth/routes/error.rb', line 56 def self.valid_error_code?(value) /\A['A-Za-z0-9_-]+\z/.match?(value.to_s) end |
.valid_oauth_state_cookie?(ctx, state) ⇒ Boolean
441 442 443 444 445 446 447 448 |
# File 'lib/better_auth/routes/social.rb', line 441 def self.(ctx, state) return true unless ctx.request = ctx.context.("state", max_age: 600) stored = ctx.(.name, ctx.context.secret) Cookies.(ctx, ) stored == state end |
.validate_auth_callback_url!(context, value, label) ⇒ Object
143 144 145 146 147 148 |
# File 'lib/better_auth/routes/sign_up.rb', line 143 def self.validate_auth_callback_url!(context, value, label) return if value.nil? || value.to_s.empty? return if context.trusted_origin?(value.to_s, allow_relative_paths: true) raise APIError.new("FORBIDDEN", message: "Invalid #{label}") end |
.validate_callback_url!(context, callback_url) ⇒ Object
276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/better_auth/routes/password.rb', line 276 def self.validate_callback_url!(context, callback_url) return if callback_url.nil? || callback_url.to_s.empty? value = callback_url.to_s if value.start_with?("/") return if Configuration.relative_path_allowed?(value) else uri = Configuration.parse_uri(value) base_uri = Configuration.parse_uri(context.base_url.to_s) base_origin = base_uri && Configuration.origin_for(base_uri) return if uri && Configuration.origin_for(uri) == base_origin return if context.trusted_origin?(value) end raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_CALLBACK_URL"]) end |
.validate_change_email_token!(ctx, token, payload, callback_url) ⇒ Object
218 219 220 221 222 223 224 225 226 227 228 229 |
# File 'lib/better_auth/routes/email_verification.rb', line 218 def self.validate_change_email_token!(ctx, token, payload, callback_url) update_to = payload["updateTo"] || payload["update_to"] request_type = payload["requestType"] || payload["request_type"] return unless update_to && ["change-email-confirmation", "change-email-verification"].include?(request_type) identifier = change_email_token_identifier(token) verification = ctx.context.internal_adapter.find_verification_value(identifier) unless verification && !expired_time?(verification["expiresAt"]) redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["TOKEN_ALREADY_USED"], code: "TOKEN_ALREADY_USED") end identifier end |
.validate_password_length!(password, email_config) ⇒ Object
209 210 211 212 213 214 215 |
# File 'lib/better_auth/routes/password.rb', line 209 def self.validate_password_length!(password, email_config) unless password.is_a?(String) && !password.empty? raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"]) end raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_SHORT"]) if password.length < email_config[:min_password_length].to_i raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_LONG"]) if password.length > email_config[:max_password_length].to_i end |
.validate_redirect_url!(context, redirect_url) ⇒ Object
293 294 295 296 297 298 299 |
# File 'lib/better_auth/routes/password.rb', line 293 def self.validate_redirect_url!(context, redirect_url) validate_callback_url!(context, redirect_url) rescue APIError => error raise error unless error. == BASE_ERROR_CODES["INVALID_CALLBACK_URL"] raise APIError.new("FORBIDDEN", message: BASE_ERROR_CODES["INVALID_REDIRECT_URL"]) end |
.validate_sign_up_input!(email, password, email_config) ⇒ Object
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/better_auth/routes/sign_up.rb', line 125 def self.validate_sign_up_input!(email, password, email_config) unless EMAIL_PATTERN.match?(email) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) end unless password.is_a?(String) && !password.empty? raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"]) end if password.length < email_config[:min_password_length].to_i raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_SHORT"]) end if password.length > email_config[:max_password_length].to_i raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_LONG"]) end end |
.validate_social_callback_url!(context, callback_url, error_code) ⇒ Object
577 578 579 580 581 582 583 584 |
# File 'lib/better_auth/routes/social.rb', line 577 def self.(context, callback_url, error_code) validate_callback_url!(context, callback_url) rescue APIError => error return if oauth_proxy_callback_url?(context, callback_url) raise error unless error. == BASE_ERROR_CODES["INVALID_CALLBACK_URL"] raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES[error_code]) end |
.verification_email_monotonic_clock ⇒ Object
64 65 66 |
# File 'lib/better_auth/routes/email_verification.rb', line 64 def self.verification_email_monotonic_clock Process.clock_gettime(Process::CLOCK_MONOTONIC) end |
.verification_email_sleep(seconds) ⇒ Object
68 69 70 |
# File 'lib/better_auth/routes/email_verification.rb', line 68 def self.verification_email_sleep(seconds) sleep(seconds) end |
.verify_email ⇒ 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 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/better_auth/routes/email_verification.rb', line 77 def self.verify_email Endpoint.new( path: "/verify-email", method: "GET", metadata: { openapi: { operationId: "verifyEmail", description: "Verify an email address by token", parameters: [ { name: "token", in: "query", required: true, schema: {type: "string"} }, { name: "callbackURL", in: "query", required: false, schema: {type: "string"} } ], responses: { "200" => OpenAPI.json_response( "Email verified", OpenAPI.object_schema( { status: {type: "boolean"}, user: {type: ["object", "null"], "$ref": "#/components/schemas/User"} }, required: ["status"] ) ) } } } ) do |ctx| token = fetch_value(ctx.query, "token").to_s callback_url = fetch_value(ctx.query, "callbackURL") validate_callback_url!(ctx.context, callback_url) payload = verify_email_token(ctx, token, callback_url) change_email_token_identifier = validate_change_email_token!(ctx, token, payload, callback_url) email = payload["email"].to_s.downcase update_to = payload["updateTo"] || payload["update_to"] preflight_follow_up_verification_token_link!(ctx, payload, update_to) user_data = ctx.context.internal_adapter.find_user_by_email(email) return redirect_or_error(ctx, callback_url, "user_not_found") unless user_data user = user_data[:user] if update_to session = current_session(ctx, allow_nil: true) return redirect_or_error(ctx, callback_url, "invalid_user") if session && session[:user]["email"] != email consume_change_email_token!(ctx, change_email_token_identifier, callback_url) request_type = payload["requestType"] || payload["request_type"] case request_type when "change-email-confirmation" send_change_email_verification_payload(ctx, user, update_to, callback_url) next redirect_or_json(ctx, callback_url, {status: true}) when "change-email-verification" session_data = email_change_session_data(ctx, session, user) updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: true) updated_user = updated || user.merge("email" => update_to, "emailVerified" => true) call_option(ctx.context..email_verification[:after_email_verification], updated_user, ctx.request) Cookies.(ctx, {session: session_data, user: updated_user}) next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context., "user", updated_user)}) else session_data = email_change_session_data(ctx, session, user) updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: false) updated_user = updated || user.merge("email" => update_to, "emailVerified" => false) send_verification_email_payload(ctx, updated_user, callback_url) if ctx.context..email_verification[:send_verification_email].respond_to?(:call) Cookies.(ctx, {session: session_data, user: updated_user}) next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context., "user", updated)}) end end if user["emailVerified"] next redirect_or_json(ctx, callback_url, {status: true, user: nil}) end call_option(ctx.context..email_verification[:before_email_verification], user, ctx.request) call_option(ctx.context..email_verification[:on_email_verification], user, ctx.request) updated = ctx.context.internal_adapter.update_user_by_email(email, emailVerified: true) call_option(ctx.context..email_verification[:after_email_verification], updated, ctx.request) (ctx, updated) if ctx.context..email_verification[:auto_sign_in_after_verification] redirect_or_json(ctx, callback_url, {status: true, user: nil}) end end |
.verify_email_token(ctx, token, callback_url) ⇒ Object
198 199 200 201 202 203 204 205 |
# File 'lib/better_auth/routes/email_verification.rb', line 198 def self.verify_email_token(ctx, token, callback_url) decoded, = JWT.decode(token.to_s, ctx.context.secret.to_s, true, algorithm: "HS256") decoded rescue JWT::ExpiredSignature redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["TOKEN_EXPIRED"], code: "TOKEN_EXPIRED") rescue JWT::DecodeError redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["INVALID_TOKEN"], code: "INVALID_TOKEN") end |
.verify_password ⇒ Object
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/better_auth/routes/password.rb', line 176 def self.verify_password Endpoint.new( path: "/verify-password", method: "POST", metadata: { scope: "server", openapi: { operationId: "verifyPassword", description: "Verify the current user's password", requestBody: OpenAPI.json_request_body( OpenAPI.object_schema( { password: {type: "string", description: "The password to verify"} }, required: ["password"] ) ), responses: { "200" => OpenAPI.json_response("Password verified", OpenAPI.status_response_schema) } } } ) do |ctx| session = current_session(ctx, sensitive: true) password = normalize_hash(ctx.body)["password"].to_s account = credential_account(ctx, session[:user]["id"]) valid = account && account["password"] && verify_password_value(ctx, password, account["password"]) raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"]) unless valid ctx.json({status: true}) end end |
.verify_password_value(ctx, password, digest) ⇒ Object
234 235 236 237 238 239 240 241 |
# File 'lib/better_auth/routes/password.rb', line 234 def self.verify_password_value(ctx, password, digest) Password.verify( password: password, hash: digest, verifier: ctx.context..email_and_password.dig(:password, :verify), algorithm: ctx.context..password_hasher ) end |