better_auth 0.4.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +5 -5
  5. data/lib/better_auth/adapters/sql.rb +96 -18
  6. data/lib/better_auth/api.rb +113 -13
  7. data/lib/better_auth/configuration.rb +97 -7
  8. data/lib/better_auth/context.rb +165 -12
  9. data/lib/better_auth/cookies.rb +6 -4
  10. data/lib/better_auth/core.rb +2 -0
  11. data/lib/better_auth/crypto/jwe.rb +27 -5
  12. data/lib/better_auth/crypto.rb +32 -0
  13. data/lib/better_auth/database_hooks.rb +5 -5
  14. data/lib/better_auth/endpoint.rb +87 -3
  15. data/lib/better_auth/error.rb +8 -1
  16. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  17. data/lib/better_auth/plugins/admin.rb +344 -16
  18. data/lib/better_auth/plugins/anonymous.rb +37 -3
  19. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  20. data/lib/better_auth/plugins/dub.rb +148 -0
  21. data/lib/better_auth/plugins/email_otp.rb +246 -15
  22. data/lib/better_auth/plugins/expo.rb +17 -1
  23. data/lib/better_auth/plugins/generic_oauth.rb +53 -7
  24. data/lib/better_auth/plugins/jwt.rb +37 -4
  25. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  26. data/lib/better_auth/plugins/magic_link.rb +66 -3
  27. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  28. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  29. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  30. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  31. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  32. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  33. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  34. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  35. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  36. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  37. data/lib/better_auth/plugins/mcp.rb +111 -263
  38. data/lib/better_auth/plugins/multi_session.rb +61 -3
  39. data/lib/better_auth/plugins/oauth_protocol.rb +2 -2
  40. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  41. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  42. data/lib/better_auth/plugins/one_tap.rb +7 -2
  43. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  44. data/lib/better_auth/plugins/open_api.rb +163 -318
  45. data/lib/better_auth/plugins/organization.rb +135 -36
  46. data/lib/better_auth/plugins/phone_number.rb +141 -6
  47. data/lib/better_auth/plugins/siwe.rb +69 -3
  48. data/lib/better_auth/plugins/two_factor.rb +65 -23
  49. data/lib/better_auth/plugins/username.rb +57 -2
  50. data/lib/better_auth/rate_limiter.rb +20 -0
  51. data/lib/better_auth/response.rb +42 -0
  52. data/lib/better_auth/router.rb +7 -1
  53. data/lib/better_auth/routes/account.rb +204 -38
  54. data/lib/better_auth/routes/email_verification.rb +98 -14
  55. data/lib/better_auth/routes/password.rb +125 -8
  56. data/lib/better_auth/routes/session.rb +128 -13
  57. data/lib/better_auth/routes/sign_in.rb +24 -2
  58. data/lib/better_auth/routes/sign_out.rb +13 -1
  59. data/lib/better_auth/routes/sign_up.rb +62 -4
  60. data/lib/better_auth/routes/social.rb +102 -7
  61. data/lib/better_auth/routes/user.rb +222 -20
  62. data/lib/better_auth/routes/validation.rb +50 -0
  63. data/lib/better_auth/secret_config.rb +115 -0
  64. data/lib/better_auth/session.rb +1 -1
  65. data/lib/better_auth/url_helpers.rb +12 -1
  66. data/lib/better_auth/version.rb +1 -1
  67. data/lib/better_auth.rb +4 -0
  68. metadata +15 -1
@@ -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
@@ -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 register_client(ctx, config)
9
+ set_cors_headers(ctx)
10
+ body = OAuthProtocol.stringify_keys(ctx.body)
11
+ body["token_endpoint_auth_method"] ||= "none"
12
+ body["grant_types"] ||= [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT]
13
+ body["response_types"] ||= ["code"]
14
+ body["require_pkce"] = true unless body.key?("require_pkce") || body.key?("requirePKCE")
15
+
16
+ OAuthProtocol.create_client(
17
+ ctx,
18
+ model: "oauthClient",
19
+ body: body,
20
+ default_auth_method: "none",
21
+ store_client_secret: config[:store_client_secret],
22
+ default_scopes: config[:scopes],
23
+ allowed_scopes: config[:scopes],
24
+ prefix: config[:prefix],
25
+ dynamic_registration: true,
26
+ strip_client_metadata: true
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module MCP
6
+ module ResourceHandler
7
+ module_function
8
+
9
+ def with_mcp_auth(app, resource_metadata_url:, auth: nil, resource_metadata_mappings: {})
10
+ lambda do |env|
11
+ authorization = env["HTTP_AUTHORIZATION"].to_s
12
+ return unauthorized(resource_metadata_url) unless authorization.start_with?("Bearer ")
13
+
14
+ session = auth&.api&.get_mcp_session(headers: {"authorization" => authorization})
15
+ return unauthorized(resource_metadata_url) unless session
16
+
17
+ env["better_auth.mcp_session"] = session
18
+ app.call(env)
19
+ rescue APIError
20
+ unauthorized(resource_metadata_url)
21
+ end
22
+ end
23
+
24
+ def unauthorized(resource_metadata_url)
25
+ [
26
+ 401,
27
+ {
28
+ "www-authenticate" => %(Bearer resource_metadata="#{resource_metadata_url}"),
29
+ "access-control-expose-headers" => "WWW-Authenticate"
30
+ },
31
+ ["unauthorized"]
32
+ ]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module MCP
6
+ module_function
7
+
8
+ def schema
9
+ {
10
+ oauthClient: {
11
+ modelName: "oauthClient",
12
+ fields: {
13
+ clientId: {type: "string", unique: true, required: true},
14
+ clientSecret: {type: "string", required: false},
15
+ disabled: {type: "boolean", default_value: false, required: false},
16
+ skipConsent: {type: "boolean", required: false},
17
+ enableEndSession: {type: "boolean", required: false},
18
+ clientSecretExpiresAt: {type: "number", required: false},
19
+ scopes: {type: "string[]", required: false},
20
+ userId: {type: "string", required: false},
21
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
22
+ updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }},
23
+ name: {type: "string", required: false},
24
+ uri: {type: "string", required: false},
25
+ icon: {type: "string", required: false},
26
+ contacts: {type: "string[]", required: false},
27
+ tos: {type: "string", required: false},
28
+ policy: {type: "string", required: false},
29
+ softwareId: {type: "string", required: false},
30
+ softwareVersion: {type: "string", required: false},
31
+ softwareStatement: {type: "string", required: false},
32
+ redirectUris: {type: "string[]", required: true},
33
+ postLogoutRedirectUris: {type: "string[]", required: false},
34
+ tokenEndpointAuthMethod: {type: "string", required: false},
35
+ grantTypes: {type: "string[]", required: false},
36
+ responseTypes: {type: "string[]", required: false},
37
+ public: {type: "boolean", required: false},
38
+ type: {type: "string", required: false},
39
+ requirePKCE: {type: "boolean", required: false},
40
+ subjectType: {type: "string", required: false},
41
+ referenceId: {type: "string", required: false},
42
+ metadata: {type: "json", required: false}
43
+ }
44
+ },
45
+ oauthRefreshToken: {
46
+ fields: {
47
+ token: {type: "string", required: true},
48
+ clientId: {type: "string", required: true},
49
+ sessionId: {type: "string", required: false},
50
+ userId: {type: "string", required: false},
51
+ referenceId: {type: "string", required: false},
52
+ authTime: {type: "date", required: false},
53
+ expiresAt: {type: "date", required: false},
54
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
55
+ revoked: {type: "date", required: false},
56
+ scopes: {type: "string[]", required: true}
57
+ }
58
+ },
59
+ oauthAccessToken: {
60
+ modelName: "oauthAccessToken",
61
+ fields: {
62
+ token: {type: "string", unique: true, required: true},
63
+ expiresAt: {type: "date", required: true},
64
+ clientId: {type: "string", required: true},
65
+ userId: {type: "string", required: false},
66
+ sessionId: {type: "string", required: false},
67
+ scopes: {type: "string[]", required: true},
68
+ revoked: {type: "date", required: false},
69
+ referenceId: {type: "string", required: false},
70
+ authTime: {type: "date", required: false},
71
+ refreshId: {type: "string", required: false},
72
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
73
+ updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
74
+ }
75
+ },
76
+ oauthConsent: {
77
+ modelName: "oauthConsent",
78
+ fields: {
79
+ clientId: {type: "string", required: true},
80
+ userId: {type: "string", required: false},
81
+ referenceId: {type: "string", required: false},
82
+ scopes: {type: "string[]", required: true},
83
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
84
+ updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
85
+ }
86
+ }
87
+ }
88
+ end
89
+ end
90
+ end
91
+ end