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/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"

Class Method Summary collapse

Class Method Details

.absolute_callback(context, callback_url, params) ⇒ Object



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

def self.absolute_callback(context, callback_url, params)
  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)


153
154
155
156
# File 'lib/better_auth/routes/account.rb', line 153

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


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

def self.(ctx, provider_id,  = nil, user_id = nil)
  return nil unless ctx.context.options.[:store_account_cookie]

   = Cookies.(ctx)
  return nil unless  && ["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



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

def self.
  Endpoint.new(path: "/account-info", method: "GET") do |ctx|
    session = current_session(ctx)
     = fetch_value(ctx.query, "accountId")
     = if 
      ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |entry|
        entry["id"] ==  || entry["accountId"] == 
      end
    end
    raise APIError.new("BAD_REQUEST", message: "Account not found") unless  && ["userId"] == session[:user]["id"]

    provider = social_provider(ctx.context, ["providerId"])
    raise APIError.new("INTERNAL_SERVER_ERROR", message: "Provider account provider is #{["providerId"]} but it is not configured") unless provider
    raise APIError.new("BAD_REQUEST", message: "Access token not found") if ["accessToken"].to_s.empty?

    info = call_provider(provider, :get_user_info, {
      accessToken: oauth_token_value(ctx, ["accessToken"]),
      access_token: oauth_token_value(ctx, ["accessToken"]),
      idToken: ["idToken"],
      scopes: ["scope"].to_s.split(",")
    })
    ctx.json(info)
  end
end

.append_query(url, query) ⇒ Object



47
48
49
50
# File 'lib/better_auth/routes/error.rb', line 47

def self.append_query(url, query)
  separator = url.include?("?") ? "&" : "?"
  "#{url}#{separator}#{query}"
end

.call_option(callback, user, request) ⇒ Object



104
105
106
# File 'lib/better_auth/routes/email_verification.rb', line 104

def self.call_option(callback, user, request)
  callback.call(user, request) if callback.respond_to?(:call)
end

.call_provider(provider, key, *arguments) ⇒ Object



204
205
206
207
208
209
# File 'lib/better_auth/routes/account.rb', line 204

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



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

def self.callback_oauth
  Endpoint.new(
    path: "/callback/:providerId",
    method: ["GET", "POST"],
    metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}
  ) do |ctx|
    source = (ctx.method == "POST") ? ctx.body.merge(ctx.query) : ctx.query
    data = normalize_hash(source)
    provider_id = fetch_value(ctx.params, "providerId").to_s
    provider = social_provider(ctx.context, provider_id)
    state = data["state"].to_s
    state_data = Crypto.verify_jwt(state, ctx.context.secret) || {}
    error_url = state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error"

    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, "oauth_provider_not_found")) unless provider
    raise ctx.redirect(oauth_error_url(error_url, "state_not_found")) if state.empty?
    raise ctx.redirect(oauth_error_url(error_url, "no_code")) if data["code"].to_s.empty?

    tokens = call_provider(provider, :validate_authorization_code, {
      code: data["code"],
      codeVerifier: state_data["codeVerifier"],
      code_verifier: state_data["codeVerifier"],
      redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
      redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}"
    })
    raise ctx.redirect(oauth_error_url(error_url, "invalid_code")) unless tokens

     = call_provider(provider, :get_user_info, token_hash(tokens))
    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?

    session_data = persist_social_user(ctx, provider_id, user, token_hash(tokens).merge("accountId" => fetch_value(user, "id").to_s))
    Cookies.set_session_cookie(ctx, session_data)
    callback_url = state_data["callbackURL"] || "/"
    raise ctx.redirect(callback_url)
  end
end

.change_emailObject



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
# File 'lib/better_auth/routes/user.rb', line 110

def self.change_email
  Endpoint.new(path: "/change-email", method: "POST") do |ctx|
    enabled = ctx.context.options.user.dig(:change_email, :enabled)
    raise APIError.new("BAD_REQUEST", message: "Change email is 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"]
    raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"]) if ctx.context.internal_adapter.find_user_by_email(new_email)

    if !session[:user]["emailVerified"] && ctx.context.options.user.dig(:change_email, :update_email_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})
      next ctx.json({status: true})
    end

    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)

    token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-verification"})
    sender.call({user: session[:user].merge("email" => new_email), token: token}, ctx.request)
    ctx.json({status: true})
  end
end

.change_passwordObject



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 20

def self.change_password
  Endpoint.new(path: "/change-password", method: "POST") 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"])
    unless  && ["password"] && 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_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



175
176
177
178
179
180
# File 'lib/better_auth/routes/user.rb', line 175

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

.core_model_fields(model) ⇒ Object



182
183
184
185
186
187
188
189
190
191
# File 'lib/better_auth/routes/user.rb', line 182

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



71
72
73
74
75
# File 'lib/better_auth/routes/email_verification.rb', line 71

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



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

def self.(ctx, body, email, name, image)
  reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
  additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
  ctx.context.internal_adapter.create_user(
    additional.merge(
      "email" => email.downcase,
      "name" => name,
      "image" => image,
      "emailVerified" => false
    )
  )
rescue APIError
  raise
rescue
  raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"])
end

.credential_account(ctx, user_id) ⇒ Object



132
133
134
# File 'lib/better_auth/routes/password.rb', line 132

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

Raises:



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

def self.current_session(ctx, allow_nil: false, sensitive: false)
  data = Session.find_current(
    ctx,
    disable_cookie_cache: truthy_query?(ctx.query, "disableCookieCache"),
    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"]),
    user: stringify_keys(data[:user] || data["user"])
  }
end

.default_error_description(code) ⇒ Object



88
89
90
91
# File 'lib/better_auth/routes/error.rb', line 88

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



144
145
146
147
148
149
150
151
# File 'lib/better_auth/routes/user.rb', line 144

def self.delete_current_user!(ctx, session)
  config = ctx.context.options.user[:delete_user] || {}
  call_option(config[:before_delete], session[:user], ctx.request)
  ctx.context.internal_adapter.delete_user(session[:user]["id"])
  ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
  Cookies.delete_session_cookie(ctx)
  call_option(config[:after_delete], session[:user], ctx.request)
end

.delete_userObject



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
# File 'lib/better_auth/routes/user.rb', line 63

def self.delete_user
  Endpoint.new(path: "/delete-user", method: "POST") 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)
    if body["password"]
       = (ctx, session[:user]["id"])
      unless  && ["password"] && 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 = ctx.context.options.user.dig(:delete_user, :send_delete_account_verification))
      token = SecureRandom.hex(16)
      ctx.context.internal_adapter.create_verification_value(
        identifier: "delete-account-#{token}",
        value: session[:user]["id"],
        expiresAt: Time.now + ctx.context.options.user.dig(:delete_user, :delete_token_expires_in).to_i
      )
      sender.call({user: session[:user], token: token}, ctx.request)
      next ctx.json({success: true, message: "Verification email sent"})
    end

    delete_current_user!(ctx, session)
    ctx.json({success: true, message: "User deleted"})
  end
end

.delete_user_by_token!(ctx, session, token) ⇒ Object



136
137
138
139
140
141
142
# File 'lib/better_auth/routes/user.rb', line 136

def self.delete_user_by_token!(ctx, session, token)
  verification = ctx.context.internal_adapter.find_verification_value("delete-account-#{token}")
  unless verification && verification["value"] == session[:user]["id"] && !expired_time?(verification["expiresAt"])
    raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["INVALID_TOKEN"])
  end
  ctx.context.internal_adapter.delete_verification_value(verification["id"])
end

.delete_user_callbackObject



95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/better_auth/routes/user.rb', line 95

def self.delete_user_callback
  Endpoint.new(path: "/delete-user/callback", method: "GET") 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")
    delete_user_by_token!(ctx, session, token)
    delete_current_user!(ctx, session)
    callback_url = fetch_value(ctx.query, "callbackURL")
    raise ctx.redirect(callback_url) if callback_url

    ctx.json({success: true, message: "User deleted"})
  end
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
# File 'lib/better_auth/routes/error.rb', line 7

def self.error
  Endpoint.new(
    path: "/error",
    method: "GET",
    metadata: {hide: true}
  ) 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



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/better_auth/routes/error.rb', line 52

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



41
42
43
44
45
# File 'lib/better_auth/routes/error.rb', line 41

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)


136
137
138
# File 'lib/better_auth/routes/password.rb', line 136

def self.expired_time?(value)
  value && value < Time.now
end

.fetch_value(hash, key) ⇒ Object



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

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



136
137
138
139
140
# File 'lib/better_auth/routes/account.rb', line 136

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



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/better_auth/routes/account.rb', line 38

def self.get_access_token
  Endpoint.new(path: "/get-access-token", method: "POST") do |ctx|
    session = current_session(ctx, allow_nil: true)
    body = normalize_hash(ctx.body)
    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"]
    provider = social_provider(ctx.context, provider_id)
    raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider

     = 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 

    if ["refreshToken"] && access_token_expired?() && provider_callable(provider, :refresh_access_token)
      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)))
    end

    ctx.json({
      accessToken: oauth_token_value(ctx, ["accessToken"]),
      accessTokenExpiresAt: ["accessTokenExpiresAt"],
      scopes: ["scopes"] || (["scope"].to_s.empty? ? [] : ["scope"].to_s.split(",")),
      idToken: ["idToken"]
    })
  end
end

.get_sessionObject



5
6
7
8
9
10
11
12
13
14
15
16
17
# File 'lib/better_auth/routes/session.rb', line 5

def self.get_session
  Endpoint.new(path: "/get-session", method: "GET") do |ctx|
    session = current_session(ctx, allow_nil: true)
    next ctx.json(nil) unless session

    ctx.json(parsed_session_response(ctx, session))
  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



106
107
108
109
110
111
112
113
114
115
116
# File 'lib/better_auth/routes/password.rb', line 106

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)


118
119
120
121
# File 'lib/better_auth/routes/password.rb', line 118

def self.hasher_arity_accepts_context?(hasher)
  arity = hasher.arity
  arity != 1 && arity != -1
end


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
# File 'lib/better_auth/routes/social.rb', line 93

def self.link_social
  Endpoint.new(path: "/link-social", method: "POST") 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

    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 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
        ctx.context.internal_adapter.(data[:account].merge("userId" => session[:user]["id"]))
      end
      next ctx.json({url: "", status: true, redirect: false})
    end

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

.list_accountsObject



5
6
7
8
9
10
11
12
13
14
15
# File 'lib/better_auth/routes/account.rb', line 5

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



19
20
21
22
23
24
25
26
27
28
29
# File 'lib/better_auth/routes/session.rb', line 19

def self.list_sessions
  Endpoint.new(path: "/list-sessions", method: "GET") do |ctx|
    session = current_session(ctx)
    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



128
129
130
131
132
133
134
135
# File 'lib/better_auth/routes/session.rb', line 128

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



139
140
141
142
143
# File 'lib/better_auth/routes/sign_up.rb', line 139

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

.oauth_error_url(base_url, error, description = nil) ⇒ Object



179
180
181
182
183
184
185
186
# File 'lib/better_auth/routes/social.rb', line 179

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_token_for_storage(ctx, token) ⇒ Object



186
187
188
189
190
191
# File 'lib/better_auth/routes/account.rb', line 186

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

.oauth_token_value(ctx, token) ⇒ Object



193
194
195
196
197
198
# File 'lib/better_auth/routes/account.rb', line 193

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

.okObject



5
6
7
8
9
10
11
12
13
# File 'lib/better_auth/routes/ok.rb', line 5

def self.ok
  Endpoint.new(
    path: "/ok",
    method: "GET",
    metadata: {hide: true}
  ) do |ctx|
    ctx.json({ok: true})
  end
end

.parse_declared_input(ctx, model, data, allowed_base: []) ⇒ Object



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/better_auth/routes/user.rb', line 153

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



158
159
160
161
162
163
164
165
# File 'lib/better_auth/routes/account.rb', line 158

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



100
101
102
103
104
105
# File 'lib/better_auth/routes/session.rb', line 100

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



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/better_auth/routes/social.rb', line 152

def self.persist_social_user(ctx, provider_id, , )
  email = fetch_value(, "email").to_s.downcase
   = (["accountId"] || [:accountId] || [:account_id] || fetch_value(, "id")).to_s
  existing = ctx.context.internal_adapter.find_oauth_user(email, , provider_id)

  if existing && existing[:linked_account]
    user = existing[:user]
  elsif existing
    user = existing[:user]
    ctx.context.internal_adapter.(.merge("providerId" => provider_id, "accountId" => , "userId" => user["id"]))
  else
    created = ctx.context.internal_adapter.create_oauth_user(
      {
        email: email,
        name: fetch_value(, "name").to_s,
        image: fetch_value(, "image"),
        emailVerified: !!fetch_value(, "emailVerified")
      },
      .merge("providerId" => provider_id, "accountId" => )
    )
    user = created[:user]
  end

  session = ctx.context.internal_adapter.create_session(user["id"], false, session_overrides(ctx), true, ctx)
  {session: session, user: user}
end

.provider_callable(provider, key) ⇒ Object



200
201
202
# File 'lib/better_auth/routes/account.rb', line 200

def self.provider_callable(provider, key)
  provider.respond_to?(key) || (provider.is_a?(Hash) && (provider[key] || provider[key.to_s]))
end

.redirect_or_error(ctx, callback_url, error) ⇒ Object

Raises:



84
85
86
87
88
89
90
# File 'lib/better_auth/routes/email_verification.rb', line 84

def self.redirect_or_error(ctx, callback_url, error)
  if callback_url
    separator = callback_url.include?("?") ? "&" : "?"
    raise ctx.redirect("#{callback_url}#{separator}error=#{error}")
  end
  raise APIError.new("UNAUTHORIZED", message: error)
end

.redirect_or_json(ctx, callback_url, data) ⇒ Object



92
93
94
95
96
# File 'lib/better_auth/routes/email_verification.rb', line 92

def self.redirect_or_json(ctx, callback_url, data)
  raise ctx.redirect(callback_url) if callback_url

  ctx.json(data)
end

.refresh_tokenObject



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
# File 'lib/better_auth/routes/account.rb', line 69

def self.refresh_token
  Endpoint.new(path: "/refresh-token", method: "POST") do |ctx|
    session = current_session(ctx, allow_nil: true)
    body = normalize_hash(ctx.body)
    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"]
    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?

    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)))
    ctx.json({
      accessToken: values["accessToken"],
      refreshToken: values["refreshToken"],
      accessTokenExpiresAt: values["accessTokenExpiresAt"],
      refreshTokenExpiresAt: values["refreshTokenExpiresAt"],
      scope: Array(values["scopes"]).join(","),
      idToken: values["idToken"] || ["idToken"],
      providerId: ["providerId"],
      accountId: ["accountId"]
    })
  end
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
# File 'lib/better_auth/routes/password.rb', line 10

def self.request_password_reset
  Endpoint.new(path: "/request-password-reset", method: "POST") do |ctx|
    sender = ctx.context.options.email_and_password[:send_reset_password]
    raise APIError.new("BAD_REQUEST", message: "Reset password isn't enabled") unless sender.respond_to?(:call)

    body = normalize_hash(ctx.body)
    email = body["email"].to_s.downcase
    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
    )

    redirect_to = body["redirectTo"] || body["redirect_to"]
    callback = redirect_to ? URI.encode_www_form_component(redirect_to) : ""
    url = "#{ctx.context.base_url}/reset-password/#{token}?callbackURL=#{callback}"
    sender.call({user: found[:user], url: url, token: token}, ctx.request)
    ctx.json({status: true, message: PASSWORD_RESET_MESSAGE})
  end
end

.request_password_reset_callbackObject



40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/better_auth/routes/password.rb', line 40

def self.request_password_reset_callback
  Endpoint.new(path: "/reset-password/:token", method: "GET") do |ctx|
    token = ctx.params[:token].to_s
    callback_url = fetch_value(ctx.query, "callbackURL") || "/error"
    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

.reset_passwordObject



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
# File 'lib/better_auth/routes/password.rb', line 54

def self.reset_password
  Endpoint.new(path: "/reset-password", method: "POST") 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.find_verification_value("reset-password:#{token}")
    raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless verification && !expired_time?(verification["expiresAt"])

    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
    ctx.context.internal_adapter.delete_verification_value(verification["id"])

    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_sessions(user_id) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]

    ctx.json({status: true})
  end
end

.revoke_other_sessionsObject



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

def self.revoke_other_sessions
  Endpoint.new(path: "/revoke-other-sessions", method: "POST") 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



44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/better_auth/routes/session.rb', line 44

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



59
60
61
62
63
64
65
66
# File 'lib/better_auth/routes/session.rb', line 59

def self.revoke_sessions
  Endpoint.new(path: "/revoke-sessions", method: "POST") do |ctx|
    session = current_session(ctx, sensitive: true)
    ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
    Cookies.delete_session_cookie(ctx)
    ctx.json({status: true})
  end
end

.sanitize_html(value) ⇒ Object



93
94
95
96
97
98
99
100
# File 'lib/better_auth/routes/error.rb', line 93

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



75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/better_auth/routes/sign_in.rb', line 75

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



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/better_auth/routes/sign_up.rb', line 113

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.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
# File 'lib/better_auth/routes/email_verification.rb', line 7

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

    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

    found = ctx.context.internal_adapter.find_user_by_email(email)
    if found && !found[:user]["emailVerified"]
      send_verification_email_payload(ctx, found[:user], body["callbackURL"] || body["callbackUrl"] || body["callback_url"])
    else
      create_email_verification_token(ctx, email)
    end
    ctx.json({status: true})
  end
end

.send_verification_email_payload(ctx, user, callback_url) ⇒ Object



64
65
66
67
68
69
# File 'lib/better_auth/routes/email_verification.rb', line 64

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.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_overrides(ctx) ⇒ Object



132
133
134
135
136
137
# File 'lib/better_auth/routes/sign_up.rb', line 132

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



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/better_auth/routes/user.rb', line 44

def self.set_password
  Endpoint.new(path: "/set-password", method: "POST") 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: "user already has a password") 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


98
99
100
101
102
# File 'lib/better_auth/routes/email_verification.rb', line 98

def self.set_verified_session_cookie(ctx, user)
  session = current_session(ctx, allow_nil: true)
  session_data = session ? session[:session] : ctx.context.internal_adapter.create_session(user["id"])
  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
# File 'lib/better_auth/routes/sign_in.rb', line 7

def self.
  Endpoint.new(
    path: "/sign-in/email",
    method: "POST",
    metadata: {
      allowed_media_types: [
        "application/x-www-form-urlencoded",
        "application/json"
      ]
    }
  ) do |ctx|
    options = ctx.context.options
    email_config = options.email_and_password
    if email_config[:enabled] == false
      raise APIError.new("BAD_REQUEST", message: "Email and password is not enabled")
    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"]

    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



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
# File 'lib/better_auth/routes/social.rb', line 8

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

    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], data[:account])
      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

    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"]
      },
      ctx.context.secret,
      expires_in: 600
    )
    code_verifier = SecureRandom.hex(16)
    url = call_provider(provider, :create_authorization_url, {
      state: state,
      codeVerifier: code_verifier,
      code_verifier: code_verifier,
      redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
      redirect_uri: "#{ctx.context.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
# File 'lib/better_auth/routes/sign_out.rb', line 5

def self.sign_out
  Endpoint.new(path: "/sign-out", method: "POST") 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



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
# File 'lib/better_auth/routes/sign_up.rb', line 9

def self.
  Endpoint.new(
    path: "/sign-up/email",
    method: "POST",
    metadata: {
      allowed_media_types: [
        "application/x-www-form-urlencoded",
        "application/json"
      ]
    }
  ) do |ctx|
    options = ctx.context.options
    email_config = options.email_and_password
    if email_config[:enabled] == false || email_config[:disable_sign_up]
      raise APIError.new("BAD_REQUEST", message: "Email and password sign up is not enabled")
    end

    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"]

    (email, password, email_config)

    ctx.context.adapter.transaction do
      existing = ctx.context.internal_adapter.find_user_by_email(email)
      if existing
        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, body, 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_provider(context, provider_id) ⇒ Object



129
130
131
132
133
134
# File 'lib/better_auth/routes/account.rb', line 129

def self.social_provider(context, provider_id)
  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:



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
# File 'lib/better_auth/routes/social.rb', line 123

def self.social_user_from_id_token!(ctx, provider, id_token)
  token = fetch_value(id_token, "token").to_s
  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

   = 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 = [: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?

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

.stringify_keys(value) ⇒ Object



121
122
123
124
125
126
# File 'lib/better_auth/routes/session.rb', line 121

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

.token_hash(tokens) ⇒ Object



173
174
175
176
177
# File 'lib/better_auth/routes/account.rb', line 173

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



179
180
181
182
183
184
# File 'lib/better_auth/routes/account.rb', line 179

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)


107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/better_auth/routes/session.rb', line 107

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


17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/better_auth/routes/account.rb', line 17

def self.
  Endpoint.new(path: "/unlink-account", method: "POST") do |ctx|
    session = current_session(ctx, sensitive: 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"]
     = 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



167
168
169
170
171
# File 'lib/better_auth/routes/account.rb', line 167

def self.(ctx, , tokens)
  return nil if ["id"].to_s.empty?

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

.update_sessionObject



31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/better_auth/routes/session.rb', line 31

def self.update_session
  Endpoint.new(path: "/update-session", method: "POST") do |ctx|
    session = current_session(ctx, sensitive: true)
    body = Routes.parse_declared_input(ctx, "session", ctx.body, allowed_base: [])
    raise APIError.new("BAD_REQUEST", message: "No fields to update") if body.empty?

    updated = ctx.context.internal_adapter.update_session(session[:session]["token"], body)
    merged = session[:session].merge(updated || body)
    Cookies.set_session_cookie(ctx, {session: merged, user: session[:user]}, Cookies.dont_remember?(ctx))
    ctx.json({status: true})
  end
end

.update_userObject



5
6
7
8
9
10
11
12
13
14
15
16
17
18
# File 'lib/better_auth/routes/user.rb', line 5

def self.update_user
  Endpoint.new(path: "/update-user", method: "POST") do |ctx|
    session = current_session(ctx)
    body = normalize_hash(ctx.body)
    raise APIError.new("BAD_REQUEST", message: "Body must be an object") unless body.is_a?(Hash)
    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

.valid_error_code?(value) ⇒ Boolean

Returns:

  • (Boolean)


37
38
39
# File 'lib/better_auth/routes/error.rb', line 37

def self.valid_error_code?(value)
  /\A['A-Za-z0-9_-]+\z/.match?(value.to_s)
end

.validate_password_length!(password, email_config) ⇒ Object

Raises:



98
99
100
101
102
103
104
# File 'lib/better_auth/routes/password.rb', line 98

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_sign_up_input!(email, password, email_config) ⇒ Object



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

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

.verify_emailObject



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 34

def self.verify_email
  Endpoint.new(path: "/verify-email", method: "GET") do |ctx|
    token = fetch_value(ctx.query, "token").to_s
    callback_url = fetch_value(ctx.query, "callbackURL")
    payload = verify_email_token(ctx, token, callback_url)
    email = payload["email"].to_s.downcase
    update_to = payload["updateTo"] || 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
      updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: true)
      set_verified_session_cookie(ctx, updated || user.merge("email" => update_to, "emailVerified" => true))
      next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
    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



77
78
79
80
81
82
# File 'lib/better_auth/routes/email_verification.rb', line 77

def self.verify_email_token(ctx, token, callback_url)
  payload = Crypto.verify_jwt(token, ctx.context.secret)
  return payload if payload

  redirect_or_error(ctx, callback_url, "invalid_token")
end

.verify_passwordObject



86
87
88
89
90
91
92
93
94
95
96
# File 'lib/better_auth/routes/password.rb', line 86

def self.verify_password
  Endpoint.new(path: "/verify-password", method: "POST") 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



123
124
125
126
127
128
129
130
# File 'lib/better_auth/routes/password.rb', line 123

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