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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module MCP
6
+ module_function
7
+
8
+ def token(ctx, config)
9
+ set_cors_headers(ctx)
10
+ body = OAuthProtocol.stringify_keys(ctx.body)
11
+ client = OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
12
+ audience = validate_resource!(config, body)
13
+
14
+ case body["grant_type"]
15
+ when OAuthProtocol::AUTH_CODE_GRANT
16
+ code = OAuthProtocol.consume_code!(
17
+ config[:store],
18
+ body["code"],
19
+ client_id: OAuthProtocol.stringify_keys(client)["clientId"],
20
+ redirect_uri: body["redirect_uri"],
21
+ code_verifier: body["code_verifier"]
22
+ )
23
+ OAuthProtocol.issue_tokens(
24
+ ctx,
25
+ config[:store],
26
+ model: "oauthAccessToken",
27
+ client: client,
28
+ session: code[:session],
29
+ scopes: code[:scopes],
30
+ include_refresh: code[:scopes].include?("offline_access"),
31
+ issuer: validate_issuer_url(OAuthProtocol.issuer(ctx)),
32
+ prefix: config[:prefix],
33
+ refresh_token_expires_in: config[:refresh_token_expires_in],
34
+ access_token_expires_in: config[:access_token_expires_in],
35
+ audience: audience,
36
+ grant_type: OAuthProtocol::AUTH_CODE_GRANT,
37
+ jwt_access_token: !audience.nil?,
38
+ nonce: code[:nonce],
39
+ auth_time: code[:auth_time],
40
+ reference_id: code[:reference_id],
41
+ filter_id_token_claims_by_scope: true
42
+ )
43
+ when OAuthProtocol::REFRESH_GRANT
44
+ OAuthProtocol.refresh_tokens(
45
+ ctx,
46
+ config[:store],
47
+ model: "oauthAccessToken",
48
+ client: client,
49
+ refresh_token: body["refresh_token"],
50
+ scopes: body["scope"],
51
+ issuer: validate_issuer_url(OAuthProtocol.issuer(ctx)),
52
+ prefix: config[:prefix],
53
+ refresh_token_expires_in: config[:refresh_token_expires_in],
54
+ access_token_expires_in: config[:access_token_expires_in],
55
+ audience: audience,
56
+ jwt_access_token: !audience.nil?,
57
+ filter_id_token_claims_by_scope: true
58
+ )
59
+ else
60
+ raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
61
+ end
62
+ end
63
+
64
+ def validate_resource!(config, body)
65
+ resources = Array(body["resource"]).compact.map(&:to_s)
66
+ return nil if resources.empty?
67
+
68
+ valid = Array(config[:valid_audiences]).map(&:to_s)
69
+ resources.each do |resource|
70
+ raise APIError.new("BAD_REQUEST", message: "requested resource invalid") unless valid.empty? || valid.include?(resource)
71
+ end
72
+ (resources.length == 1) ? resources.first : resources
73
+ end
74
+
75
+ def introspect(ctx, config)
76
+ OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
77
+ body = OAuthProtocol.stringify_keys(ctx.body)
78
+ token_record = OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, body["token_type_hint"], prefix: config[:prefix])
79
+ return inactive_token_response if token_record.nil? || token_record["revoked"] || (token_record["expiresAt"] && token_record["expiresAt"] <= Time.now)
80
+
81
+ {
82
+ active: true,
83
+ client_id: token_record["clientId"],
84
+ scope: OAuthProtocol.scope_string(token_record["scope"] || token_record["scopes"]),
85
+ sub: token_record["subject"] || token_record.dig("user", "id"),
86
+ iss: token_record["issuer"],
87
+ iat: token_record["issuedAt"]&.to_i,
88
+ exp: token_record["expiresAt"]&.to_i,
89
+ sid: token_record["sessionId"],
90
+ aud: token_record["audience"]
91
+ }.compact
92
+ end
93
+
94
+ def revoke(ctx, config)
95
+ OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
96
+ body = OAuthProtocol.stringify_keys(ctx.body)
97
+ if (token_record = OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, body["token_type_hint"], prefix: config[:prefix]))
98
+ token_record["revoked"] = Time.now
99
+ end
100
+ {revoked: true}
101
+ end
102
+
103
+ def inactive_token_response
104
+ {active: false}
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+
5
+ module BetterAuth
6
+ module Plugins
7
+ module MCP
8
+ module_function
9
+
10
+ def userinfo(ctx, config)
11
+ OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"], prefix: config[:prefix], jwt_secret: ctx.context.secret)
12
+ end
13
+
14
+ def session_from_token(ctx, config)
15
+ authorization = ctx.headers["authorization"].to_s
16
+ token_value = authorization.start_with?("Bearer ") ? authorization.delete_prefix("Bearer ").strip : authorization.strip
17
+ return nil if token_value.empty?
18
+
19
+ token_record = OAuthProtocol.token_record(config[:store], token_value, prefix: config[:prefix])
20
+ return token_record if token_record
21
+
22
+ payload = ::JWT.decode(token_value, ctx.context.secret, true, algorithm: "HS256").first
23
+ {
24
+ "clientId" => payload["azp"],
25
+ "userId" => payload["sub"],
26
+ "sessionId" => payload["sid"],
27
+ "scopes" => OAuthProtocol.parse_scopes(payload["scope"]),
28
+ "audience" => payload["aud"],
29
+ "subject" => payload["sub"],
30
+ "expiresAt" => payload["exp"] ? Time.at(payload["exp"].to_i) : nil
31
+ }.compact
32
+ rescue ::JWT::DecodeError
33
+ nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,72 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "mcp/config"
5
+ require_relative "mcp/metadata"
6
+ require_relative "mcp/schema"
7
+ require_relative "mcp/registration"
8
+ require_relative "mcp/authorization"
9
+ require_relative "mcp/consent"
10
+ require_relative "mcp/token"
11
+ require_relative "mcp/userinfo"
12
+ require_relative "mcp/resource_handler"
13
+ require_relative "mcp/legacy_aliases"
4
14
 
5
15
  module BetterAuth
6
16
  module Plugins
7
17
  module MCP
8
18
  module_function
9
19
 
10
- def with_mcp_auth(app, resource_metadata_url:, auth: nil)
11
- lambda do |env|
12
- authorization = env["HTTP_AUTHORIZATION"].to_s
13
- unless authorization.start_with?("Bearer ")
14
- return unauthorized(resource_metadata_url)
15
- end
16
-
17
- session = auth&.api&.get_mcp_session(headers: {"authorization" => authorization})
18
- return unauthorized(resource_metadata_url) unless session
19
-
20
- env["better_auth.mcp_session"] = session
21
-
22
- app.call(env)
23
- rescue APIError
24
- unauthorized(resource_metadata_url)
25
- end
26
- end
27
-
28
- def unauthorized(resource_metadata_url)
29
- [
30
- 401,
31
- {
32
- "www-authenticate" => %(Bearer resource_metadata="#{resource_metadata_url}"),
33
- "access-control-expose-headers" => "WWW-Authenticate"
34
- },
35
- ["unauthorized"]
36
- ]
20
+ def with_mcp_auth(app, resource_metadata_url:, auth: nil, resource_metadata_mappings: {})
21
+ ResourceHandler.with_mcp_auth(
22
+ app,
23
+ resource_metadata_url: resource_metadata_url,
24
+ auth: auth,
25
+ resource_metadata_mappings: resource_metadata_mappings
26
+ )
37
27
  end
38
28
  end
39
29
 
40
30
  module_function
41
31
 
42
32
  def mcp(options = {})
43
- config = {
44
- login_page: "/login",
45
- consent_page: "/oauth/consent",
46
- resource: nil,
47
- oidc_config: {},
48
- code_expires_in: 600,
49
- default_scope: "openid",
50
- access_token_expires_in: 3600,
51
- refresh_token_expires_in: 604_800,
52
- allow_plain_code_challenge_method: true,
53
- scopes: %w[openid profile email offline_access],
54
- store: OAuthProtocol.stores
55
- }.merge(normalize_hash(options))
56
- config = mcp_normalize_config(config)
57
-
33
+ config = MCP.normalize_config(options)
58
34
  Plugin.new(
59
35
  id: "mcp",
60
36
  endpoints: mcp_endpoints(config),
61
- hooks: {
62
- after: [
63
- {
64
- matcher: ->(_ctx) { true },
65
- handler: ->(ctx) { mcp_restore_login_prompt(ctx, config) }
66
- }
67
- ]
68
- },
69
- schema: oidc_provider_schema,
37
+ hooks: {after: [{matcher: ->(_ctx) { true }, handler: ->(ctx) { MCP.restore_login_prompt(ctx, config) }}]},
38
+ schema: MCP.schema,
70
39
  options: config
71
40
  )
72
41
  end
@@ -75,268 +44,147 @@ module BetterAuth
75
44
  {
76
45
  get_mcp_o_auth_config: mcp_oauth_config_endpoint(config),
77
46
  get_mcp_protected_resource: mcp_protected_resource_endpoint(config),
47
+ mcp_register: mcp_register_endpoint(config),
48
+ legacy_mcp_register: MCP.legacy_register_endpoint(config),
78
49
  mcp_o_auth_authorize: mcp_authorize_endpoint(config),
50
+ legacy_mcp_o_auth_authorize: MCP.legacy_authorize_endpoint(config),
51
+ o_auth_consent: mcp_consent_endpoint(config),
79
52
  mcp_o_auth_token: mcp_token_endpoint(config),
53
+ legacy_mcp_o_auth_token: MCP.legacy_token_endpoint(config),
80
54
  mcp_o_auth_user_info: mcp_userinfo_endpoint(config),
81
- mcp_register: mcp_register_endpoint(config),
55
+ legacy_mcp_o_auth_user_info: MCP.legacy_userinfo_endpoint(config),
82
56
  get_mcp_session: mcp_get_session_endpoint(config),
83
- o_auth_consent: oidc_consent_endpoint(config),
84
- mcp_jwks: mcp_jwks_endpoint(config)
57
+ mcp_jwks: mcp_jwks_endpoint(config),
58
+ legacy_mcp_jwks: MCP.legacy_jwks_endpoint(config),
59
+ mcp_o_auth_introspect: mcp_introspect_endpoint(config),
60
+ mcp_o_auth_revoke: mcp_revoke_endpoint(config)
85
61
  }
86
62
  end
87
63
 
88
64
  def mcp_oauth_config_endpoint(config)
89
65
  Endpoint.new(path: "/.well-known/oauth-authorization-server", method: "GET", metadata: {hide: true}) do |ctx|
90
- base = OAuthProtocol.endpoint_base(ctx)
91
- ctx.json({
92
- issuer: OAuthProtocol.issuer(ctx),
93
- authorization_endpoint: "#{base}/mcp/authorize",
94
- token_endpoint: "#{base}/mcp/token",
95
- userinfo_endpoint: "#{base}/mcp/userinfo",
96
- jwks_uri: "#{base}/mcp/jwks",
97
- registration_endpoint: "#{base}/mcp/register",
98
- scopes_supported: config[:scopes],
99
- response_types_supported: ["code"],
100
- response_modes_supported: ["query"],
101
- grant_types_supported: ["authorization_code", "refresh_token"],
102
- acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"],
103
- subject_types_supported: ["public"],
104
- id_token_signing_alg_values_supported: ["RS256", "none"],
105
- token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
106
- code_challenge_methods_supported: ["S256"],
107
- claims_supported: %w[sub iss aud exp nbf iat jti email email_verified name]
108
- }.merge(config[:oidc_config][:metadata] || {}))
66
+ ctx.json(MCP.oauth_metadata(ctx, config))
109
67
  end
110
68
  end
111
69
 
112
70
  def mcp_protected_resource_endpoint(config)
113
71
  Endpoint.new(path: "/.well-known/oauth-protected-resource", method: "GET", metadata: {hide: true}) do |ctx|
114
- origin = OAuthProtocol.origin_for(OAuthProtocol.endpoint_base(ctx))
115
- ctx.json({
116
- resource: config[:resource] || origin,
117
- authorization_servers: [origin],
118
- jwks_uri: config.dig(:oidc_config, :metadata, :jwks_uri) || "#{OAuthProtocol.endpoint_base(ctx)}/mcp/jwks",
119
- scopes_supported: config.dig(:oidc_config, :metadata, :scopes_supported) || config[:scopes],
120
- bearer_methods_supported: ["header"],
121
- resource_signing_alg_values_supported: ["RS256", "none"]
122
- })
72
+ ctx.json(MCP.protected_resource_metadata(ctx, config))
123
73
  end
124
74
  end
125
75
 
126
76
  def mcp_register_endpoint(config)
127
- Endpoint.new(path: "/mcp/register", method: "POST") do |ctx|
128
- mcp_set_cors_headers(ctx)
129
- ctx.json(
130
- OAuthProtocol.create_client(
131
- ctx,
132
- model: "oauthApplication",
133
- body: ctx.body,
134
- default_auth_method: "none",
135
- store_client_secret: config[:store_client_secret] || "plain"
136
- ),
137
- status: 201,
138
- headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"}
139
- )
77
+ Endpoint.new(path: "/oauth2/register", method: "POST", metadata: mcp_openapi("registerMcpClient", "Register an OAuth2 application", "OAuth2 application registered successfully", mcp_client_schema)) do |ctx|
78
+ ctx.json(MCP.register_client(ctx, config), status: 201, headers: MCP.no_store_headers)
140
79
  end
141
80
  end
142
81
 
143
82
  def mcp_authorize_endpoint(config)
144
- Endpoint.new(path: "/mcp/authorize", method: "GET") do |ctx|
145
- query = OAuthProtocol.stringify_keys(ctx.query)
146
- session = Routes.current_session(ctx, allow_nil: true)
147
- unless session
148
- ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
149
- raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query))
150
- end
151
-
152
- raise ctx.redirect(mcp_authorization_redirect(ctx, config, query, session))
83
+ Endpoint.new(path: "/oauth2/authorize", method: "GET", metadata: mcp_openapi("mcpOAuthAuthorize", "Authorize an OAuth2 request using MCP", "Authorization response generated successfully", {type: "object", additionalProperties: true})) do |ctx|
84
+ MCP.authorize(ctx, config)
153
85
  end
154
86
  end
155
87
 
156
- def mcp_restore_login_prompt(ctx, config)
157
- cookie = ctx.get_signed_cookie("oidc_login_prompt", ctx.context.secret)
158
- return unless cookie
159
-
160
- session = ctx.context.new_session
161
- return unless session && session[:session] && ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)
162
-
163
- query = mcp_parse_login_prompt(cookie)
164
- return unless query
165
-
166
- ctx.set_cookie("oidc_login_prompt", "", path: "/", max_age: 0)
167
- ctx.context.set_current_session(session) if ctx.context.respond_to?(:set_current_session)
168
- [302, ctx.response_headers.merge("location" => mcp_authorization_redirect(ctx, config, query, session)), [""]]
169
- end
170
-
171
- def mcp_authorization_redirect(ctx, config, query, session)
172
- query = OAuthProtocol.stringify_keys(query)
173
- query["prompt"] = mcp_prompt_without_login(query["prompt"]) if query.key?("prompt")
174
- prompts = OIDCProvider.parse_prompt(query["prompt"])
175
- unless query["client_id"]
176
- raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client")
177
- end
178
- unless query["response_type"]
179
- raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(ctx.context.base_url + "/error", error: "invalid_request", error_description: "response_type is required"))
180
- end
181
- client = OAuthProtocol.find_client(ctx, "oauthApplication", query["client_id"])
182
- raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") unless client
183
- OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])
184
- client_data = OAuthProtocol.stringify_keys(client)
185
- raise ctx.redirect("#{ctx.context.base_url}/error?error=client_disabled") if client_data["disabled"]
186
- unless query["response_type"] == "code"
187
- raise ctx.redirect("#{ctx.context.base_url}/error?error=unsupported_response_type")
188
- end
189
-
190
- scopes = OAuthProtocol.parse_scopes(query["scope"] || config[:default_scope])
191
- invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) }
192
- unless invalid_scopes.empty?
193
- 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"])
194
- raise ctx.redirect(redirect)
88
+ def mcp_consent_endpoint(config)
89
+ Endpoint.new(path: "/oauth2/consent", method: "POST") do |ctx|
90
+ ctx.json(MCP.consent(ctx, config))
195
91
  end
196
- if config[:require_pkce] && (query["code_challenge"].to_s.empty? || query["code_challenge_method"].to_s.empty?)
197
- redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "pkce is required", state: query["state"])
198
- raise ctx.redirect(redirect)
199
- end
200
- challenge_method = query["code_challenge_method"].to_s
201
- if challenge_method.empty?
202
- query["code_challenge_method"] = "plain" if query["code_challenge"]
203
- elsif !valid_code_challenge_method?(challenge_method, config)
204
- redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "invalid code_challenge method", state: query["state"])
205
- raise ctx.redirect(redirect)
206
- end
207
-
208
- if prompts.include?("consent")
209
- consent_code = Crypto.random_string(32)
210
- config[:store][:consents][consent_code] = {
211
- query: query,
212
- session: session,
213
- client: client,
214
- scopes: scopes,
215
- expires_at: Time.now + config[:code_expires_in].to_i
216
- }
217
- 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)))
218
- end
219
-
220
- code = Crypto.random_string(32)
221
- OAuthProtocol.store_code(
222
- config[:store],
223
- code: code,
224
- client_id: query["client_id"],
225
- redirect_uri: query["redirect_uri"],
226
- session: session,
227
- scopes: scopes,
228
- code_challenge: query["code_challenge"],
229
- code_challenge_method: query["code_challenge_method"]
230
- )
231
- OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"])
232
- end
233
-
234
- def mcp_prompt_without_login(value)
235
- prompts = value.to_s.split(/\s+/).reject(&:empty?)
236
- prompts.delete("login")
237
- prompts.join(" ")
238
- end
239
-
240
- def mcp_parse_login_prompt(value)
241
- parsed = JSON.parse(value.to_s)
242
- parsed.is_a?(Hash) ? parsed : nil
243
- rescue JSON::ParserError
244
- nil
245
92
  end
246
93
 
247
94
  def mcp_token_endpoint(config)
248
- Endpoint.new(path: "/mcp/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
249
- mcp_set_cors_headers(ctx)
250
- body = OAuthProtocol.stringify_keys(ctx.body)
251
- client = mcp_authenticate_token_client!(ctx, body, config)
252
- raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client
253
-
254
- response = case body["grant_type"]
255
- when OAuthProtocol::AUTH_CODE_GRANT
256
- client_data = OAuthProtocol.stringify_keys(client)
257
- if client_data["type"] == "public" && body["code_verifier"].to_s.empty?
258
- raise APIError.new("BAD_REQUEST", message: "invalid_request")
259
- end
260
- code = OAuthProtocol.consume_code!(
261
- config[:store],
262
- body["code"],
263
- client_id: client_data["clientId"],
264
- redirect_uri: body["redirect_uri"],
265
- code_verifier: body["code_verifier"]
266
- )
267
- OAuthProtocol.issue_tokens(
268
- ctx,
269
- config[:store],
270
- model: "oauthAccessToken",
271
- client: client,
272
- session: code[:session],
273
- scopes: code[:scopes],
274
- include_refresh: code[:scopes].include?("offline_access"),
275
- issuer: OAuthProtocol.issuer(ctx),
276
- access_token_expires_in: config[:access_token_expires_in]
277
- )
278
- when OAuthProtocol::REFRESH_GRANT
279
- OAuthProtocol.refresh_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, refresh_token: body["refresh_token"], scopes: body["scope"], issuer: OAuthProtocol.issuer(ctx), access_token_expires_in: config[:access_token_expires_in])
280
- else
281
- raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
282
- end
283
- ctx.json(response, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
95
+ Endpoint.new(
96
+ path: "/oauth2/token",
97
+ method: "POST",
98
+ metadata: mcp_openapi("mcpOAuthToken", "Exchange OAuth2 code for MCP tokens", "OAuth2 tokens issued successfully", mcp_token_response_schema).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
99
+ ) do |ctx|
100
+ ctx.json(MCP.token(ctx, config), headers: MCP.no_store_headers)
284
101
  end
285
102
  end
286
103
 
287
104
  def mcp_userinfo_endpoint(config)
288
- Endpoint.new(path: "/mcp/userinfo", method: "GET") do |ctx|
289
- ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"]))
105
+ Endpoint.new(path: "/oauth2/userinfo", method: "GET", metadata: mcp_openapi("mcpOAuthUserinfo", "Get MCP OAuth2 user information", "User information retrieved successfully", mcp_userinfo_schema)) do |ctx|
106
+ ctx.json(MCP.userinfo(ctx, config))
290
107
  end
291
108
  end
292
109
 
293
110
  def mcp_get_session_endpoint(config)
294
- Endpoint.new(path: "/mcp/get-session", method: "GET") do |ctx|
295
- authorization = ctx.headers["authorization"].to_s
296
- token = authorization.start_with?("Bearer ") ? authorization.delete_prefix("Bearer ").strip : ""
297
- next ctx.json(nil) if token.empty?
298
-
299
- ctx.json(OAuthProtocol.token_record(config[:store], token))
111
+ Endpoint.new(path: "/mcp/get-session", method: "GET", metadata: mcp_openapi("getMcpSession", "Get the MCP session", "MCP session retrieved successfully", {type: ["object", "null"]})) do |ctx|
112
+ ctx.json(MCP.session_from_token(ctx, config))
300
113
  end
301
114
  end
302
115
 
303
116
  def mcp_jwks_endpoint(config)
304
- Endpoint.new(path: "/mcp/jwks", method: "GET") do |ctx|
305
- jwt_config = config[:jwt] || {}
306
- create_jwk(ctx, jwt_config) if all_jwks(ctx, jwt_config).empty?
307
- ctx.json({keys: public_jwks(ctx, jwt_config).map { |key| public_jwk(key, jwt_config) }})
117
+ Endpoint.new(path: "/oauth2/jwks", method: "GET", metadata: mcp_openapi("getMcpJSONWebKeySet", "Get the MCP JSON Web Key Set", "JSON Web Key Set retrieved successfully", mcp_jwks_response_schema)) do |ctx|
118
+ ctx.json(MCP.jwks(ctx, config))
308
119
  end
309
120
  end
310
121
 
311
- def mcp_normalize_config(config)
312
- oidc = normalize_hash(config[:oidc_config] || {})
313
- merged = config.merge(oidc.except(:metadata))
314
- merged[:scopes] = (Array(config[:scopes]) + Array(oidc[:scopes])).compact.map(&:to_s).uniq
315
- merged
122
+ def mcp_introspect_endpoint(config)
123
+ Endpoint.new(path: "/oauth2/introspect", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
124
+ ctx.json(MCP.introspect(ctx, config))
125
+ end
316
126
  end
317
127
 
318
- def mcp_set_cors_headers(ctx)
319
- ctx.set_header("Access-Control-Allow-Origin", "*")
320
- ctx.set_header("Access-Control-Allow-Methods", "POST, OPTIONS")
321
- ctx.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
322
- ctx.set_header("Access-Control-Max-Age", "86400")
128
+ def mcp_revoke_endpoint(config)
129
+ Endpoint.new(path: "/oauth2/revoke", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
130
+ ctx.json(MCP.revoke(ctx, config))
131
+ end
323
132
  end
324
133
 
325
- def mcp_authenticate_token_client!(ctx, body, config)
326
- authorization = ctx.headers["authorization"].to_s
327
- if authorization.start_with?("Basic ") && body["client_id"].to_s.empty?
328
- return OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret] || "plain")
329
- end
134
+ def mcp_openapi(operation_id, description, response_description, response_schema)
135
+ {
136
+ openapi: {
137
+ operationId: operation_id,
138
+ description: description,
139
+ responses: {
140
+ "200" => OpenAPI.json_response(response_description, response_schema)
141
+ }
142
+ }
143
+ }
144
+ end
330
145
 
331
- client = OAuthProtocol.find_client(ctx, "oauthApplication", body["client_id"])
332
- raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client
146
+ def mcp_client_schema
147
+ OpenAPI.object_schema(
148
+ {
149
+ clientId: {type: "string"},
150
+ clientSecret: {type: ["string", "null"]},
151
+ name: {type: ["string", "null"]},
152
+ redirectUris: {type: "array", items: {type: "string"}}
153
+ },
154
+ required: ["clientId"]
155
+ )
156
+ end
333
157
 
334
- data = OAuthProtocol.stringify_keys(client)
335
- method = data["tokenEndpointAuthMethod"] || "client_secret_basic"
336
- if method != "none" && !OAuthProtocol.verify_client_secret(ctx, data["clientSecret"], body["client_secret"], config[:store_client_secret] || "plain")
337
- raise APIError.new("UNAUTHORIZED", message: "invalid_client")
338
- end
339
- client
158
+ def mcp_token_response_schema
159
+ OpenAPI.object_schema(
160
+ {
161
+ access_token: {type: "string"},
162
+ token_type: {type: "string"},
163
+ expires_in: {type: "number"},
164
+ refresh_token: {type: ["string", "null"]},
165
+ scope: {type: ["string", "null"]}
166
+ },
167
+ required: ["access_token", "token_type", "expires_in"]
168
+ )
169
+ end
170
+
171
+ def mcp_userinfo_schema
172
+ OpenAPI.object_schema(
173
+ {
174
+ sub: {type: "string"},
175
+ email: {type: ["string", "null"]},
176
+ email_verified: {type: ["boolean", "null"]},
177
+ name: {type: ["string", "null"]}
178
+ },
179
+ required: ["sub"]
180
+ )
181
+ end
182
+
183
+ def mcp_jwks_response_schema
184
+ OpenAPI.object_schema(
185
+ {keys: {type: "array", items: {type: "object"}}},
186
+ required: ["keys"]
187
+ )
340
188
  end
341
189
  end
342
190
  end