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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -0
- data/LICENSE.md +1 -1
- data/README.md +138 -91
- data/lib/better_auth/adapters/base.rb +64 -1
- data/lib/better_auth/adapters/internal_adapter.rb +197 -11
- data/lib/better_auth/adapters/join_support.rb +10 -0
- data/lib/better_auth/adapters/memory.rb +191 -56
- data/lib/better_auth/adapters/mssql.rb +7 -3
- data/lib/better_auth/adapters/sql.rb +294 -49
- data/lib/better_auth/api.rb +3 -24
- data/lib/better_auth/configuration.rb +41 -46
- data/lib/better_auth/context.rb +43 -67
- data/lib/better_auth/cookies.rb +110 -14
- data/lib/better_auth/core.rb +0 -2
- data/lib/better_auth/crypto.rb +4 -3
- data/lib/better_auth/doctor.rb +14 -3
- data/lib/better_auth/error.rb +3 -1
- data/lib/better_auth/logger.rb +1 -1
- data/lib/better_auth/middleware/origin_check.rb +9 -1
- data/lib/better_auth/migration_plan.rb +4 -4
- data/lib/better_auth/oauth_state.rb +147 -0
- data/lib/better_auth/plugin_loader.rb +220 -0
- data/lib/better_auth/plugins/access.rb +10 -13
- data/lib/better_auth/plugins/admin.rb +79 -28
- data/lib/better_auth/plugins/api_key.rb +4 -7
- data/lib/better_auth/plugins/bearer.rb +8 -22
- data/lib/better_auth/plugins/captcha.rb +24 -6
- data/lib/better_auth/plugins/device_authorization.rb +47 -9
- data/lib/better_auth/plugins/dub.rb +2 -2
- data/lib/better_auth/plugins/email_otp.rb +30 -47
- data/lib/better_auth/plugins/generic_oauth.rb +141 -146
- data/lib/better_auth/plugins/i18n.rb +215 -0
- data/lib/better_auth/plugins/jwt.rb +37 -19
- data/lib/better_auth/plugins/magic_link.rb +18 -21
- data/lib/better_auth/plugins/oauth_popup.rb +330 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
- data/lib/better_auth/plugins/oauth_provider.rb +4 -7
- data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
- data/lib/better_auth/plugins/one_tap.rb +61 -60
- data/lib/better_auth/plugins/one_time_token.rb +1 -4
- data/lib/better_auth/plugins/open_api.rb +35 -7
- data/lib/better_auth/plugins/organization.rb +455 -122
- data/lib/better_auth/plugins/passkey.rb +4 -7
- data/lib/better_auth/plugins/phone_number.rb +24 -26
- data/lib/better_auth/plugins/scim.rb +4 -7
- data/lib/better_auth/plugins/siwe.rb +126 -16
- data/lib/better_auth/plugins/sso.rb +4 -7
- data/lib/better_auth/plugins/stripe.rb +4 -7
- data/lib/better_auth/plugins/two_factor.rb +186 -21
- data/lib/better_auth/plugins/username.rb +56 -13
- data/lib/better_auth/plugins.rb +57 -0
- data/lib/better_auth/rate_limiter.rb +297 -135
- data/lib/better_auth/request_ip.rb +123 -13
- data/lib/better_auth/router.rb +3 -3
- data/lib/better_auth/routes/account.rb +40 -8
- data/lib/better_auth/routes/email_verification.rb +88 -5
- data/lib/better_auth/routes/password.rb +5 -6
- data/lib/better_auth/routes/session.rb +7 -2
- data/lib/better_auth/routes/sign_in.rb +10 -1
- data/lib/better_auth/routes/sign_up.rb +45 -22
- data/lib/better_auth/routes/social.rb +169 -54
- data/lib/better_auth/routes/user.rb +11 -8
- data/lib/better_auth/schema/sql.rb +90 -24
- data/lib/better_auth/schema.rb +124 -3
- data/lib/better_auth/session.rb +27 -9
- data/lib/better_auth/session_store.rb +1 -1
- data/lib/better_auth/social_providers/apple.rb +6 -1
- data/lib/better_auth/social_providers/atlassian.rb +1 -0
- data/lib/better_auth/social_providers/base.rb +41 -14
- data/lib/better_auth/social_providers/cognito.rb +75 -5
- data/lib/better_auth/social_providers/facebook.rb +95 -1
- data/lib/better_auth/social_providers/figma.rb +2 -0
- data/lib/better_auth/social_providers/google.rb +12 -1
- data/lib/better_auth/social_providers/line.rb +35 -1
- data/lib/better_auth/social_providers/linear.rb +1 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
- data/lib/better_auth/social_providers/naver.rb +24 -1
- data/lib/better_auth/social_providers/notion.rb +1 -0
- data/lib/better_auth/social_providers/paybin.rb +25 -1
- data/lib/better_auth/social_providers/paypal.rb +118 -2
- data/lib/better_auth/social_providers/reddit.rb +25 -4
- data/lib/better_auth/social_providers/salesforce.rb +1 -0
- data/lib/better_auth/social_providers/twitch.rb +24 -1
- data/lib/better_auth/social_providers/twitter.rb +36 -1
- data/lib/better_auth/social_providers/vk.rb +34 -1
- data/lib/better_auth/social_providers/wechat.rb +6 -4
- data/lib/better_auth/sql_migration.rb +5 -5
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +11 -41
- metadata +5 -13
- data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
- data/lib/better_auth/plugins/mcp/config.rb +0 -51
- data/lib/better_auth/plugins/mcp/consent.rb +0 -31
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
- data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
- data/lib/better_auth/plugins/mcp/registration.rb +0 -31
- data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
- data/lib/better_auth/plugins/mcp/schema.rb +0 -91
- data/lib/better_auth/plugins/mcp/token.rb +0 -108
- data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
- data/lib/better_auth/plugins/mcp.rb +0 -256
- 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.
|
|
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
|