mcp-auth 0.1.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +107 -0
- data/LICENSE.txt +21 -0
- data/README.md +869 -0
- data/Rakefile +8 -0
- data/app/controllers/mcp/auth/oauth_controller.rb +494 -0
- data/app/controllers/mcp/auth/well_known_controller.rb +147 -0
- data/app/models/mcp/auth/access_token.rb +30 -0
- data/app/models/mcp/auth/authorization_code.rb +33 -0
- data/app/models/mcp/auth/oauth_client.rb +60 -0
- data/app/models/mcp/auth/refresh_token.rb +32 -0
- data/app/views/mcp/auth/consent.html.erb +527 -0
- data/config/routes.rb +43 -0
- data/lib/generators/mcp/auth/install_generator.rb +80 -0
- data/lib/generators/mcp/auth/templates/README +114 -0
- data/lib/generators/mcp/auth/templates/create_access_tokens.rb.erb +23 -0
- data/lib/generators/mcp/auth/templates/create_authorization_codes.rb.erb +26 -0
- data/lib/generators/mcp/auth/templates/create_oauth_clients.rb.erb +22 -0
- data/lib/generators/mcp/auth/templates/create_refresh_tokens.rb.erb +22 -0
- data/lib/generators/mcp/auth/templates/initializer.rb +199 -0
- data/lib/generators/mcp/auth/templates/views/consent.html.erb +527 -0
- data/lib/mcp/auth/engine.rb +32 -0
- data/lib/mcp/auth/scope_registry.rb +113 -0
- data/lib/mcp/auth/services/authorization_service.rb +102 -0
- data/lib/mcp/auth/services/token_service.rb +230 -0
- data/lib/mcp/auth/version.rb +7 -0
- data/lib/mcp/auth.rb +109 -0
- data/lib/tasks/mcp_auth_tasks.rake +89 -0
- metadata +254 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcp
|
|
4
|
+
module Auth
|
|
5
|
+
class OauthController < ApplicationController
|
|
6
|
+
skip_before_action :verify_authenticity_token, only: %i[token register revoke introspect userinfo]
|
|
7
|
+
before_action :set_cors_headers
|
|
8
|
+
before_action :handle_options_request
|
|
9
|
+
before_action :require_https, only: %i[authorize token]
|
|
10
|
+
|
|
11
|
+
# OAuth 2.1 Authorization endpoint (GET/POST)
|
|
12
|
+
def authorize
|
|
13
|
+
Rails.logger.info "[OAuth] Authorization request: #{params.inspect}"
|
|
14
|
+
|
|
15
|
+
unless valid_authorization_params?
|
|
16
|
+
return render_error('invalid_request', 'Missing or invalid required parameters')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if user_signed_in?
|
|
20
|
+
handle_signed_in_user
|
|
21
|
+
else
|
|
22
|
+
redirect_to_login
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Consent approval endpoint
|
|
27
|
+
def approve
|
|
28
|
+
unless user_signed_in?
|
|
29
|
+
return redirect_to main_app.new_user_session_path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
unless valid_authorization_params?
|
|
33
|
+
return render_error('invalid_request', 'Missing required parameters')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if params[:approved] == 'true'
|
|
37
|
+
# Get selected scopes from checkboxes
|
|
38
|
+
selected_scopes = Array(params[:scopes]).compact.reject(&:blank?)
|
|
39
|
+
|
|
40
|
+
Rails.logger.info "[OAuth] User selected scopes: #{selected_scopes.inspect}"
|
|
41
|
+
|
|
42
|
+
# Validate selected scopes
|
|
43
|
+
if selected_scopes.blank?
|
|
44
|
+
Rails.logger.warn "[OAuth] No scopes selected"
|
|
45
|
+
return render_error('invalid_request', 'At least one scope must be selected')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Get originally requested scopes
|
|
49
|
+
requested_scopes = params[:scope]&.split || []
|
|
50
|
+
|
|
51
|
+
# Get required scopes from the requested list
|
|
52
|
+
required_scopes = get_required_scopes(requested_scopes)
|
|
53
|
+
|
|
54
|
+
# Check all required scopes are selected
|
|
55
|
+
missing_required = required_scopes - selected_scopes
|
|
56
|
+
if missing_required.any?
|
|
57
|
+
Rails.logger.warn "[OAuth] Missing required scopes: #{missing_required.join(', ')}"
|
|
58
|
+
return render_error('invalid_request', 'Required scopes must be selected')
|
|
59
|
+
end
|
|
60
|
+
approved_scopes = Mcp::Auth::ScopeRegistry.validate_scopes(selected_scopes)
|
|
61
|
+
# Generate authorization code with ONLY approved scopes
|
|
62
|
+
approved_scope_string = approved_scopes.join(' ')
|
|
63
|
+
generate_and_redirect_with_code(approved_scope_string)
|
|
64
|
+
else
|
|
65
|
+
redirect_with_error('access_denied', 'User denied the request')
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# OAuth 2.1 Token endpoint
|
|
70
|
+
def token
|
|
71
|
+
case params[:grant_type]
|
|
72
|
+
when 'authorization_code'
|
|
73
|
+
handle_authorization_code_grant
|
|
74
|
+
when 'refresh_token'
|
|
75
|
+
handle_refresh_token_grant
|
|
76
|
+
else
|
|
77
|
+
render_error('unsupported_grant_type', 'Grant type not supported')
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# RFC 7591: Dynamic Client Registration
|
|
82
|
+
def register
|
|
83
|
+
Rails.logger.info "[OAuth] Client registration request"
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
client_data = build_client_registration
|
|
87
|
+
oauth_client = Mcp::Auth::OauthClient.create!(client_data)
|
|
88
|
+
|
|
89
|
+
Rails.logger.info "[OAuth] Client registered: #{oauth_client.client_id}"
|
|
90
|
+
render json: format_client_response(oauth_client), content_type: 'application/json'
|
|
91
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
92
|
+
render_error('invalid_client_metadata', e.message)
|
|
93
|
+
rescue ArgumentError => e
|
|
94
|
+
render_error('invalid_request', e.message)
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
Rails.logger.error "[OAuth] Registration error: #{e.message}"
|
|
97
|
+
render_error('server_error', 'An unexpected error occurred')
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# RFC 7009: Token Revocation
|
|
102
|
+
def revoke
|
|
103
|
+
token = params[:token]
|
|
104
|
+
|
|
105
|
+
if token.blank?
|
|
106
|
+
return render_error('invalid_request', 'Token parameter is required')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Try refresh token first
|
|
110
|
+
revoked = Services::TokenService.revoke_refresh_token(token)
|
|
111
|
+
|
|
112
|
+
# Try access token if not found
|
|
113
|
+
unless revoked
|
|
114
|
+
access_token = Mcp::Auth::AccessToken.find_by(token: token)
|
|
115
|
+
if access_token
|
|
116
|
+
access_token.destroy
|
|
117
|
+
revoked = true
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# RFC 7009: Always return 200 OK
|
|
122
|
+
Rails.logger.info "[OAuth] Token revocation: #{revoked ? 'success' : 'not found'}"
|
|
123
|
+
head :ok
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# RFC 7662: Token Introspection
|
|
127
|
+
def introspect
|
|
128
|
+
token = params[:token]
|
|
129
|
+
|
|
130
|
+
if token.blank?
|
|
131
|
+
return render json: { active: false }, content_type: 'application/json'
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Try as JWT access token
|
|
135
|
+
payload = Services::TokenService.validate_access_token(token)
|
|
136
|
+
|
|
137
|
+
response = if payload
|
|
138
|
+
build_access_token_introspection(payload)
|
|
139
|
+
else
|
|
140
|
+
# Try as refresh token
|
|
141
|
+
refresh_data = Services::TokenService.validate_refresh_token(token)
|
|
142
|
+
refresh_data ? build_refresh_token_introspection(refresh_data) : { active: false }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
render json: response, content_type: 'application/json'
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# OpenID Connect UserInfo Endpoint
|
|
149
|
+
def userinfo
|
|
150
|
+
auth_header = request.headers['Authorization']
|
|
151
|
+
|
|
152
|
+
unless auth_header&.start_with?('Bearer ')
|
|
153
|
+
return render json: { error: 'invalid_token' }, status: :unauthorized
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
token = auth_header.split(' ', 2).last
|
|
157
|
+
payload = Services::TokenService.validate_access_token(token)
|
|
158
|
+
|
|
159
|
+
unless payload
|
|
160
|
+
return render json: { error: 'invalid_token' }, status: :unauthorized
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
user_info = {
|
|
164
|
+
sub: payload[:sub],
|
|
165
|
+
email: payload[:email],
|
|
166
|
+
email_verified: true,
|
|
167
|
+
name: payload[:email],
|
|
168
|
+
preferred_username: payload[:email]
|
|
169
|
+
}
|
|
170
|
+
user_info[:org] = payload[:org] if payload[:org]
|
|
171
|
+
|
|
172
|
+
render json: user_info, content_type: 'application/json'
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
# === Validation ===
|
|
178
|
+
|
|
179
|
+
def valid_authorization_params?
|
|
180
|
+
params[:response_type] == 'code' &&
|
|
181
|
+
params[:client_id].present? &&
|
|
182
|
+
params[:redirect_uri].present? &&
|
|
183
|
+
params[:code_challenge].present? &&
|
|
184
|
+
params[:code_challenge_method] == 'S256'
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# === Authorization Flow ===
|
|
188
|
+
|
|
189
|
+
def handle_signed_in_user
|
|
190
|
+
if params[:approved] == 'true'
|
|
191
|
+
generate_and_redirect_with_code
|
|
192
|
+
else
|
|
193
|
+
show_consent_screen
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def redirect_to_login
|
|
198
|
+
session[:oauth_params] = request.query_parameters
|
|
199
|
+
redirect_to main_app.new_user_session_path
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def generate_and_redirect_with_code(approved_scope = nil)
|
|
203
|
+
# Use approved scopes if provided, otherwise use requested scopes,
|
|
204
|
+
# otherwise use all registered scopes
|
|
205
|
+
final_scope = approved_scope.presence ||
|
|
206
|
+
params[:scope].presence ||
|
|
207
|
+
Mcp::Auth::ScopeRegistry.default_scope_string
|
|
208
|
+
|
|
209
|
+
Rails.logger.info "[OAuth] Generating auth code with scope: #{final_scope}"
|
|
210
|
+
|
|
211
|
+
# Create a new params hash with the final scope
|
|
212
|
+
auth_params = params.to_unsafe_h.merge(scope: final_scope)
|
|
213
|
+
|
|
214
|
+
# Pass the params with approved scope to authorization service
|
|
215
|
+
code = Services::AuthorizationService.generate_authorization_code(
|
|
216
|
+
auth_params,
|
|
217
|
+
user: current_user,
|
|
218
|
+
org: current_org
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
unless code
|
|
222
|
+
return render_error('server_error', 'Failed to generate authorization code')
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
redirect_with_code(code)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def redirect_with_code(code)
|
|
229
|
+
redirect_uri = URI.parse(params[:redirect_uri])
|
|
230
|
+
query_params = { code: code }
|
|
231
|
+
query_params[:state] = params[:state] if params[:state]
|
|
232
|
+
query_params[:iss] = authorization_server_url # OAuth 2.1
|
|
233
|
+
|
|
234
|
+
redirect_uri.query = URI.encode_www_form(query_params)
|
|
235
|
+
redirect_to redirect_uri.to_s, allow_other_host: true
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def redirect_with_error(error, description)
|
|
239
|
+
redirect_uri = URI.parse(params[:redirect_uri])
|
|
240
|
+
query_params = { error: error, error_description: description }
|
|
241
|
+
query_params[:state] = params[:state] if params[:state]
|
|
242
|
+
|
|
243
|
+
redirect_uri.query = URI.encode_www_form(query_params)
|
|
244
|
+
redirect_to redirect_uri.to_s, allow_other_host: true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# === Token Grants ===
|
|
248
|
+
|
|
249
|
+
def handle_authorization_code_grant
|
|
250
|
+
code_data = Services::AuthorizationService.validate_authorization_code(params[:code])
|
|
251
|
+
|
|
252
|
+
unless code_data
|
|
253
|
+
return render_error('invalid_grant', 'Authorization code is invalid or expired')
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Validate PKCE
|
|
257
|
+
unless Services::AuthorizationService.validate_pkce?(code_data[:code_challenge], params[:code_verifier])
|
|
258
|
+
return render_error('invalid_grant', 'PKCE validation failed')
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Validate redirect URI
|
|
262
|
+
unless code_data[:redirect_uri] == params[:redirect_uri]
|
|
263
|
+
return render_error('invalid_grant', 'Redirect URI mismatch')
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Use the APPROVED scope from the authorization code, not the original request
|
|
267
|
+
Rails.logger.info "[OAuth] Token generation using scope from auth code: #{code_data[:scope]}"
|
|
268
|
+
|
|
269
|
+
# Generate tokens with the APPROVED scope from authorization code
|
|
270
|
+
token_data = code_data.merge(resource: code_data[:resource] || params[:resource])
|
|
271
|
+
token_response = Services::TokenService.generate_token_response(
|
|
272
|
+
token_data, # This includes the approved :scope from authorization code
|
|
273
|
+
base_url: request.base_url
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Consume authorization code (one-time use)
|
|
277
|
+
Services::AuthorizationService.consume_authorization_code(params[:code])
|
|
278
|
+
|
|
279
|
+
render json: token_response, content_type: 'application/json'
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def handle_refresh_token_grant
|
|
283
|
+
token_data = Services::TokenService.validate_refresh_token(params[:refresh_token])
|
|
284
|
+
|
|
285
|
+
unless token_data
|
|
286
|
+
return render_error('invalid_grant', 'Refresh token is invalid or expired')
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Include resource parameter if provided
|
|
290
|
+
token_data[:resource] = params[:resource] if params[:resource]
|
|
291
|
+
|
|
292
|
+
# Generate new tokens
|
|
293
|
+
token_response = Services::TokenService.generate_token_response(
|
|
294
|
+
token_data,
|
|
295
|
+
base_url: request.base_url
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Rotate refresh token (OAuth 2.1 requirement)
|
|
299
|
+
Services::TokenService.revoke_refresh_token(params[:refresh_token])
|
|
300
|
+
|
|
301
|
+
render json: token_response, content_type: 'application/json'
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# === Client Registration ===
|
|
305
|
+
|
|
306
|
+
def build_client_registration
|
|
307
|
+
{
|
|
308
|
+
redirect_uris: extract_redirect_uris,
|
|
309
|
+
grant_types: params[:grant_types] || %w[authorization_code refresh_token],
|
|
310
|
+
response_types: params[:response_types] || %w[code],
|
|
311
|
+
scope: params[:scope] || Mcp::Auth::ScopeRegistry.default_scope_string,
|
|
312
|
+
client_name: params[:client_name] || 'MCP Client',
|
|
313
|
+
client_uri: params[:client_uri]
|
|
314
|
+
}
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def extract_redirect_uris
|
|
318
|
+
uris = params[:redirect_uris] || []
|
|
319
|
+
Array(uris).uniq
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def format_client_response(client)
|
|
323
|
+
{
|
|
324
|
+
client_id: client.client_id,
|
|
325
|
+
client_secret: client.client_secret,
|
|
326
|
+
client_id_issued_at: client.created_at.to_i,
|
|
327
|
+
client_secret_expires_at: 0,
|
|
328
|
+
redirect_uris: client.redirect_uris,
|
|
329
|
+
grant_types: client.grant_types,
|
|
330
|
+
response_types: client.response_types,
|
|
331
|
+
scope: client.scope,
|
|
332
|
+
token_endpoint_auth_method: 'client_secret_basic',
|
|
333
|
+
client_name: client.client_name,
|
|
334
|
+
client_uri: client.client_uri
|
|
335
|
+
}.compact
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# === Token Introspection ===
|
|
339
|
+
|
|
340
|
+
def build_access_token_introspection(payload)
|
|
341
|
+
{
|
|
342
|
+
active: true,
|
|
343
|
+
client_id: payload[:client_id] || 'unknown',
|
|
344
|
+
username: payload[:email],
|
|
345
|
+
scope: payload[:scope],
|
|
346
|
+
exp: payload[:exp],
|
|
347
|
+
iat: payload[:iat],
|
|
348
|
+
sub: payload[:sub],
|
|
349
|
+
aud: payload[:aud],
|
|
350
|
+
iss: payload[:iss],
|
|
351
|
+
token_type: 'Bearer'
|
|
352
|
+
}
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def build_refresh_token_introspection(data)
|
|
356
|
+
{
|
|
357
|
+
active: true,
|
|
358
|
+
client_id: data[:client_id],
|
|
359
|
+
scope: data[:scope],
|
|
360
|
+
token_type: 'refresh_token'
|
|
361
|
+
}
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# === Consent Screen - ONLY ONE DEFINITION ===
|
|
365
|
+
|
|
366
|
+
def show_consent_screen
|
|
367
|
+
@client_name = get_client_name
|
|
368
|
+
@requested_scopes = parse_and_validate_scopes
|
|
369
|
+
@authorization_params = params.to_unsafe_h.slice(
|
|
370
|
+
:response_type, :client_id, :redirect_uri, :scope,
|
|
371
|
+
:state, :code_challenge, :code_challenge_method, :resource
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if use_custom_consent_view?
|
|
375
|
+
render Rails.application.config.mcp_auth.consent_view_path, layout: 'application'
|
|
376
|
+
else
|
|
377
|
+
render 'mcp/auth/consent', layout: false
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def use_custom_consent_view?
|
|
382
|
+
config = Rails.application.config.mcp_auth
|
|
383
|
+
config.use_custom_consent_view &&
|
|
384
|
+
template_exists?(config.consent_view_path)
|
|
385
|
+
rescue
|
|
386
|
+
false
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def template_exists?(path)
|
|
390
|
+
lookup_context.exists?(path, [], false)
|
|
391
|
+
rescue
|
|
392
|
+
false
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def get_client_name
|
|
396
|
+
client = Mcp::Auth::OauthClient.find_by_client_id(params[:client_id])
|
|
397
|
+
client&.client_name || params[:client_name] || params[:client_id] || 'Unknown Application'
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def parse_and_validate_scopes
|
|
401
|
+
# Get requested scopes, or default to all registered scopes
|
|
402
|
+
scope_string = params[:scope].presence || Mcp::Auth::ScopeRegistry.default_scope_string
|
|
403
|
+
requested = scope_string.split
|
|
404
|
+
|
|
405
|
+
# Get ALL available scopes (what the gem owner registered)
|
|
406
|
+
all_available = Mcp::Auth::ScopeRegistry.available_scopes.keys
|
|
407
|
+
|
|
408
|
+
# Filter by user permissions if configured
|
|
409
|
+
if Mcp::Auth.configuration.validate_scope_for_user
|
|
410
|
+
all_available = all_available.select do |scope|
|
|
411
|
+
Mcp::Auth.configuration.validate_scope_for_user.call(
|
|
412
|
+
current_user,
|
|
413
|
+
current_org,
|
|
414
|
+
scope
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Format all available scopes for display
|
|
420
|
+
# Mark as pre-selected if they were in the original request
|
|
421
|
+
all_scopes = Mcp::Auth::ScopeRegistry.format_for_display(all_available)
|
|
422
|
+
|
|
423
|
+
# Add a flag to indicate if scope was originally requested
|
|
424
|
+
all_scopes.map do |scope_data|
|
|
425
|
+
scope_data.merge(
|
|
426
|
+
pre_selected: requested.include?(scope_data[:key])
|
|
427
|
+
)
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Get required scopes from requested scopes list
|
|
432
|
+
def get_required_scopes(requested_scopes)
|
|
433
|
+
validated = Mcp::Auth::ScopeRegistry.validate_scopes(requested_scopes)
|
|
434
|
+
|
|
435
|
+
validated.select do |scope|
|
|
436
|
+
metadata = Mcp::Auth::ScopeRegistry.scope_metadata(scope)
|
|
437
|
+
metadata[:required]
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# === Headers & Security ===
|
|
442
|
+
|
|
443
|
+
def set_cors_headers
|
|
444
|
+
response.headers['Access-Control-Allow-Origin'] = '*'
|
|
445
|
+
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
|
|
446
|
+
response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def handle_options_request
|
|
450
|
+
head :no_content if request.method == 'OPTIONS'
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def require_https
|
|
454
|
+
return if request.ssl? || request.local? || Rails.env.development?
|
|
455
|
+
|
|
456
|
+
render_error('invalid_request', 'HTTPS required')
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def authorization_server_url
|
|
460
|
+
config_url = Rails.application.config.mcp_auth.authorization_server_url
|
|
461
|
+
config_url.presence || "#{request.scheme}://#{request.host_with_port}"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# === Error Handling ===
|
|
465
|
+
|
|
466
|
+
def render_error(error, description)
|
|
467
|
+
error_response = {
|
|
468
|
+
error: error,
|
|
469
|
+
error_description: description
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
Rails.logger.error "[OAuth] Error: #{error_response.inspect}"
|
|
473
|
+
render json: error_response, status: :bad_request, content_type: 'application/json'
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def current_org
|
|
477
|
+
# If current_org_method is nil in config, always return nil
|
|
478
|
+
return nil if Mcp::Auth.configuration.current_org_method.nil?
|
|
479
|
+
|
|
480
|
+
# Otherwise, call the configured method
|
|
481
|
+
method_name = Mcp::Auth.configuration.current_org_method
|
|
482
|
+
|
|
483
|
+
# If it's a proc, execute it in this controller's context
|
|
484
|
+
return instance_exec(&method_name) if method_name.respond_to?(:call)
|
|
485
|
+
|
|
486
|
+
# If it's a symbol/string, call it (from parent ApplicationController)
|
|
487
|
+
super if defined?(super)
|
|
488
|
+
rescue NoMethodError
|
|
489
|
+
# Method doesn't exist in parent, return nil
|
|
490
|
+
nil
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcp
|
|
4
|
+
module Auth
|
|
5
|
+
class WellKnownController < ActionController::Base
|
|
6
|
+
skip_before_action :verify_authenticity_token
|
|
7
|
+
before_action :set_cors_headers
|
|
8
|
+
before_action :handle_options_request
|
|
9
|
+
|
|
10
|
+
# RFC 9728: OAuth 2.0 Protected Resource Metadata
|
|
11
|
+
def protected_resource
|
|
12
|
+
resource_url = canonical_resource_url
|
|
13
|
+
|
|
14
|
+
metadata = {
|
|
15
|
+
resource: resource_url,
|
|
16
|
+
authorization_servers: [authorization_server_url],
|
|
17
|
+
scopes_supported: Mcp::Auth::ScopeRegistry.available_scopes.keys,
|
|
18
|
+
bearer_methods_supported: %w[header],
|
|
19
|
+
resource_documentation: mcp_documentation_url,
|
|
20
|
+
resource_parameter_supported: true, # RFC 8707 support
|
|
21
|
+
authorization_response_iss_parameter_supported: true # OAuth 2.1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
render json: metadata, status: :ok, content_type: 'application/json'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# RFC 8414: OAuth 2.0 Authorization Server Metadata
|
|
28
|
+
def authorization_server
|
|
29
|
+
supported_scopes = Mcp::Auth::ScopeRegistry.available_scopes.keys
|
|
30
|
+
|
|
31
|
+
metadata = {
|
|
32
|
+
issuer: authorization_server_url,
|
|
33
|
+
authorization_endpoint: "#{authorization_server_url}/oauth/authorize",
|
|
34
|
+
token_endpoint: "#{authorization_server_url}/oauth/token",
|
|
35
|
+
registration_endpoint: "#{authorization_server_url}/oauth/register",
|
|
36
|
+
revocation_endpoint: "#{authorization_server_url}/oauth/revoke",
|
|
37
|
+
introspection_endpoint: "#{authorization_server_url}/oauth/introspect",
|
|
38
|
+
|
|
39
|
+
# Supported features
|
|
40
|
+
scopes_supported: supported_scopes.uniq,
|
|
41
|
+
response_types_supported: %w[code],
|
|
42
|
+
grant_types_supported: %w[authorization_code refresh_token],
|
|
43
|
+
code_challenge_methods_supported: %w[S256], # PKCE required
|
|
44
|
+
token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post none],
|
|
45
|
+
|
|
46
|
+
# RFC 8707: Resource Indicators
|
|
47
|
+
resource_parameter_supported: true,
|
|
48
|
+
|
|
49
|
+
# OAuth 2.1 features
|
|
50
|
+
authorization_response_iss_parameter_supported: true,
|
|
51
|
+
require_pushed_authorization_requests: false,
|
|
52
|
+
require_signed_request_object: false,
|
|
53
|
+
|
|
54
|
+
# Token revocation and introspection
|
|
55
|
+
revocation_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post none],
|
|
56
|
+
introspection_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post none]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
render json: metadata, status: :ok, content_type: 'application/json'
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# OpenID Connect Discovery
|
|
63
|
+
def openid_configuration
|
|
64
|
+
# Get all registered scopes plus openid, profile, email
|
|
65
|
+
supported_scopes = Mcp::Auth::ScopeRegistry.available_scopes.keys + %w[openid profile email]
|
|
66
|
+
|
|
67
|
+
metadata = {
|
|
68
|
+
issuer: authorization_server_url,
|
|
69
|
+
authorization_endpoint: "#{authorization_server_url}/oauth/authorize",
|
|
70
|
+
token_endpoint: "#{authorization_server_url}/oauth/token",
|
|
71
|
+
registration_endpoint: "#{authorization_server_url}/oauth/register",
|
|
72
|
+
jwks_uri: "#{authorization_server_url}/.well-known/jwks.json",
|
|
73
|
+
userinfo_endpoint: "#{authorization_server_url}/oauth/userinfo",
|
|
74
|
+
|
|
75
|
+
scopes_supported: supported_scopes.uniq,
|
|
76
|
+
response_types_supported: %w[code],
|
|
77
|
+
grant_types_supported: %w[authorization_code refresh_token],
|
|
78
|
+
code_challenge_methods_supported: %w[S256],
|
|
79
|
+
token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post none],
|
|
80
|
+
subject_types_supported: %w[public],
|
|
81
|
+
id_token_signing_alg_values_supported: %w[HS256]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
render json: metadata, status: :ok, content_type: 'application/json'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# JWKS endpoint (empty for HMAC)
|
|
88
|
+
def jwks
|
|
89
|
+
keys = { keys: [] }
|
|
90
|
+
render json: keys, status: :ok, content_type: 'application/json'
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def set_cors_headers
|
|
96
|
+
response.headers['Access-Control-Allow-Origin'] = '*'
|
|
97
|
+
response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS'
|
|
98
|
+
response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
|
|
99
|
+
response.headers['Content-Type'] = 'application/json; charset=utf-8' unless request.method == 'OPTIONS'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handle_options_request
|
|
103
|
+
head :no_content if request.method == 'OPTIONS'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def canonical_resource_url
|
|
107
|
+
# Use configured MCP server path
|
|
108
|
+
mcp_path = Mcp::Auth.configuration&.mcp_server_path || '/mcp'
|
|
109
|
+
|
|
110
|
+
# Ensure path starts with /
|
|
111
|
+
mcp_path = "/#{mcp_path}" unless mcp_path.start_with?('/')
|
|
112
|
+
|
|
113
|
+
# Remove trailing slash if present
|
|
114
|
+
mcp_path = mcp_path.chomp('/')
|
|
115
|
+
|
|
116
|
+
# Build the full resource URL
|
|
117
|
+
base_url = "#{request.scheme}://#{request.host_with_port}"
|
|
118
|
+
"#{base_url}#{mcp_path}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def mcp_documentation_url
|
|
122
|
+
# Use configured docs URL, or generate default based on server path
|
|
123
|
+
docs_url = Mcp::Auth.configuration&.mcp_docs_url
|
|
124
|
+
|
|
125
|
+
if docs_url.present?
|
|
126
|
+
# If it's a full URL, use as-is
|
|
127
|
+
return docs_url if docs_url.start_with?('http://', 'https://')
|
|
128
|
+
|
|
129
|
+
# If it's a path, prepend base URL
|
|
130
|
+
return "#{request.base_url}#{docs_url}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Default: append /docs to the MCP server path
|
|
134
|
+
mcp_path = Mcp::Auth.configuration&.mcp_server_path || '/mcp'
|
|
135
|
+
mcp_path = "/#{mcp_path}" unless mcp_path.start_with?('/')
|
|
136
|
+
mcp_path = mcp_path.chomp('/')
|
|
137
|
+
|
|
138
|
+
"#{request.base_url}#{mcp_path}/docs"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def authorization_server_url
|
|
142
|
+
config_url = Mcp::Auth.configuration&.authorization_server_url
|
|
143
|
+
config_url.presence || "#{request.scheme}://#{request.host_with_port}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Mcp
|
|
2
|
+
module Auth
|
|
3
|
+
class AccessToken < ActiveRecord::Base
|
|
4
|
+
self.table_name = "mcp_auth_access_tokens"
|
|
5
|
+
|
|
6
|
+
belongs_to :user
|
|
7
|
+
belongs_to :org, optional: true
|
|
8
|
+
belongs_to :oauth_client,
|
|
9
|
+
class_name: "Mcp::Auth::OauthClient",
|
|
10
|
+
foreign_key: :client_id,
|
|
11
|
+
primary_key: :client_id,
|
|
12
|
+
optional: true
|
|
13
|
+
|
|
14
|
+
validates :token, presence: true, uniqueness: true
|
|
15
|
+
validates :client_id, presence: true
|
|
16
|
+
validates :expires_at, presence: true
|
|
17
|
+
|
|
18
|
+
scope :active, -> { where('expires_at > ?', Time.current) }
|
|
19
|
+
scope :expired, -> { where('expires_at <= ?', Time.current) }
|
|
20
|
+
|
|
21
|
+
def expired?
|
|
22
|
+
expires_at <= Time.current
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.cleanup_expired
|
|
26
|
+
expired.delete_all
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|