better_auth 0.8.0 → 0.11.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "base64"
4
+ require "json"
4
5
  require "jwt"
5
6
  require "openssl"
6
7
  require "time"
@@ -29,12 +30,18 @@ module BetterAuth
29
30
  parse_scopes(value).join(" ")
30
31
  end
31
32
 
33
+ def request_body!(value)
34
+ return stringify_keys(value || {}) if value.nil? || value.is_a?(Hash)
35
+
36
+ raise APIError.new("BAD_REQUEST", message: "request body must be an object")
37
+ end
38
+
32
39
  def issuer(ctx)
33
- ctx.context.options.base_url.to_s.empty? ? origin_for(ctx.context.base_url) : ctx.context.options.base_url
40
+ origin_for(ctx.context.canonical_base_url)
34
41
  end
35
42
 
36
43
  def endpoint_base(ctx)
37
- ctx.context.base_url
44
+ ctx.context.canonical_base_url
38
45
  end
39
46
 
40
47
  def origin_for(url)
@@ -53,6 +60,7 @@ module BetterAuth
53
60
  end
54
61
 
55
62
  def validate_redirect_uri!(client, redirect_uri)
63
+ validate_safe_url!(redirect_uri, field: "redirect_uri")
56
64
  redirects = client_redirect_uris(client)
57
65
  return if redirects.include?(redirect_uri.to_s)
58
66
  return if loopback_redirect_match?(redirects, redirect_uri)
@@ -99,15 +107,19 @@ module BetterAuth
99
107
  value.to_s.split(",").map(&:strip).reject(&:empty?)
100
108
  end
101
109
 
102
- 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 || {})
110
+ 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, generate_client_id: nil, generate_client_secret: nil, client_registration_client_secret_expiration: nil)
111
+ body = request_body!(body || {})
104
112
  requested_auth_method = body["token_endpoint_auth_method"] || default_auth_method
105
113
  validate_client_metadata_enums!(requested_auth_method, body)
106
114
  validate_admin_only_fields!(body, admin: admin)
107
115
  auth_method = unauthenticated ? "none" : requested_auth_method
108
116
  public_client = auth_method == "none"
109
- client_id = Crypto.random_string(32)
110
- client_secret = public_client ? nil : Crypto.random_string(32)
117
+ client_id = generate_client_id.respond_to?(:call) ? generate_client_id.call : Crypto.random_string(32)
118
+ client_secret = if public_client
119
+ nil
120
+ else
121
+ generate_client_secret.respond_to?(:call) ? generate_client_secret.call : Crypto.random_string(32)
122
+ end
111
123
  redirects = Array(body["redirect_uris"]).map(&:to_s)
112
124
  raise APIError.new("BAD_REQUEST", message: "redirect_uris is required") if redirects.empty?
113
125
  redirects.each { |uri| validate_safe_url!(uri, field: "redirect_uris") }
@@ -116,6 +128,7 @@ module BetterAuth
116
128
  grant_types = Array(body["grant_types"] || [AUTH_CODE_GRANT]).map(&:to_s)
117
129
  response_types = Array(body["response_types"] || ["code"]).map(&:to_s)
118
130
  validate_client_registration!(auth_method, grant_types, response_types, body, unauthenticated: unauthenticated, dynamic_registration: dynamic_registration)
131
+ validate_redirect_scheme_for_client!(auth_method, body, redirects)
119
132
  validate_pairwise_client!(body, redirects, pairwise_secret)
120
133
 
121
134
  scopes = parse_scopes(body["scope"] || body["scopes"])
@@ -139,6 +152,12 @@ module BetterAuth
139
152
  else
140
153
  body["type"] || (public_client ? nil : "web")
141
154
  end
155
+ issued_at = Time.now.to_i
156
+ secret_expires_at = if client_secret && dynamic_registration && !client_registration_client_secret_expiration.nil?
157
+ resolve_client_secret_expires_at(client_registration_client_secret_expiration, issued_at)
158
+ elsif admin
159
+ body["client_secret_expires_at"] || 0
160
+ end
142
161
  data = {
143
162
  "clientId" => client_id,
144
163
  "clientSecret" => client_secret ? store_client_secret_value(ctx, client_secret, store_client_secret) : nil,
@@ -156,7 +175,7 @@ module BetterAuth
156
175
  "redirectUris" => redirects,
157
176
  "redirectUrls" => redirects.join(","),
158
177
  "postLogoutRedirectUris" => Array(body["post_logout_redirect_uris"]).map(&:to_s),
159
- "clientSecretExpiresAt" => admin ? (body["client_secret_expires_at"] || 0) : nil,
178
+ "clientSecretExpiresAt" => secret_expires_at,
160
179
  "tokenEndpointAuthMethod" => auth_method,
161
180
  "grantTypes" => grant_types,
162
181
  "responseTypes" => response_types,
@@ -176,7 +195,10 @@ module BetterAuth
176
195
  client_id_issued_at: Time.now.to_i
177
196
  ).compact
178
197
  response[:require_pkce] = require_pkce unless require_pkce.nil?
179
- response[:client_secret_expires_at] = 0 if client_secret
198
+ if client_secret
199
+ expires = stringify_keys(created || {})["clientSecretExpiresAt"] || secret_expires_at
200
+ response[:client_secret_expires_at] = timestamp_seconds(expires) || 0
201
+ end
180
202
  response
181
203
  end
182
204
 
@@ -237,6 +259,20 @@ module BetterAuth
237
259
  end
238
260
  end
239
261
 
262
+ def validate_redirect_scheme_for_client!(auth_method, body, redirects)
263
+ return if auth_method == "none" && body["type"] != "web"
264
+
265
+ redirects.each do |value|
266
+ uri = URI.parse(value.to_s)
267
+ next if uri.scheme == "https"
268
+ next if uri.scheme == "http" && loopback_host?(uri.hostname || uri.host)
269
+
270
+ raise APIError.new("BAD_REQUEST", message: "redirect_uris is invalid")
271
+ end
272
+ rescue URI::InvalidURIError
273
+ raise APIError.new("BAD_REQUEST", message: "redirect_uris is invalid")
274
+ end
275
+
240
276
  def validate_pairwise_client!(body, redirects, pairwise_secret)
241
277
  subject_type = body["subject_type"] || body["subjectType"]
242
278
  return unless subject_type == "pairwise"
@@ -244,7 +280,12 @@ module BetterAuth
244
280
  raise APIError.new("BAD_REQUEST", message: "pairwise subject_type requires pairwise_secret") if pairwise_secret.to_s.empty?
245
281
 
246
282
  hosts = redirects.map { |uri| URI.parse(uri).host }.uniq
247
- raise APIError.new("BAD_REQUEST", message: "pairwise redirect_uris must share the same host") if hosts.length > 1
283
+ if hosts.length > 1
284
+ raise APIError.new(
285
+ "BAD_REQUEST",
286
+ message: "pairwise clients with redirect_uris on different hosts require a sector_identifier_uri, which is not yet supported. All redirect_uris must share the same host."
287
+ )
288
+ end
248
289
  rescue URI::InvalidURIError
249
290
  raise APIError.new("BAD_REQUEST", message: "invalid redirect_uris")
250
291
  end
@@ -266,7 +307,11 @@ module BetterAuth
266
307
  end
267
308
 
268
309
  def client_metadata(body, strip_unknown: false)
269
- metadata = stringify_keys(body["metadata"] || {})
310
+ raw_metadata = body["metadata"]
311
+ unless raw_metadata.nil? || raw_metadata.is_a?(Hash)
312
+ raise APIError.new("BAD_REQUEST", message: "metadata must be an object")
313
+ end
314
+ metadata = stringify_keys(raw_metadata || {})
270
315
  metadata = metadata.slice("software_id", "software_version", "software_statement", "tos_uri", "policy_uri") if strip_unknown
271
316
  metadata["software_id"] = body["software_id"] if body["software_id"]
272
317
  metadata["software_version"] = body["software_version"] if body["software_version"]
@@ -309,19 +354,36 @@ module BetterAuth
309
354
  end
310
355
  end
311
356
 
312
- def find_client(ctx, model, client_id)
313
- ctx.context.adapter.find_one(model: model, where: [{field: "clientId", value: client_id.to_s}])
357
+ def find_client(ctx, model, client_id, cached_trusted_clients: nil)
358
+ client_id = client_id.to_s
359
+ if (cached = trusted_client_cache[client_id])
360
+ return cached
361
+ end
362
+
363
+ client = ctx.context.adapter.find_one(model: model, where: [{field: "clientId", value: client_id}])
364
+ if client && cached_trusted_clients&.include?(client_id)
365
+ trusted_client_cache[client_id] = client
366
+ end
367
+ client
314
368
  end
315
369
 
316
- def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {})
317
- body = stringify_keys(ctx.body || {})
370
+ def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {}, require_confidential: false)
371
+ body = request_body!(ctx.body || {})
318
372
  client_id = body["client_id"]
319
373
  client_secret = strip_prefix(body["client_secret"], prefix, :client_secret) || body["client_secret"]
320
374
 
321
375
  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 "))
376
+ auth_method_used = client_secret.to_s.empty? ? nil : "client_secret_post"
377
+ if authorization.to_s.start_with?("Basic ")
378
+ decoded = Base64.strict_decode64(authorization.delete_prefix("Basic "))
379
+ unless decoded.include?(":")
380
+ raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
381
+ end
324
382
  client_id, client_secret = decoded.split(":", 2)
383
+ if client_id.to_s.empty? || client_secret.to_s.empty?
384
+ raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
385
+ end
386
+ auth_method_used = "client_secret_basic"
325
387
  end
326
388
 
327
389
  client = find_client(ctx, model, client_id)
@@ -332,20 +394,35 @@ module BetterAuth
332
394
 
333
395
  method = client_data["tokenEndpointAuthMethod"] || "client_secret_basic"
334
396
  if method == "none"
397
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client") if require_confidential
335
398
  raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client_secret.to_s.empty?
336
399
  return client
337
400
  end
401
+ expected_method = (method == "client_secret_post") ? "client_secret_post" : "client_secret_basic"
402
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless auth_method_used == expected_method
403
+ if client_secret_expired?(client_data["clientSecretExpiresAt"])
404
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client")
405
+ end
338
406
  if method != "none" && !verify_client_secret(ctx, stringify_keys(client)["clientSecret"], client_secret, store_client_secret)
339
407
  raise APIError.new("UNAUTHORIZED", message: "invalid_client")
340
408
  end
341
409
 
342
- client
410
+ client.merge("__providedClientSecret" => client_secret)
343
411
  rescue ArgumentError
344
- raise APIError.new("UNAUTHORIZED", message: "invalid_client")
412
+ raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
345
413
  end
346
414
 
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] = {
415
+ def client_secret_expired?(value)
416
+ return false if value.nil? || value.to_i == 0
417
+
418
+ seconds = timestamp_seconds(value)
419
+ seconds && seconds <= Time.now.to_i
420
+ end
421
+
422
+ def store_code(internal_adapter, 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")
423
+ stored_code = get_stored_token(store_tokens, code, "authorization_code")
424
+ expires_at = Time.now + expires_in.to_i
425
+ data = {
349
426
  client_id: client_id,
350
427
  redirect_uri: redirect_uri,
351
428
  session: session,
@@ -355,14 +432,19 @@ module BetterAuth
355
432
  nonce: nonce,
356
433
  reference_id: reference_id,
357
434
  auth_time: auth_time || session_auth_time(session),
358
- expires_at: Time.now + expires_in.to_i
435
+ expires_at: expires_at.iso8601(6)
359
436
  }
437
+ internal_adapter.create_verification_value(identifier: stored_code, value: JSON.generate(data), expiresAt: expires_at)
360
438
  end
361
439
 
362
- def consume_code!(store, code, client_id:, redirect_uri:, code_verifier: nil)
363
- data = store[:codes].delete(code.to_s)
364
- raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
365
- raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data[:expires_at] <= Time.now
440
+ def consume_code!(internal_adapter, code, client_id:, redirect_uri:, code_verifier: nil, store_tokens: "hashed")
441
+ stored_code = get_stored_token(store_tokens, code.to_s, "authorization_code")
442
+ verification = internal_adapter.consume_verification_value(stored_code)
443
+ verification ||= internal_adapter.consume_verification_value(code.to_s) if stored_code != code.to_s
444
+ data = verification && JSON.parse(verification.fetch("value"), symbolize_names: true)
445
+ unless data.is_a?(Hash) && !data[:client_id].to_s.empty? && !data[:redirect_uri].to_s.empty? && data[:session].is_a?(Hash) && data[:scopes].is_a?(Array)
446
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant")
447
+ end
366
448
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:client_id] == client_id.to_s
367
449
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:redirect_uri] == redirect_uri.to_s
368
450
  if data[:code_challenge]
@@ -372,6 +454,8 @@ module BetterAuth
372
454
  end
373
455
 
374
456
  data
457
+ rescue JSON::ParserError, KeyError, TypeError
458
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant")
375
459
  end
376
460
 
377
461
  def verify_pkce!(code_data, verifier)
@@ -396,6 +480,7 @@ module BetterAuth
396
480
 
397
481
  def pkce_required?(client, scopes)
398
482
  data = stringify_keys(client)
483
+ return true if data["public"] || data["tokenEndpointAuthMethod"] == "none" || ["native", "user-agent-based"].include?(data["type"])
399
484
  return true if parse_scopes(scopes).include?("offline_access")
400
485
  require_pkce = client_require_pkce(data)
401
486
  return require_pkce unless require_pkce.nil?
@@ -403,7 +488,7 @@ module BetterAuth
403
488
  true
404
489
  end
405
490
 
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)
491
+ 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", generate_opaque_access_token: nil, generate_refresh_token: nil, format_refresh_token: nil)
407
492
  data = stringify_keys(session || {})
408
493
  user = stringify_keys(data["user"] || data[:user] || {})
409
494
  session_data = stringify_keys(data["session"] || data[:session] || {})
@@ -411,9 +496,11 @@ module BetterAuth
411
496
  subject = subject_identifier(user["id"], client_data, pairwise_secret)
412
497
  token_auth_time = auth_time || session_auth_time({"session" => session_data})
413
498
  token_reference_id = reference_id || client_data["referenceId"]
414
- access_token_value = Crypto.random_string(32)
415
- refresh_token_value = include_refresh ? Crypto.random_string(32) : nil
416
- refresh_token = refresh_token_value ? apply_prefix(refresh_token_value, prefix, :refresh_token) : nil
499
+ access_token_value = generate_opaque_access_token.respond_to?(:call) ? generate_opaque_access_token.call : Crypto.random_string(32)
500
+ refresh_token_value = if include_refresh
501
+ generate_refresh_token.respond_to?(:call) ? generate_refresh_token.call : Crypto.random_string(32)
502
+ end
503
+ refresh_token = refresh_token_value ? encode_refresh_token(refresh_token_value, prefix: prefix, format_refresh_token: format_refresh_token, session_id: session_data["id"]) : nil
417
504
  scope = scope_string(scopes)
418
505
  expires_at = Time.now + access_token_expires_in.to_i
419
506
  access_token = if jwt_access_token && audience
@@ -424,7 +511,7 @@ module BetterAuth
424
511
  refresh_record = nil
425
512
  if refresh_token_value
426
513
  refresh_record = {
427
- "token" => refresh_token_value,
514
+ "token" => store_token_value(store_tokens, refresh_token_value, "refresh_token"),
428
515
  "clientId" => client_data["clientId"],
429
516
  "sessionId" => session_data["id"],
430
517
  "userId" => user["id"],
@@ -440,13 +527,13 @@ module BetterAuth
440
527
  "issuedAt" => Time.now
441
528
  }
442
529
  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)
530
+ 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
531
  store[:refresh_tokens][refresh_token_value] = refresh_record
445
532
  store[:refresh_tokens][refresh_token] = refresh_record
446
533
  end
447
534
  unless jwt_access_token && audience
448
535
  record = {
449
- "token" => access_token_value,
536
+ "token" => store_token_value(store_tokens, access_token_value, "access_token"),
450
537
  "expiresAt" => expires_at,
451
538
  "clientId" => client_data["clientId"],
452
539
  "userId" => user["id"],
@@ -464,7 +551,7 @@ module BetterAuth
464
551
  created_access = ctx.context.adapter.create(model: model, data: record)
465
552
  created = stringify_keys(created_access || {})
466
553
  record = record.merge("id" => created["id"]) if created["id"]
467
- stored_record = record.merge("user" => user, "session" => session_data, "client" => client_data)
554
+ stored_record = record.merge("token" => access_token_value, "user" => user, "session" => session_data, "client" => client_data)
468
555
  store[:tokens][access_token_value] = stored_record
469
556
  store[:tokens][access_token] = stored_record
470
557
  end
@@ -478,7 +565,8 @@ module BetterAuth
478
565
  }
479
566
  response[:audience] = audience if audience
480
567
  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")
568
+ id_token_client_data = client_data.merge("clientSecret" => client_data["__providedClientSecret"] || client_data["clientSecret"])
569
+ 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
570
  if custom_token_response_fields.respond_to?(:call)
483
571
  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
572
  response.merge!(stringify_keys(extra).reject { |key, _value| standard_token_response_field?(key) }.transform_keys(&:to_sym)) if extra.is_a?(Hash)
@@ -486,9 +574,10 @@ module BetterAuth
486
574
  response
487
575
  end
488
576
 
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)
490
- refresh_token_value = strip_prefix(refresh_token, prefix, :refresh_token)
491
- data = refresh_token_value ? store[:refresh_tokens][refresh_token_value] : nil
577
+ 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", generate_opaque_access_token: nil, generate_refresh_token: nil, format_refresh_token: nil)
578
+ refresh_token_value = decode_refresh_token(refresh_token, prefix: prefix, format_refresh_token: format_refresh_token)
579
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant") if refresh_token_value.to_s.empty?
580
+ data = store[:refresh_tokens][refresh_token_value]
492
581
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
493
582
  if data["revoked"]
494
583
  revoke_refresh_family!(ctx, store, data)
@@ -532,7 +621,11 @@ module BetterAuth
532
621
  id_token_expires_in: id_token_expires_in,
533
622
  auth_time: data["authTime"],
534
623
  reference_id: data["referenceId"],
535
- filter_id_token_claims_by_scope: filter_id_token_claims_by_scope
624
+ filter_id_token_claims_by_scope: filter_id_token_claims_by_scope,
625
+ store_tokens: store_tokens,
626
+ generate_opaque_access_token: generate_opaque_access_token,
627
+ generate_refresh_token: generate_refresh_token,
628
+ format_refresh_token: format_refresh_token
536
629
  )
537
630
  end
538
631
 
@@ -565,8 +658,7 @@ module BetterAuth
565
658
  "exp" => expires_at.to_i
566
659
  ).compact
567
660
  if use_jwt_plugin
568
- signed = sign_oauth_jwt(ctx, payload, issuer: issuer_value, audience: audience)
569
- return signed if signed
661
+ return sign_oauth_jwt!(ctx, payload, issuer: issuer_value, audience: audience)
570
662
  end
571
663
 
572
664
  ::JWT.encode(payload, ctx.context.secret, "HS256")
@@ -643,9 +735,12 @@ module BetterAuth
643
735
  )
644
736
  end
645
737
 
646
- def find_token_by_hint(store, token, hint, prefix: {})
738
+ def find_token_by_hint(store, token, hint, prefix: {}, format_refresh_token: nil)
647
739
  access = -> { (value = strip_prefix(token, prefix, :access_token)) && store[:tokens][value] }
648
- refresh = -> { (value = strip_prefix(token, prefix, :refresh_token)) && store[:refresh_tokens][value] }
740
+ refresh = lambda {
741
+ value = decode_refresh_token(token, prefix: prefix, format_refresh_token: format_refresh_token)
742
+ value && store[:refresh_tokens][value]
743
+ }
649
744
 
650
745
  case hint.to_s
651
746
  when "access_token"
@@ -695,6 +790,65 @@ module BetterAuth
695
790
  "#{token_prefix(prefix, kind)}#{value}"
696
791
  end
697
792
 
793
+ def encode_refresh_token(token_value, prefix:, format_refresh_token: nil, session_id: nil)
794
+ formatted = if format_refresh_token.is_a?(Hash) && format_refresh_token[:encrypt].respond_to?(:call)
795
+ format_refresh_token[:encrypt].call(token_value, session_id)
796
+ else
797
+ token_value
798
+ end
799
+ apply_prefix(formatted, prefix, :refresh_token)
800
+ end
801
+
802
+ def decode_refresh_token(token, prefix:, format_refresh_token: nil)
803
+ value = strip_prefix(token, prefix, :refresh_token)
804
+ return nil if value.nil?
805
+
806
+ if format_refresh_token.is_a?(Hash) && format_refresh_token[:decrypt].respond_to?(:call)
807
+ decrypted = format_refresh_token[:decrypt].call(value)
808
+ if decrypted.is_a?(Hash)
809
+ decrypted[:token] || decrypted["token"]
810
+ else
811
+ decrypted.to_s
812
+ end
813
+ else
814
+ value
815
+ end
816
+ end
817
+
818
+ def trusted_client_cache
819
+ @trusted_client_cache ||= {}
820
+ end
821
+
822
+ def resolve_client_secret_expires_at(value, issued_at)
823
+ return value.to_i if value.is_a?(Numeric)
824
+ return value.to_i if value.is_a?(Time)
825
+
826
+ seconds = timestamp_seconds(value)
827
+ return seconds if seconds && !value.is_a?(String)
828
+
829
+ issued_at.to_i + parse_duration(value.to_s)
830
+ rescue TypeError
831
+ 0
832
+ end
833
+
834
+ def parse_duration(value)
835
+ match = value.strip.match(/\A(-?\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|y|yr|yrs|year|years)(?:\s+from now|\s+ago)?\z/i)
836
+ raise TypeError, "Invalid time string" unless match
837
+
838
+ amount = match[1].to_i
839
+ amount = -amount if value.include?("ago")
840
+ unit = match[2].downcase
841
+ multiplier = case unit
842
+ when "s", "sec", "secs", "second", "seconds" then 1
843
+ when "m", "min", "mins", "minute", "minutes" then 60
844
+ when "h", "hr", "hrs", "hour", "hours" then 3600
845
+ when "d", "day", "days" then 86_400
846
+ when "w", "week", "weeks" then 604_800
847
+ else 31_557_600
848
+ end
849
+ amount * multiplier
850
+ end
851
+
698
852
  def strip_prefix(value, prefix, kind)
699
853
  token = value.to_s
700
854
  expected = token_prefix(prefix, kind)
@@ -745,8 +899,7 @@ module BetterAuth
745
899
  return signer.call(ctx, payload) if signer.respond_to?(:call)
746
900
 
747
901
  if use_jwt_plugin && ctx
748
- signed = sign_oauth_jwt(ctx, payload, issuer: issuer_value, audience: audience)
749
- return signed if signed
902
+ return sign_oauth_jwt!(ctx, payload, issuer: issuer_value, audience: audience)
750
903
  end
751
904
 
752
905
  Crypto.sign_jwt(
@@ -757,6 +910,11 @@ module BetterAuth
757
910
  end
758
911
 
759
912
  def id_token_hs256_key(ctx, client_id, client_secret = nil)
913
+ oauth_provider = ctx&.context&.options&.plugins&.find { |plugin| plugin.id == "oauth-provider" }
914
+ if oauth_provider&.options&.fetch(:store_client_secret, nil).to_s == "hashed"
915
+ label = client_id.to_s.empty? ? "better-auth" : client_id.to_s
916
+ return OpenSSL::HMAC.hexdigest("SHA256", ctx.context.secret.to_s, "oidc.id_token.#{label}")
917
+ end
760
918
  return client_secret.to_s unless client_secret.to_s.empty?
761
919
 
762
920
  label = client_id.to_s.empty? ? "better-auth" : client_id.to_s
@@ -782,6 +940,15 @@ module BetterAuth
782
940
  BetterAuth::Plugins.sign_jwt_payload(ctx, stringify_keys(payload), config)
783
941
  end
784
942
 
943
+ def sign_oauth_jwt!(ctx, payload, issuer:, audience:)
944
+ raise BetterAuth::Error, "jwt_config" unless jwt_plugin_options(ctx)
945
+
946
+ signed = sign_oauth_jwt(ctx, payload, issuer: issuer, audience: audience)
947
+ raise BetterAuth::Error, "jwt_config" unless signed
948
+
949
+ signed
950
+ end
951
+
785
952
  def verify_oauth_jwt(ctx, token, issuer:, hs256_secret:)
786
953
  payload = ::JWT.decode(token.to_s, nil, false).first
787
954
  audience = payload["aud"]
@@ -857,10 +1024,29 @@ module BetterAuth
857
1024
  secret
858
1025
  end
859
1026
 
1027
+ def store_token_value(storage_method, token, type)
1028
+ case storage_method
1029
+ when "hashed", :hashed
1030
+ Crypto.sha256(token.to_s, encoding: :base64url)
1031
+ else
1032
+ mode = normalize_secret_storage_mode(storage_method)
1033
+ return mode[:hash].call(token.to_s, type) if mode.is_a?(Hash) && mode[:hash].respond_to?(:call)
1034
+
1035
+ raise Error, "storeToken: unsupported storageMethod type '#{storage_method}'"
1036
+ end
1037
+ end
1038
+
1039
+ def get_stored_token(storage_method, token, type)
1040
+ store_token_value(storage_method, token, type)
1041
+ end
1042
+
860
1043
  def verify_client_secret(ctx, stored_secret, provided_secret, mode)
861
1044
  mode = normalize_secret_storage_mode(mode)
862
1045
  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"
1046
+ if mode == "encrypted"
1047
+ decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_secret)
1048
+ return Crypto.constant_time_compare(decrypted.to_s, provided_secret.to_s)
1049
+ end
864
1050
 
865
1051
  if mode.is_a?(Hash)
866
1052
  return Crypto.constant_time_compare(mode[:hash].call(provided_secret).to_s, stored_secret.to_s) if mode[:hash].respond_to?(:call)
@@ -868,6 +1054,8 @@ module BetterAuth
868
1054
  end
869
1055
 
870
1056
  Crypto.constant_time_compare(stored_secret.to_s, provided_secret.to_s)
1057
+ rescue Error, ArgumentError
1058
+ false
871
1059
  end
872
1060
 
873
1061
  def normalize_secret_storage_mode(mode)
@@ -878,7 +1066,6 @@ module BetterAuth
878
1066
 
879
1067
  def stores
880
1068
  {
881
- codes: {},
882
1069
  tokens: {},
883
1070
  refresh_tokens: {},
884
1071
  consents: {}
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ return if defined?(BetterAuth::Plugins::OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION)
4
+
3
5
  module BetterAuth
4
6
  module Plugins
5
7
  module_function
6
8
 
7
- def oauth_provider(*args)
8
- Kernel.require "better_auth/oauth_provider"
9
- BetterAuth::Plugins.oauth_provider(*args)
10
- rescue LoadError => error
11
- raise if error.path && error.path != "better_auth/oauth_provider"
12
-
13
- raise LoadError, "BetterAuth::Plugins.oauth_provider requires the better_auth-oauth-provider gem. Add `gem \"better_auth-oauth-provider\"` and `require \"better_auth/oauth_provider\"`."
9
+ def oauth_provider(*args, &block)
10
+ call_external_plugin!(:oauth_provider, *args, implementation_constant: :OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-oauth-provider", entry: "lib/better_auth/oauth_provider.rb", &block)
14
11
  end
15
12
  end
16
13
  end
@@ -14,7 +14,7 @@ module BetterAuth
14
14
  Plugin.new(
15
15
  id: "oauth-proxy",
16
16
  endpoints: {
17
- o_auth_proxy: oauth_proxy_endpoint(config)
17
+ oauth_proxy: oauth_proxy_endpoint(config)
18
18
  },
19
19
  hooks: {
20
20
  before: [