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)


319
320
321
322
# File 'lib/better_auth/routes/account.rb', line 319

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:



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

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


307
308
309
310
311
312
313
314
315
316
317
# File 'lib/better_auth/routes/account.rb', line 307

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



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

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"}
          }
        ],
        responses: {
          "200" => OpenAPI.json_response("Provider user info", {type: "object"})
        }
      }
    }
  ) 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
    else
      (ctx, nil, nil, session[:user]["id"])
    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

    tokens = access_token_response(
      ctx,
      user_id: session[: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



359
360
361
362
# File 'lib/better_auth/routes/account.rb', line 359

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



355
356
357
# File 'lib/better_auth/routes/account.rb', line 355

def self.(ctx, tokens)
  (token_hash_for_storage(ctx, tokens))
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_existing_sign_up_callback(ctx, email_config, existing) ⇒ Object



165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/better_auth/routes/sign_up.rb', line 165

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



191
192
193
# File 'lib/better_auth/routes/email_verification.rb', line 191

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

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



382
383
384
385
386
387
# File 'lib/better_auth/routes/account.rb', line 382

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



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/better_auth/routes/social.rb', line 109

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.base_url}/callback/#{provider_id}"
      target = "#{target}?#{query}" unless query.empty?
      raise ctx.redirect(target)
    end

    source = ctx.query
    data = normalize_hash(source)
    provider = social_provider(ctx.context, provider_id)
    state = data["state"].to_s
    state_data = state.empty? ? nil : Crypto.verify_jwt(state, ctx.context.secret)
    error_url = state_data ? (state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error") : "#{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")) unless state_data
    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

    token_data = token_hash(tokens)
    token_data["user"] = parse_json_hash(data["user"]) if data["user"]
     = call_provider(provider, :get_user_info, token_data)
    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?

    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["newUserCallbackURL"] || state_data["callbackURL"] || "/") : (state_data["callbackURL"] || "/")
    raise ctx.redirect(callback_url)
  end
end

.change_emailObject



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/better_auth/routes/user.rb', line 257

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

    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"})
      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_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
# 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"])
    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



395
396
397
398
399
400
# File 'lib/better_auth/routes/user.rb', line 395

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



402
403
404
405
406
407
408
409
410
411
# File 'lib/better_auth/routes/user.rb', line 402

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



156
157
158
159
160
# File 'lib/better_auth/routes/email_verification.rb', line 156

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



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/better_auth/routes/sign_up.rb', line 147

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
    ),
    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:



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/better_auth/routes/session.rb', line 180

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



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

Raises:



352
353
354
355
356
357
358
359
360
361
# File 'lib/better_auth/routes/user.rb', line 352

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

.delete_userObject



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

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)
    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
      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 = "#{ctx.context.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



344
345
346
347
348
349
350
# File 'lib/better_auth/routes/user.rb', line 344

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



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/better_auth/routes/user.rb', line 206

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



339
340
341
342
# File 'lib/better_auth/routes/user.rb', line 339

def self.email_verification_url(ctx, token, callback_url)
  callback = URI.encode_www_form_component(callback_url || "/")
  "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
end

.ensure_fresh_session!(ctx, session) ⇒ Object

Raises:



209
210
211
212
213
214
215
216
217
# File 'lib/better_auth/routes/session.rb', line 209

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
# 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)


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



301
302
303
304
305
# File 'lib/better_auth/routes/account.rb', line 301

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
# 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)
    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(nullable: true))
        }
      }
    }
  ) 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


193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/better_auth/routes/social.rb', line 193

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
        ctx.context.internal_adapter.(token_hash_for_storage(ctx, data[:account]).merge("userId" => session[:user]["id"]))
      end
      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)
    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


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

def self.(ctx, provider_id, , tokens, link)
  return {error: "unable_to_link_account"} unless linkable_provider?(ctx, provider_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)
  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

  if ctx.context.options..dig(:account_linking, :update_user_info_on_link)
    ctx.context.internal_adapter.update_user(user_id, {
      name: fetch_value(, "name"),
      image: fetch_value(, "image")
    }.compact)
  end
  update_verified_email_on_link(ctx, user_id, link_email, )

  {status: true}
end

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

Returns:

  • (Boolean)


380
381
382
383
384
385
386
387
# File 'lib/better_auth/routes/social.rb', line 380

def self.linkable_provider?(ctx, provider_id, , implicit: false)
  linking = ctx.context.options.[:account_linking] || {}
  return false if linking[:enabled] == false
  return false if implicit && linking[:disable_implicit_linking] == true

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



266
267
268
269
270
271
272
273
# File 'lib/better_auth/routes/session.rb', line 266

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



256
257
258
259
260
# File 'lib/better_auth/routes/sign_up.rb', line 256

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



229
230
231
232
233
234
235
236
# File 'lib/better_auth/routes/session.rb', line 229

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



363
364
365
366
367
368
369
370
# File 'lib/better_auth/routes/social.rb', line 363

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)


463
464
465
466
467
468
469
470
471
472
473
# File 'lib/better_auth/routes/social.rb', line 463

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



364
365
366
367
368
369
# File 'lib/better_auth/routes/account.rb', line 364

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



371
372
373
374
375
376
# File 'lib/better_auth/routes/account.rb', line 371

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

.override_social_user_info(ctx, user, user_info) ⇒ Object



430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'lib/better_auth/routes/social.rb', line 430

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
  update = {
    "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) || user
end

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



373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'lib/better_auth/routes/user.rb', line 373

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



482
483
484
485
486
487
488
489
490
# File 'lib/better_auth/routes/social.rb', line 482

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



324
325
326
327
328
329
330
331
# File 'lib/better_auth/routes/account.rb', line 324

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



238
239
240
241
242
243
# File 'lib/better_auth/routes/session.rb', line 238

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



318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'lib/better_auth/routes/social.rb', line 318

def self.persist_social_user(ctx, provider_id, , , callback_url: nil, disable_sign_up: false)
  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]
    if ctx.context.options.[:update_account_on_sign_in] != false
      update_data = ()
      ctx.context.internal_adapter.(existing[:linked_account]["id"], update_data) unless update_data.empty?
    end
    verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], )
    user = verified_user if verified_user
    new_user = false
  elsif existing
    unless linkable_provider?(ctx, provider_id, , implicit: true)
      return {error: "account not linked"}
    end
    user = existing[:user]
    ctx.context.internal_adapter.(.merge("providerId" => provider_id, "accountId" => , "userId" => user["id"]))
    verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], )
    user = verified_user if verified_user
    new_user = false
  else
    return {error: "signup disabled"} if 

    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" => ),
      context: ctx
    )
    user = created[:user]
    new_user = true
  end
  user = (ctx, user, ) if existing && (provider_id, ctx.context)

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

.provider_callable(provider, key) ⇒ Object



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

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)


372
373
374
# File 'lib/better_auth/routes/social.rb', line 372

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)


376
377
378
# File 'lib/better_auth/routes/social.rb', line 376

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)


425
426
427
428
# File 'lib/better_auth/routes/social.rb', line 425

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:



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

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



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

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

  ctx.json(data)
end

.refresh_tokenObject



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

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

.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



200
201
202
203
204
205
206
207
# File 'lib/better_auth/routes/session.rb', line 200

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

    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 = "#{ctx.context.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



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

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:



363
364
365
366
367
368
369
370
371
# File 'lib/better_auth/routes/user.rb', line 363

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



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

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

.resolve_default(value) ⇒ Object



220
221
222
# File 'lib/better_auth/routes/sign_up.rb', line 220

def self.resolve_default(value)
  value.respond_to?(:call) ? value.call : value
end

.revoke_other_sessionsObject



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

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



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/better_auth/routes/session.rb', line 97

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



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/better_auth/routes/session.rb', line 132

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_sessions(session[:user]["id"])
    Cookies.delete_session_cookie(ctx)
    ctx.json({status: true})
  end
end

.safe_additional_state(body) ⇒ Object



446
447
448
449
450
451
452
# File 'lib/better_auth/routes/social.rb', line 446

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



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_change_email_verification(ctx, sender, user, current_email, new_email, callback_url) ⇒ Object



334
335
336
337
# File 'lib/better_auth/routes/user.rb', line 334

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"})
  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



146
147
148
149
150
151
152
153
154
# File 'lib/better_auth/routes/email_verification.rb', line 146

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"})
  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.merge("email" => update_to), url: url, token: token}, ctx.request)
end

.send_sign_in_verification_email(ctx, user, callback_url) ⇒ Object



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

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



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/better_auth/routes/sign_up.rb', line 230

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/better_auth/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)

    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



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

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_needs_refresh?(ctx, session) ⇒ Boolean

Returns:

  • (Boolean)


219
220
221
222
223
224
225
226
227
# File 'lib/better_auth/routes/session.rb', line 219

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



249
250
251
252
253
254
# File 'lib/better_auth/routes/sign_up.rb', line 249

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



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

def self.set_password
  Endpoint.new(
    path: "/set-password",
    method: "POST",
    metadata: {
      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


185
186
187
188
189
# File 'lib/better_auth/routes/email_verification.rb', line 185

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# 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")

    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



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
99
100
101
102
103
104
105
106
107
# File 'lib/better_auth/routes/social.rb', line 9

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
    )
    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
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
# 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

    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)

    ctx.context.adapter.transaction do
      existing = ctx.context.internal_adapter.find_user_by_email(email)
      if existing
        if email_config[:require_email_verification]
          hash_password(ctx, password)
          (ctx, email_config, existing)
          synthetic_user = (ctx, body, 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, 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



292
293
294
295
296
297
298
299
# File 'lib/better_auth/routes/account.rb', line 292

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:



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

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

  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?

  {
    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

.stringify_keys(value) ⇒ Object



259
260
261
262
263
264
# File 'lib/better_auth/routes/session.rb', line 259

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



224
225
226
227
228
# File 'lib/better_auth/routes/sign_up.rb', line 224

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_additional_user_fields(ctx, data) ⇒ Object



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/better_auth/routes/sign_up.rb', line 204

def self.synthetic_additional_user_fields(ctx, data)
  additional = parse_declared_input(ctx, "user", data, allowed_base: [])
  configured = ctx.context.options.user[:additional_fields] || {}
  configured.each do |field, attributes|
    storage_field = Schema.storage_key(field)
    next if additional.key?(storage_field)

    field_attributes = normalize_hash(attributes || {})
    next unless field_attributes.key?("defaultValue") || field_attributes.key?("default_value")

    default = field_attributes.key?("defaultValue") ? field_attributes["defaultValue"] : field_attributes["default_value"]
    additional[storage_field] = resolve_default(default)
  end
  additional
end

.synthetic_sign_up_user(ctx, body, email, name, image) ⇒ Object



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/better_auth/routes/sign_up.rb', line 178

def self.(ctx, body, 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
  }
  reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
  additional = synthetic_additional_user_fields(ctx, body.except(*reserved))
  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



342
343
344
345
346
# File 'lib/better_auth/routes/account.rb', line 342

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



348
349
350
351
352
353
# File 'lib/better_auth/routes/account.rb', line 348

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)


245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/better_auth/routes/session.rb', line 245

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



333
334
335
336
337
338
339
340
# File 'lib/better_auth/routes/account.rb', line 333

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
# 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)
    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

.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


475
476
477
478
479
480
# File 'lib/better_auth/routes/social.rb', line 475

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)


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_auth_callback_url!(context, value, label) ⇒ Object

Raises:



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

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



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/better_auth/routes/sign_up.rb', line 122

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



454
455
456
457
458
459
460
461
# File 'lib/better_auth/routes/social.rb', line 454

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

.verify_emailObject



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/better_auth/routes/email_verification.rb', line 55

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)
    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
      session = current_session(ctx, allow_nil: true)
      return redirect_or_error(ctx, callback_url, "invalid_user") if session && session[:user]["email"] != email

      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"
        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)
        set_verified_session_cookie(ctx, updated_user)
        next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated_user)})
      else
        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)
        set_verified_session_cookie(ctx, 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



162
163
164
165
166
167
168
169
# File 'lib/better_auth/routes/email_verification.rb', line 162

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



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 177

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