better_auth 0.3.0 → 0.5.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +10 -7
  5. data/lib/better_auth/adapters/memory.rb +57 -11
  6. data/lib/better_auth/adapters/sql.rb +123 -20
  7. data/lib/better_auth/api.rb +114 -9
  8. data/lib/better_auth/async.rb +70 -0
  9. data/lib/better_auth/configuration.rb +97 -7
  10. data/lib/better_auth/context.rb +165 -12
  11. data/lib/better_auth/cookies.rb +6 -4
  12. data/lib/better_auth/core.rb +2 -0
  13. data/lib/better_auth/crypto/jwe.rb +27 -5
  14. data/lib/better_auth/crypto.rb +32 -0
  15. data/lib/better_auth/database_hooks.rb +8 -8
  16. data/lib/better_auth/deprecate.rb +28 -0
  17. data/lib/better_auth/endpoint.rb +92 -5
  18. data/lib/better_auth/error.rb +8 -1
  19. data/lib/better_auth/host.rb +166 -0
  20. data/lib/better_auth/instrumentation.rb +74 -0
  21. data/lib/better_auth/logger.rb +31 -0
  22. data/lib/better_auth/middleware/origin_check.rb +2 -2
  23. data/lib/better_auth/oauth2.rb +94 -0
  24. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  25. data/lib/better_auth/plugins/admin.rb +344 -16
  26. data/lib/better_auth/plugins/anonymous.rb +37 -3
  27. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  28. data/lib/better_auth/plugins/dub.rb +148 -0
  29. data/lib/better_auth/plugins/email_otp.rb +261 -19
  30. data/lib/better_auth/plugins/expo.rb +17 -1
  31. data/lib/better_auth/plugins/generic_oauth.rb +67 -35
  32. data/lib/better_auth/plugins/jwt.rb +37 -4
  33. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  34. data/lib/better_auth/plugins/magic_link.rb +66 -3
  35. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  36. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  37. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  38. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  39. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  40. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  41. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  42. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  43. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  44. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  45. data/lib/better_auth/plugins/mcp.rb +111 -263
  46. data/lib/better_auth/plugins/multi_session.rb +61 -3
  47. data/lib/better_auth/plugins/oauth_protocol.rb +173 -30
  48. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  49. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  50. data/lib/better_auth/plugins/one_tap.rb +7 -2
  51. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  52. data/lib/better_auth/plugins/open_api.rb +163 -318
  53. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  54. data/lib/better_auth/plugins/organization.rb +186 -56
  55. data/lib/better_auth/plugins/phone_number.rb +141 -6
  56. data/lib/better_auth/plugins/siwe.rb +69 -3
  57. data/lib/better_auth/plugins/two_factor.rb +118 -41
  58. data/lib/better_auth/plugins/username.rb +57 -2
  59. data/lib/better_auth/rate_limiter.rb +38 -0
  60. data/lib/better_auth/request_state.rb +44 -0
  61. data/lib/better_auth/response.rb +42 -0
  62. data/lib/better_auth/router.rb +7 -1
  63. data/lib/better_auth/routes/account.rb +220 -42
  64. data/lib/better_auth/routes/email_verification.rb +98 -14
  65. data/lib/better_auth/routes/password.rb +126 -8
  66. data/lib/better_auth/routes/session.rb +128 -13
  67. data/lib/better_auth/routes/sign_in.rb +26 -2
  68. data/lib/better_auth/routes/sign_out.rb +13 -1
  69. data/lib/better_auth/routes/sign_up.rb +70 -4
  70. data/lib/better_auth/routes/social.rb +132 -7
  71. data/lib/better_auth/routes/user.rb +228 -20
  72. data/lib/better_auth/routes/validation.rb +50 -0
  73. data/lib/better_auth/secret_config.rb +115 -0
  74. data/lib/better_auth/session.rb +13 -2
  75. data/lib/better_auth/url_helpers.rb +206 -0
  76. data/lib/better_auth/version.rb +1 -1
  77. data/lib/better_auth.rb +12 -0
  78. metadata +23 -1
@@ -52,19 +52,7 @@ module BetterAuth
52
52
  data,
53
53
  provider_id: "auth0",
54
54
  discovery_url: "https://#{domain}/.well-known/openid-configuration",
55
- scopes: ["openid", "profile", "email"],
56
- get_user_info: ->(tokens) {
57
- profile = generic_oauth_fetch_json("https://#{domain}/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
58
- return nil unless profile
59
-
60
- {
61
- id: fetch_value(profile, "sub"),
62
- name: fetch_value(profile, "name") || fetch_value(profile, "nickname"),
63
- email: fetch_value(profile, "email"),
64
- image: fetch_value(profile, "picture"),
65
- emailVerified: fetch_value(profile, "email_verified") || false
66
- }
67
- }
55
+ scopes: ["openid", "profile", "email"]
68
56
  )
69
57
  end
70
58
 
@@ -225,7 +213,19 @@ module BetterAuth
225
213
  end
226
214
 
227
215
  def sign_in_with_oauth2_endpoint(config)
228
- Endpoint.new(path: "/sign-in/oauth2", method: "POST") do |ctx|
216
+ Endpoint.new(
217
+ path: "/sign-in/oauth2",
218
+ method: "POST",
219
+ metadata: {
220
+ openapi: {
221
+ operationId: "signInOAuth2",
222
+ description: "Sign in with OAuth2",
223
+ responses: {
224
+ "200" => OpenAPI.json_response("Sign in with OAuth2", generic_oauth_url_response_schema)
225
+ }
226
+ }
227
+ }
228
+ ) do |ctx|
229
229
  body = normalize_hash(ctx.body)
230
230
  provider_id = body[:provider_id].to_s
231
231
  provider = generic_oauth_provider!(config, provider_id)
@@ -235,7 +235,19 @@ module BetterAuth
235
235
  end
236
236
 
237
237
  def o_auth2_link_account_endpoint(config)
238
- Endpoint.new(path: "/oauth2/link", method: "POST") do |ctx|
238
+ Endpoint.new(
239
+ path: "/oauth2/link",
240
+ method: "POST",
241
+ metadata: {
242
+ openapi: {
243
+ operationId: "linkOAuth2",
244
+ description: "Link an OAuth2 account to the current user session",
245
+ responses: {
246
+ "200" => OpenAPI.json_response("Authorization URL generated successfully for linking an OAuth2 account", generic_oauth_url_response_schema)
247
+ }
248
+ }
249
+ }
250
+ ) do |ctx|
239
251
  session = Routes.current_session(ctx)
240
252
  body = normalize_hash(ctx.body)
241
253
  provider_id = body[:provider_id].to_s
@@ -255,8 +267,20 @@ module BetterAuth
255
267
  def o_auth2_callback_endpoint(config)
256
268
  Endpoint.new(
257
269
  path: "/oauth2/callback/:providerId",
258
- method: ["GET", "POST"],
259
- metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}
270
+ method: "GET",
271
+ metadata: {
272
+ allowed_media_types: ["application/x-www-form-urlencoded", "application/json"],
273
+ openapi: {
274
+ operationId: "oauth2Callback",
275
+ description: "OAuth2 callback",
276
+ responses: {
277
+ "200" => OpenAPI.json_response(
278
+ "OAuth2 callback",
279
+ OpenAPI.object_schema({url: {type: "string"}}, required: ["url"])
280
+ )
281
+ }
282
+ }
283
+ }
260
284
  ) do |ctx|
261
285
  query = normalize_hash(ctx.query)
262
286
  provider_id = (fetch_value(ctx.params, "providerId") || query[:provider_id]).to_s
@@ -318,6 +342,16 @@ module BetterAuth
318
342
  end
319
343
  end
320
344
 
345
+ def generic_oauth_url_response_schema
346
+ OpenAPI.object_schema(
347
+ {
348
+ url: {type: "string"},
349
+ redirect: {type: "boolean"}
350
+ },
351
+ required: ["url", "redirect"]
352
+ )
353
+ end
354
+
321
355
  def generic_oauth_authorization_url(ctx, provider, body, link:)
322
356
  authorization_url = provider[:authorization_url] || generic_oauth_discovery(provider)["authorization_endpoint"]
323
357
  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
@@ -380,7 +414,7 @@ module BetterAuth
380
414
  state = Crypto.random_string(32)
381
415
  if strategy.to_s == "cookie"
382
416
  cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
383
- encrypted = Crypto.symmetric_encrypt(key: ctx.context.secret, data: JSON.generate(state_data.merge("state" => state)))
417
+ encrypted = Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: JSON.generate(state_data.merge("state" => state)))
384
418
  ctx.set_cookie(cookie.name, encrypted, cookie.attributes)
385
419
  return state
386
420
  end
@@ -428,7 +462,7 @@ module BetterAuth
428
462
  end
429
463
 
430
464
  begin
431
- decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, data: encrypted)
465
+ decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: encrypted)
432
466
  unless decrypted
433
467
  Cookies.expire_cookie(ctx, cookie)
434
468
  raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
@@ -537,7 +571,7 @@ module BetterAuth
537
571
  return token if token.to_s.empty?
538
572
  return token unless ctx.context.options.account[:encrypt_oauth_tokens]
539
573
 
540
- Crypto.symmetric_encrypt(key: ctx.context.secret, data: token)
574
+ Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: token)
541
575
  end
542
576
 
543
577
  def generic_oauth_set_account_cookie(ctx, provider_id, account_id, user_id)
@@ -688,24 +722,12 @@ module BetterAuth
688
722
  nil
689
723
  end
690
724
 
691
- def generic_oidc_helper_provider(options, provider_id, issuer, discovery_url, user_info_url)
725
+ def generic_oidc_helper_provider(options, provider_id, issuer, discovery_url, _user_info_url)
692
726
  generic_oauth_provider_config(
693
727
  options,
694
728
  provider_id: provider_id,
695
729
  discovery_url: discovery_url,
696
- scopes: ["openid", "profile", "email"],
697
- get_user_info: ->(tokens) {
698
- profile = generic_oauth_fetch_json(user_info_url, authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
699
- return nil unless profile
700
-
701
- {
702
- id: fetch_value(profile, "sub"),
703
- name: fetch_value(profile, "name") || fetch_value(profile, "preferred_username"),
704
- email: fetch_value(profile, "email"),
705
- image: fetch_value(profile, "picture"),
706
- emailVerified: fetch_value(profile, "email_verified") || false
707
- }
708
- }
730
+ scopes: ["openid", "profile", "email"]
709
731
  )
710
732
  end
711
733
 
@@ -730,12 +752,22 @@ module BetterAuth
730
752
  result[provider_id.to_sym] = {
731
753
  id: provider_id,
732
754
  name: provider_id,
733
- get_user_info: ->(tokens) { generic_oauth_user_info(provider, tokens) },
755
+ get_user_info: ->(tokens) { generic_oauth_provider_user_info(provider, tokens) },
734
756
  refresh_access_token: ->(refresh_token) { generic_oauth_refresh_access_token(context, provider, refresh_token) }
735
757
  }
736
758
  end
737
759
  end
738
760
 
761
+ def generic_oauth_provider_user_info(provider, tokens)
762
+ user_info = generic_oauth_user_info(provider, tokens)
763
+ return nil unless user_info
764
+
765
+ {
766
+ user: generic_oauth_map_user(provider, user_info),
767
+ data: user_info
768
+ }
769
+ end
770
+
739
771
  def generic_oauth_refresh_access_token(ctx, provider, refresh_token)
740
772
  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
741
773
  raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty?
@@ -103,7 +103,25 @@ module BetterAuth
103
103
  end
104
104
 
105
105
  def get_jwks_endpoint(config, path)
106
- Endpoint.new(path: path, method: "GET") do |ctx|
106
+ Endpoint.new(
107
+ path: path,
108
+ method: "GET",
109
+ metadata: {
110
+ openapi: {
111
+ operationId: "getJSONWebKeySet",
112
+ description: "Get the JSON Web Key Set",
113
+ responses: {
114
+ "200" => OpenAPI.json_response(
115
+ "JSON Web Key Set retrieved successfully",
116
+ OpenAPI.object_schema(
117
+ {keys: {type: "array", description: "Array of public JSON Web Keys", items: {type: "object"}}},
118
+ required: ["keys"]
119
+ )
120
+ )
121
+ }
122
+ }
123
+ }
124
+ ) do |ctx|
107
125
  raise APIError.new("NOT_FOUND") if config.dig(:jwks, :remote_url)
108
126
 
109
127
  create_jwk(ctx, config) if all_jwks(ctx, config).empty?
@@ -112,7 +130,22 @@ module BetterAuth
112
130
  end
113
131
 
114
132
  def get_token_endpoint(config)
115
- Endpoint.new(path: "/token", method: "GET") do |ctx|
133
+ Endpoint.new(
134
+ path: "/token",
135
+ method: "GET",
136
+ metadata: {
137
+ openapi: {
138
+ operationId: "getJSONWebToken",
139
+ description: "Get a JWT token",
140
+ responses: {
141
+ "200" => OpenAPI.json_response(
142
+ "Success",
143
+ OpenAPI.object_schema({token: {type: "string"}}, required: ["token"])
144
+ )
145
+ }
146
+ }
147
+ }
148
+ ) do |ctx|
116
149
  session = Session.find_current(ctx)
117
150
  raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"]) unless session
118
151
 
@@ -306,12 +339,12 @@ module BetterAuth
306
339
  def jwk_private_key_for_storage(ctx, private_key, config)
307
340
  return private_key if config.dig(:jwks, :disable_private_key_encryption)
308
341
 
309
- Crypto.symmetric_encrypt(key: ctx.context.secret, data: private_key)
342
+ Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: private_key)
310
343
  end
311
344
 
312
345
  def jwk_private_key_value(ctx, key, _config)
313
346
  value = key["privateKey"]
314
- Crypto.symmetric_decrypt(key: ctx.context.secret, data: value) || value
347
+ Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: value) || value
315
348
  end
316
349
 
317
350
  def jwt_payload_valid?(payload)
@@ -74,8 +74,8 @@ module BetterAuth
74
74
  case path
75
75
  when "/sign-in/email", "/sign-up/email"
76
76
  "email"
77
- when "/callback/:providerId"
78
- fetch_value(ctx.params, "providerId")
77
+ when "/callback/:id"
78
+ fetch_value(ctx.params, "id") || fetch_value(ctx.params, "providerId")
79
79
  when "/oauth2/callback/:providerId"
80
80
  fetch_value(ctx.params, "providerId")
81
81
  else
@@ -28,7 +28,32 @@ module BetterAuth
28
28
  end
29
29
 
30
30
  def sign_in_magic_link_endpoint(config)
31
- Endpoint.new(path: "/sign-in/magic-link", method: "POST") do |ctx|
31
+ Endpoint.new(
32
+ path: "/sign-in/magic-link",
33
+ method: "POST",
34
+ metadata: {
35
+ openapi: {
36
+ operationId: "signInMagicLink",
37
+ description: "Send a magic sign-in link",
38
+ requestBody: OpenAPI.json_request_body(
39
+ OpenAPI.object_schema(
40
+ {
41
+ email: {type: "string", description: "The email address to sign in"},
42
+ name: {type: ["string", "null"], description: "The user name to use when creating a new user"},
43
+ callbackURL: {type: ["string", "null"]},
44
+ errorCallbackURL: {type: ["string", "null"]},
45
+ newUserCallbackURL: {type: ["string", "null"]},
46
+ metadata: {type: ["object", "null"]}
47
+ },
48
+ required: ["email"]
49
+ )
50
+ ),
51
+ responses: {
52
+ "200" => OpenAPI.json_response("Magic link sent", OpenAPI.status_response_schema)
53
+ }
54
+ }
55
+ }
56
+ ) do |ctx|
32
57
  body = normalize_hash(ctx.body)
33
58
  email = body[:email].to_s.downcase
34
59
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email)
@@ -51,7 +76,44 @@ module BetterAuth
51
76
  end
52
77
 
53
78
  def magic_link_verify_endpoint(config)
54
- Endpoint.new(path: "/magic-link/verify", method: "GET") do |ctx|
79
+ Endpoint.new(
80
+ path: "/magic-link/verify",
81
+ method: "GET",
82
+ metadata: {
83
+ openapi: {
84
+ operationId: "magicLinkVerify",
85
+ description: "Verify a magic link token",
86
+ parameters: [
87
+ {
88
+ name: "token",
89
+ in: "query",
90
+ required: true,
91
+ schema: {type: "string"}
92
+ },
93
+ {
94
+ name: "callbackURL",
95
+ in: "query",
96
+ required: false,
97
+ schema: {type: "string"}
98
+ }
99
+ ],
100
+ responses: {
101
+ "200" => OpenAPI.json_response(
102
+ "Magic link verified",
103
+ OpenAPI.object_schema(
104
+ {
105
+ token: {type: "string"},
106
+ user: {type: "object", "$ref": "#/components/schemas/User"},
107
+ session: {type: "object", "$ref": "#/components/schemas/Session"}
108
+ },
109
+ required: ["token", "user", "session"]
110
+ )
111
+ ),
112
+ "302" => {description: "Redirects to callback URL when callbackURL is provided"}
113
+ }
114
+ }
115
+ }
116
+ ) do |ctx|
55
117
  query = normalize_hash(ctx.query)
56
118
  token = query[:token].to_s
57
119
  callback_url = query[:callback_url] || "/"
@@ -97,7 +159,8 @@ module BetterAuth
97
159
  user = ctx.context.internal_adapter.create_user(
98
160
  email: email,
99
161
  emailVerified: true,
100
- name: name || ""
162
+ name: name || "",
163
+ context: ctx
101
164
  )
102
165
  new_user = true
103
166
  redirect_with_error.call("failed_to_create_user") unless user
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module MCP
6
+ module_function
7
+
8
+ def authorize(ctx, config)
9
+ set_cors_headers(ctx)
10
+ query = OAuthProtocol.stringify_keys(ctx.query)
11
+ session = Routes.current_session(ctx, allow_nil: true)
12
+ unless session
13
+ ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
14
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query))
15
+ end
16
+
17
+ redirect_with_code(ctx, config, query, session)
18
+ end
19
+
20
+ def restore_login_prompt(ctx, config)
21
+ cookie = ctx.get_signed_cookie("oidc_login_prompt", ctx.context.secret)
22
+ return unless cookie
23
+
24
+ session = ctx.context.new_session
25
+ return unless session && session[:session] && ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)
26
+
27
+ query = parse_login_prompt(cookie)
28
+ return unless query
29
+
30
+ query["prompt"] = prompt_without_login(query["prompt"]) if query.key?("prompt")
31
+ ctx.set_cookie("oidc_login_prompt", "", path: "/", max_age: 0)
32
+ ctx.context.set_current_session(session) if ctx.context.respond_to?(:set_current_session)
33
+ [302, ctx.response_headers.merge("location" => authorization_redirect_uri(ctx, config, query, session)), [""]]
34
+ end
35
+
36
+ def redirect_with_code(ctx, config, query, session)
37
+ raise ctx.redirect(authorization_redirect_uri(ctx, config, query, session))
38
+ end
39
+
40
+ def authorization_redirect_uri(ctx, config, query, session)
41
+ query = OAuthProtocol.stringify_keys(query)
42
+ prompts = OAuthProtocol.parse_scopes(query["prompt"])
43
+ raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") if query["client_id"].to_s.empty?
44
+ unless query["response_type"]
45
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(ctx.context.base_url + "/error", error: "invalid_request", error_description: "response_type is required"))
46
+ end
47
+
48
+ client = OAuthProtocol.find_client(ctx, "oauthClient", query["client_id"])
49
+ raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") unless client
50
+ OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])
51
+ client_data = OAuthProtocol.stringify_keys(client)
52
+ raise ctx.redirect("#{ctx.context.base_url}/error?error=client_disabled") if client_data["disabled"]
53
+ raise ctx.redirect("#{ctx.context.base_url}/error?error=unsupported_response_type") unless query["response_type"] == "code"
54
+
55
+ scopes = OAuthProtocol.parse_scopes(query["scope"] || "openid")
56
+ allowed_scopes = OAuthProtocol.parse_scopes(client_data["scopes"])
57
+ allowed_scopes = OAuthProtocol.parse_scopes(config[:scopes]) if allowed_scopes.empty?
58
+ invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) && allowed_scopes.include?(scope) }
59
+ unless invalid_scopes.empty?
60
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_scope", error_description: "The following scopes are invalid: #{invalid_scopes.join(", ")}", state: query["state"]))
61
+ end
62
+
63
+ pkce_error = OAuthProtocol.validate_authorize_pkce(client_data, scopes, query["code_challenge"], query["code_challenge_method"])
64
+ if pkce_error
65
+ description = (pkce_error == "PKCE is required") ? "pkce is required" : pkce_error
66
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: description, state: query["state"]))
67
+ end
68
+
69
+ if prompts.include?("consent")
70
+ consent_code = Crypto.random_string(32)
71
+ config[:store][:consents][consent_code] = {
72
+ query: query,
73
+ session: session,
74
+ client: client,
75
+ scopes: scopes,
76
+ expires_at: Time.now + config[:code_expires_in].to_i
77
+ }
78
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:consent_page], consent_code: consent_code, client_id: client_data["clientId"], scope: OAuthProtocol.scope_string(scopes)))
79
+ end
80
+
81
+ code = Crypto.random_string(32)
82
+ OAuthProtocol.store_code(
83
+ config[:store],
84
+ code: code,
85
+ client_id: query["client_id"],
86
+ redirect_uri: query["redirect_uri"],
87
+ session: session,
88
+ scopes: scopes,
89
+ code_challenge: query["code_challenge"],
90
+ code_challenge_method: query["code_challenge_method"],
91
+ nonce: query["nonce"],
92
+ reference_id: client_data["referenceId"]
93
+ )
94
+ OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: validate_issuer_url(OAuthProtocol.issuer(ctx)))
95
+ end
96
+
97
+ def prompt_without_login(value)
98
+ prompts = OAuthProtocol.parse_scopes(value)
99
+ prompts.delete("login")
100
+ OAuthProtocol.scope_string(prompts)
101
+ end
102
+
103
+ def parse_login_prompt(value)
104
+ parsed = JSON.parse(value.to_s)
105
+ parsed.is_a?(Hash) ? parsed : nil
106
+ rescue JSON::ParserError
107
+ nil
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module MCP
6
+ DEFAULT_SCOPES = %w[openid profile email offline_access].freeze
7
+ DEFAULT_GRANT_TYPES = [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT, OAuthProtocol::CLIENT_CREDENTIALS_GRANT].freeze
8
+
9
+ module_function
10
+
11
+ def normalize_config(options)
12
+ incoming = BetterAuth::Plugins.normalize_hash(options || {})
13
+ oidc = BetterAuth::Plugins.normalize_hash(incoming[:oidc_config] || {})
14
+ base = {
15
+ login_page: "/login",
16
+ consent_page: "/oauth2/consent",
17
+ resource: nil,
18
+ scopes: DEFAULT_SCOPES,
19
+ grant_types: DEFAULT_GRANT_TYPES,
20
+ allow_dynamic_client_registration: true,
21
+ allow_unauthenticated_client_registration: true,
22
+ require_pkce: true,
23
+ code_expires_in: 600,
24
+ access_token_expires_in: 3600,
25
+ refresh_token_expires_in: 604_800,
26
+ m2m_access_token_expires_in: 3600,
27
+ store_client_secret: "plain",
28
+ prefix: {},
29
+ store: OAuthProtocol.stores
30
+ }
31
+ config = base.merge(oidc.except(:metadata)).merge(incoming)
32
+ config[:oidc_config] = oidc
33
+ config[:scopes] = (Array(base[:scopes]) + Array(oidc[:scopes]) + Array(incoming[:scopes])).compact.map(&:to_s).uniq
34
+ config[:grant_types] = Array(config[:grant_types]).map(&:to_s)
35
+ config[:prefix] = BetterAuth::Plugins.normalize_hash(config[:prefix] || {})
36
+ config
37
+ end
38
+
39
+ def set_cors_headers(ctx)
40
+ ctx.set_header("Access-Control-Allow-Origin", "*")
41
+ ctx.set_header("Access-Control-Allow-Methods", "POST, OPTIONS")
42
+ ctx.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
43
+ ctx.set_header("Access-Control-Max-Age", "86400")
44
+ end
45
+
46
+ def no_store_headers
47
+ {"Cache-Control" => "no-store", "Pragma" => "no-cache"}
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module MCP
6
+ module_function
7
+
8
+ def consent(ctx, config)
9
+ current_session = Routes.current_session(ctx)
10
+ body = OAuthProtocol.stringify_keys(ctx.body)
11
+ pending = config[:store][:consents].delete(body["consent_code"].to_s)
12
+ raise APIError.new("BAD_REQUEST", message: "invalid consent_code") unless pending
13
+ raise APIError.new("BAD_REQUEST", message: "expired consent_code") if pending[:expires_at] <= Time.now
14
+
15
+ query = pending[:query]
16
+ if body["accept"] == false || body["accept"].to_s == "false"
17
+ return {redirectURI: OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "access_denied", state: query["state"], iss: validate_issuer_url(OAuthProtocol.issuer(ctx)))}
18
+ end
19
+
20
+ granted_scopes = OAuthProtocol.parse_scopes(body["scope"] || body["scopes"])
21
+ granted_scopes = pending[:scopes] if granted_scopes.empty?
22
+ unless granted_scopes.all? { |scope| pending[:scopes].include?(scope) }
23
+ raise APIError.new("BAD_REQUEST", message: "invalid_scope")
24
+ end
25
+ pending[:session] = current_session if current_session
26
+ query = query.merge("scope" => OAuthProtocol.scope_string(granted_scopes)).except("prompt")
27
+ {redirectURI: authorization_redirect_uri(ctx, config, query, pending[:session])}
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module MCP
6
+ module_function
7
+
8
+ def legacy_register_endpoint(config)
9
+ Endpoint.new(path: "/mcp/register", method: "POST") do |ctx|
10
+ ctx.json(register_client(ctx, config), status: 201, headers: no_store_headers)
11
+ end
12
+ end
13
+
14
+ def legacy_authorize_endpoint(config)
15
+ Endpoint.new(path: "/mcp/authorize", method: "GET") do |ctx|
16
+ authorize(ctx, config)
17
+ end
18
+ end
19
+
20
+ def legacy_token_endpoint(config)
21
+ Endpoint.new(path: "/mcp/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
22
+ ctx.json(token(ctx, config), headers: no_store_headers)
23
+ end
24
+ end
25
+
26
+ def legacy_userinfo_endpoint(config)
27
+ Endpoint.new(path: "/mcp/userinfo", method: "GET") do |ctx|
28
+ ctx.json(userinfo(ctx, config))
29
+ end
30
+ end
31
+
32
+ def legacy_jwks_endpoint(config)
33
+ Endpoint.new(path: "/mcp/jwks", method: "GET") do |ctx|
34
+ ctx.json(jwks(ctx, config))
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module BetterAuth
6
+ module Plugins
7
+ module MCP
8
+ module_function
9
+
10
+ def validate_issuer_url(value)
11
+ uri = URI.parse(value.to_s)
12
+ uri.query = nil
13
+ uri.fragment = nil
14
+ if uri.scheme == "http" && !["localhost", "127.0.0.1", "::1"].include?(uri.hostname || uri.host)
15
+ uri.scheme = "https"
16
+ end
17
+ uri.to_s.sub(%r{/+\z}, "")
18
+ rescue URI::InvalidURIError
19
+ value.to_s.split(/[?#]/).first.sub(%r{/+\z}, "")
20
+ end
21
+
22
+ def oauth_metadata(ctx, config)
23
+ base = OAuthProtocol.endpoint_base(ctx)
24
+ {
25
+ issuer: validate_issuer_url(OAuthProtocol.issuer(ctx)),
26
+ authorization_endpoint: "#{base}/oauth2/authorize",
27
+ token_endpoint: "#{base}/oauth2/token",
28
+ userinfo_endpoint: "#{base}/oauth2/userinfo",
29
+ registration_endpoint: "#{base}/oauth2/register",
30
+ introspection_endpoint: "#{base}/oauth2/introspect",
31
+ revocation_endpoint: "#{base}/oauth2/revoke",
32
+ jwks_uri: mcp_jwks_uri(ctx, config),
33
+ scopes_supported: config[:scopes],
34
+ response_types_supported: ["code"],
35
+ response_modes_supported: ["query"],
36
+ grant_types_supported: config[:grant_types],
37
+ subject_types_supported: ["public"],
38
+ id_token_signing_alg_values_supported: mcp_signing_algs(ctx, config),
39
+ token_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"],
40
+ introspection_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
41
+ revocation_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
42
+ code_challenge_methods_supported: ["S256"],
43
+ authorization_response_iss_parameter_supported: true,
44
+ claims_supported: %w[sub iss aud exp iat sid scope azp email email_verified name picture family_name given_name]
45
+ }.merge(BetterAuth::Plugins.normalize_hash(config.dig(:oidc_config, :metadata) || {}))
46
+ end
47
+
48
+ def protected_resource_metadata(ctx, config)
49
+ base = OAuthProtocol.endpoint_base(ctx)
50
+ origin = OAuthProtocol.origin_for(base)
51
+ {
52
+ resource: config[:resource] || origin,
53
+ authorization_servers: [origin],
54
+ jwks_uri: mcp_jwks_uri(ctx, config),
55
+ scopes_supported: config[:scopes],
56
+ bearer_methods_supported: ["header"],
57
+ resource_signing_alg_values_supported: mcp_signing_algs(ctx, config)
58
+ }
59
+ end
60
+
61
+ def mcp_jwks_uri(ctx, config)
62
+ config.dig(:oidc_config, :metadata, :jwks_uri) ||
63
+ config.dig(:advertised_metadata, :jwks_uri) ||
64
+ "#{OAuthProtocol.endpoint_base(ctx)}/oauth2/jwks"
65
+ end
66
+
67
+ def mcp_signing_algs(ctx, config)
68
+ jwt_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "jwt" }
69
+ alg = config.dig(:jwt, :jwks, :key_pair_config, :alg) ||
70
+ jwt_plugin&.options&.dig(:jwks, :key_pair_config, :alg)
71
+ [alg || "EdDSA"]
72
+ end
73
+
74
+ def jwks(ctx, config)
75
+ jwt_config = config[:jwt] || {}
76
+ BetterAuth::Plugins.create_jwk(ctx, jwt_config) if BetterAuth::Plugins.all_jwks(ctx, jwt_config).empty?
77
+ {keys: BetterAuth::Plugins.public_jwks(ctx, jwt_config).map { |key| BetterAuth::Plugins.public_jwk(key, jwt_config) }}
78
+ end
79
+ end
80
+ end
81
+ end