mcp-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 +257 -2
- data/README.md +25 -7
- data/app/controllers/mcp/auth/oauth_controller.rb +306 -51
- data/app/controllers/mcp/auth/well_known_controller.rb +23 -8
- data/app/models/mcp/auth/access_token.rb +5 -0
- data/app/models/mcp/auth/authorization_code.rb +4 -0
- data/app/models/mcp/auth/oauth_client.rb +154 -4
- data/app/models/mcp/auth/refresh_token.rb +12 -1
- data/app/views/mcp/auth/consent.html.erb +13 -0
- data/lib/generators/mcp/auth/hash_secrets_generator.rb +48 -0
- data/lib/generators/mcp/auth/install_generator.rb +13 -1
- data/lib/generators/mcp/auth/templates/add_mcp_auth_confidential_client_and_reuse.rb.erb +39 -0
- data/lib/generators/mcp/auth/templates/hash_mcp_auth_secrets_at_rest.rb.erb +72 -0
- data/lib/generators/mcp/auth/templates/initializer.rb +75 -8
- data/lib/generators/mcp/auth/templates/views/consent.html.erb +13 -0
- data/lib/generators/mcp/auth/upgrade_generator.rb +60 -0
- data/lib/mcp/auth/engine.rb +35 -1
- data/lib/mcp/auth/protected_resource.rb +29 -6
- data/lib/mcp/auth/schema_guard.rb +67 -0
- data/lib/mcp/auth/scope_registry.rb +12 -0
- data/lib/mcp/auth/secret_hashing.rb +83 -0
- data/lib/mcp/auth/services/authorization_service.rb +30 -11
- data/lib/mcp/auth/services/token_service.rb +161 -24
- data/lib/mcp/auth/version.rb +1 -1
- data/lib/mcp/auth.rb +51 -4
- data/lib/tasks/mcp_auth_tasks.rake +40 -6
- metadata +18 -1
|
@@ -4,8 +4,16 @@ module Mcp
|
|
|
4
4
|
module Auth
|
|
5
5
|
class OauthController < ApplicationController
|
|
6
6
|
skip_before_action :verify_authenticity_token, only: %i[token register revoke introspect userinfo]
|
|
7
|
-
|
|
7
|
+
# CORS only on the endpoints browser-based clients call directly. RFC 9700
|
|
8
|
+
# §2.6: the authorization endpoint (and its consent/approve step) is a
|
|
9
|
+
# top-level navigation and MUST NOT be exposed cross-origin.
|
|
10
|
+
before_action :set_cors_headers, except: %i[authorize approve]
|
|
8
11
|
before_action :handle_options_request
|
|
12
|
+
# RFC 6749 §5.1: responses carrying tokens/credentials MUST NOT be cached.
|
|
13
|
+
before_action :set_no_store_headers, only: %i[token register introspect userinfo]
|
|
14
|
+
# The consent page must not be framed (clickjacking a one-click Authorize).
|
|
15
|
+
before_action :set_anti_framing_headers, only: %i[authorize approve]
|
|
16
|
+
before_action :require_current_schema
|
|
9
17
|
before_action :require_https, only: %i[authorize approve token register revoke introspect userinfo]
|
|
10
18
|
|
|
11
19
|
# OAuth 2.1 Authorization endpoint (GET/POST)
|
|
@@ -16,6 +24,10 @@ module Mcp
|
|
|
16
24
|
return render_error('invalid_request', 'Missing or invalid required parameters')
|
|
17
25
|
end
|
|
18
26
|
|
|
27
|
+
# RFC 6749 §4.1.2.1: the redirect_uri is registered and exact-matched by
|
|
28
|
+
# now, so it is safe to report an unknown scope back to the client.
|
|
29
|
+
return redirect_with_error('invalid_scope', 'Unsupported scope requested') if unsupported_scope_requested?
|
|
30
|
+
|
|
19
31
|
if mcp_user_signed_in?
|
|
20
32
|
handle_signed_in_user
|
|
21
33
|
else
|
|
@@ -28,6 +40,7 @@ module Mcp
|
|
|
28
40
|
return redirect_to main_app.new_user_session_path unless mcp_user_signed_in?
|
|
29
41
|
|
|
30
42
|
return render_error('invalid_request', 'Missing required parameters') unless valid_authorization_params?
|
|
43
|
+
return redirect_with_error('invalid_scope', 'Unsupported scope requested') if unsupported_scope_requested?
|
|
31
44
|
|
|
32
45
|
if params[:approved] == 'true'
|
|
33
46
|
# Get selected scopes from checkboxes
|
|
@@ -55,6 +68,21 @@ module Mcp
|
|
|
55
68
|
end
|
|
56
69
|
approved_scopes = Mcp::Auth::ScopeRegistry.validate_scopes(selected_scopes)
|
|
57
70
|
|
|
71
|
+
# Least privilege: a client must never RECEIVE a scope it did not
|
|
72
|
+
# REQUEST. The consent screen can surface scopes beyond the request, so
|
|
73
|
+
# intersect with the requested set — plus the server's registered
|
|
74
|
+
# REQUIRED scopes, which every token needs to be usable at all. (When the
|
|
75
|
+
# client requested nothing, keep the validated set.)
|
|
76
|
+
if requested_scopes.any?
|
|
77
|
+
approved_scopes &= (requested_scopes | Mcp::Auth::ScopeRegistry.validate_scopes([]))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The host app's per-user scope policy (validate_scope_for_user) hides
|
|
81
|
+
# scopes on the consent screen, but a user can still POST any scope
|
|
82
|
+
# name here — enforce the policy on what is actually granted, required
|
|
83
|
+
# scopes included.
|
|
84
|
+
approved_scopes = approved_scopes.select { |scope| scope_permitted_for_user?(scope) }
|
|
85
|
+
|
|
58
86
|
# Preserve standard OpenID Connect scopes that were originally requested.
|
|
59
87
|
# They gate identity claims (already governed by the userinfo/id_token
|
|
60
88
|
# endpoints) rather than application resources, so they are not rendered
|
|
@@ -62,6 +90,13 @@ module Mcp
|
|
|
62
90
|
oidc_scopes = requested_scopes & Mcp::Auth::ScopeRegistry::STANDARD_OIDC_SCOPES
|
|
63
91
|
approved_scope_string = (approved_scopes + oidc_scopes).uniq.join(' ')
|
|
64
92
|
|
|
93
|
+
# Nothing the user approved survives the filter. Refuse rather than let
|
|
94
|
+
# generate_and_redirect_with_code fall back to the ORIGINAL request —
|
|
95
|
+
# that would issue exactly the scopes the user just unticked.
|
|
96
|
+
if approved_scope_string.blank?
|
|
97
|
+
return redirect_with_error('invalid_scope', 'None of the approved scopes were requested')
|
|
98
|
+
end
|
|
99
|
+
|
|
65
100
|
# Generate authorization code with ONLY approved scopes
|
|
66
101
|
generate_and_redirect_with_code(approved_scope_string)
|
|
67
102
|
else
|
|
@@ -166,14 +201,48 @@ module Mcp
|
|
|
166
201
|
params[:redirect_uri].present? &&
|
|
167
202
|
params[:code_challenge].present? &&
|
|
168
203
|
params[:code_challenge_method] == 'S256' &&
|
|
204
|
+
valid_requested_resource? &&
|
|
169
205
|
registered_client_with_valid_redirect?
|
|
170
206
|
end
|
|
171
207
|
|
|
208
|
+
# RFC 8707 / MCP authorization spec: if the client sends a `resource`, it
|
|
209
|
+
# MUST identify this server. A token whose audience is some other resource
|
|
210
|
+
# must never be minted, so the request is rejected here — before any code
|
|
211
|
+
# is issued — rather than silently binding the token to a foreign audience.
|
|
212
|
+
def valid_requested_resource?
|
|
213
|
+
return true if params[:resource].blank?
|
|
214
|
+
|
|
215
|
+
allowed = Services::TokenService.resource_allowed?(params[:resource], canonical_resource_identifier)
|
|
216
|
+
Rails.logger.warn "[OAuth] Rejected unknown resource indicator: #{params[:resource]}" unless allowed
|
|
217
|
+
allowed
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Canonical resource identifier this server issues/accepts tokens for
|
|
221
|
+
# (server origin + configured mcp_server_path). Mirrors the value published
|
|
222
|
+
# in the protected-resource metadata and minted into the token `aud`.
|
|
223
|
+
def canonical_resource_identifier
|
|
224
|
+
path = Mcp::Auth.configuration&.mcp_server_path.presence || '/mcp'
|
|
225
|
+
path = "/#{path}" unless path.start_with?('/')
|
|
226
|
+
"#{resource_origin}#{path.chomp('/')}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# With strict_scope_validation (the default; only an explicit false turns it
|
|
230
|
+
# off), a request naming a scope the server doesn't know is refused.
|
|
231
|
+
def unsupported_scope_requested?
|
|
232
|
+
return false if Mcp::Auth.configuration&.strict_scope_validation == false
|
|
233
|
+
|
|
234
|
+
unknown = Mcp::Auth::ScopeRegistry.unknown_scopes(params[:scope].to_s)
|
|
235
|
+
Rails.logger.warn "[OAuth] Rejected unsupported scope(s): #{unknown.join(' ')}" if unknown.any?
|
|
236
|
+
unknown.any?
|
|
237
|
+
end
|
|
238
|
+
|
|
172
239
|
# OAuth 2.1 / RFC 6749 §3.1.2.3: the authorization endpoint MUST reject any
|
|
173
240
|
# redirect_uri that is not pre-registered for the client. This is the gate
|
|
174
241
|
# that prevents authorization-code interception via open redirect, so it is
|
|
175
242
|
# validated BEFORE the code is ever issued — and on failure we render an
|
|
176
243
|
# error instead of redirecting (we must never redirect to an unverified URI).
|
|
244
|
+
# A client that did not register the authorization_code grant never gets
|
|
245
|
+
# a code (RFC 7591 §2: the server must honor registered grant types).
|
|
177
246
|
def registered_client_with_valid_redirect?
|
|
178
247
|
client = oauth_client
|
|
179
248
|
unless client
|
|
@@ -181,6 +250,11 @@ module Mcp
|
|
|
181
250
|
return false
|
|
182
251
|
end
|
|
183
252
|
|
|
253
|
+
unless client.supports_grant_type?('authorization_code')
|
|
254
|
+
Rails.logger.warn "[OAuth] Client not registered for authorization_code: #{params[:client_id]}"
|
|
255
|
+
return false
|
|
256
|
+
end
|
|
257
|
+
|
|
184
258
|
return true if client.valid_redirect_uri?(params[:redirect_uri])
|
|
185
259
|
|
|
186
260
|
Rails.logger.warn "[OAuth] Unregistered redirect_uri for client=#{params[:client_id]}: #{params[:redirect_uri]}"
|
|
@@ -195,12 +269,13 @@ module Mcp
|
|
|
195
269
|
|
|
196
270
|
# === Authorization Flow ===
|
|
197
271
|
|
|
272
|
+
# The authorization endpoint (GET/POST /oauth/authorize) MUST NOT issue a
|
|
273
|
+
# code on its own: doing so let any client skip consent by appending
|
|
274
|
+
# `approved=true` to the authorization URL (a GET, so not even CSRF
|
|
275
|
+
# protected). Approval is an explicit, CSRF-protected POST to
|
|
276
|
+
# /oauth/approve — so here we only ever render the consent screen.
|
|
198
277
|
def handle_signed_in_user
|
|
199
|
-
|
|
200
|
-
generate_and_redirect_with_code
|
|
201
|
-
else
|
|
202
|
-
show_consent_screen
|
|
203
|
-
end
|
|
278
|
+
show_consent_screen
|
|
204
279
|
end
|
|
205
280
|
|
|
206
281
|
def redirect_to_login
|
|
@@ -263,10 +338,19 @@ module Mcp
|
|
|
263
338
|
# RFC 6749 §4.1.3: the code MUST be bound to the client it was issued to.
|
|
264
339
|
# The requesting client identifies itself via HTTP Basic auth (confidential
|
|
265
340
|
# clients) or the client_id parameter (public clients using PKCE).
|
|
266
|
-
unless
|
|
341
|
+
unless requesting_client_owns?(code_data[:client_id])
|
|
267
342
|
return render_error('invalid_grant', 'Authorization code was issued to a different client')
|
|
268
343
|
end
|
|
269
344
|
|
|
345
|
+
# Confidential clients must authenticate; public clients rely on PKCE.
|
|
346
|
+
unless client_authentication_valid?(code_data[:client_id])
|
|
347
|
+
return render_error('invalid_client', 'Client authentication failed', status: :unauthorized)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# RFC 8707: a `resource` sent at the token endpoint (used as the audience
|
|
351
|
+
# fallback when the code carried none) must still identify this server.
|
|
352
|
+
return render_error('invalid_target', 'Invalid resource indicator') unless valid_requested_resource?
|
|
353
|
+
|
|
270
354
|
# Validate PKCE
|
|
271
355
|
unless Services::AuthorizationService.validate_pkce?(code_data[:code_challenge], params[:code_verifier])
|
|
272
356
|
return render_error('invalid_grant', 'PKCE validation failed')
|
|
@@ -277,20 +361,27 @@ module Mcp
|
|
|
277
361
|
return render_error('invalid_grant', 'Redirect URI mismatch')
|
|
278
362
|
end
|
|
279
363
|
|
|
280
|
-
|
|
364
|
+
consume_code_and_issue_tokens(code_data)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Consume the authorization code (one-time use) and mint the token response.
|
|
368
|
+
# consume_* deletes the row atomically and only the request that actually
|
|
369
|
+
# removed it gets a truthy result, so a replayed/raced code can never yield a
|
|
370
|
+
# second set of tokens — abort if we did not win the consumption. The tokens
|
|
371
|
+
# carry the APPROVED scope stored on the code, not the original request.
|
|
372
|
+
def consume_code_and_issue_tokens(code_data)
|
|
281
373
|
Rails.logger.info "[OAuth] Token generation using scope from auth code: #{code_data[:scope]}"
|
|
282
374
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
Services::AuthorizationService.consume_authorization_code(params[:code])
|
|
375
|
+
unless Services::AuthorizationService.consume_authorization_code(params[:code])
|
|
376
|
+
return render_error('invalid_grant', 'Authorization code is invalid or expired')
|
|
377
|
+
end
|
|
287
378
|
|
|
288
|
-
#
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
base_url: request.base_url
|
|
379
|
+
# `.presence` throughout: a blank `resource` (e.g. `resource=`) must fall
|
|
380
|
+
# back to this server's canonical resource, not to an empty audience.
|
|
381
|
+
token_data = code_data.merge(
|
|
382
|
+
resource: code_data[:resource].presence || params[:resource].presence || canonical_resource_identifier
|
|
293
383
|
)
|
|
384
|
+
token_response = Services::TokenService.generate_token_response(token_data, base_url: server_origin)
|
|
294
385
|
|
|
295
386
|
render json: token_response, content_type: 'application/json'
|
|
296
387
|
rescue StandardError => e
|
|
@@ -299,33 +390,109 @@ module Mcp
|
|
|
299
390
|
end
|
|
300
391
|
|
|
301
392
|
def handle_refresh_token_grant
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
393
|
+
record = Services::TokenService.find_refresh_token(params[:refresh_token])
|
|
394
|
+
return render_error('invalid_grant', 'Refresh token is invalid or expired') unless record
|
|
395
|
+
|
|
396
|
+
# Authenticate the CLIENT before any state-changing reaction below, so an
|
|
397
|
+
# unauthenticated party who merely replays a stolen/expired token can't
|
|
398
|
+
# trigger family revocation (a DoS) or probe token state.
|
|
399
|
+
# OAuth 2.1 §4.3.1 / RFC 6749 §6: bind the refresh token to its client.
|
|
400
|
+
unless requesting_client_owns?(record.client_id)
|
|
401
|
+
return render_error('invalid_grant', 'Refresh token was issued to a different client')
|
|
402
|
+
end
|
|
305
403
|
|
|
306
|
-
#
|
|
307
|
-
|
|
308
|
-
|
|
404
|
+
# Confidential clients must authenticate; public clients rely on PKCE.
|
|
405
|
+
unless client_authentication_valid?(record.client_id)
|
|
406
|
+
return render_error('invalid_client', 'Client authentication failed', status: :unauthorized)
|
|
407
|
+
end
|
|
309
408
|
|
|
310
|
-
#
|
|
311
|
-
|
|
409
|
+
# OAuth 2.1 §4.14.2 reuse detection: a refresh token is single-use. An
|
|
410
|
+
# authenticated client replaying an already-rotated (revoked) token means
|
|
411
|
+
# the token leaked — treat it as theft and revoke the entire family, both
|
|
412
|
+
# refresh tokens AND the access tokens already issued to this principal,
|
|
413
|
+
# so the attacker and the client are cut off immediately (client re-auths).
|
|
414
|
+
#
|
|
415
|
+
# EXCEPT within the rotation grace period: real MCP clients commonly fire
|
|
416
|
+
# several refreshes at once when the access token expires, so a replay
|
|
417
|
+
# moments after rotation is almost certainly a benign race/retry — reject
|
|
418
|
+
# it softly (the client falls back to the successor it already received)
|
|
419
|
+
# WITHOUT revoking the family. Only a replay after the window is theft.
|
|
420
|
+
if record.revoked?
|
|
421
|
+
unless replayed_within_rotation_grace?(record)
|
|
422
|
+
Services::TokenService.revoke_refresh_family(record.family_id)
|
|
423
|
+
Services::TokenService.revoke_access_tokens_for(user_id: record.user_id, client_id: record.client_id)
|
|
424
|
+
Rails.logger.warn "[OAuth] Refresh-token reuse detected; family #{record.family_id} revoked"
|
|
425
|
+
end
|
|
426
|
+
return render_error('invalid_grant', 'Refresh token has already been used')
|
|
427
|
+
end
|
|
312
428
|
|
|
313
|
-
|
|
314
|
-
# so a replayed refresh token cannot mint a second token family.
|
|
315
|
-
Services::TokenService.revoke_refresh_token(params[:refresh_token])
|
|
429
|
+
return render_error('invalid_grant', 'Refresh token is invalid or expired') if record.expired?
|
|
316
430
|
|
|
317
|
-
#
|
|
318
|
-
|
|
319
|
-
token_data,
|
|
320
|
-
base_url: request.base_url
|
|
321
|
-
)
|
|
431
|
+
# RFC 8707: a resource indicator, when supplied, must name this server.
|
|
432
|
+
return render_error('invalid_target', 'Invalid resource indicator') unless valid_requested_resource?
|
|
322
433
|
|
|
323
|
-
|
|
434
|
+
rotate_and_issue_from_refresh(record)
|
|
324
435
|
rescue StandardError => e
|
|
325
436
|
Rails.logger.error "[OAuth] Token refresh failed: #{e.message}"
|
|
326
437
|
render_error('server_error', 'Failed to issue tokens', status: :internal_server_error)
|
|
327
438
|
end
|
|
328
439
|
|
|
440
|
+
# True when a revoked refresh token is being replayed within the rotation
|
|
441
|
+
# grace period — i.e. it was rotated only moments ago, so this is very
|
|
442
|
+
# likely a benign concurrent refresh/retry rather than theft. A grace of 0
|
|
443
|
+
# disables the window (any replay is treated as reuse).
|
|
444
|
+
def replayed_within_rotation_grace?(record)
|
|
445
|
+
grace = Mcp::Auth.configuration&.refresh_token_reuse_grace_period.to_i
|
|
446
|
+
return false unless grace.positive?
|
|
447
|
+
|
|
448
|
+
record.revoked_at.present? && record.revoked_at > grace.seconds.ago
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Rotate the presented refresh token and mint its successor. Rotation marks
|
|
452
|
+
# THIS token revoked atomically (only the request that flips revoked_at from
|
|
453
|
+
# NULL wins the race); the successor is issued in the SAME family so a future
|
|
454
|
+
# replay of this token is detectable as reuse. A client may narrow scope
|
|
455
|
+
# (RFC 6749 §6) but never widen it.
|
|
456
|
+
#
|
|
457
|
+
# Rotation and issuance share ONE transaction: if minting the successor
|
|
458
|
+
# fails (or yields no refresh token), the revocation rolls back and the
|
|
459
|
+
# client can retry with the token it holds, instead of being left with no
|
|
460
|
+
# valid refresh token at all (a forced logout).
|
|
461
|
+
def rotate_and_issue_from_refresh(record)
|
|
462
|
+
scope = record.scope
|
|
463
|
+
scope = narrow_scope(scope, params[:scope]) if params[:scope].present?
|
|
464
|
+
|
|
465
|
+
token_data = {
|
|
466
|
+
client_id: record.client_id, scope: scope, user_id: record.user_id,
|
|
467
|
+
org_id: record.org_id, family_id: record.family_id,
|
|
468
|
+
resource: params[:resource].presence || canonical_resource_identifier
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
outcome = nil
|
|
472
|
+
token_response = nil
|
|
473
|
+
Mcp::Auth::RefreshToken.transaction do
|
|
474
|
+
unless Services::TokenService.rotate_refresh_token(record)
|
|
475
|
+
outcome = :lost_race
|
|
476
|
+
raise ActiveRecord::Rollback
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
token_response = Services::TokenService.generate_token_response(token_data, base_url: server_origin)
|
|
480
|
+
next if token_response[:refresh_token].present?
|
|
481
|
+
|
|
482
|
+
outcome = :no_successor
|
|
483
|
+
raise ActiveRecord::Rollback
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
case outcome
|
|
487
|
+
when :lost_race
|
|
488
|
+
render_error('invalid_grant', 'Refresh token is invalid or expired')
|
|
489
|
+
when :no_successor
|
|
490
|
+
render_error('server_error', 'Failed to issue tokens', status: :internal_server_error)
|
|
491
|
+
else
|
|
492
|
+
render json: token_response, content_type: 'application/json'
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
329
496
|
# Intersection of the originally granted scope and a requested subset.
|
|
330
497
|
def narrow_scope(granted_scope, requested_scope)
|
|
331
498
|
granted = granted_scope.to_s.split
|
|
@@ -340,6 +507,36 @@ module Mcp
|
|
|
340
507
|
basic_id.presence || params[:client_id]
|
|
341
508
|
end
|
|
342
509
|
|
|
510
|
+
# True only when the token-requesting client identifies itself AND that
|
|
511
|
+
# identity matches the client a grant (code/refresh token) was issued to.
|
|
512
|
+
def requesting_client_owns?(client_id)
|
|
513
|
+
requesting_client_id.present? && requesting_client_id == client_id
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# client_secret presented on a token request, from HTTP Basic auth or the
|
|
517
|
+
# client_secret form param (nil if the client presents none).
|
|
518
|
+
def presented_client_secret
|
|
519
|
+
_, basic_secret = extract_client_credentials_from_basic
|
|
520
|
+
basic_secret.presence || params[:client_secret].presence
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Token-endpoint client authentication (RFC 6749 §2.3):
|
|
524
|
+
# * CONFIDENTIAL clients (token_endpoint_auth_method != 'none') MUST
|
|
525
|
+
# present a valid client_secret.
|
|
526
|
+
# * PUBLIC clients ('none') present no secret and rely on PKCE — but if
|
|
527
|
+
# one IS presented it must still be valid.
|
|
528
|
+
def client_authentication_valid?(client_id)
|
|
529
|
+
client = Mcp::Auth::OauthClient.find_by(client_id: client_id)
|
|
530
|
+
return false unless client
|
|
531
|
+
|
|
532
|
+
secret = presented_client_secret
|
|
533
|
+
if client.confidential?
|
|
534
|
+
secret.present? && client.authenticate_secret(secret)
|
|
535
|
+
else
|
|
536
|
+
secret.blank? || client.authenticate_secret(secret)
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
343
540
|
# === Client Registration ===
|
|
344
541
|
|
|
345
542
|
def build_client_registration
|
|
@@ -349,7 +546,11 @@ module Mcp
|
|
|
349
546
|
response_types: params[:response_types] || %w[code],
|
|
350
547
|
scope: params[:scope] || Mcp::Auth::ScopeRegistry.default_scope_string,
|
|
351
548
|
client_name: params[:client_name] || 'MCP Client',
|
|
352
|
-
client_uri: params[:client_uri]
|
|
549
|
+
client_uri: params[:client_uri],
|
|
550
|
+
# Default to a PUBLIC (PKCE) client unless the caller opts into a
|
|
551
|
+
# confidential method; an unsupported value is rejected by the model.
|
|
552
|
+
token_endpoint_auth_method: params[:token_endpoint_auth_method].presence ||
|
|
553
|
+
Mcp::Auth::OauthClient::PUBLIC_AUTH_METHOD
|
|
353
554
|
}
|
|
354
555
|
end
|
|
355
556
|
|
|
@@ -361,14 +562,16 @@ module Mcp
|
|
|
361
562
|
def format_client_response(client)
|
|
362
563
|
{
|
|
363
564
|
client_id: client.client_id,
|
|
364
|
-
|
|
565
|
+
# Return the plaintext secret exactly once, at registration; only its
|
|
566
|
+
# digest is stored.
|
|
567
|
+
client_secret: client.plaintext_secret,
|
|
365
568
|
client_id_issued_at: client.created_at.to_i,
|
|
366
569
|
client_secret_expires_at: 0,
|
|
367
570
|
redirect_uris: client.redirect_uris,
|
|
368
571
|
grant_types: client.grant_types,
|
|
369
572
|
response_types: client.response_types,
|
|
370
573
|
scope: client.scope,
|
|
371
|
-
token_endpoint_auth_method:
|
|
574
|
+
token_endpoint_auth_method: client.token_endpoint_auth_method,
|
|
372
575
|
client_name: client.client_name,
|
|
373
576
|
client_uri: client.client_uri
|
|
374
577
|
}.compact
|
|
@@ -384,7 +587,7 @@ module Mcp
|
|
|
384
587
|
|
|
385
588
|
client = Mcp::Auth::OauthClient.find_by(client_id: client_id)
|
|
386
589
|
return nil unless client
|
|
387
|
-
return nil unless
|
|
590
|
+
return nil unless client.authenticate_secret(client_secret)
|
|
388
591
|
|
|
389
592
|
client
|
|
390
593
|
end
|
|
@@ -418,7 +621,8 @@ module Mcp
|
|
|
418
621
|
end
|
|
419
622
|
|
|
420
623
|
def revoke_access_for_client(token, client)
|
|
421
|
-
|
|
624
|
+
candidates = Mcp::Auth::SecretHashing.lookup_candidates(token)
|
|
625
|
+
access_token = Mcp::Auth::AccessToken.where(token: candidates, client_id: client.client_id).first
|
|
422
626
|
return false unless access_token
|
|
423
627
|
|
|
424
628
|
access_token.destroy
|
|
@@ -426,7 +630,8 @@ module Mcp
|
|
|
426
630
|
end
|
|
427
631
|
|
|
428
632
|
def revoke_refresh_for_client(token, client)
|
|
429
|
-
|
|
633
|
+
candidates = Mcp::Auth::SecretHashing.lookup_candidates(token)
|
|
634
|
+
refresh_token = Mcp::Auth::RefreshToken.where(token: candidates, client_id: client.client_id).first
|
|
430
635
|
return false unless refresh_token
|
|
431
636
|
|
|
432
637
|
refresh_token.destroy
|
|
@@ -478,6 +683,9 @@ module Mcp
|
|
|
478
683
|
|
|
479
684
|
def show_consent_screen
|
|
480
685
|
@client_name = get_client_name
|
|
686
|
+
@redirect_host = URI.parse(params[:redirect_uri].to_s).host.to_s rescue ''
|
|
687
|
+
@redirect_loopback = Mcp::Auth::OauthClient::LOOPBACK_HOSTS.include?(@redirect_host.downcase)
|
|
688
|
+
@redirect_verified = verified_redirect_host?(@redirect_host)
|
|
481
689
|
@requested_scopes = parse_and_validate_scopes
|
|
482
690
|
@authorization_params = params.to_unsafe_h.slice(
|
|
483
691
|
:response_type, :client_id, :redirect_uri, :scope,
|
|
@@ -505,6 +713,14 @@ module Mcp
|
|
|
505
713
|
false
|
|
506
714
|
end
|
|
507
715
|
|
|
716
|
+
# Exact host or '*.example.com' wildcard from config.verified_redirect_hosts.
|
|
717
|
+
def verified_redirect_host?(host)
|
|
718
|
+
Array(Mcp::Auth.configuration&.verified_redirect_hosts).any? do |entry|
|
|
719
|
+
entry = entry.to_s.downcase
|
|
720
|
+
entry.start_with?('*.') ? host.downcase.end_with?(entry[1..]) : host.downcase == entry
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
508
724
|
def get_client_name
|
|
509
725
|
client = Mcp::Auth::OauthClient.find_by_client_id(params[:client_id])
|
|
510
726
|
client&.client_name || params[:client_name] || params[:client_id] || 'Unknown Application'
|
|
@@ -519,15 +735,7 @@ module Mcp
|
|
|
519
735
|
all_available = Mcp::Auth::ScopeRegistry.available_scopes.keys
|
|
520
736
|
|
|
521
737
|
# Filter by user permissions if configured
|
|
522
|
-
|
|
523
|
-
all_available = all_available.select do |scope|
|
|
524
|
-
Mcp::Auth.configuration.validate_scope_for_user.call(
|
|
525
|
-
mcp_current_user,
|
|
526
|
-
current_org,
|
|
527
|
-
scope
|
|
528
|
-
)
|
|
529
|
-
end
|
|
530
|
-
end
|
|
738
|
+
all_available = all_available.select { |scope| scope_permitted_for_user?(scope) }
|
|
531
739
|
|
|
532
740
|
# Format all available scopes for display
|
|
533
741
|
# Mark as pre-selected if they were in the original request
|
|
@@ -541,6 +749,15 @@ module Mcp
|
|
|
541
749
|
end
|
|
542
750
|
end
|
|
543
751
|
|
|
752
|
+
# Whether the host app's per-user scope policy allows `scope` for the
|
|
753
|
+
# signed-in user (always true when no policy is configured).
|
|
754
|
+
def scope_permitted_for_user?(scope)
|
|
755
|
+
policy = Mcp::Auth.configuration&.validate_scope_for_user
|
|
756
|
+
return true unless policy
|
|
757
|
+
|
|
758
|
+
policy.call(mcp_current_user, current_org, scope) ? true : false
|
|
759
|
+
end
|
|
760
|
+
|
|
544
761
|
# Get required scopes from requested scopes list
|
|
545
762
|
def get_required_scopes(requested_scopes)
|
|
546
763
|
validated = Mcp::Auth::ScopeRegistry.validate_scopes(requested_scopes)
|
|
@@ -563,6 +780,26 @@ module Mcp
|
|
|
563
780
|
head :no_content if request.method == 'OPTIONS'
|
|
564
781
|
end
|
|
565
782
|
|
|
783
|
+
def set_no_store_headers
|
|
784
|
+
response.headers['Cache-Control'] = 'no-store'
|
|
785
|
+
response.headers['Pragma'] = 'no-cache'
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def set_anti_framing_headers
|
|
789
|
+
response.headers['X-Frame-Options'] = 'DENY'
|
|
790
|
+
response.headers['Content-Security-Policy'] = "frame-ancestors 'none'"
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# Fail with a clear, actionable error (not a cryptic `unknown attribute`)
|
|
794
|
+
# when the gem was upgraded but its migrations haven't been run.
|
|
795
|
+
def require_current_schema
|
|
796
|
+
return if Mcp::Auth::SchemaGuard.up_to_date? # memoized; cheap after warmup
|
|
797
|
+
|
|
798
|
+
Rails.logger.error "[OAuth] #{Mcp::Auth::SchemaGuard.guidance}"
|
|
799
|
+
render_error('server_error', 'Server database schema is out of date; a pending migration must be run',
|
|
800
|
+
status: :internal_server_error)
|
|
801
|
+
end
|
|
802
|
+
|
|
566
803
|
def require_https
|
|
567
804
|
return if request.ssl? || request.local? || Rails.env.local?
|
|
568
805
|
|
|
@@ -570,8 +807,26 @@ module Mcp
|
|
|
570
807
|
end
|
|
571
808
|
|
|
572
809
|
def authorization_server_url
|
|
573
|
-
|
|
574
|
-
|
|
810
|
+
server_origin
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
# The pinned public origin of this server. Prefer the explicitly configured
|
|
814
|
+
# authorization_server_url so issued-token `iss`/`aud`, the `iss` returned
|
|
815
|
+
# on the authorization redirect, and the canonical resource cannot be
|
|
816
|
+
# poisoned by a forged Host / X-Forwarded-Host header. Falls back to the
|
|
817
|
+
# request origin ONLY when unconfigured — in which case the host app MUST
|
|
818
|
+
# restrict permitted hosts via Rails `config.hosts`.
|
|
819
|
+
def server_origin
|
|
820
|
+
configured = Mcp::Auth.configuration&.authorization_server_url
|
|
821
|
+
configured.presence || "#{request.scheme}://#{request.host_with_port}"
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# Origin of the MCP RESOURCE server (the token audience). Distinct from
|
|
825
|
+
# server_origin: with a separate authorization server, the MCP endpoint
|
|
826
|
+
# still lives on this app's host. Pinned via mcp_server_url when set; else
|
|
827
|
+
# the request origin (restrict hosts via `config.hosts`).
|
|
828
|
+
def resource_origin
|
|
829
|
+
Mcp::Auth.configuration&.mcp_server_url.presence || request.base_url
|
|
575
830
|
end
|
|
576
831
|
|
|
577
832
|
# === Error Handling ===
|
|
@@ -116,9 +116,10 @@ module Mcp
|
|
|
116
116
|
# Remove trailing slash if present
|
|
117
117
|
mcp_path = mcp_path.chomp('/')
|
|
118
118
|
|
|
119
|
-
# Build the full resource URL
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
# Build the full resource URL from the MCP server's own origin (pinned via
|
|
120
|
+
# mcp_server_url when set), NOT the authorization server's — they differ
|
|
121
|
+
# when a separate authorization server is configured.
|
|
122
|
+
"#{resource_origin}#{mcp_path}"
|
|
122
123
|
end
|
|
123
124
|
|
|
124
125
|
def mcp_documentation_url
|
|
@@ -129,8 +130,8 @@ module Mcp
|
|
|
129
130
|
# If it's a full URL, use as-is
|
|
130
131
|
return docs_url if docs_url.start_with?('http://', 'https://')
|
|
131
132
|
|
|
132
|
-
# If it's a path, prepend
|
|
133
|
-
return "#{
|
|
133
|
+
# If it's a path, prepend the MCP server origin
|
|
134
|
+
return "#{resource_origin}#{docs_url}"
|
|
134
135
|
end
|
|
135
136
|
|
|
136
137
|
# Default: append /docs to the MCP server path
|
|
@@ -138,12 +139,26 @@ module Mcp
|
|
|
138
139
|
mcp_path = "/#{mcp_path}" unless mcp_path.start_with?('/')
|
|
139
140
|
mcp_path = mcp_path.chomp('/')
|
|
140
141
|
|
|
141
|
-
"#{
|
|
142
|
+
"#{resource_origin}#{mcp_path}/docs"
|
|
142
143
|
end
|
|
143
144
|
|
|
144
145
|
def authorization_server_url
|
|
145
|
-
|
|
146
|
-
|
|
146
|
+
server_origin
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The pinned public origin of this server. Prefer the configured
|
|
150
|
+
# authorization_server_url so discovery metadata (issuer, every endpoint
|
|
151
|
+
# URL, jwks_uri) and the canonical resource cannot be poisoned by a forged
|
|
152
|
+
# Host / X-Forwarded-Host header. Falls back to the request origin ONLY when
|
|
153
|
+
# unconfigured — the host app MUST then restrict hosts via `config.hosts`.
|
|
154
|
+
def server_origin
|
|
155
|
+
configured = Mcp::Auth.configuration&.authorization_server_url
|
|
156
|
+
configured.presence || "#{request.scheme}://#{request.host_with_port}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Origin of the MCP resource server; see OauthController#resource_origin.
|
|
160
|
+
def resource_origin
|
|
161
|
+
Mcp::Auth.configuration&.mcp_server_url.presence || request.base_url
|
|
147
162
|
end
|
|
148
163
|
end
|
|
149
164
|
end
|
|
@@ -13,6 +13,11 @@ module Mcp
|
|
|
13
13
|
primary_key: :client_id,
|
|
14
14
|
optional: true
|
|
15
15
|
|
|
16
|
+
# Plaintext token, available only in memory (never persisted); the `token`
|
|
17
|
+
# column stores the digest. Set by the token service / factories so callers
|
|
18
|
+
# can present the raw value.
|
|
19
|
+
attr_accessor :plaintext_token
|
|
20
|
+
|
|
16
21
|
validates :token, presence: true, uniqueness: true
|
|
17
22
|
validates :client_id, presence: true
|
|
18
23
|
validates :expires_at, presence: true
|
|
@@ -13,6 +13,10 @@ module Mcp
|
|
|
13
13
|
primary_key: :client_id,
|
|
14
14
|
optional: true
|
|
15
15
|
|
|
16
|
+
# Plaintext code, available only in memory (never persisted); the `code`
|
|
17
|
+
# column stores the digest.
|
|
18
|
+
attr_accessor :plaintext_code
|
|
19
|
+
|
|
16
20
|
validates :code, presence: true, uniqueness: true
|
|
17
21
|
validates :client_id, presence: true
|
|
18
22
|
validates :redirect_uri, presence: true
|