better_auth 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +138 -91
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +191 -56
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +294 -49
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +41 -46
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +110 -14
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +14 -3
  18. data/lib/better_auth/error.rb +3 -1
  19. data/lib/better_auth/logger.rb +1 -1
  20. data/lib/better_auth/middleware/origin_check.rb +9 -1
  21. data/lib/better_auth/migration_plan.rb +4 -4
  22. data/lib/better_auth/oauth_state.rb +147 -0
  23. data/lib/better_auth/plugin_loader.rb +220 -0
  24. data/lib/better_auth/plugins/access.rb +10 -13
  25. data/lib/better_auth/plugins/admin.rb +79 -28
  26. data/lib/better_auth/plugins/api_key.rb +4 -7
  27. data/lib/better_auth/plugins/bearer.rb +8 -22
  28. data/lib/better_auth/plugins/captcha.rb +24 -6
  29. data/lib/better_auth/plugins/device_authorization.rb +47 -9
  30. data/lib/better_auth/plugins/dub.rb +2 -2
  31. data/lib/better_auth/plugins/email_otp.rb +30 -47
  32. data/lib/better_auth/plugins/generic_oauth.rb +141 -146
  33. data/lib/better_auth/plugins/i18n.rb +215 -0
  34. data/lib/better_auth/plugins/jwt.rb +37 -19
  35. data/lib/better_auth/plugins/magic_link.rb +18 -21
  36. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  37. data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
  38. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  39. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  40. data/lib/better_auth/plugins/one_tap.rb +61 -60
  41. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  42. data/lib/better_auth/plugins/open_api.rb +35 -7
  43. data/lib/better_auth/plugins/organization.rb +455 -122
  44. data/lib/better_auth/plugins/passkey.rb +4 -7
  45. data/lib/better_auth/plugins/phone_number.rb +24 -26
  46. data/lib/better_auth/plugins/scim.rb +4 -7
  47. data/lib/better_auth/plugins/siwe.rb +126 -16
  48. data/lib/better_auth/plugins/sso.rb +4 -7
  49. data/lib/better_auth/plugins/stripe.rb +4 -7
  50. data/lib/better_auth/plugins/two_factor.rb +186 -21
  51. data/lib/better_auth/plugins/username.rb +56 -13
  52. data/lib/better_auth/plugins.rb +57 -0
  53. data/lib/better_auth/rate_limiter.rb +297 -135
  54. data/lib/better_auth/request_ip.rb +123 -13
  55. data/lib/better_auth/router.rb +3 -3
  56. data/lib/better_auth/routes/account.rb +40 -8
  57. data/lib/better_auth/routes/email_verification.rb +88 -5
  58. data/lib/better_auth/routes/password.rb +5 -6
  59. data/lib/better_auth/routes/session.rb +7 -2
  60. data/lib/better_auth/routes/sign_in.rb +10 -1
  61. data/lib/better_auth/routes/sign_up.rb +45 -22
  62. data/lib/better_auth/routes/social.rb +169 -54
  63. data/lib/better_auth/routes/user.rb +11 -8
  64. data/lib/better_auth/schema/sql.rb +90 -24
  65. data/lib/better_auth/schema.rb +124 -3
  66. data/lib/better_auth/session.rb +27 -9
  67. data/lib/better_auth/session_store.rb +1 -1
  68. data/lib/better_auth/social_providers/apple.rb +6 -1
  69. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  70. data/lib/better_auth/social_providers/base.rb +41 -14
  71. data/lib/better_auth/social_providers/cognito.rb +75 -5
  72. data/lib/better_auth/social_providers/facebook.rb +95 -1
  73. data/lib/better_auth/social_providers/figma.rb +2 -0
  74. data/lib/better_auth/social_providers/google.rb +12 -1
  75. data/lib/better_auth/social_providers/line.rb +35 -1
  76. data/lib/better_auth/social_providers/linear.rb +1 -0
  77. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  78. data/lib/better_auth/social_providers/naver.rb +24 -1
  79. data/lib/better_auth/social_providers/notion.rb +1 -0
  80. data/lib/better_auth/social_providers/paybin.rb +25 -1
  81. data/lib/better_auth/social_providers/paypal.rb +118 -2
  82. data/lib/better_auth/social_providers/reddit.rb +25 -4
  83. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  84. data/lib/better_auth/social_providers/twitch.rb +24 -1
  85. data/lib/better_auth/social_providers/twitter.rb +36 -1
  86. data/lib/better_auth/social_providers/vk.rb +34 -1
  87. data/lib/better_auth/social_providers/wechat.rb +6 -4
  88. data/lib/better_auth/sql_migration.rb +5 -5
  89. data/lib/better_auth/version.rb +1 -1
  90. data/lib/better_auth.rb +11 -41
  91. metadata +5 -13
  92. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  93. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  94. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  95. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  96. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  97. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  98. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  99. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  100. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  101. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  102. data/lib/better_auth/plugins/mcp.rb +0 -256
  103. data/lib/better_auth/plugins/oidc_provider.rb +0 -769
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -292,9 +292,11 @@ files:
292
292
  - lib/better_auth/middleware/origin_check.rb
293
293
  - lib/better_auth/migration_plan.rb
294
294
  - lib/better_auth/oauth2.rb
295
+ - lib/better_auth/oauth_state.rb
295
296
  - lib/better_auth/password.rb
296
297
  - lib/better_auth/plugin.rb
297
298
  - lib/better_auth/plugin_context.rb
299
+ - lib/better_auth/plugin_loader.rb
298
300
  - lib/better_auth/plugin_registry.rb
299
301
  - lib/better_auth/plugins.rb
300
302
  - lib/better_auth/plugins/access.rb
@@ -312,25 +314,15 @@ files:
312
314
  - lib/better_auth/plugins/expo.rb
313
315
  - lib/better_auth/plugins/generic_oauth.rb
314
316
  - lib/better_auth/plugins/have_i_been_pwned.rb
317
+ - lib/better_auth/plugins/i18n.rb
315
318
  - lib/better_auth/plugins/jwt.rb
316
319
  - lib/better_auth/plugins/last_login_method.rb
317
320
  - lib/better_auth/plugins/magic_link.rb
318
- - lib/better_auth/plugins/mcp.rb
319
- - lib/better_auth/plugins/mcp/authorization.rb
320
- - lib/better_auth/plugins/mcp/config.rb
321
- - lib/better_auth/plugins/mcp/consent.rb
322
- - lib/better_auth/plugins/mcp/legacy_aliases.rb
323
- - lib/better_auth/plugins/mcp/metadata.rb
324
- - lib/better_auth/plugins/mcp/registration.rb
325
- - lib/better_auth/plugins/mcp/resource_handler.rb
326
- - lib/better_auth/plugins/mcp/schema.rb
327
- - lib/better_auth/plugins/mcp/token.rb
328
- - lib/better_auth/plugins/mcp/userinfo.rb
329
321
  - lib/better_auth/plugins/multi_session.rb
322
+ - lib/better_auth/plugins/oauth_popup.rb
330
323
  - lib/better_auth/plugins/oauth_protocol.rb
331
324
  - lib/better_auth/plugins/oauth_provider.rb
332
325
  - lib/better_auth/plugins/oauth_proxy.rb
333
- - lib/better_auth/plugins/oidc_provider.rb
334
326
  - lib/better_auth/plugins/one_tap.rb
335
327
  - lib/better_auth/plugins/one_time_token.rb
336
328
  - lib/better_auth/plugins/open_api.rb
@@ -1,111 +0,0 @@
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
@@ -1,51 +0,0 @@
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
@@ -1,31 +0,0 @@
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
@@ -1,43 +0,0 @@
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", metadata: BetterAuth::Plugins.mcp_openapi("legacyRegisterMcpClient", "Register an OAuth2 application using the legacy MCP path", "OAuth2 application registered successfully", BetterAuth::Plugins.mcp_client_schema)) 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", metadata: BetterAuth::Plugins.mcp_openapi("legacyMcpOAuthAuthorize", "Authorize an OAuth2 request using the legacy MCP path", "Authorization response generated successfully", {type: "object", additionalProperties: true})) do |ctx|
16
- authorize(ctx, config)
17
- end
18
- end
19
-
20
- def legacy_token_endpoint(config)
21
- Endpoint.new(
22
- path: "/mcp/token",
23
- method: "POST",
24
- metadata: BetterAuth::Plugins.mcp_openapi("legacyMcpOAuthToken", "Exchange OAuth2 code for MCP tokens using the legacy path", "OAuth2 tokens issued successfully", BetterAuth::Plugins.mcp_token_response_schema).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
25
- ) do |ctx|
26
- ctx.json(token(ctx, config), headers: no_store_headers)
27
- end
28
- end
29
-
30
- def legacy_userinfo_endpoint(config)
31
- Endpoint.new(path: "/mcp/userinfo", method: "GET", metadata: BetterAuth::Plugins.mcp_openapi("legacyMcpOAuthUserinfo", "Get MCP OAuth2 user information using the legacy path", "User information retrieved successfully", BetterAuth::Plugins.mcp_userinfo_schema)) do |ctx|
32
- ctx.json(userinfo(ctx, config))
33
- end
34
- end
35
-
36
- def legacy_jwks_endpoint(config)
37
- Endpoint.new(path: "/mcp/jwks", method: "GET", metadata: BetterAuth::Plugins.mcp_openapi("legacyMcpJSONWebKeySet", "Get the MCP JSON Web Key Set using the legacy path", "JSON Web Key Set retrieved successfully", BetterAuth::Plugins.mcp_jwks_response_schema)) do |ctx|
38
- ctx.json(jwks(ctx, config))
39
- end
40
- end
41
- end
42
- end
43
- end
@@ -1,81 +0,0 @@
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
@@ -1,31 +0,0 @@
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
@@ -1,37 +0,0 @@
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
@@ -1,91 +0,0 @@
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
- model_name: "oauth_clients",
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, references: {model: "user", field: "id"}},
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, references: {model: "oauthClient", field: "clientId"}},
49
- sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
50
- userId: {type: "string", required: false, references: {model: "user", field: "id"}},
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
- model_name: "oauth_access_tokens",
61
- fields: {
62
- token: {type: "string", unique: true, required: true},
63
- expiresAt: {type: "date", required: true},
64
- clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
65
- userId: {type: "string", required: false, references: {model: "user", field: "id"}},
66
- sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
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, references: {model: "oauthRefreshToken", field: "id"}},
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
- model_name: "oauth_consents",
78
- fields: {
79
- clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
80
- userId: {type: "string", required: false, references: {model: "user", field: "id"}},
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
@@ -1,108 +0,0 @@
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