better_auth 0.7.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -1
  3. data/README.md +6 -6
  4. data/lib/better_auth/adapters/memory.rb +131 -17
  5. data/lib/better_auth/adapters/mongodb.rb +3 -3
  6. data/lib/better_auth/adapters/sql.rb +139 -57
  7. data/lib/better_auth/auth.rb +68 -1
  8. data/lib/better_auth/configuration.rb +15 -10
  9. data/lib/better_auth/context.rb +1 -1
  10. data/lib/better_auth/cookies.rb +11 -3
  11. data/lib/better_auth/doctor.rb +97 -0
  12. data/lib/better_auth/endpoint.rb +88 -5
  13. data/lib/better_auth/env.rb +38 -0
  14. data/lib/better_auth/http_client.rb +46 -0
  15. data/lib/better_auth/migration_plan.rb +15 -0
  16. data/lib/better_auth/oauth2.rb +1 -1
  17. data/lib/better_auth/plugins/admin.rb +6 -1
  18. data/lib/better_auth/plugins/anonymous.rb +2 -0
  19. data/lib/better_auth/plugins/captcha.rb +1 -1
  20. data/lib/better_auth/plugins/device_authorization.rb +34 -0
  21. data/lib/better_auth/plugins/dub.rb +8 -0
  22. data/lib/better_auth/plugins/generic_oauth.rb +34 -7
  23. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  24. data/lib/better_auth/plugins/jwt.rb +10 -3
  25. data/lib/better_auth/plugins/mcp/schema.rb +13 -13
  26. data/lib/better_auth/plugins/mcp.rb +41 -0
  27. data/lib/better_auth/plugins/oauth_protocol.rb +98 -21
  28. data/lib/better_auth/plugins/oidc_provider.rb +62 -3
  29. data/lib/better_auth/plugins/one_tap.rb +17 -5
  30. data/lib/better_auth/plugins/open_api.rb +42 -2
  31. data/lib/better_auth/plugins/organization.rb +122 -11
  32. data/lib/better_auth/plugins/phone_number.rb +1 -1
  33. data/lib/better_auth/plugins/two_factor.rb +21 -0
  34. data/lib/better_auth/rate_limiter.rb +7 -2
  35. data/lib/better_auth/routes/account.rb +4 -0
  36. data/lib/better_auth/routes/email_verification.rb +5 -1
  37. data/lib/better_auth/routes/password.rb +1 -0
  38. data/lib/better_auth/routes/social.rb +29 -1
  39. data/lib/better_auth/routes/user.rb +6 -2
  40. data/lib/better_auth/schema/sql.rb +104 -15
  41. data/lib/better_auth/schema.rb +35 -2
  42. data/lib/better_auth/session.rb +2 -1
  43. data/lib/better_auth/social_providers/base.rb +4 -9
  44. data/lib/better_auth/social_providers/facebook.rb +1 -1
  45. data/lib/better_auth/social_providers/github.rb +2 -0
  46. data/lib/better_auth/social_providers/line.rb +1 -1
  47. data/lib/better_auth/social_providers/paypal.rb +1 -1
  48. data/lib/better_auth/sql_migration.rb +566 -0
  49. data/lib/better_auth/url_helpers.rb +1 -1
  50. data/lib/better_auth/version.rb +1 -1
  51. data/lib/better_auth.rb +4 -0
  52. metadata +15 -8
@@ -38,6 +38,14 @@ module BetterAuth
38
38
  openapi: {
39
39
  operationId: "dubLink",
40
40
  description: "Link a Dub OAuth account",
41
+ requestBody: OpenAPI.json_request_body(
42
+ OpenAPI.object_schema(
43
+ {
44
+ callbackURL: {type: "string", description: "The URL to redirect to after linking"},
45
+ callback_url: {type: "string", description: "The URL to redirect to after linking"}
46
+ }
47
+ )
48
+ ),
41
49
  responses: {
42
50
  "200" => OpenAPI.json_response(
43
51
  "Authorization URL generated successfully for linking a Dub account",
@@ -220,6 +220,7 @@ module BetterAuth
220
220
  openapi: {
221
221
  operationId: "signInOAuth2",
222
222
  description: "Sign in with OAuth2",
223
+ requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
223
224
  responses: {
224
225
  "200" => OpenAPI.json_response("Sign in with OAuth2", generic_oauth_url_response_schema)
225
226
  }
@@ -242,6 +243,7 @@ module BetterAuth
242
243
  openapi: {
243
244
  operationId: "linkOAuth2",
244
245
  description: "Link an OAuth2 account to the current user session",
246
+ requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
245
247
  responses: {
246
248
  "200" => OpenAPI.json_response("Authorization URL generated successfully for linking an OAuth2 account", generic_oauth_url_response_schema)
247
249
  }
@@ -352,6 +354,28 @@ module BetterAuth
352
354
  )
353
355
  end
354
356
 
357
+ def generic_oauth_start_body_schema
358
+ OpenAPI.object_schema(
359
+ {
360
+ providerId: {type: "string", description: "OAuth provider ID"},
361
+ provider_id: {type: "string", description: "OAuth provider ID"},
362
+ callbackURL: {type: "string", description: "URL to redirect to after success"},
363
+ callback_url: {type: "string", description: "URL to redirect to after success"},
364
+ errorCallbackURL: {type: "string", description: "URL to redirect to after error"},
365
+ error_callback_url: {type: "string", description: "URL to redirect to after error"},
366
+ newUserCallbackURL: {type: "string", description: "URL to redirect to for new users"},
367
+ new_user_callback_url: {type: "string", description: "URL to redirect to for new users"},
368
+ requestSignUp: {type: "boolean", description: "Whether this request is a sign-up flow"},
369
+ request_sign_up: {type: "boolean", description: "Whether this request is a sign-up flow"},
370
+ scopes: {type: "array", items: {type: "string"}, description: "Additional OAuth scopes"},
371
+ disableRedirect: {type: "boolean", description: "Return the URL instead of redirecting"},
372
+ disable_redirect: {type: "boolean", description: "Return the URL instead of redirecting"},
373
+ additionalData: {type: "object", additionalProperties: true},
374
+ additional_data: {type: "object", additionalProperties: true}
375
+ }
376
+ )
377
+ end
378
+
355
379
  def generic_oauth_authorization_url(ctx, provider, body, link:)
356
380
  authorization_url = provider[:authorization_url] || generic_oauth_discovery(provider)["authorization_endpoint"]
357
381
  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
@@ -487,14 +511,17 @@ module BetterAuth
487
511
  if verification
488
512
  cookie = ctx.context.create_auth_cookie("state")
489
513
  cookie_state = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
490
- if cookie_state && cookie_state != state
514
+ if ctx.request && cookie_state != state
515
+ Cookies.expire_cookie(ctx, cookie)
516
+ raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
517
+ elsif !ctx.request && cookie_state && cookie_state != state
491
518
  Cookies.expire_cookie(ctx, cookie)
492
519
  raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
493
520
  end
494
521
 
495
522
  parsed = JSON.parse(verification.fetch("value"))
496
523
  ctx.context.internal_adapter.delete_verification_value(verification.fetch("id"))
497
- Cookies.expire_cookie(ctx, cookie) if cookie_state
524
+ Cookies.expire_cookie(ctx, cookie) if ctx.request || cookie_state
498
525
  return parsed
499
526
  end
500
527
  end
@@ -521,7 +548,7 @@ module BetterAuth
521
548
  uri = URI(user_info_url)
522
549
  request = Net::HTTP::Get.new(uri)
523
550
  request["authorization"] = "Bearer #{fetch_value(tokens, "accessToken")}"
524
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
551
+ response = HTTPClient.request(uri, request)
525
552
  return nil unless response.is_a?(Net::HTTPSuccess)
526
553
 
527
554
  generic_oauth_normalize_user_info(JSON.parse(response.body))
@@ -615,7 +642,7 @@ module BetterAuth
615
642
  normalize_hash(provider[:discovery_headers] || provider[:discoveryHeaders]).each do |key, value|
616
643
  request[key.to_s.tr("_", "-")] = value.to_s
617
644
  end
618
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
645
+ response = HTTPClient.request(uri, request)
619
646
  provider[:_discovery] = response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : {}
620
647
  rescue
621
648
  {}
@@ -649,7 +676,7 @@ module BetterAuth
649
676
  form_data[key] = value unless form_data.key?(key)
650
677
  end
651
678
  request.set_form_data(form_data)
652
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
679
+ response = HTTPClient.request(uri, request)
653
680
  return nil unless response.is_a?(Net::HTTPSuccess)
654
681
 
655
682
  generic_oauth_normalize_tokens(JSON.parse(response.body))
@@ -714,7 +741,7 @@ module BetterAuth
714
741
  uri = URI(url)
715
742
  request = Net::HTTP::Get.new(uri)
716
743
  normalize_hash(headers).each { |key, value| request[key.to_s.tr("_", "-")] = value.to_s }
717
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
744
+ response = HTTPClient.request(uri, request)
718
745
  return nil unless response.is_a?(Net::HTTPSuccess)
719
746
 
720
747
  JSON.parse(response.body)
@@ -790,7 +817,7 @@ module BetterAuth
790
817
  token_url_params = token_url_params.call(ctx) if token_url_params.respond_to?(:call)
791
818
  normalize_hash(token_url_params || {}).each { |key, value| form_data[key] = value }
792
819
  request.set_form_data(form_data.compact)
793
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
820
+ response = HTTPClient.request(uri, request)
794
821
  raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIG"]) unless response.is_a?(Net::HTTPSuccess)
795
822
 
796
823
  generic_oauth_normalize_tokens(JSON.parse(response.body))
@@ -83,7 +83,7 @@ module BetterAuth
83
83
  request = Net::HTTP::Get.new(uri)
84
84
  request["Add-Padding"] = "true"
85
85
  request["User-Agent"] = "BetterAuth Password Checker"
86
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
86
+ response = HTTPClient.request(uri, request)
87
87
  unless response.is_a?(Net::HTTPSuccess)
88
88
  raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Status: #{response.code}")
89
89
  end
@@ -63,6 +63,7 @@ module BetterAuth
63
63
  schema: {
64
64
  jwks: {
65
65
  fields: {
66
+ id: {type: "string", required: true},
66
67
  publicKey: {type: "string", required: true},
67
68
  privateKey: {type: "string", required: true},
68
69
  createdAt: {type: "date", required: true},
@@ -279,9 +280,15 @@ module BetterAuth
279
280
  payload = if fetcher.respond_to?(:call)
280
281
  fetcher.call(url)
281
282
  else
282
- uri = URI.parse(url.to_s)
283
- response = Net::HTTP.get_response(uri)
284
- response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil
283
+ cached = @jwt_remote_jwks_cache ||= {}
284
+ entry = cached[url.to_s]
285
+ if entry && entry[:expires_at] > Time.now
286
+ entry[:payload]
287
+ else
288
+ fetched = HTTPClient.get_json(url)
289
+ cached[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
290
+ fetched
291
+ end
285
292
  end
286
293
  keys = fetch_value(payload, "keys")
287
294
  Array(keys).map { |entry| normalize_remote_jwk(entry) }
@@ -8,7 +8,7 @@ module BetterAuth
8
8
  def schema
9
9
  {
10
10
  oauthClient: {
11
- modelName: "oauthClient",
11
+ model_name: "oauth_clients",
12
12
  fields: {
13
13
  clientId: {type: "string", unique: true, required: true},
14
14
  clientSecret: {type: "string", required: false},
@@ -17,7 +17,7 @@ module BetterAuth
17
17
  enableEndSession: {type: "boolean", required: false},
18
18
  clientSecretExpiresAt: {type: "number", required: false},
19
19
  scopes: {type: "string[]", required: false},
20
- userId: {type: "string", required: false},
20
+ userId: {type: "string", required: false, references: {model: "user", field: "id"}},
21
21
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},
22
22
  updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }},
23
23
  name: {type: "string", required: false},
@@ -45,9 +45,9 @@ module BetterAuth
45
45
  oauthRefreshToken: {
46
46
  fields: {
47
47
  token: {type: "string", required: true},
48
- clientId: {type: "string", required: true},
49
- sessionId: {type: "string", required: false},
50
- userId: {type: "string", required: false},
48
+ clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
49
+ sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
50
+ userId: {type: "string", required: false, references: {model: "user", field: "id"}},
51
51
  referenceId: {type: "string", required: false},
52
52
  authTime: {type: "date", required: false},
53
53
  expiresAt: {type: "date", required: false},
@@ -57,27 +57,27 @@ module BetterAuth
57
57
  }
58
58
  },
59
59
  oauthAccessToken: {
60
- modelName: "oauthAccessToken",
60
+ model_name: "oauth_access_tokens",
61
61
  fields: {
62
62
  token: {type: "string", unique: true, required: true},
63
63
  expiresAt: {type: "date", required: true},
64
- clientId: {type: "string", required: true},
65
- userId: {type: "string", required: false},
66
- sessionId: {type: "string", required: false},
64
+ clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
65
+ userId: {type: "string", required: false, references: {model: "user", field: "id"}},
66
+ sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
67
67
  scopes: {type: "string[]", required: true},
68
68
  revoked: {type: "date", required: false},
69
69
  referenceId: {type: "string", required: false},
70
70
  authTime: {type: "date", required: false},
71
- refreshId: {type: "string", required: false},
71
+ refreshId: {type: "string", required: false, references: {model: "oauthRefreshToken", field: "id"}},
72
72
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},
73
73
  updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
74
74
  }
75
75
  },
76
76
  oauthConsent: {
77
- modelName: "oauthConsent",
77
+ model_name: "oauth_consents",
78
78
  fields: {
79
- clientId: {type: "string", required: true},
80
- userId: {type: "string", required: false},
79
+ clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
80
+ userId: {type: "string", required: false, references: {model: "user", field: "id"}},
81
81
  referenceId: {type: "string", required: false},
82
82
  scopes: {type: "string[]", required: true},
83
83
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},
@@ -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)