better_auth 0.4.0 → 0.6.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 +2 -0
- data/README.md +24 -0
- data/lib/better_auth/adapters/internal_adapter.rb +5 -5
- data/lib/better_auth/adapters/sql.rb +96 -18
- data/lib/better_auth/api.rb +113 -13
- data/lib/better_auth/configuration.rb +97 -7
- data/lib/better_auth/context.rb +165 -12
- data/lib/better_auth/cookies.rb +6 -4
- data/lib/better_auth/core.rb +2 -0
- data/lib/better_auth/crypto/jwe.rb +27 -5
- data/lib/better_auth/crypto.rb +32 -0
- data/lib/better_auth/database_hooks.rb +5 -5
- data/lib/better_auth/endpoint.rb +87 -3
- data/lib/better_auth/error.rb +8 -1
- data/lib/better_auth/plugins/admin/schema.rb +2 -2
- data/lib/better_auth/plugins/admin.rb +344 -16
- data/lib/better_auth/plugins/anonymous.rb +37 -3
- data/lib/better_auth/plugins/device_authorization.rb +102 -5
- data/lib/better_auth/plugins/dub.rb +148 -0
- data/lib/better_auth/plugins/email_otp.rb +246 -15
- data/lib/better_auth/plugins/expo.rb +17 -1
- data/lib/better_auth/plugins/generic_oauth.rb +53 -7
- data/lib/better_auth/plugins/jwt.rb +37 -4
- data/lib/better_auth/plugins/last_login_method.rb +2 -2
- data/lib/better_auth/plugins/magic_link.rb +66 -3
- data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
- data/lib/better_auth/plugins/mcp/config.rb +51 -0
- data/lib/better_auth/plugins/mcp/consent.rb +31 -0
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
- data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
- data/lib/better_auth/plugins/mcp/registration.rb +31 -0
- data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
- data/lib/better_auth/plugins/mcp/schema.rb +91 -0
- data/lib/better_auth/plugins/mcp/token.rb +108 -0
- data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
- data/lib/better_auth/plugins/mcp.rb +111 -263
- data/lib/better_auth/plugins/multi_session.rb +61 -3
- data/lib/better_auth/plugins/oauth_protocol.rb +2 -2
- data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
- data/lib/better_auth/plugins/oidc_provider.rb +118 -14
- data/lib/better_auth/plugins/one_tap.rb +7 -2
- data/lib/better_auth/plugins/one_time_token.rb +42 -2
- data/lib/better_auth/plugins/open_api.rb +163 -318
- data/lib/better_auth/plugins/organization.rb +135 -36
- data/lib/better_auth/plugins/phone_number.rb +141 -6
- data/lib/better_auth/plugins/siwe.rb +69 -3
- data/lib/better_auth/plugins/two_factor.rb +65 -23
- data/lib/better_auth/plugins/username.rb +57 -2
- data/lib/better_auth/rate_limiter.rb +20 -0
- data/lib/better_auth/response.rb +42 -0
- data/lib/better_auth/router.rb +7 -1
- data/lib/better_auth/routes/account.rb +204 -38
- data/lib/better_auth/routes/email_verification.rb +98 -14
- data/lib/better_auth/routes/password.rb +125 -8
- data/lib/better_auth/routes/session.rb +128 -13
- data/lib/better_auth/routes/sign_in.rb +24 -2
- data/lib/better_auth/routes/sign_out.rb +13 -1
- data/lib/better_auth/routes/sign_up.rb +62 -4
- data/lib/better_auth/routes/social.rb +102 -7
- data/lib/better_auth/routes/user.rb +222 -20
- data/lib/better_auth/routes/validation.rb +50 -0
- data/lib/better_auth/secret_config.rb +115 -0
- data/lib/better_auth/session.rb +1 -1
- data/lib/better_auth/url_helpers.rb +12 -1
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +4 -0
- metadata +15 -1
|
@@ -103,7 +103,25 @@ module BetterAuth
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
def get_jwks_endpoint(config, path)
|
|
106
|
-
Endpoint.new(
|
|
106
|
+
Endpoint.new(
|
|
107
|
+
path: path,
|
|
108
|
+
method: "GET",
|
|
109
|
+
metadata: {
|
|
110
|
+
openapi: {
|
|
111
|
+
operationId: "getJSONWebKeySet",
|
|
112
|
+
description: "Get the JSON Web Key Set",
|
|
113
|
+
responses: {
|
|
114
|
+
"200" => OpenAPI.json_response(
|
|
115
|
+
"JSON Web Key Set retrieved successfully",
|
|
116
|
+
OpenAPI.object_schema(
|
|
117
|
+
{keys: {type: "array", description: "Array of public JSON Web Keys", items: {type: "object"}}},
|
|
118
|
+
required: ["keys"]
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
) do |ctx|
|
|
107
125
|
raise APIError.new("NOT_FOUND") if config.dig(:jwks, :remote_url)
|
|
108
126
|
|
|
109
127
|
create_jwk(ctx, config) if all_jwks(ctx, config).empty?
|
|
@@ -112,7 +130,22 @@ module BetterAuth
|
|
|
112
130
|
end
|
|
113
131
|
|
|
114
132
|
def get_token_endpoint(config)
|
|
115
|
-
Endpoint.new(
|
|
133
|
+
Endpoint.new(
|
|
134
|
+
path: "/token",
|
|
135
|
+
method: "GET",
|
|
136
|
+
metadata: {
|
|
137
|
+
openapi: {
|
|
138
|
+
operationId: "getJSONWebToken",
|
|
139
|
+
description: "Get a JWT token",
|
|
140
|
+
responses: {
|
|
141
|
+
"200" => OpenAPI.json_response(
|
|
142
|
+
"Success",
|
|
143
|
+
OpenAPI.object_schema({token: {type: "string"}}, required: ["token"])
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
) do |ctx|
|
|
116
149
|
session = Session.find_current(ctx)
|
|
117
150
|
raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"]) unless session
|
|
118
151
|
|
|
@@ -306,12 +339,12 @@ module BetterAuth
|
|
|
306
339
|
def jwk_private_key_for_storage(ctx, private_key, config)
|
|
307
340
|
return private_key if config.dig(:jwks, :disable_private_key_encryption)
|
|
308
341
|
|
|
309
|
-
Crypto.symmetric_encrypt(key: ctx.context.
|
|
342
|
+
Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: private_key)
|
|
310
343
|
end
|
|
311
344
|
|
|
312
345
|
def jwk_private_key_value(ctx, key, _config)
|
|
313
346
|
value = key["privateKey"]
|
|
314
|
-
Crypto.symmetric_decrypt(key: ctx.context.
|
|
347
|
+
Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: value) || value
|
|
315
348
|
end
|
|
316
349
|
|
|
317
350
|
def jwt_payload_valid?(payload)
|
|
@@ -74,8 +74,8 @@ module BetterAuth
|
|
|
74
74
|
case path
|
|
75
75
|
when "/sign-in/email", "/sign-up/email"
|
|
76
76
|
"email"
|
|
77
|
-
when "/callback/:
|
|
78
|
-
fetch_value(ctx.params, "providerId")
|
|
77
|
+
when "/callback/:id"
|
|
78
|
+
fetch_value(ctx.params, "id") || fetch_value(ctx.params, "providerId")
|
|
79
79
|
when "/oauth2/callback/:providerId"
|
|
80
80
|
fetch_value(ctx.params, "providerId")
|
|
81
81
|
else
|
|
@@ -28,7 +28,32 @@ module BetterAuth
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def sign_in_magic_link_endpoint(config)
|
|
31
|
-
Endpoint.new(
|
|
31
|
+
Endpoint.new(
|
|
32
|
+
path: "/sign-in/magic-link",
|
|
33
|
+
method: "POST",
|
|
34
|
+
metadata: {
|
|
35
|
+
openapi: {
|
|
36
|
+
operationId: "signInMagicLink",
|
|
37
|
+
description: "Send a magic sign-in link",
|
|
38
|
+
requestBody: OpenAPI.json_request_body(
|
|
39
|
+
OpenAPI.object_schema(
|
|
40
|
+
{
|
|
41
|
+
email: {type: "string", description: "The email address to sign in"},
|
|
42
|
+
name: {type: ["string", "null"], description: "The user name to use when creating a new user"},
|
|
43
|
+
callbackURL: {type: ["string", "null"]},
|
|
44
|
+
errorCallbackURL: {type: ["string", "null"]},
|
|
45
|
+
newUserCallbackURL: {type: ["string", "null"]},
|
|
46
|
+
metadata: {type: ["object", "null"]}
|
|
47
|
+
},
|
|
48
|
+
required: ["email"]
|
|
49
|
+
)
|
|
50
|
+
),
|
|
51
|
+
responses: {
|
|
52
|
+
"200" => OpenAPI.json_response("Magic link sent", OpenAPI.status_response_schema)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
) do |ctx|
|
|
32
57
|
body = normalize_hash(ctx.body)
|
|
33
58
|
email = body[:email].to_s.downcase
|
|
34
59
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email)
|
|
@@ -51,7 +76,44 @@ module BetterAuth
|
|
|
51
76
|
end
|
|
52
77
|
|
|
53
78
|
def magic_link_verify_endpoint(config)
|
|
54
|
-
Endpoint.new(
|
|
79
|
+
Endpoint.new(
|
|
80
|
+
path: "/magic-link/verify",
|
|
81
|
+
method: "GET",
|
|
82
|
+
metadata: {
|
|
83
|
+
openapi: {
|
|
84
|
+
operationId: "magicLinkVerify",
|
|
85
|
+
description: "Verify a magic link token",
|
|
86
|
+
parameters: [
|
|
87
|
+
{
|
|
88
|
+
name: "token",
|
|
89
|
+
in: "query",
|
|
90
|
+
required: true,
|
|
91
|
+
schema: {type: "string"}
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "callbackURL",
|
|
95
|
+
in: "query",
|
|
96
|
+
required: false,
|
|
97
|
+
schema: {type: "string"}
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
responses: {
|
|
101
|
+
"200" => OpenAPI.json_response(
|
|
102
|
+
"Magic link verified",
|
|
103
|
+
OpenAPI.object_schema(
|
|
104
|
+
{
|
|
105
|
+
token: {type: "string"},
|
|
106
|
+
user: {type: "object", "$ref": "#/components/schemas/User"},
|
|
107
|
+
session: {type: "object", "$ref": "#/components/schemas/Session"}
|
|
108
|
+
},
|
|
109
|
+
required: ["token", "user", "session"]
|
|
110
|
+
)
|
|
111
|
+
),
|
|
112
|
+
"302" => {description: "Redirects to callback URL when callbackURL is provided"}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
) do |ctx|
|
|
55
117
|
query = normalize_hash(ctx.query)
|
|
56
118
|
token = query[:token].to_s
|
|
57
119
|
callback_url = query[:callback_url] || "/"
|
|
@@ -97,7 +159,8 @@ module BetterAuth
|
|
|
97
159
|
user = ctx.context.internal_adapter.create_user(
|
|
98
160
|
email: email,
|
|
99
161
|
emailVerified: true,
|
|
100
|
-
name: name || ""
|
|
162
|
+
name: name || "",
|
|
163
|
+
context: ctx
|
|
101
164
|
)
|
|
102
165
|
new_user = true
|
|
103
166
|
redirect_with_error.call("failed_to_create_user") unless user
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
|
@@ -0,0 +1,39 @@
|
|
|
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") 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") do |ctx|
|
|
16
|
+
authorize(ctx, config)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def legacy_token_endpoint(config)
|
|
21
|
+
Endpoint.new(path: "/mcp/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
|
|
22
|
+
ctx.json(token(ctx, config), headers: no_store_headers)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def legacy_userinfo_endpoint(config)
|
|
27
|
+
Endpoint.new(path: "/mcp/userinfo", method: "GET") do |ctx|
|
|
28
|
+
ctx.json(userinfo(ctx, config))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def legacy_jwks_endpoint(config)
|
|
33
|
+
Endpoint.new(path: "/mcp/jwks", method: "GET") do |ctx|
|
|
34
|
+
ctx.json(jwks(ctx, config))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|