better_auth 0.10.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +138 -91
  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 +191 -56
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +294 -49
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +41 -46
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +110 -14
  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 +14 -3
  18. data/lib/better_auth/error.rb +3 -1
  19. data/lib/better_auth/logger.rb +1 -1
  20. data/lib/better_auth/middleware/origin_check.rb +9 -1
  21. data/lib/better_auth/migration_plan.rb +4 -4
  22. data/lib/better_auth/oauth_state.rb +147 -0
  23. data/lib/better_auth/plugin_loader.rb +220 -0
  24. data/lib/better_auth/plugins/access.rb +10 -13
  25. data/lib/better_auth/plugins/admin.rb +79 -28
  26. data/lib/better_auth/plugins/api_key.rb +4 -7
  27. data/lib/better_auth/plugins/bearer.rb +8 -22
  28. data/lib/better_auth/plugins/captcha.rb +24 -6
  29. data/lib/better_auth/plugins/device_authorization.rb +47 -9
  30. data/lib/better_auth/plugins/dub.rb +2 -2
  31. data/lib/better_auth/plugins/email_otp.rb +30 -47
  32. data/lib/better_auth/plugins/generic_oauth.rb +141 -146
  33. data/lib/better_auth/plugins/i18n.rb +215 -0
  34. data/lib/better_auth/plugins/jwt.rb +37 -19
  35. data/lib/better_auth/plugins/magic_link.rb +18 -21
  36. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  37. data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
  38. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  39. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  40. data/lib/better_auth/plugins/one_tap.rb +61 -60
  41. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  42. data/lib/better_auth/plugins/open_api.rb +35 -7
  43. data/lib/better_auth/plugins/organization.rb +455 -122
  44. data/lib/better_auth/plugins/passkey.rb +4 -7
  45. data/lib/better_auth/plugins/phone_number.rb +24 -26
  46. data/lib/better_auth/plugins/scim.rb +4 -7
  47. data/lib/better_auth/plugins/siwe.rb +126 -16
  48. data/lib/better_auth/plugins/sso.rb +4 -7
  49. data/lib/better_auth/plugins/stripe.rb +4 -7
  50. data/lib/better_auth/plugins/two_factor.rb +186 -21
  51. data/lib/better_auth/plugins/username.rb +56 -13
  52. data/lib/better_auth/plugins.rb +57 -0
  53. data/lib/better_auth/rate_limiter.rb +297 -135
  54. data/lib/better_auth/request_ip.rb +123 -13
  55. data/lib/better_auth/router.rb +3 -3
  56. data/lib/better_auth/routes/account.rb +40 -8
  57. data/lib/better_auth/routes/email_verification.rb +88 -5
  58. data/lib/better_auth/routes/password.rb +5 -6
  59. data/lib/better_auth/routes/session.rb +7 -2
  60. data/lib/better_auth/routes/sign_in.rb +10 -1
  61. data/lib/better_auth/routes/sign_up.rb +45 -22
  62. data/lib/better_auth/routes/social.rb +169 -54
  63. data/lib/better_auth/routes/user.rb +11 -8
  64. data/lib/better_auth/schema/sql.rb +90 -24
  65. data/lib/better_auth/schema.rb +124 -3
  66. data/lib/better_auth/session.rb +27 -9
  67. data/lib/better_auth/session_store.rb +1 -1
  68. data/lib/better_auth/social_providers/apple.rb +6 -1
  69. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  70. data/lib/better_auth/social_providers/base.rb +41 -14
  71. data/lib/better_auth/social_providers/cognito.rb +75 -5
  72. data/lib/better_auth/social_providers/facebook.rb +95 -1
  73. data/lib/better_auth/social_providers/figma.rb +2 -0
  74. data/lib/better_auth/social_providers/google.rb +12 -1
  75. data/lib/better_auth/social_providers/line.rb +35 -1
  76. data/lib/better_auth/social_providers/linear.rb +1 -0
  77. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  78. data/lib/better_auth/social_providers/naver.rb +24 -1
  79. data/lib/better_auth/social_providers/notion.rb +1 -0
  80. data/lib/better_auth/social_providers/paybin.rb +25 -1
  81. data/lib/better_auth/social_providers/paypal.rb +118 -2
  82. data/lib/better_auth/social_providers/reddit.rb +25 -4
  83. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  84. data/lib/better_auth/social_providers/twitch.rb +24 -1
  85. data/lib/better_auth/social_providers/twitter.rb +36 -1
  86. data/lib/better_auth/social_providers/vk.rb +34 -1
  87. data/lib/better_auth/social_providers/wechat.rb +6 -4
  88. data/lib/better_auth/sql_migration.rb +5 -5
  89. data/lib/better_auth/version.rb +1 -1
  90. data/lib/better_auth.rb +11 -41
  91. metadata +5 -13
  92. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  93. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  94. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  95. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  96. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  97. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  98. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  99. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  100. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  101. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  102. data/lib/better_auth/plugins/mcp.rb +0 -256
  103. data/lib/better_auth/plugins/oidc_provider.rb +0 -769
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module BetterAuth
7
+ module Plugins
8
+ OAUTH_POPUP_MESSAGE_TYPE = "better-auth:oauth-popup"
9
+ OAUTH_POPUP_DATA_ELEMENT_ID = "better-auth-oauth-popup"
10
+ OAUTH_POPUP_MARKER_COOKIE = "oauth_popup"
11
+ OAUTH_POPUP_ERROR_CODES = {
12
+ "POPUP_SIGN_IN_FAILED" => "Popup sign-in failed",
13
+ "POPUP_BLOCKED" => "Sign-in popup was blocked by the browser",
14
+ "POPUP_CLOSED" => "Sign-in popup was closed before completing",
15
+ "POPUP_TIMEOUT" => "Sign-in popup timed out"
16
+ }.freeze
17
+ OAUTH_POPUP_INTERNAL_STATE_KEYS = OAuthState::INTERNAL_KEYS
18
+ OAUTH_POPUP_COMPLETE_SCRIPT = [
19
+ "(function () {",
20
+ "\tvar el = document.getElementById(\"better-auth-oauth-popup\");",
21
+ "\tif (!el) return;",
22
+ "\tvar payload;",
23
+ "\ttry {",
24
+ "\t\tpayload = JSON.parse(el.textContent || \"\");",
25
+ "\t} catch (e) {",
26
+ "\t\treturn;",
27
+ "\t}",
28
+ "\tvar target = window.opener || window.parent;",
29
+ "\tif (target && target !== window) {",
30
+ "\t\ttry {",
31
+ "\t\t\ttarget.postMessage(",
32
+ "\t\t\t\t{",
33
+ "\t\t\t\t\ttype: payload.type,",
34
+ "\t\t\t\t\tnonce: payload.nonce,",
35
+ "\t\t\t\t\ttoken: payload.token,",
36
+ "\t\t\t\t\tredirectTo: payload.redirectTo,",
37
+ "\t\t\t\t\terror: payload.error,",
38
+ "\t\t\t\t},",
39
+ "\t\t\t\tpayload.targetOrigin,",
40
+ "\t\t\t);",
41
+ "\t\t} catch (e) {}",
42
+ "\t}",
43
+ "\twindow.close();",
44
+ "})();",
45
+ ""
46
+ ].join("\n")
47
+ OAUTH_POPUP_SCRIPT_CSP_HASH = "sha256-tIo2K8VBC9SnhvdZ+9GsGkQoZm+jm/JcxL+d+i8b8KQ="
48
+
49
+ module_function
50
+
51
+ def oauth_popup
52
+ Plugin.new(
53
+ id: "oauth-popup",
54
+ endpoints: {
55
+ oauth_popup_start: oauth_popup_start_endpoint
56
+ },
57
+ hooks: {
58
+ after: [
59
+ {
60
+ matcher: ->(ctx) { oauth_popup_callback_path?(ctx.path) },
61
+ handler: ->(ctx) { oauth_popup_after_callback(ctx) }
62
+ }
63
+ ]
64
+ },
65
+ error_codes: OAUTH_POPUP_ERROR_CODES
66
+ )
67
+ end
68
+
69
+ def oauth_popup_start_endpoint
70
+ Endpoint.new(
71
+ path: "/oauth-popup/start",
72
+ method: "GET",
73
+ query_schema: Routes.request_query_schema(
74
+ required_strings: %w[provider popupOrigin],
75
+ optional_strings: %w[popupNonce callbackURL errorCallbackURL newUserCallbackURL scopes requestSignUp additionalData]
76
+ ),
77
+ metadata: {
78
+ hide: true,
79
+ openapi: {
80
+ parameters: [
81
+ {name: "provider", in: "query", required: true, schema: {type: "string"}},
82
+ {name: "popupOrigin", in: "query", required: true, schema: {type: "string"}},
83
+ {name: "popupNonce", in: "query", required: false, schema: {type: "string"}},
84
+ {name: "callbackURL", in: "query", required: false, schema: {type: "string"}},
85
+ {name: "errorCallbackURL", in: "query", required: false, schema: {type: "string"}},
86
+ {name: "newUserCallbackURL", in: "query", required: false, schema: {type: "string"}},
87
+ {name: "scopes", in: "query", required: false, schema: {type: "string"}},
88
+ {name: "requestSignUp", in: "query", required: false, schema: {type: "string"}},
89
+ {name: "additionalData", in: "query", required: false, schema: {type: "string"}}
90
+ ]
91
+ }
92
+ }
93
+ ) do |ctx|
94
+ query = normalize_hash(ctx.query)
95
+ popup_origin = oauth_popup_validate_origin!(ctx, query[:popup_origin].to_s)
96
+
97
+ nonce = query[:popup_nonce].to_s
98
+ invalid_redirect = oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce)
99
+ next invalid_redirect if invalid_redirect
100
+
101
+ provider_id = query[:provider].to_s
102
+ provider = Routes.social_provider(ctx.context, provider_id)
103
+ unless provider
104
+ next oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: {
105
+ code: "provider_not_found",
106
+ description: "Unknown provider: #{provider_id}"
107
+ })
108
+ end
109
+
110
+ code_verifier = Crypto.random_string(128)
111
+ scopes = query[:scopes]&.to_s&.split(",")
112
+ additional_data = oauth_popup_additional_data(query[:additional_data])
113
+ callback_url = query[:callback_url].to_s
114
+ callback_url = ctx.context.base_url if callback_url.empty?
115
+ request_sign_up = (query[:request_sign_up].to_s == "true") ? true : nil
116
+
117
+ begin
118
+ authorization_url = if (start = fetch_value(provider, "oauthPopupAuthorizationUrl")).respond_to?(:call)
119
+ start.call(
120
+ ctx,
121
+ callbackURL: callback_url,
122
+ errorCallbackURL: query[:error_callback_url],
123
+ newUserCallbackURL: query[:new_user_callback_url],
124
+ requestSignUp: request_sign_up,
125
+ scopes: scopes,
126
+ additionalData: additional_data,
127
+ codeVerifier: code_verifier
128
+ )
129
+ else
130
+ state_data = additional_data.merge(
131
+ "callbackURL" => callback_url,
132
+ "errorURL" => query[:error_callback_url],
133
+ "newUserURL" => query[:new_user_callback_url],
134
+ "requestSignUp" => request_sign_up,
135
+ "codeVerifier" => code_verifier,
136
+ "expiresAt" => Time.now.to_i + 600
137
+ ).compact
138
+ state = OAuthState.generate(ctx, state_data)
139
+ Routes.call_provider(provider, :create_authorization_url, {
140
+ state: state,
141
+ codeVerifier: code_verifier,
142
+ code_verifier: code_verifier,
143
+ redirectURI: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
144
+ redirect_uri: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
145
+ scopes: scopes
146
+ })
147
+ end
148
+
149
+ oauth_popup_set_marker(ctx, popup_origin, nonce)
150
+ rescue => error
151
+ oauth_popup_log(ctx.context, :error, "OAuth popup failed to start", error)
152
+ next oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: {
153
+ code: "popup_sign_in_failed",
154
+ description: "Failed to start the OAuth flow."
155
+ })
156
+ end
157
+
158
+ raise ctx.redirect(authorization_url.to_s)
159
+ end
160
+ end
161
+
162
+ def oauth_popup_validate_origin!(ctx, popup_origin)
163
+ uri = URI.parse(popup_origin)
164
+ valid_scheme = %w[http https].include?(uri.scheme)
165
+ valid_path = uri.path.to_s.empty? || uri.path == "/"
166
+ absolute = valid_scheme && !uri.host.to_s.empty?
167
+ canonical = Configuration.origin_for(uri) if absolute
168
+ valid = absolute &&
169
+ uri.userinfo.nil? &&
170
+ valid_path &&
171
+ uri.query.nil? &&
172
+ uri.fragment.nil? &&
173
+ !popup_origin.match?(/[?*]/) &&
174
+ ctx.context.trusted_origin?(canonical, allow_relative_paths: false)
175
+ return canonical if valid
176
+
177
+ oauth_popup_log(ctx.context, :error, "OAuth popup origin is not trusted")
178
+ raise APIError.new("FORBIDDEN", code: "INVALID_ORIGIN", message: BASE_ERROR_CODES["INVALID_ORIGIN"])
179
+ rescue URI::InvalidURIError
180
+ raise APIError.new("FORBIDDEN", code: "INVALID_ORIGIN", message: BASE_ERROR_CODES["INVALID_ORIGIN"])
181
+ end
182
+
183
+ def oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce)
184
+ {
185
+ callback_url: "invalid_callback_url",
186
+ error_callback_url: "invalid_error_callback_url",
187
+ new_user_callback_url: "invalid_new_user_callback_url"
188
+ }.each do |key, code|
189
+ value = query[key]
190
+ next if value.to_s.empty?
191
+ next if ctx.context.trusted_origin?(value.to_s, allow_relative_paths: true)
192
+
193
+ oauth_popup_log(ctx.context, :error, "OAuth popup redirect URL is not trusted")
194
+ return oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: {
195
+ code: code,
196
+ description: "Untrusted redirect URL"
197
+ })
198
+ end
199
+ nil
200
+ end
201
+
202
+ def oauth_popup_additional_data(value)
203
+ parsed = value.is_a?(Hash) ? value : JSON.parse(value.to_s)
204
+ return {} unless parsed.is_a?(Hash)
205
+
206
+ parsed.reject { |key, _entry| OAUTH_POPUP_INTERNAL_STATE_KEYS.include?(key.to_s) }
207
+ rescue JSON::ParserError
208
+ {}
209
+ end
210
+
211
+ def oauth_popup_set_marker(ctx, popup_origin, nonce)
212
+ cookie = ctx.context.create_auth_cookie(OAUTH_POPUP_MARKER_COOKIE, max_age: 600)
213
+ ctx.set_signed_cookie(
214
+ cookie.name,
215
+ JSON.generate({popupOrigin: popup_origin, popupNonce: nonce}),
216
+ ctx.context.secret,
217
+ cookie.attributes
218
+ )
219
+ end
220
+
221
+ def oauth_popup_callback_path?(path)
222
+ path.to_s.start_with?("/callback/", "/oauth2/callback/")
223
+ end
224
+
225
+ def oauth_popup_after_callback(ctx)
226
+ location = ctx.response_headers["location"].to_s
227
+ return if location.empty?
228
+
229
+ marker_cookie = ctx.context.create_auth_cookie(OAUTH_POPUP_MARKER_COOKIE)
230
+ marker = ctx.get_signed_cookie(marker_cookie.name, ctx.context.secret)
231
+ return unless marker
232
+
233
+ Cookies.expire_cookie(ctx, marker_cookie)
234
+ data = JSON.parse(marker)
235
+ popup_origin = data.fetch("popupOrigin").to_s
236
+ nonce = data["popupNonce"].to_s
237
+ return if popup_origin.empty?
238
+
239
+ token = oauth_popup_session_token(ctx)
240
+ message = if token
241
+ {nonce: nonce, token: token, redirectTo: location}
242
+ else
243
+ error = oauth_popup_redirect_error(location)
244
+ return unless error
245
+
246
+ {nonce: nonce, error: error}
247
+ end
248
+
249
+ oauth_popup_completion(ctx, popup_origin, message)
250
+ rescue JSON::ParserError, KeyError
251
+ nil
252
+ end
253
+
254
+ def oauth_popup_session_token(ctx)
255
+ name = ctx.context.auth_cookies[:session_token].name
256
+ Cookies.split_set_cookie_header(ctx.response_headers["set-cookie"]).each do |line|
257
+ cookie = Cookies.parse_set_cookie(line)
258
+ next unless cookie && cookie[:name] == name
259
+ max_age = cookie.dig(:attributes, "max-age")
260
+ next if cookie[:value].empty? || (max_age.to_s.strip.match?(/\A[+-]?\d+\z/) && max_age.to_i == 0)
261
+
262
+ return URI.decode_uri_component(cookie[:value])
263
+ rescue ArgumentError
264
+ return cookie[:value]
265
+ end
266
+ nil
267
+ end
268
+
269
+ def oauth_popup_redirect_error(location)
270
+ uri = URI.parse(location)
271
+ params = URI.decode_www_form(uri.query.to_s).to_h
272
+ return if params["error"].to_s.empty?
273
+
274
+ {code: params["error"], description: params["error_description"]}.compact
275
+ rescue URI::InvalidURIError
276
+ nil
277
+ end
278
+
279
+ def oauth_popup_completion(ctx, popup_origin, message)
280
+ oauth_popup_warn_missing_bearer(ctx) if message[:token]
281
+ payload = {
282
+ type: OAUTH_POPUP_MESSAGE_TYPE,
283
+ targetOrigin: popup_origin
284
+ }.merge(message)
285
+ serialized = JSON.generate(payload)
286
+ .gsub("<", "\\u003c")
287
+ .gsub("\u2028", "\\u2028")
288
+ .gsub("\u2029", "\\u2029")
289
+ html = <<~HTML
290
+ <!doctype html>
291
+ <html>
292
+ <head><meta charset="utf-8"><title>Completing sign-in</title></head>
293
+ <body>
294
+ <script type="application/json" id="#{OAUTH_POPUP_DATA_ELEMENT_ID}">#{serialized}</script>
295
+ <script>#{OAUTH_POPUP_COMPLETE_SCRIPT}</script>
296
+ </body>
297
+ </html>
298
+ HTML
299
+ headers = ctx.response_headers.merge(
300
+ "content-type" => "text/html; charset=utf-8",
301
+ "content-security-policy" => "default-src 'none'; script-src '#{OAUTH_POPUP_SCRIPT_CSP_HASH}'; base-uri 'none'",
302
+ "cache-control" => "no-store",
303
+ "pragma" => "no-cache"
304
+ )
305
+ headers.delete("location")
306
+ Endpoint::Result.new(response: html, status: 200, headers: headers)
307
+ end
308
+
309
+ def oauth_popup_warn_missing_bearer(ctx)
310
+ return if ctx.context.options.plugins.any? { |plugin| plugin.id == "bearer" }
311
+ return if @oauth_popup_warned_missing_bearer
312
+
313
+ @oauth_popup_warned_missing_bearer = true
314
+ oauth_popup_log(
315
+ ctx.context,
316
+ :warn,
317
+ "OAuth popup hands the session token back via postMessage, but the `bearer` plugin is not registered. Add bearer() for embedded cross-site authentication."
318
+ )
319
+ end
320
+
321
+ def oauth_popup_log(context, level, message, *details)
322
+ logger = context.logger
323
+ if logger.respond_to?(:call)
324
+ logger.call(level, message, *details)
325
+ elsif logger.respond_to?(level)
326
+ logger.public_send(level, message, *details)
327
+ end
328
+ end
329
+ end
330
+ end
@@ -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"
@@ -36,11 +37,11 @@ module BetterAuth
36
37
  end
37
38
 
38
39
  def issuer(ctx)
39
- 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)
40
41
  end
41
42
 
42
43
  def endpoint_base(ctx)
43
- ctx.context.base_url
44
+ ctx.context.canonical_base_url
44
45
  end
45
46
 
46
47
  def origin_for(url)
@@ -59,6 +60,7 @@ module BetterAuth
59
60
  end
60
61
 
61
62
  def validate_redirect_uri!(client, redirect_uri)
63
+ validate_safe_url!(redirect_uri, field: "redirect_uri")
62
64
  redirects = client_redirect_uris(client)
63
65
  return if redirects.include?(redirect_uri.to_s)
64
66
  return if loopback_redirect_match?(redirects, redirect_uri)
@@ -105,15 +107,19 @@ module BetterAuth
105
107
  value.to_s.split(",").map(&:strip).reject(&:empty?)
106
108
  end
107
109
 
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)
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)
109
111
  body = request_body!(body || {})
110
112
  requested_auth_method = body["token_endpoint_auth_method"] || default_auth_method
111
113
  validate_client_metadata_enums!(requested_auth_method, body)
112
114
  validate_admin_only_fields!(body, admin: admin)
113
115
  auth_method = unauthenticated ? "none" : requested_auth_method
114
116
  public_client = auth_method == "none"
115
- client_id = Crypto.random_string(32)
116
- 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
117
123
  redirects = Array(body["redirect_uris"]).map(&:to_s)
118
124
  raise APIError.new("BAD_REQUEST", message: "redirect_uris is required") if redirects.empty?
119
125
  redirects.each { |uri| validate_safe_url!(uri, field: "redirect_uris") }
@@ -146,6 +152,12 @@ module BetterAuth
146
152
  else
147
153
  body["type"] || (public_client ? nil : "web")
148
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
149
161
  data = {
150
162
  "clientId" => client_id,
151
163
  "clientSecret" => client_secret ? store_client_secret_value(ctx, client_secret, store_client_secret) : nil,
@@ -163,7 +175,7 @@ module BetterAuth
163
175
  "redirectUris" => redirects,
164
176
  "redirectUrls" => redirects.join(","),
165
177
  "postLogoutRedirectUris" => Array(body["post_logout_redirect_uris"]).map(&:to_s),
166
- "clientSecretExpiresAt" => admin ? (body["client_secret_expires_at"] || 0) : nil,
178
+ "clientSecretExpiresAt" => secret_expires_at,
167
179
  "tokenEndpointAuthMethod" => auth_method,
168
180
  "grantTypes" => grant_types,
169
181
  "responseTypes" => response_types,
@@ -183,7 +195,10 @@ module BetterAuth
183
195
  client_id_issued_at: Time.now.to_i
184
196
  ).compact
185
197
  response[:require_pkce] = require_pkce unless require_pkce.nil?
186
- 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
187
202
  response
188
203
  end
189
204
 
@@ -265,7 +280,12 @@ module BetterAuth
265
280
  raise APIError.new("BAD_REQUEST", message: "pairwise subject_type requires pairwise_secret") if pairwise_secret.to_s.empty?
266
281
 
267
282
  hosts = redirects.map { |uri| URI.parse(uri).host }.uniq
268
- 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
269
289
  rescue URI::InvalidURIError
270
290
  raise APIError.new("BAD_REQUEST", message: "invalid redirect_uris")
271
291
  end
@@ -334,8 +354,17 @@ module BetterAuth
334
354
  end
335
355
  end
336
356
 
337
- def find_client(ctx, model, client_id)
338
- 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
339
368
  end
340
369
 
341
370
  def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {}, require_confidential: false)
@@ -390,9 +419,10 @@ module BetterAuth
390
419
  seconds && seconds <= Time.now.to_i
391
420
  end
392
421
 
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")
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")
394
423
  stored_code = get_stored_token(store_tokens, code, "authorization_code")
395
- store[:codes][stored_code] = {
424
+ expires_at = Time.now + expires_in.to_i
425
+ data = {
396
426
  client_id: client_id,
397
427
  redirect_uri: redirect_uri,
398
428
  session: session,
@@ -402,15 +432,19 @@ module BetterAuth
402
432
  nonce: nonce,
403
433
  reference_id: reference_id,
404
434
  auth_time: auth_time || session_auth_time(session),
405
- expires_at: Time.now + expires_in.to_i
435
+ expires_at: expires_at.iso8601(6)
406
436
  }
437
+ internal_adapter.create_verification_value(identifier: stored_code, value: JSON.generate(data), expiresAt: expires_at)
407
438
  end
408
439
 
409
- def consume_code!(store, code, client_id:, redirect_uri:, code_verifier: nil, store_tokens: "hashed")
440
+ def consume_code!(internal_adapter, code, client_id:, redirect_uri:, code_verifier: nil, store_tokens: "hashed")
410
441
  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)
412
- raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
413
- raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data[:expires_at] <= Time.now
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
414
448
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:client_id] == client_id.to_s
415
449
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:redirect_uri] == redirect_uri.to_s
416
450
  if data[:code_challenge]
@@ -420,6 +454,8 @@ module BetterAuth
420
454
  end
421
455
 
422
456
  data
457
+ rescue JSON::ParserError, KeyError, TypeError
458
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant")
423
459
  end
424
460
 
425
461
  def verify_pkce!(code_data, verifier)
@@ -452,7 +488,7 @@ module BetterAuth
452
488
  true
453
489
  end
454
490
 
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")
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)
456
492
  data = stringify_keys(session || {})
457
493
  user = stringify_keys(data["user"] || data[:user] || {})
458
494
  session_data = stringify_keys(data["session"] || data[:session] || {})
@@ -460,9 +496,11 @@ module BetterAuth
460
496
  subject = subject_identifier(user["id"], client_data, pairwise_secret)
461
497
  token_auth_time = auth_time || session_auth_time({"session" => session_data})
462
498
  token_reference_id = reference_id || client_data["referenceId"]
463
- access_token_value = Crypto.random_string(32)
464
- refresh_token_value = include_refresh ? Crypto.random_string(32) : nil
465
- 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
466
504
  scope = scope_string(scopes)
467
505
  expires_at = Time.now + access_token_expires_in.to_i
468
506
  access_token = if jwt_access_token && audience
@@ -536,9 +574,10 @@ module BetterAuth
536
574
  response
537
575
  end
538
576
 
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")
540
- refresh_token_value = strip_prefix(refresh_token, prefix, :refresh_token)
541
- 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]
542
581
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
543
582
  if data["revoked"]
544
583
  revoke_refresh_family!(ctx, store, data)
@@ -583,7 +622,10 @@ module BetterAuth
583
622
  auth_time: data["authTime"],
584
623
  reference_id: data["referenceId"],
585
624
  filter_id_token_claims_by_scope: filter_id_token_claims_by_scope,
586
- store_tokens: store_tokens
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
587
629
  )
588
630
  end
589
631
 
@@ -616,8 +658,7 @@ module BetterAuth
616
658
  "exp" => expires_at.to_i
617
659
  ).compact
618
660
  if use_jwt_plugin
619
- signed = sign_oauth_jwt(ctx, payload, issuer: issuer_value, audience: audience)
620
- return signed if signed
661
+ return sign_oauth_jwt!(ctx, payload, issuer: issuer_value, audience: audience)
621
662
  end
622
663
 
623
664
  ::JWT.encode(payload, ctx.context.secret, "HS256")
@@ -694,9 +735,12 @@ module BetterAuth
694
735
  )
695
736
  end
696
737
 
697
- def find_token_by_hint(store, token, hint, prefix: {})
738
+ def find_token_by_hint(store, token, hint, prefix: {}, format_refresh_token: nil)
698
739
  access = -> { (value = strip_prefix(token, prefix, :access_token)) && store[:tokens][value] }
699
- 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
+ }
700
744
 
701
745
  case hint.to_s
702
746
  when "access_token"
@@ -746,6 +790,65 @@ module BetterAuth
746
790
  "#{token_prefix(prefix, kind)}#{value}"
747
791
  end
748
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
+
749
852
  def strip_prefix(value, prefix, kind)
750
853
  token = value.to_s
751
854
  expected = token_prefix(prefix, kind)
@@ -796,8 +899,7 @@ module BetterAuth
796
899
  return signer.call(ctx, payload) if signer.respond_to?(:call)
797
900
 
798
901
  if use_jwt_plugin && ctx
799
- signed = sign_oauth_jwt(ctx, payload, issuer: issuer_value, audience: audience)
800
- return signed if signed
902
+ return sign_oauth_jwt!(ctx, payload, issuer: issuer_value, audience: audience)
801
903
  end
802
904
 
803
905
  Crypto.sign_jwt(
@@ -838,6 +940,15 @@ module BetterAuth
838
940
  BetterAuth::Plugins.sign_jwt_payload(ctx, stringify_keys(payload), config)
839
941
  end
840
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
+
841
952
  def verify_oauth_jwt(ctx, token, issuer:, hs256_secret:)
842
953
  payload = ::JWT.decode(token.to_s, nil, false).first
843
954
  audience = payload["aud"]
@@ -955,7 +1066,6 @@ module BetterAuth
955
1066
 
956
1067
  def stores
957
1068
  {
958
- codes: {},
959
1069
  tokens: {},
960
1070
  refresh_tokens: {},
961
1071
  consents: {}