better_auth 0.8.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  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 +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  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 +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
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.8.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -281,17 +281,22 @@ files:
281
281
  - lib/better_auth/crypto/jwe.rb
282
282
  - lib/better_auth/database_hooks.rb
283
283
  - lib/better_auth/deprecate.rb
284
+ - lib/better_auth/doctor.rb
284
285
  - lib/better_auth/endpoint.rb
285
286
  - lib/better_auth/env.rb
286
287
  - lib/better_auth/error.rb
287
288
  - lib/better_auth/host.rb
289
+ - lib/better_auth/http_client.rb
288
290
  - lib/better_auth/instrumentation.rb
289
291
  - lib/better_auth/logger.rb
290
292
  - lib/better_auth/middleware/origin_check.rb
293
+ - lib/better_auth/migration_plan.rb
291
294
  - lib/better_auth/oauth2.rb
295
+ - lib/better_auth/oauth_state.rb
292
296
  - lib/better_auth/password.rb
293
297
  - lib/better_auth/plugin.rb
294
298
  - lib/better_auth/plugin_context.rb
299
+ - lib/better_auth/plugin_loader.rb
295
300
  - lib/better_auth/plugin_registry.rb
296
301
  - lib/better_auth/plugins.rb
297
302
  - lib/better_auth/plugins/access.rb
@@ -309,25 +314,15 @@ files:
309
314
  - lib/better_auth/plugins/expo.rb
310
315
  - lib/better_auth/plugins/generic_oauth.rb
311
316
  - lib/better_auth/plugins/have_i_been_pwned.rb
317
+ - lib/better_auth/plugins/i18n.rb
312
318
  - lib/better_auth/plugins/jwt.rb
313
319
  - lib/better_auth/plugins/last_login_method.rb
314
320
  - lib/better_auth/plugins/magic_link.rb
315
- - lib/better_auth/plugins/mcp.rb
316
- - lib/better_auth/plugins/mcp/authorization.rb
317
- - lib/better_auth/plugins/mcp/config.rb
318
- - lib/better_auth/plugins/mcp/consent.rb
319
- - lib/better_auth/plugins/mcp/legacy_aliases.rb
320
- - lib/better_auth/plugins/mcp/metadata.rb
321
- - lib/better_auth/plugins/mcp/registration.rb
322
- - lib/better_auth/plugins/mcp/resource_handler.rb
323
- - lib/better_auth/plugins/mcp/schema.rb
324
- - lib/better_auth/plugins/mcp/token.rb
325
- - lib/better_auth/plugins/mcp/userinfo.rb
326
321
  - lib/better_auth/plugins/multi_session.rb
322
+ - lib/better_auth/plugins/oauth_popup.rb
327
323
  - lib/better_auth/plugins/oauth_protocol.rb
328
324
  - lib/better_auth/plugins/oauth_provider.rb
329
325
  - lib/better_auth/plugins/oauth_proxy.rb
330
- - lib/better_auth/plugins/oidc_provider.rb
331
326
  - lib/better_auth/plugins/one_tap.rb
332
327
  - lib/better_auth/plugins/one_time_token.rb
333
328
  - lib/better_auth/plugins/open_api.rb
@@ -400,16 +395,17 @@ files:
400
395
  - lib/better_auth/social_providers/vk.rb
401
396
  - lib/better_auth/social_providers/wechat.rb
402
397
  - lib/better_auth/social_providers/zoom.rb
398
+ - lib/better_auth/sql_migration.rb
403
399
  - lib/better_auth/url_helpers.rb
404
400
  - lib/better_auth/version.rb
405
- homepage: https://github.com/sebasxsala/better-auth
401
+ homepage: https://github.com/sebasxsala/better-auth-rb
406
402
  licenses:
407
403
  - MIT
408
404
  metadata:
409
- homepage_uri: https://github.com/sebasxsala/better-auth
410
- source_code_uri: https://github.com/sebasxsala/better-auth
411
- changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth/CHANGELOG.md
412
- bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
405
+ homepage_uri: https://github.com/sebasxsala/better-auth-rb
406
+ source_code_uri: https://github.com/sebasxsala/better-auth-rb
407
+ changelog_uri: https://github.com/sebasxsala/better-auth-rb/blob/main/packages/better_auth/CHANGELOG.md
408
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth-rb/issues
413
409
  rdoc_options: []
414
410
  require_paths:
415
411
  - lib
@@ -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
- 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
@@ -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