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

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

Returns:

  • (Boolean)


377
378
379
380
# File 'lib/better_auth/routes/account.rb', line 377

def self.access_token_expired?()
  value = parse_time(["accessTokenExpiresAt"])
  value && value < Time.now + 5
end

.access_token_response(ctx, user_id:, provider_id:, account_id: nil, provider: nil) ⇒ Object

Raises:



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 ||= social_provider(ctx.context, provider_id)
  raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider

   = (ctx, provider_id, , user_id) || (ctx, user_id, provider_id, )
  raise APIError.new("BAD_REQUEST", message: "Account not found") unless 

  if ["refreshToken"] && access_token_expired?() && provider_callable(provider, :refresh_access_token)
    begin
      tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, ["refreshToken"]))
      updated = (ctx, , tokens)
       = .merge(token_hash(tokens))
      Cookies.(ctx, updated || .merge(token_hash_for_storage(ctx, tokens)))
    rescue => error
      log(ctx.context, :error, "FAILED_TO_GET_ACCESS_TOKEN #{error.message}")
      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, ["accessToken"]),
    accessTokenExpiresAt: ["accessTokenExpiresAt"],
    scopes: ["scopes"] || (["scope"].to_s.empty? ? [] : ["scope"].to_s.split(",")),
    idToken: ["idToken"]
  }
end


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,  = nil, user_id = nil)
  return nil unless ctx.context.options.[:store_account_cookie]

   = Cookies.(ctx)
  return nil unless 
  return nil if provider_id && ["providerId"] != provider_id
  return nil unless .to_s.empty? || ["id"] ==  || ["accountId"] == 
  return nil unless user_id.to_s.empty? || ["userId"].to_s.empty? || ["userId"] == user_id

  
end

.account_infoObject



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

     = fetch_value(ctx.query, "accountId")
    provider_id = fetch_value(ctx.query, "providerId")
     = if !.to_s.empty?
      matching_accounts = ctx.context.internal_adapter.find_accounts(user_id).select do |entry|
        entry["accountId"] ==  && (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  && ["userId"] == user_id

    provider = social_provider(ctx.context, ["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: ["providerId"],
      account_id: ["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.(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.(ctx, tokens)
  (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

Returns:

  • (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.(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_oauthObject



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.options.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 = social_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
      social_log(ctx.context, :error, "OAuth code validation failed: #{error.message}")
      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"]
     = begin
      call_provider(provider, :get_user_info, token_data)
    rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError
      nil
    end
    user = [:user] || ["user"] if 
    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

     = token_hash_for_storage(ctx, tokens).merge("accountId" => fetch_value(user, "id").to_s)
    session_data = persist_social_user(
      ctx,
      provider_id,
      user,
      ,
      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.set_session_cookie(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_emailObject



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.options.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.options.email_verification[:send_verification_email]
    confirmation_sender = ctx.context.options.user.dig(:change_email, :send_change_email_confirmation)
    can_update_without_verification = !session[:user]["emailVerified"] && ctx.context.options.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.set_session_cookie(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_passwordObject



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.options.email_and_password)
     = (ctx, session[:user]["id"])
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless  && ["password"]

    unless verify_password_value(ctx, current_password.to_s, ["password"])
      raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
    end

    ctx.context.internal_adapter.(["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.set_session_cookie(ctx, {session: new_session, user: session[:user]})
      token = new_session["token"]
    end
    ctx.json({token: token, user: Schema.parse_output(ctx.context.options, "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.options.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.(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.(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

Raises:



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

Raises:



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.options.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.delete_session_cookie(ctx)
  call_option(config[:after_delete], session[:user], ctx.request)
end

.delete_userObject



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.options.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.options.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"]
       = (ctx, session[:user]["id"])
      raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless  && ["password"]

      unless verify_password_value(ctx, body["password"], ["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.options.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_callbackObject



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

Raises:



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

.errorObject



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.options.on_api_error[:error_url]

    if error_url
      location = append_query(error_url, query_params)
      next [302, {"location" => location}, [""]]
    end

    if ctx.context.options.production? && !ctx.context.options.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

Returns:

  • (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.(ctx, user_id, provider_id,  = nil)
  ctx.context.internal_adapter.find_accounts(user_id).find do ||
    ["providerId"] == provider_id && (.to_s.empty? || ["id"] ==  || ["accountId"] == )
  end
end

.get_access_tokenObject



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?

     = body["accountId"] || body["account_id"]
    ctx.json(access_token_response(ctx, user_id: user_id, provider_id: provider_id, account_id: ))
  end
end

.get_sessionObject



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.message}")
    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.options.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.options.password_hasher
  )
end

.hasher_arity_accepts_context?(hasher) ⇒ Boolean

Returns:

  • (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

Returns:

  • (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.options.[: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

Returns:

  • (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


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.link_social
  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 = social_provider(ctx.context, provider_id)
    raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider
    validate_social_callback_url!(ctx.context, body["callbackURL"] || body["callbackUrl"] || body["callback_url"], "INVALID_CALLBACK_URL")
    validate_social_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 = social_user_from_id_token!(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.options..dig(:account_linking, :allow_different_emails)
        raise APIError.new("UNAUTHORIZED", message: "Account not linked - different emails not allowed")
      end

       = fetch_value(data[:user], "id").to_s
      existing = ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do ||
        ["providerId"] == provider_id && ["accountId"] == 
      end
      unless existing
        begin
           = ctx.context.internal_adapter.(token_hash_for_storage(ctx, data[:account]).merge("userId" => session[:user]["id"]))
          raise BetterAuth::Error, "Unable to link social account" unless 
        rescue
          social_log(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)
    store_oauth_state_cookie(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


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, , tokens, link)
  return {error: "unable_to_link_account"} unless linkable_provider?(ctx, provider_id, )
  return {error: "unable_to_link_account"} if blank_remote_id?(fetch_value(, "id"))

  email = fetch_value(, "email").to_s.downcase
  link_email = fetch_value(link, "email").to_s.downcase
  unless email == link_email || ctx.context.options..dig(:account_linking, :allow_different_emails)
    return {error: "email_doesn't_match"}
  end

   = fetch_value(, "id").to_s
  user_id = fetch_value(link, "userId").to_s
   = token_hash_for_storage(ctx, tokens).merge(
    "providerId" => provider_id,
    "accountId" => ,
    "userId" => user_id
  )
  existing = ctx.context.internal_adapter.(, provider_id)
  begin
     = if existing
      return {error: "account_already_linked_to_different_user"} if existing["userId"].to_s != user_id

      ctx.context.internal_adapter.(existing["id"], )
    else
      ctx.context.internal_adapter.()
    end
    raise BetterAuth::Error, "Unable to link social account" unless 
  rescue
    social_log(ctx.context, :error, "Unable to link social account")
    return {error: "unable_to_link_account"}
  end

  (ctx, user_id, )
  update_verified_email_on_link(ctx, user_id, link_email, )

  {status: true}
end

.linkable_provider?(ctx, provider_id, user_info, implicit: false, local_user: nil) ⇒ Boolean

Returns:

  • (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, , implicit: false, local_user: nil)
  linking = ctx.context.options.[: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(, "emailVerified")
end

.list_accountsObject



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 ||
      parsed = Schema.parse_output(ctx.context.options, "account", )
      scope = parsed.delete("scope")
      parsed.merge("scopes" => scope.to_s.empty? ? [] : scope.to_s.split(","))
    end
    ctx.json(accounts)
  end
end

.list_sessionsObject



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.options, "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, message)
  logger = context.logger
  if logger.respond_to?(:call)
    logger.call(level, message)
  elsif logger.respond_to?(level)
    logger.public_send(level, message)
  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

Returns:

  • (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.options.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.options.[: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.options.[:encrypt_oauth_tokens]

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

.okObject



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, )
  email = fetch_value(, "email").to_s.downcase
  email_verified = if email == user["email"].to_s.downcase
    !!(user["emailVerified"] || fetch_value(, "emailVerified"))
  else
    !!fetch_value(, "emailVerified")
  end
  provider_fields = Schema.parse_provider_profile_user_input(ctx.context.options, , action: :update)
  update = provider_fields.merge(
    "email" => email,
    "name" => fetch_value(, "name").to_s,
    "image" => fetch_value(, "image"),
    "emailVerified" => email_verified
  ).reject { |_key, value| value.nil? }
  ctx.context.internal_adapter.update_user(user["id"], update)
rescue
  social_log(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.options)[model.to_s]
  fields = table ? table.fetch(:fields) : {}
  additional = ctx.context.options.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.(ctx, data)
  input = normalize_hash(data || {})
  fields = Schema.auth_tables(ctx.context.options).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.options, "session", session[:session]),
    user: Schema.parse_output(ctx.context.options, "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.persist_social_user(ctx, provider_id, , , callback_url: nil, disable_sign_up: false, override_user_info: false)
  email = fetch_value(, "email").to_s.downcase
   = (["accountId"] || [:accountId] || [:account_id] || fetch_value(, "id")).to_s
  return {error: "unable to get user info"} if blank_remote_id?()

  existing = begin
    ctx.context.internal_adapter.find_oauth_user(email, , provider_id)
  rescue
    social_log(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.options.[:update_account_on_sign_in] != false
      update_data = ()
      unless update_data.empty?
        begin
          ctx.context.internal_adapter.(existing[:linked_account]["id"], update_data)
        rescue
          social_log(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 = verified_user if verified_user
    new_user = false
  elsif existing
    linkable = linkable_provider?(ctx, provider_id, , 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
       = begin
        ctx.context.internal_adapter.(.merge("providerId" => provider_id, "accountId" => , "userId" => user["id"]))
      rescue
        social_log(ctx.context, :error, "Unable to link social account")
        raise
      end
      unless 
        social_log(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, )
      linked_user = promoted || user
      (ctx, linked_user["id"], ) || linked_user
    end
    new_user = false
  else
    return {error: "signup disabled"} if 

    begin
      provider_fields = Schema.parse_provider_profile_user_input(ctx.context.options, , action: :create)
      created = ctx.context.internal_adapter.create_oauth_user(
        provider_fields.merge(
          email: email,
          name: fetch_value(, "name").to_s,
          image: fetch_value(, "image"),
          emailVerified: !!fetch_value(, "emailVerified")
        ),
        .merge("providerId" => provider_id, "accountId" => ),
        context: ctx
      )
      raise BetterAuth::Error, "Failed to create social user" unless created
    rescue
      social_log(ctx.context, :error, "Unable to create social user")
      return {error: "unable to create user"}
    end
    user = created[:user]
    new_user = true
  end
  if existing && ( || (provider_id, ctx.context))
    user = (ctx, user, )
  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.options.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.(ctx)
  verification = ctx.context.options.email_verification
  password_config = ctx.context.options.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.(ctx)
  verification = ctx.context.options.email_verification
  password_config = ctx.context.options.email_and_password
   = verification.key?(:send_on_sign_up) ? verification[:send_on_sign_up] : password_config[:require_email_verification]
  ctx.context.token_link_base_url if  && verification[:send_verification_email].respond_to?(:call)
end

.promote_verified_email_on_link!(ctx, local_user, social_user) ⇒ Object

Raises:



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, social_user)
  return if local_user["emailVerified"] == true
  return unless fetch_value(social_user, "emailVerified")
  return unless fetch_value(social_user, "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

Returns:

  • (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

Returns:

  • (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

Returns:

  • (Boolean)


528
529
530
531
# File 'lib/better_auth/routes/social.rb', line 528

def self.(provider_id, context)
  provider = social_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

Raises:



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_tokenObject



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

     = body["accountId"] || body["account_id"]
     = (ctx, provider_id, , user_id) || (ctx, user_id, provider_id, )
    raise APIError.new("BAD_REQUEST", message: "Account not found") unless 
    refresh_token = oauth_token_value(ctx, ["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 = (ctx, , tokens)
      values = token_hash(tokens)
      Cookies.(ctx, updated || .merge(token_hash_for_storage(ctx, tokens)))
    rescue => error
      log(ctx.context, :error, "FAILED_TO_REFRESH_ACCESS_TOKEN #{error.message}")
      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"] || ["refreshTokenExpiresAt"],
      scope: values["scope"] || ["scope"],
      idToken: values["idToken"] || ["idToken"],
      providerId: ["providerId"],
      accountId: ["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.options.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_resetObject



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.options.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.options.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.message}")
    end
    ctx.json({status: true, message: PASSWORD_RESET_MESSAGE})
  end
end

.request_password_reset_callbackObject



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

Returns:

  • (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

Raises:



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_passwordObject



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.options.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)
     = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
    if 
      ctx.context.internal_adapter.update_password(user_id, hashed)
    else
      ctx.context.internal_adapter.(userId: user_id, providerId: "credential", accountId: user_id, password: hashed)
    end
    if (callback = ctx.context.options.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.options.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_sessionsObject



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_sessionObject



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_sessionsObject



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.delete_session_cookie(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("<", "&lt;")
    .gsub(">", "&gt;")
    .gsub('"', "&quot;")
    .gsub("'", "&#39;")
    .gsub(/&(?!(?:amp|lt|gt|quot|#39|#x[0-9a-fA-F]+|#[0-9]+);)/, "&amp;")
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.options.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.(ctx, user, callback_url)
  verification = ctx.context.options.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.(ctx, user, callback_url)
  verification = ctx.context.options.email_verification
  password_config = ctx.context.options.email_and_password
   = verification.key?(:send_on_sign_up) ? verification[:send_on_sign_up] : password_config[:require_email_verification]
  return unless 

  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_emailObject



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.options.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.options.email_verification[:send_verification_email].call({user: user, url: url, token: token}, ctx.request)
end

.session_needs_refresh?(ctx, session) ⇒ Boolean

Returns:

  • (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.options).to_s,
    userAgent: ctx.headers["user-agent"].to_s
  }
end

.set_passwordObject



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.options.email_and_password)
     = (ctx, session[:user]["id"])
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_ALREADY_SET"]) if  && ["password"]

    ctx.context.internal_adapter.(
      userId: session[:user]["id"],
      providerId: "credential",
      accountId: session[:user]["id"],
      password: hash_password(ctx, new_password)
    )
    ctx.json({status: true})
  end
end


258
259
260
261
262
263
264
265
266
# File 'lib/better_auth/routes/email_verification.rb', line 258

def self.set_verified_session_cookie(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.set_session_cookie(ctx, {session: session_data, user: user})
end

.sign_in_emailObject



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.
  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|
    options = ctx.context.options
    email_config = options.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")
    (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"] || []
     = accounts.find { || ["providerId"] == "credential" || [:providerId] == "credential" }
    current_password =  && (["password"] || [: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"]
      (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.set_session_cookie(ctx, {session: session, user: user}, dont_remember_me)
    ctx.set_header("location", callback_url) if callback_url
    ctx.json({
      redirect: !!callback_url,
      token: session["token"],
      url: callback_url,
      user: Schema.parse_output(options, "user", user)
    })
  end
end

.sign_in_socialObject



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 = social_provider(ctx.context, provider_id)
    raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider
    validate_social_callback_url!(ctx.context, body["callbackURL"] || body["callbackUrl"] || body["callback_url"], "INVALID_CALLBACK_URL")
    validate_social_callback_url!(ctx.context, body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"], "INVALID_ERROR_CALLBACK_URL")
    validate_social_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 = social_user_from_id_token!(ctx, provider, id_token)
      session_data = persist_social_user(
        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.set_session_cookie(ctx, session_data)
      next ctx.json({
        redirect: false,
        token: session_data[:session]["token"],
        url: nil,
        user: Schema.parse_output(ctx.context.options, "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
    )
    store_oauth_state_cookie(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_outObject



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|
    token_cookie = ctx.context.auth_cookies[:session_token]
    token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
    ctx.context.internal_adapter.delete_session(token) if token
    Cookies.delete_session_cookie(ctx)
    ctx.json({success: true})
  end
end

.sign_up_emailObject



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.
  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|
    options = ctx.context.options
    email_config = options.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
    (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")
    (email, password, email_config)
    reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
    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)
          (ctx, email_config, existing)
          synthetic_user = (ctx, additional_user_fields, email, name, image)
          next ctx.json({token: nil, user: Schema.parse_output(options, "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 = (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.(
        userId: created_user["id"],
        providerId: "credential",
        accountId: created_user["id"],
        password: hashed_password
      )

      (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(options, "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.set_session_cookie(ctx, {session: session, user: created_user}, dont_remember_me)
      ctx.json({token: session["token"], user: Schema.parse_output(options, "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.social_log(context, level, message)
  logger = context.logger
  if logger.respond_to?(:call)
    logger.call(level, message)
  elsif logger.respond_to?(level)
    logger.public_send(level, message)
  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.social_provider(context, provider_id)
  return nil if provider_id.to_s.empty?

  provider = context.social_providers[provider_id.to_sym] || context.social_providers[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

Raises:



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.social_user_from_id_token!(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"))
   = 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] || ["user"] if 
  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


434
435
436
437
438
439
# File 'lib/better_auth/routes/social.rb', line 434

def self.store_oauth_state_cookie(ctx, state)
  return unless ctx.request

  cookie = ctx.context.create_auth_cookie("state", max_age: 600)
  ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.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.(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.options.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

Returns:

  • (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


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

     = body["accountId"] || body["account_id"]
     = accounts.find do |entry|
      entry["providerId"] == provider_id && (.to_s.empty? || entry["accountId"] == )
    end
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["ACCOUNT_NOT_FOUND"]) unless 

    ctx.context.internal_adapter.(["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.(ctx, , tokens)
  return nil if ["id"].to_s.empty?

  data = (ctx, tokens)
  return nil if data.empty?

  ctx.context.internal_adapter.(["id"], data)
end

.update_sessionObject



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.delete_session_cookie(ctx)
      raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"])
    end

    merged = session[:session].merge(updated || update)
    Cookies.set_session_cookie(ctx, {session: merged, user: session[:user]}, Cookies.dont_remember?(ctx))
    ctx.json({session: Schema.parse_output(ctx.context.options, "session", merged)})
  end
end


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, )
  return unless ctx.context.options..dig(:account_linking, :update_user_info_on_link)

  provider_fields = Schema.parse_provider_profile_user_input(ctx.context.options, , action: :update)
  update = provider_fields.merge(
    "name" => fetch_value(, "name"),
    "image" => fetch_value(, "image")
  ).reject { |_key, value| value.nil? }
  return if update.empty?

  ctx.context.internal_adapter.update_user(user_id, update)
rescue
  social_log(ctx.context, :warn, "Could not update user info on account link")
  nil
end

.update_userObject



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.set_session_cookie(ctx, {session: session[:session], user: updated}, Cookies.dont_remember?(ctx))
    ctx.json({status: true})
  end
end


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, social_user)
  return unless fetch_value(social_user, "emailVerified")
  return unless fetch_value(social_user, "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

Returns:

  • (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

Returns:

  • (Boolean)


441
442
443
444
445
446
447
448
# File 'lib/better_auth/routes/social.rb', line 441

def self.valid_oauth_state_cookie?(ctx, state)
  return true unless ctx.request

  cookie = ctx.context.create_auth_cookie("state", max_age: 600)
  stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
  Cookies.expire_cookie(ctx, cookie)
  stored == state
end

.validate_auth_callback_url!(context, value, label) ⇒ Object

Raises:



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

Raises:



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

Raises:



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.message == 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.(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.validate_social_callback_url!(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.message == BASE_ERROR_CODES["INVALID_CALLBACK_URL"]

  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES[error_code])
end

.verification_email_monotonic_clockObject



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_emailObject



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.options.email_verification[:after_email_verification], updated_user, ctx.request)
        Cookies.set_session_cookie(ctx, {session: session_data, user: updated_user})
        next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "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.options.email_verification[:send_verification_email].respond_to?(:call)
        Cookies.set_session_cookie(ctx, {session: session_data, user: updated_user})
        next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
      end
    end

    if user["emailVerified"]
      next redirect_or_json(ctx, callback_url, {status: true, user: nil})
    end

    call_option(ctx.context.options.email_verification[:before_email_verification], user, ctx.request)
    call_option(ctx.context.options.email_verification[:on_email_verification], user, ctx.request)
    updated = ctx.context.internal_adapter.update_user_by_email(email, emailVerified: true)
    call_option(ctx.context.options.email_verification[:after_email_verification], updated, ctx.request)
    set_verified_session_cookie(ctx, updated) if ctx.context.options.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_passwordObject



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
     = (ctx, session[:user]["id"])
    valid =  && ["password"] && verify_password_value(ctx, password, ["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.options.email_and_password.dig(:password, :verify),
    algorithm: ctx.context.options.password_hasher
  )
end