Skip to content

Commit 6c83842

Browse files
committed
Adds authorization handling mechanisms
1 parent 9535a06 commit 6c83842

File tree

7 files changed

+281
-30
lines changed

7 files changed

+281
-30
lines changed

lib/mcp.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
require_relative "mcp/auth/server/providers/mcp_authorization_server_provider"
3131
require_relative "mcp/auth/server/handlers/metadata_handler"
3232
require_relative "mcp/auth/server/handlers/registration_handler"
33+
require_relative "mcp/auth/server/handlers/authorization_handler"
3334

3435
module MCP
3536
class << self

lib/mcp/auth/errors.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ class InvalidGrantsError < StandardError; end
99

1010
class InvalidRedirectUriError < StandardError; end
1111

12+
class MissingClientIdError < StandardError; end
13+
1214
class RegistrationError < StandardError
1315
INVALID_REDIRECT_URI = "invalid_redirect_uri"
1416
INVALID_CLIENT_METADATA = "invalid_client_metadata"
@@ -38,6 +40,12 @@ def initialize(error_code:, message: nil)
3840
super(message)
3941
@error_code = error_code
4042
end
43+
44+
class << self
45+
def invalid_request(message)
46+
AuthorizationError.new(error_code: INVALID_REQUEST, message:)
47+
end
48+
end
4149
end
4250

4351
class TokenError < StandardError

lib/mcp/auth/models.rb

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -115,33 +115,22 @@ def initialize(
115115
@software_version = software_version
116116
end
117117

118-
def validate_scope(requested_scope)
119-
return if requested_scope.nil? || requested_scope.empty?
120-
121-
requested_scopes = requested_scope.split(" ")
118+
def validate_scopes!(requested_scopes)
122119
allowed_scopes = @scope.nil? ? [] : @scope.split(" ")
123120

124121
requested_scopes.each do |s|
125122
unless allowed_scopes.include?(s)
126123
raise Errors::InvalidScopeError, "Client was not registered with scope '#{s}'"
127124
end
128125
end
129-
130-
requested_scopes
131126
end
132127

133-
def validate_redirect_uri(redirect_uri)
134-
if redirect_uri
135-
unless @redirect_uris.include?(redirect_uri)
136-
raise Errors::InvalidRedirectUriError, "Redirect URI '#{redirect_uri}' not registered for client"
137-
end
128+
def valid_redirect_uri?(redirect_uri)
129+
@redirect_uris.include?(redirect_uri)
130+
end
138131

139-
redirect_uri_str
140-
elsif @redirect_uris.one?
141-
@redirect_uris.first
142-
else
143-
raise Errors::InvalidRedirectUriError, "redirect_uri must be specified when client has multiple registered URIs"
144-
end
132+
def multiple_redirect_uris?
133+
@redirect_uris.size > 1
145134
end
146135
end
147136

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../../errors"
4+
require_relative "../../server/provider"
5+
6+
module MCP
7+
module Auth
8+
module Server
9+
module Handlers
10+
class AuthorizationRequest
11+
attr_reader :client_id,
12+
:redirect_uri,
13+
:code_challenge_method,
14+
:code_challenge,
15+
:response_type,
16+
:state,
17+
:scope
18+
19+
def initialize(
20+
client_id: nil,
21+
redirect_uri: nil,
22+
code_challenge_method: nli,
23+
response_type: nil,
24+
code_challenge: nil,
25+
state: nil,
26+
scope: nil
27+
)
28+
if client_id.nil?
29+
raise Errors::AuthorizationError.invalid_request("client_id must be defined")
30+
end
31+
32+
if response_type != "code"
33+
raise Errors::AuthorizationError.new(
34+
error_code: Errors::AuthorizationError::UNSUPPORTED_RESPONSE_TYPE,
35+
message: "response_type must be 'code'",
36+
)
37+
end
38+
39+
if code_challenge_method != "S256"
40+
raise Errors::AuthorizationError.invalid_request("code_challenge_method must be 'S256'")
41+
end
42+
43+
if code_challenge.nil?
44+
raise Errors::AuthorizationError.invalid_request("code_challenge must be defined")
45+
end
46+
47+
@client_id = client_id
48+
@code_challenge = code_challenge
49+
@code_challenge_method = code_challenge_method
50+
@response_type = response_type
51+
@redirect_uri = redirect_uri
52+
@state = state
53+
@scope = scope
54+
end
55+
56+
def scopes_array
57+
return [] if @scope.nil?
58+
59+
@scope.split(" ")
60+
end
61+
62+
def redirect_uri_provided?
63+
!@redirect_uri.nil?
64+
end
65+
end
66+
67+
class AuthorizationHandler
68+
def initialize(
69+
auth_server_provider:,
70+
request_parser:
71+
)
72+
@auth_server_provider = auth_server_provider
73+
@request_parser = request_parser
74+
end
75+
76+
def handle(request)
77+
# implements authorization requests for grant_type=code;
78+
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
79+
# For error handling, refer to https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
80+
params_h = as_params_h(request) || {}
81+
client_info, redirect_uri, auth_error = get_client_and_redirect_uri(params_h)
82+
return bad_request_error(params_h:, auth_error:) if auth_error
83+
84+
begin
85+
auth_request = AuthorizationRequest.new(**params_h)
86+
scopes = auth_request.scopes_array
87+
client_info.validate_scopes!(scopes)
88+
89+
auth_params = AuthorizationParams.new(
90+
state: auth_request.state,
91+
scopes:,
92+
code_challenge: auth_request.code_challenge,
93+
redirect_uri:,
94+
redirect_uri_provided_explicitly: auth_request.redirect_uri_provided?,
95+
response_type: auth_request.response_type,
96+
)
97+
98+
location = @auth_server_provider.authorize(client_info:, auth_params:)
99+
headers = {
100+
"Cache-Control": "no-store",
101+
"Location": location,
102+
}
103+
[302, headers, nil]
104+
rescue => e
105+
error_code = case e
106+
in Errors::InvalidScopeError then Errors::AuthorizationError::INVALID_SCOPE
107+
in Errors::AuthorizationError then e.error_code
108+
else
109+
Errors::AuthorizationError::SERVER_ERROR
110+
end
111+
112+
redirect_response_error(
113+
redirect_uri:,
114+
error_code:,
115+
error_description: e.message,
116+
params_h:,
117+
)
118+
end
119+
end
120+
121+
private
122+
123+
def as_params_h(request)
124+
@request_parser.get?(request) ? @request_parser.parse_query_params(request) : @request_parser.parse_body(request)
125+
end
126+
127+
# Validates the client_id and redirect_uri parameters from the authorization request.
128+
# Returns a tuple of [client_info, redirect_uri, error] where:
129+
# - client_info is the OAuthClientInformationFull for the client if found and valid
130+
# - redirect_uri is a string derived from the params and client
131+
# - error is an AuthorizationError if validation fails, nil otherwise
132+
#
133+
# @param params_h [Hash] The authorization request parameters
134+
# @return [OAuthClientInformationFull, String, AuthorizationError] Tuple of client_info, redirect_uri, error
135+
def get_client_and_redirect_uri(params_h)
136+
client_id = params_h[:client_id]
137+
if client_id.nil?
138+
return [nil, nil, Errors::AuthorizationError.invalid_request("client_id must be defined")]
139+
end
140+
141+
client_info = @auth_server_provider.get_client(client_id)
142+
if client_info.nil?
143+
return [nil, nil, Errors::AuthorizationError.invalid_request("client '#{client_id}' not found")]
144+
end
145+
146+
redirect_uri = params_h[:redirect_uri]
147+
if client_info.multiple_redirect_uris? && redirect_uri.nil?
148+
return [nil, nil, Errors::AuthorizationError.invalid_request("redirect_uri must be defined because client defines multiple options")]
149+
end
150+
151+
redirect_uri ||= client_info.redirect_uris.first
152+
if redirect_uri.nil?
153+
return [
154+
nil,
155+
nil,
156+
Errors::AuthorizationError.new(
157+
error_code: Errors::AuthorizationError::SERVER_ERROR, message: "unable to select a redirect_uri",
158+
),
159+
]
160+
end
161+
162+
unless client_info.valid_redirect_uri?(redirect_uri)
163+
return [client_info, nil, Errors::AuthorizationError.invalid_request("invalid redirect_uri")]
164+
end
165+
166+
[client_info, redirect_uri, nil]
167+
end
168+
169+
def redirect_response_error(
170+
redirect_uri:,
171+
error_code:,
172+
error_description:,
173+
params_h:
174+
)
175+
end
176+
177+
def bad_request_error(
178+
params_h:,
179+
auth_error:
180+
)
181+
body = { error: auth_error.error_code, error_description: auth_error.message }
182+
if params_h[:state]
183+
body[:state] = params_h[:state]
184+
end
185+
186+
[400, { "Cache-Control": "no-store" }, body]
187+
end
188+
end
189+
end
190+
end
191+
end
192+
end

lib/mcp/auth/server/provider.rb

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,21 @@ class AuthorizationParams
1010
:scopes,
1111
:code_challenge,
1212
:redirect_uri,
13-
:redirect_uri_provided_explicitly
13+
:redirect_uri_provided_explicitly,
14+
:response_type
1415

1516
def initialize(
16-
state: nil,
17-
scopes: nil,
1817
code_challenge:,
1918
redirect_uri:,
20-
redirect_uri_provided_explicitly:
19+
redirect_uri_provided_explicitly:,
20+
response_type:,
21+
state: nil,
22+
scopes: nil
2123
)
2224
@state = state
2325
@scopes = scopes
2426
@code_challenge = code_challenge
27+
@response_type = response_type
2528
@redirect_uri = redirect_uri
2629
@redirect_uri_provided_explicitly = redirect_uri_provided_explicitly
2730
end
@@ -93,7 +96,7 @@ def initialize(
9396
end
9497
end
9598

96-
class OAuthAuthorizationServerProvider
99+
module OAuthAuthorizationServerProvider
97100
# Retrieves client information by client ID.
98101
# Implementors MAY raise NotImplementedError if dynamic client registration is
99102
# disabled in ClientRegistrationOptions.
@@ -142,11 +145,11 @@ def register_client(client_info)
142145
# entropy, and MUST generate an authorization code with at least 128 bits of entropy.
143146
# See https://datatracker.ietf.org/doc/html/rfc6749#section-10.10.
144147
#
145-
# @param client [MCP::Auth::Models::OAuthClientInformationFull] The client requesting authorization.
146-
# @param params [MCP::Auth::Server::AuthorizationParams] The parameters of the authorization request.
148+
# @param client_info [MCP::Auth::Models::OAuthClientInformationFull] The client requesting authorization.
149+
# @param params [MCP::Auth::Server::AuthorizationParams] The parameters of the authorization request.
147150
# @return [String] A URL to redirect the client to for authorization.
148151
# @raise [MCP::Auth::Errors::AuthorizeError] If the authorization request is invalid.
149-
def authorize(client_info, params)
152+
def authorize(client_info:, auth_params:)
150153
raise NotImplementedError, "#{self.class.name}#authorize is not implemented"
151154
end
152155

lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,50 @@
11
# frozen_string_literal: true
22

3+
require "securerandom"
4+
require_relative "../../../serialization_utils"
35
require_relative "../client_registry"
46
require_relative "../state_registry"
7+
require_relative "../provider"
58

69
module MCP
710
module Auth
811
module Server
912
module Providers
10-
class McpAuthorizationServerProvider < OAuthAuthorizationServerProvider
13+
class McpAuthServerSettings
14+
attr_reader :client_id,
15+
:client_secret,
16+
:auth_server_scopes,
17+
:auth_server_authorization_endpoint,
18+
:auth_server_token_endpoint,
19+
:mcp_callback_endpoint
20+
21+
def initialize(
22+
client_id:,
23+
client_secret:,
24+
auth_server_scopes:,
25+
auth_server_authorization_endpoint:,
26+
auth_server_token_endpoint:,
27+
mcp_callback_endpoint:
28+
)
29+
@client_id = client_id
30+
@client_secret = client_secret
31+
@auth_server_scopes = auth_server_scopes
32+
@auth_server_authorization_endpoint = auth_server_authorization_endpoint
33+
@auth_server_token_endpoint = auth_server_token_endpoint
34+
@mcp_callback_endpoint = mcp_callback_endpoint
35+
end
36+
end
37+
38+
class McpAuthorizationServerProvider
39+
include OAuthAuthorizationServerProvider
40+
include SerializationUtils
41+
1142
def initialize(
43+
auth_server_settings:,
1244
client_registry: nil,
1345
state_registry: nil
1446
)
47+
@settings = auth_server_settings
1548
@client_registry = client_registry || InMemoryClientRegistry.new
1649
@state_registry = state_registry || InMemoryStateRegistry.new
1750
end
@@ -23,6 +56,23 @@ def get_client(client_id)
2356
def register_client(client_info)
2457
@client_registry.create(client_info)
2558
end
59+
60+
def authorize(client_info:, auth_params:)
61+
state = auth_params.state || SecureRandom.hex(16)
62+
63+
@state_registry.create(state, to_h(auth_params))
64+
65+
auth_url = URI(@settings.auth_server_authorization_endpoint)
66+
auth_url.query = URI.encode_www_form([
67+
["client_id", @settings.client_id],
68+
["redirect_uri", @settings.mcp_callback_endpoint],
69+
["scope", @settings.auth_server_scopes],
70+
["state", state],
71+
["response_type", auth_params.response_type],
72+
])
73+
74+
auth_url.to_s
75+
end
2676
end
2777
end
2878
end

0 commit comments

Comments
 (0)