better_auth 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +4 -4
  4. data/lib/better_auth/adapters/memory.rb +131 -17
  5. data/lib/better_auth/adapters/sql.rb +139 -57
  6. data/lib/better_auth/configuration.rb +7 -1
  7. data/lib/better_auth/cookies.rb +11 -3
  8. data/lib/better_auth/doctor.rb +97 -0
  9. data/lib/better_auth/endpoint.rb +88 -5
  10. data/lib/better_auth/http_client.rb +46 -0
  11. data/lib/better_auth/migration_plan.rb +15 -0
  12. data/lib/better_auth/oauth2.rb +1 -1
  13. data/lib/better_auth/plugins/admin.rb +6 -1
  14. data/lib/better_auth/plugins/anonymous.rb +2 -0
  15. data/lib/better_auth/plugins/captcha.rb +1 -1
  16. data/lib/better_auth/plugins/device_authorization.rb +34 -0
  17. data/lib/better_auth/plugins/dub.rb +8 -0
  18. data/lib/better_auth/plugins/generic_oauth.rb +34 -7
  19. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  20. data/lib/better_auth/plugins/jwt.rb +10 -3
  21. data/lib/better_auth/plugins/mcp/schema.rb +13 -13
  22. data/lib/better_auth/plugins/mcp.rb +41 -0
  23. data/lib/better_auth/plugins/oauth_protocol.rb +98 -21
  24. data/lib/better_auth/plugins/oidc_provider.rb +62 -3
  25. data/lib/better_auth/plugins/one_tap.rb +17 -5
  26. data/lib/better_auth/plugins/open_api.rb +42 -2
  27. data/lib/better_auth/plugins/organization.rb +122 -11
  28. data/lib/better_auth/plugins/phone_number.rb +1 -1
  29. data/lib/better_auth/plugins/two_factor.rb +21 -0
  30. data/lib/better_auth/rate_limiter.rb +7 -2
  31. data/lib/better_auth/routes/account.rb +4 -0
  32. data/lib/better_auth/routes/email_verification.rb +5 -1
  33. data/lib/better_auth/routes/password.rb +1 -0
  34. data/lib/better_auth/routes/social.rb +29 -1
  35. data/lib/better_auth/routes/user.rb +6 -2
  36. data/lib/better_auth/schema/sql.rb +104 -15
  37. data/lib/better_auth/schema.rb +35 -2
  38. data/lib/better_auth/session.rb +2 -1
  39. data/lib/better_auth/social_providers/base.rb +4 -9
  40. data/lib/better_auth/social_providers/facebook.rb +1 -1
  41. data/lib/better_auth/social_providers/github.rb +2 -0
  42. data/lib/better_auth/social_providers/line.rb +1 -1
  43. data/lib/better_auth/social_providers/paypal.rb +1 -1
  44. data/lib/better_auth/sql_migration.rb +566 -0
  45. data/lib/better_auth/version.rb +1 -1
  46. data/lib/better_auth.rb +3 -0
  47. metadata +10 -6
@@ -144,6 +144,7 @@ module BetterAuth
144
144
  openapi: {
145
145
  operationId: operation_id,
146
146
  description: description,
147
+ requestBody: mcp_request_body_for(operation_id),
147
148
  responses: {
148
149
  "200" => OpenAPI.json_response(response_description, response_schema)
149
150
  }
@@ -151,6 +152,46 @@ module BetterAuth
151
152
  }
152
153
  end
153
154
 
155
+ def mcp_request_body_for(operation_id)
156
+ schema = case operation_id
157
+ when "registerMcpClient", "legacyRegisterMcpClient"
158
+ OpenAPI.object_schema(
159
+ {
160
+ redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
161
+ client_name: {type: "string"},
162
+ client_uri: {type: "string", format: "uri"},
163
+ logo_uri: {type: "string", format: "uri"},
164
+ grant_types: {type: "array", items: {type: "string"}},
165
+ response_types: {type: "array", items: {type: "string"}},
166
+ scope: {type: "string"},
167
+ contacts: {type: "array", items: {type: "string"}},
168
+ metadata: {type: "object", additionalProperties: true}
169
+ },
170
+ required: ["redirect_uris"]
171
+ )
172
+ when "mcpOAuthConsent"
173
+ OpenAPI.object_schema({consent_code: {type: "string"}, accept: {type: "boolean"}, scope: {type: "string"}, scopes: {type: "array", items: {type: "string"}}}, required: ["consent_code"])
174
+ when "mcpOAuthToken", "legacyMcpOAuthToken"
175
+ OpenAPI.object_schema(
176
+ {
177
+ grant_type: {type: "string", enum: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT]},
178
+ code: {type: "string"},
179
+ redirect_uri: {type: "string", format: "uri"},
180
+ code_verifier: {type: "string"},
181
+ client_id: {type: "string"},
182
+ client_secret: {type: "string"},
183
+ refresh_token: {type: "string"},
184
+ scope: {type: "string"},
185
+ resource: {oneOf: [{type: "string"}, {type: "array", items: {type: "string"}}]}
186
+ },
187
+ required: ["grant_type"]
188
+ )
189
+ when "mcpOAuthIntrospect", "mcpOAuthRevoke"
190
+ OpenAPI.object_schema({token: {type: "string"}, token_type_hint: {type: "string", enum: ["access_token", "refresh_token"]}}, required: ["token"])
191
+ end
192
+ schema ? OpenAPI.json_request_body(schema) : nil
193
+ end
194
+
154
195
  def mcp_client_schema
155
196
  OpenAPI.object_schema(
156
197
  {
@@ -29,6 +29,12 @@ module BetterAuth
29
29
  parse_scopes(value).join(" ")
30
30
  end
31
31
 
32
+ def request_body!(value)
33
+ return stringify_keys(value || {}) if value.nil? || value.is_a?(Hash)
34
+
35
+ raise APIError.new("BAD_REQUEST", message: "request body must be an object")
36
+ end
37
+
32
38
  def issuer(ctx)
33
39
  ctx.context.options.base_url.to_s.empty? ? origin_for(ctx.context.base_url) : ctx.context.options.base_url
34
40
  end
@@ -100,7 +106,7 @@ module BetterAuth
100
106
  end
101
107
 
102
108
  def create_client(ctx, model:, body:, owner_session: nil, default_auth_method: "client_secret_basic", store_client_secret: "plain", unauthenticated: false, default_scopes: nil, allowed_scopes: nil, prefix: {}, dynamic_registration: false, admin: false, pairwise_secret: nil, strip_client_metadata: false, reference_id: nil)
103
- body = stringify_keys(body || {})
109
+ body = request_body!(body || {})
104
110
  requested_auth_method = body["token_endpoint_auth_method"] || default_auth_method
105
111
  validate_client_metadata_enums!(requested_auth_method, body)
106
112
  validate_admin_only_fields!(body, admin: admin)
@@ -116,6 +122,7 @@ module BetterAuth
116
122
  grant_types = Array(body["grant_types"] || [AUTH_CODE_GRANT]).map(&:to_s)
117
123
  response_types = Array(body["response_types"] || ["code"]).map(&:to_s)
118
124
  validate_client_registration!(auth_method, grant_types, response_types, body, unauthenticated: unauthenticated, dynamic_registration: dynamic_registration)
125
+ validate_redirect_scheme_for_client!(auth_method, body, redirects)
119
126
  validate_pairwise_client!(body, redirects, pairwise_secret)
120
127
 
121
128
  scopes = parse_scopes(body["scope"] || body["scopes"])
@@ -237,6 +244,20 @@ module BetterAuth
237
244
  end
238
245
  end
239
246
 
247
+ def validate_redirect_scheme_for_client!(auth_method, body, redirects)
248
+ return if auth_method == "none" && body["type"] != "web"
249
+
250
+ redirects.each do |value|
251
+ uri = URI.parse(value.to_s)
252
+ next if uri.scheme == "https"
253
+ next if uri.scheme == "http" && loopback_host?(uri.hostname || uri.host)
254
+
255
+ raise APIError.new("BAD_REQUEST", message: "redirect_uris is invalid")
256
+ end
257
+ rescue URI::InvalidURIError
258
+ raise APIError.new("BAD_REQUEST", message: "redirect_uris is invalid")
259
+ end
260
+
240
261
  def validate_pairwise_client!(body, redirects, pairwise_secret)
241
262
  subject_type = body["subject_type"] || body["subjectType"]
242
263
  return unless subject_type == "pairwise"
@@ -266,7 +287,11 @@ module BetterAuth
266
287
  end
267
288
 
268
289
  def client_metadata(body, strip_unknown: false)
269
- metadata = stringify_keys(body["metadata"] || {})
290
+ raw_metadata = body["metadata"]
291
+ unless raw_metadata.nil? || raw_metadata.is_a?(Hash)
292
+ raise APIError.new("BAD_REQUEST", message: "metadata must be an object")
293
+ end
294
+ metadata = stringify_keys(raw_metadata || {})
270
295
  metadata = metadata.slice("software_id", "software_version", "software_statement", "tos_uri", "policy_uri") if strip_unknown
271
296
  metadata["software_id"] = body["software_id"] if body["software_id"]
272
297
  metadata["software_version"] = body["software_version"] if body["software_version"]
@@ -313,15 +338,23 @@ module BetterAuth
313
338
  ctx.context.adapter.find_one(model: model, where: [{field: "clientId", value: client_id.to_s}])
314
339
  end
315
340
 
316
- def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {})
317
- body = stringify_keys(ctx.body || {})
341
+ def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {}, require_confidential: false)
342
+ body = request_body!(ctx.body || {})
318
343
  client_id = body["client_id"]
319
344
  client_secret = strip_prefix(body["client_secret"], prefix, :client_secret) || body["client_secret"]
320
345
 
321
346
  authorization = ctx.headers["authorization"]
322
- if authorization.to_s.start_with?("Basic ") && client_id.to_s.empty?
323
- decoded = Base64.decode64(authorization.delete_prefix("Basic "))
347
+ auth_method_used = client_secret.to_s.empty? ? nil : "client_secret_post"
348
+ if authorization.to_s.start_with?("Basic ")
349
+ decoded = Base64.strict_decode64(authorization.delete_prefix("Basic "))
350
+ unless decoded.include?(":")
351
+ raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
352
+ end
324
353
  client_id, client_secret = decoded.split(":", 2)
354
+ if client_id.to_s.empty? || client_secret.to_s.empty?
355
+ raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
356
+ end
357
+ auth_method_used = "client_secret_basic"
325
358
  end
326
359
 
327
360
  client = find_client(ctx, model, client_id)
@@ -332,20 +365,34 @@ module BetterAuth
332
365
 
333
366
  method = client_data["tokenEndpointAuthMethod"] || "client_secret_basic"
334
367
  if method == "none"
368
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client") if require_confidential
335
369
  raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client_secret.to_s.empty?
336
370
  return client
337
371
  end
372
+ expected_method = (method == "client_secret_post") ? "client_secret_post" : "client_secret_basic"
373
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless auth_method_used == expected_method
374
+ if client_secret_expired?(client_data["clientSecretExpiresAt"])
375
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client")
376
+ end
338
377
  if method != "none" && !verify_client_secret(ctx, stringify_keys(client)["clientSecret"], client_secret, store_client_secret)
339
378
  raise APIError.new("UNAUTHORIZED", message: "invalid_client")
340
379
  end
341
380
 
342
- client
381
+ client.merge("__providedClientSecret" => client_secret)
343
382
  rescue ArgumentError
344
- raise APIError.new("UNAUTHORIZED", message: "invalid_client")
383
+ raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
345
384
  end
346
385
 
347
- def store_code(store, code:, client_id:, redirect_uri:, session:, scopes:, code_challenge: nil, code_challenge_method: nil, nonce: nil, reference_id: nil, auth_time: nil, expires_in: 600)
348
- store[:codes][code] = {
386
+ def client_secret_expired?(value)
387
+ return false if value.nil? || value.to_i == 0
388
+
389
+ seconds = timestamp_seconds(value)
390
+ seconds && seconds <= Time.now.to_i
391
+ end
392
+
393
+ def store_code(store, code:, client_id:, redirect_uri:, session:, scopes:, code_challenge: nil, code_challenge_method: nil, nonce: nil, reference_id: nil, auth_time: nil, expires_in: 600, store_tokens: "hashed")
394
+ stored_code = get_stored_token(store_tokens, code, "authorization_code")
395
+ store[:codes][stored_code] = {
349
396
  client_id: client_id,
350
397
  redirect_uri: redirect_uri,
351
398
  session: session,
@@ -359,8 +406,9 @@ module BetterAuth
359
406
  }
360
407
  end
361
408
 
362
- def consume_code!(store, code, client_id:, redirect_uri:, code_verifier: nil)
363
- data = store[:codes].delete(code.to_s)
409
+ def consume_code!(store, code, client_id:, redirect_uri:, code_verifier: nil, store_tokens: "hashed")
410
+ stored_code = get_stored_token(store_tokens, code.to_s, "authorization_code")
411
+ data = store[:codes].delete(stored_code) || store[:codes].delete(code.to_s)
364
412
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
365
413
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data[:expires_at] <= Time.now
366
414
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:client_id] == client_id.to_s
@@ -396,6 +444,7 @@ module BetterAuth
396
444
 
397
445
  def pkce_required?(client, scopes)
398
446
  data = stringify_keys(client)
447
+ return true if data["public"] || data["tokenEndpointAuthMethod"] == "none" || ["native", "user-agent-based"].include?(data["type"])
399
448
  return true if parse_scopes(scopes).include?("offline_access")
400
449
  require_pkce = client_require_pkce(data)
401
450
  return require_pkce unless require_pkce.nil?
@@ -403,7 +452,7 @@ module BetterAuth
403
452
  true
404
453
  end
405
454
 
406
- def issue_tokens(ctx, store, model:, client:, session:, scopes:, include_refresh: false, issuer: nil, jwt_audience: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, grant_type: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil, filter_id_token_claims_by_scope: false)
455
+ def issue_tokens(ctx, store, model:, client:, session:, scopes:, include_refresh: false, issuer: nil, jwt_audience: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, grant_type: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil, filter_id_token_claims_by_scope: false, store_tokens: "hashed")
407
456
  data = stringify_keys(session || {})
408
457
  user = stringify_keys(data["user"] || data[:user] || {})
409
458
  session_data = stringify_keys(data["session"] || data[:session] || {})
@@ -424,7 +473,7 @@ module BetterAuth
424
473
  refresh_record = nil
425
474
  if refresh_token_value
426
475
  refresh_record = {
427
- "token" => refresh_token_value,
476
+ "token" => store_token_value(store_tokens, refresh_token_value, "refresh_token"),
428
477
  "clientId" => client_data["clientId"],
429
478
  "sessionId" => session_data["id"],
430
479
  "userId" => user["id"],
@@ -440,13 +489,13 @@ module BetterAuth
440
489
  "issuedAt" => Time.now
441
490
  }
442
491
  created_refresh = schema_model?(ctx, "oauthRefreshToken") ? ctx.context.adapter.create(model: "oauthRefreshToken", data: refresh_record) : nil
443
- refresh_record = refresh_record.merge("id" => stringify_keys(created_refresh || {})["id"], "user" => user, "session" => session_data, "client" => client_data, "scope" => scope)
492
+ refresh_record = refresh_record.merge("id" => stringify_keys(created_refresh || {})["id"], "token" => refresh_token_value, "user" => user, "session" => session_data, "client" => client_data, "scope" => scope)
444
493
  store[:refresh_tokens][refresh_token_value] = refresh_record
445
494
  store[:refresh_tokens][refresh_token] = refresh_record
446
495
  end
447
496
  unless jwt_access_token && audience
448
497
  record = {
449
- "token" => access_token_value,
498
+ "token" => store_token_value(store_tokens, access_token_value, "access_token"),
450
499
  "expiresAt" => expires_at,
451
500
  "clientId" => client_data["clientId"],
452
501
  "userId" => user["id"],
@@ -464,7 +513,7 @@ module BetterAuth
464
513
  created_access = ctx.context.adapter.create(model: model, data: record)
465
514
  created = stringify_keys(created_access || {})
466
515
  record = record.merge("id" => created["id"]) if created["id"]
467
- stored_record = record.merge("user" => user, "session" => session_data, "client" => client_data)
516
+ stored_record = record.merge("token" => access_token_value, "user" => user, "session" => session_data, "client" => client_data)
468
517
  store[:tokens][access_token_value] = stored_record
469
518
  store[:tokens][access_token] = stored_record
470
519
  end
@@ -478,7 +527,8 @@ module BetterAuth
478
527
  }
479
528
  response[:audience] = audience if audience
480
529
  response[:refresh_token] = refresh_token if refresh_token
481
- response[:id_token] = id_token(user.merge("id" => subject), client_data["clientId"], issuer || issuer(ctx), jwt_audience || client_data["clientId"], ctx: ctx, signer: id_token_signer, session_id: session_data["id"], include_sid: !!client_data["enableEndSession"], nonce: nonce, auth_time: token_auth_time, custom_claims: custom_id_token_claims, scopes: parse_scopes(scope), client: client_data, filter_claims_by_scope: filter_id_token_claims_by_scope, expires_in: id_token_expires_in, use_jwt_plugin: use_jwt_plugin) if parse_scopes(scope).include?("openid")
530
+ id_token_client_data = client_data.merge("clientSecret" => client_data["__providedClientSecret"] || client_data["clientSecret"])
531
+ response[:id_token] = id_token(user.merge("id" => subject), client_data["clientId"], issuer || issuer(ctx), jwt_audience || client_data["clientId"], ctx: ctx, signer: id_token_signer, session_id: session_data["id"], include_sid: !!client_data["enableEndSession"], nonce: nonce, auth_time: token_auth_time, custom_claims: custom_id_token_claims, scopes: parse_scopes(scope), client: id_token_client_data, filter_claims_by_scope: filter_id_token_claims_by_scope, expires_in: id_token_expires_in, use_jwt_plugin: use_jwt_plugin) if parse_scopes(scope).include?("openid")
482
532
  if custom_token_response_fields.respond_to?(:call)
483
533
  extra = custom_token_response_fields.call({grant_type: grant_type, user: user.empty? ? nil : user, scopes: parse_scopes(scope), metadata: stringify_keys(client_data["metadata"] || {})})
484
534
  response.merge!(stringify_keys(extra).reject { |key, _value| standard_token_response_field?(key) }.transform_keys(&:to_sym)) if extra.is_a?(Hash)
@@ -486,7 +536,7 @@ module BetterAuth
486
536
  response
487
537
  end
488
538
 
489
- def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, filter_id_token_claims_by_scope: false)
539
+ def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, filter_id_token_claims_by_scope: false, store_tokens: "hashed")
490
540
  refresh_token_value = strip_prefix(refresh_token, prefix, :refresh_token)
491
541
  data = refresh_token_value ? store[:refresh_tokens][refresh_token_value] : nil
492
542
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
@@ -532,7 +582,8 @@ module BetterAuth
532
582
  id_token_expires_in: id_token_expires_in,
533
583
  auth_time: data["authTime"],
534
584
  reference_id: data["referenceId"],
535
- filter_id_token_claims_by_scope: filter_id_token_claims_by_scope
585
+ filter_id_token_claims_by_scope: filter_id_token_claims_by_scope,
586
+ store_tokens: store_tokens
536
587
  )
537
588
  end
538
589
 
@@ -757,6 +808,11 @@ module BetterAuth
757
808
  end
758
809
 
759
810
  def id_token_hs256_key(ctx, client_id, client_secret = nil)
811
+ oauth_provider = ctx&.context&.options&.plugins&.find { |plugin| plugin.id == "oauth-provider" }
812
+ if oauth_provider&.options&.fetch(:store_client_secret, nil).to_s == "hashed"
813
+ label = client_id.to_s.empty? ? "better-auth" : client_id.to_s
814
+ return OpenSSL::HMAC.hexdigest("SHA256", ctx.context.secret.to_s, "oidc.id_token.#{label}")
815
+ end
760
816
  return client_secret.to_s unless client_secret.to_s.empty?
761
817
 
762
818
  label = client_id.to_s.empty? ? "better-auth" : client_id.to_s
@@ -857,10 +913,29 @@ module BetterAuth
857
913
  secret
858
914
  end
859
915
 
916
+ def store_token_value(storage_method, token, type)
917
+ case storage_method
918
+ when "hashed", :hashed
919
+ Crypto.sha256(token.to_s, encoding: :base64url)
920
+ else
921
+ mode = normalize_secret_storage_mode(storage_method)
922
+ return mode[:hash].call(token.to_s, type) if mode.is_a?(Hash) && mode[:hash].respond_to?(:call)
923
+
924
+ raise Error, "storeToken: unsupported storageMethod type '#{storage_method}'"
925
+ end
926
+ end
927
+
928
+ def get_stored_token(storage_method, token, type)
929
+ store_token_value(storage_method, token, type)
930
+ end
931
+
860
932
  def verify_client_secret(ctx, stored_secret, provided_secret, mode)
861
933
  mode = normalize_secret_storage_mode(mode)
862
934
  return Crypto.constant_time_compare(Crypto.sha256(provided_secret, encoding: :base64url), stored_secret.to_s) if mode == "hashed"
863
- return Crypto.constant_time_compare(Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_secret).to_s, provided_secret.to_s) if mode == "encrypted"
935
+ if mode == "encrypted"
936
+ decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_secret)
937
+ return Crypto.constant_time_compare(decrypted.to_s, provided_secret.to_s)
938
+ end
864
939
 
865
940
  if mode.is_a?(Hash)
866
941
  return Crypto.constant_time_compare(mode[:hash].call(provided_secret).to_s, stored_secret.to_s) if mode[:hash].respond_to?(:call)
@@ -868,6 +943,8 @@ module BetterAuth
868
943
  end
869
944
 
870
945
  Crypto.constant_time_compare(stored_secret.to_s, provided_secret.to_s)
946
+ rescue Error, ArgumentError
947
+ false
871
948
  end
872
949
 
873
950
  def normalize_secret_storage_mode(mode)
@@ -441,6 +441,8 @@ module BetterAuth
441
441
  openapi: {
442
442
  operationId: "oauth2EndSession",
443
443
  description: "RP-Initiated Logout endpoint",
444
+ parameters: oidc_end_session_schema[:properties].keys.map { |name| OpenAPI.query_parameter(name.to_s, schema: oidc_end_session_schema[:properties][name]) },
445
+ requestBody: OpenAPI.json_request_body(oidc_end_session_schema, required: false),
444
446
  responses: {
445
447
  "302" => {description: "Redirects after clearing the session cookie"}
446
448
  }
@@ -470,6 +472,7 @@ module BetterAuth
470
472
  openapi: {
471
473
  operationId: operation_id,
472
474
  description: description,
475
+ requestBody: oidc_request_body_for(operation_id),
473
476
  responses: {
474
477
  "200" => OpenAPI.json_response(response_description, response_schema)
475
478
  }
@@ -477,6 +480,62 @@ module BetterAuth
477
480
  }
478
481
  end
479
482
 
483
+ def oidc_request_body_for(operation_id)
484
+ schema = case operation_id
485
+ when "registerOAuthApplication", "updateOAuthApplication"
486
+ oidc_client_registration_schema
487
+ when "rotateOAuthApplicationSecret"
488
+ OpenAPI.empty_request_body.dig(:content, "application/json", :schema)
489
+ when "oauth2Consent"
490
+ OpenAPI.object_schema({consent_code: {type: "string"}, accept: {type: "boolean"}}, required: ["consent_code"])
491
+ when "oauth2Token"
492
+ OpenAPI.object_schema(
493
+ {
494
+ grant_type: {type: "string", enum: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT]},
495
+ code: {type: "string"},
496
+ redirect_uri: {type: "string", format: "uri"},
497
+ code_verifier: {type: "string"},
498
+ client_id: {type: "string"},
499
+ client_secret: {type: "string"},
500
+ refresh_token: {type: "string"},
501
+ scope: {type: "string"}
502
+ },
503
+ required: ["grant_type"]
504
+ )
505
+ when "oauth2Introspect", "oauth2Revoke"
506
+ OpenAPI.object_schema({token: {type: "string"}, token_type_hint: {type: "string", enum: ["access_token", "refresh_token"]}}, required: ["token"])
507
+ end
508
+ schema ? OpenAPI.json_request_body(schema) : nil
509
+ end
510
+
511
+ def oidc_client_registration_schema
512
+ OpenAPI.object_schema(
513
+ {
514
+ redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
515
+ post_logout_redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
516
+ client_name: {type: "string"},
517
+ client_uri: {type: "string", format: "uri"},
518
+ logo_uri: {type: "string", format: "uri"},
519
+ grant_types: {type: "array", items: {type: "string"}},
520
+ response_types: {type: "array", items: {type: "string"}},
521
+ scope: {type: "string"},
522
+ scopes: {type: "array", items: {type: "string"}},
523
+ metadata: {type: "object", additionalProperties: true}
524
+ }
525
+ )
526
+ end
527
+
528
+ def oidc_end_session_schema
529
+ OpenAPI.object_schema(
530
+ {
531
+ id_token_hint: {type: "string"},
532
+ client_id: {type: "string"},
533
+ post_logout_redirect_uri: {type: "string", format: "uri"},
534
+ state: {type: "string"}
535
+ }
536
+ )
537
+ end
538
+
480
539
  def oidc_client_schema
481
540
  OpenAPI.object_schema(
482
541
  {
@@ -541,7 +600,7 @@ module BetterAuth
541
600
  def oidc_provider_schema
542
601
  {
543
602
  oauthApplication: {
544
- modelName: "oauthApplication",
603
+ model_name: "oauth_applications",
545
604
  fields: {
546
605
  name: {type: "string"},
547
606
  icon: {type: "string", required: false},
@@ -565,7 +624,7 @@ module BetterAuth
565
624
  }
566
625
  },
567
626
  oauthAccessToken: {
568
- modelName: "oauthAccessToken",
627
+ model_name: "oauth_access_tokens",
569
628
  fields: {
570
629
  accessToken: {type: "string", unique: true, required: false},
571
630
  token: {type: "string", unique: true, required: false},
@@ -583,7 +642,7 @@ module BetterAuth
583
642
  }
584
643
  },
585
644
  oauthConsent: {
586
- modelName: "oauthConsent",
645
+ model_name: "oauth_consents",
587
646
  fields: {
588
647
  clientId: {type: "string", required: true},
589
648
  userId: {type: "string", required: true},
@@ -32,6 +32,14 @@ module BetterAuth
32
32
  operationId: "oneTapCallback",
33
33
  summary: "One tap callback",
34
34
  description: "Use this endpoint to authenticate with Google One Tap",
35
+ requestBody: OpenAPI.json_request_body(
36
+ OpenAPI.object_schema(
37
+ {
38
+ id_token: {type: "string", description: "Google One Tap ID token"}
39
+ },
40
+ required: ["id_token"]
41
+ )
42
+ ),
35
43
  responses: {
36
44
  "200" => OpenAPI.json_response("Success", OpenAPI.session_response_schema_pair)
37
45
  }
@@ -105,16 +113,20 @@ module BetterAuth
105
113
  options[:aud] = audience
106
114
  options[:verify_aud] = true
107
115
  end
108
- payload, = JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
116
+ payload, = ::JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
109
117
  payload
110
118
  end
111
119
 
112
120
  def one_tap_google_jwks
113
- uri = URI("https://www.googleapis.com/oauth2/v3/certs")
114
- response = Net::HTTP.get_response(uri)
115
- raise "Unable to fetch Google JWKS" unless response.is_a?(Net::HTTPSuccess)
121
+ cached = @one_tap_google_jwks_cache
122
+ return cached[:jwks] if cached && cached[:expires_at] > Time.now
116
123
 
117
- JWT::JWK::Set.new(JSON.parse(response.body))
124
+ payload = HTTPClient.get_json("https://www.googleapis.com/oauth2/v3/certs")
125
+ raise "Unable to fetch Google JWKS" unless payload
126
+
127
+ jwks = ::JWT::JWK::Set.new(payload)
128
+ @one_tap_google_jwks_cache = {jwks: jwks, expires_at: Time.now + 300}
129
+ jwks
118
130
  end
119
131
 
120
132
  def one_tap_link_account_unless_present!(ctx, _config, user, payload, id_token)
@@ -128,6 +128,21 @@ module BetterAuth
128
128
  }
129
129
  end
130
130
 
131
+ def default_request_body
132
+ {
133
+ required: true,
134
+ content: {
135
+ "application/json" => {
136
+ schema: {
137
+ type: "object",
138
+ properties: {},
139
+ additionalProperties: true
140
+ }
141
+ }
142
+ }
143
+ }
144
+ end
145
+
131
146
  def responses(responses = nil)
132
147
  {"200" => success_response}.merge(default_error_responses).merge(responses || {})
133
148
  end
@@ -142,6 +157,17 @@ module BetterAuth
142
157
  )
143
158
  end
144
159
 
160
+ def default_success_response
161
+ json_response(
162
+ "Success",
163
+ {
164
+ type: "object",
165
+ properties: {},
166
+ additionalProperties: true
167
+ }
168
+ )
169
+ end
170
+
145
171
  def default_error_responses
146
172
  {
147
173
  "400" => error_response("Bad Request. Usually due to missing parameters, or invalid parameters.", required: true),
@@ -168,10 +194,16 @@ module BetterAuth
168
194
 
169
195
  def default_metadata(path, methods)
170
196
  method = Array(methods).reject { |value| value.to_s == "*" }.first.to_s.upcase
171
- {
197
+ metadata = {
172
198
  operationId: operation_id(path, method),
173
- description: "#{method} #{path}"
199
+ description: "Execute the #{operation_id(path, method)} endpoint.",
200
+ parameters: default_path_parameters(path),
201
+ responses: {
202
+ "200" => default_success_response
203
+ }
174
204
  }
205
+ metadata[:requestBody] = default_request_body if %w[POST PUT PATCH].include?(method)
206
+ metadata
175
207
  end
176
208
 
177
209
  def operation_id(path, method)
@@ -183,6 +215,14 @@ module BetterAuth
183
215
 
184
216
  "#{method.to_s.downcase}#{base}"
185
217
  end
218
+
219
+ def default_path_parameters(path)
220
+ path.to_s.split("/").filter_map do |part|
221
+ next unless part.start_with?(":")
222
+
223
+ path_parameter(part.delete_prefix(":"))
224
+ end
225
+ end
186
226
  end
187
227
 
188
228
  module Plugins