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
@@ -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
@@ -36,7 +36,25 @@ module BetterAuth
36
36
  end
37
37
 
38
38
  def list_device_sessions_endpoint
39
- Endpoint.new(path: "/multi-session/list-device-sessions", method: "GET") do |ctx|
39
+ Endpoint.new(
40
+ path: "/multi-session/list-device-sessions",
41
+ method: "GET",
42
+ metadata: {
43
+ openapi: {
44
+ operationId: "listDeviceSessions",
45
+ description: "List device sessions",
46
+ responses: {
47
+ "200" => OpenAPI.json_response(
48
+ "Device sessions",
49
+ {
50
+ type: "array",
51
+ items: OpenAPI.session_response_schema_pair
52
+ }
53
+ )
54
+ }
55
+ }
56
+ }
57
+ ) do |ctx|
40
58
  tokens = verified_multi_session_tokens(ctx)
41
59
  sessions = ctx.context.internal_adapter.find_sessions(tokens)
42
60
  .reject { |entry| entry[:session]["expiresAt"] && entry[:session]["expiresAt"] <= Time.now }
@@ -47,7 +65,27 @@ module BetterAuth
47
65
  end
48
66
 
49
67
  def set_active_session_endpoint
50
- Endpoint.new(path: "/multi-session/set-active", method: "POST") do |ctx|
68
+ Endpoint.new(
69
+ path: "/multi-session/set-active",
70
+ method: "POST",
71
+ metadata: {
72
+ openapi: {
73
+ operationId: "setActiveSession",
74
+ description: "Set the active session",
75
+ requestBody: OpenAPI.json_request_body(
76
+ OpenAPI.object_schema(
77
+ {
78
+ sessionToken: {type: "string", description: "The session token"}
79
+ },
80
+ required: ["sessionToken"]
81
+ )
82
+ ),
83
+ responses: {
84
+ "200" => OpenAPI.json_response("Active session", OpenAPI.session_response_schema_pair)
85
+ }
86
+ }
87
+ }
88
+ ) do |ctx|
51
89
  token = fetch_value(ctx.body, "sessionToken").to_s
52
90
  cookie_name = multi_session_cookie_name(ctx, token)
53
91
  unless !token.empty? && ctx.get_signed_cookie(cookie_name, ctx.context.secret)
@@ -66,7 +104,27 @@ module BetterAuth
66
104
  end
67
105
 
68
106
  def revoke_device_session_endpoint
69
- Endpoint.new(path: "/multi-session/revoke", method: "POST") do |ctx|
107
+ Endpoint.new(
108
+ path: "/multi-session/revoke",
109
+ method: "POST",
110
+ metadata: {
111
+ openapi: {
112
+ operationId: "revokeDeviceSession",
113
+ description: "Revoke a device session",
114
+ requestBody: OpenAPI.json_request_body(
115
+ OpenAPI.object_schema(
116
+ {
117
+ sessionToken: {type: "string", description: "The session token"}
118
+ },
119
+ required: ["sessionToken"]
120
+ )
121
+ ),
122
+ responses: {
123
+ "200" => OpenAPI.json_response("Device session revoked", OpenAPI.status_response_schema)
124
+ }
125
+ }
126
+ }
127
+ ) do |ctx|
70
128
  current = Routes.current_session(ctx)
71
129
  token = fetch_value(ctx.body, "sessionToken").to_s
72
130
  cookie_name = multi_session_cookie_name(ctx, token)