mcp-auth 0.5.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 +217 -2
- data/README.md +16 -2
- data/app/controllers/mcp/auth/oauth_controller.rb +264 -56
- 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 +67 -4
- 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 +18 -8
- data/lib/mcp/auth/services/token_service.rb +144 -23
- 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
|
|
@@ -183,12 +218,22 @@ module Mcp
|
|
|
183
218
|
end
|
|
184
219
|
|
|
185
220
|
# Canonical resource identifier this server issues/accepts tokens for
|
|
186
|
-
# (
|
|
187
|
-
# the protected-resource metadata and minted into the token `aud`.
|
|
221
|
+
# (server origin + configured mcp_server_path). Mirrors the value published
|
|
222
|
+
# in the protected-resource metadata and minted into the token `aud`.
|
|
188
223
|
def canonical_resource_identifier
|
|
189
224
|
path = Mcp::Auth.configuration&.mcp_server_path.presence || '/mcp'
|
|
190
225
|
path = "/#{path}" unless path.start_with?('/')
|
|
191
|
-
"#{
|
|
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?
|
|
192
237
|
end
|
|
193
238
|
|
|
194
239
|
# OAuth 2.1 / RFC 6749 §3.1.2.3: the authorization endpoint MUST reject any
|
|
@@ -196,6 +241,8 @@ module Mcp
|
|
|
196
241
|
# that prevents authorization-code interception via open redirect, so it is
|
|
197
242
|
# validated BEFORE the code is ever issued — and on failure we render an
|
|
198
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).
|
|
199
246
|
def registered_client_with_valid_redirect?
|
|
200
247
|
client = oauth_client
|
|
201
248
|
unless client
|
|
@@ -203,6 +250,11 @@ module Mcp
|
|
|
203
250
|
return false
|
|
204
251
|
end
|
|
205
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
|
+
|
|
206
258
|
return true if client.valid_redirect_uri?(params[:redirect_uri])
|
|
207
259
|
|
|
208
260
|
Rails.logger.warn "[OAuth] Unregistered redirect_uri for client=#{params[:client_id]}: #{params[:redirect_uri]}"
|
|
@@ -290,6 +342,11 @@ module Mcp
|
|
|
290
342
|
return render_error('invalid_grant', 'Authorization code was issued to a different client')
|
|
291
343
|
end
|
|
292
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
|
+
|
|
293
350
|
# RFC 8707: a `resource` sent at the token endpoint (used as the audience
|
|
294
351
|
# fallback when the code carried none) must still identify this server.
|
|
295
352
|
return render_error('invalid_target', 'Invalid resource indicator') unless valid_requested_resource?
|
|
@@ -304,23 +361,27 @@ module Mcp
|
|
|
304
361
|
return render_error('invalid_grant', 'Redirect URI mismatch')
|
|
305
362
|
end
|
|
306
363
|
|
|
307
|
-
|
|
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)
|
|
308
373
|
Rails.logger.info "[OAuth] Token generation using scope from auth code: #{code_data[:scope]}"
|
|
309
374
|
|
|
310
|
-
# Consume the authorization code FIRST (one-time use). consume_* deletes
|
|
311
|
-
# the row atomically and only the request that actually removed it gets a
|
|
312
|
-
# truthy result, so a replayed/raced code can never yield a second set of
|
|
313
|
-
# tokens. Abort if we did not win the consumption.
|
|
314
375
|
unless Services::AuthorizationService.consume_authorization_code(params[:code])
|
|
315
376
|
return render_error('invalid_grant', 'Authorization code is invalid or expired')
|
|
316
377
|
end
|
|
317
378
|
|
|
318
|
-
#
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
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
|
|
323
383
|
)
|
|
384
|
+
token_response = Services::TokenService.generate_token_response(token_data, base_url: server_origin)
|
|
324
385
|
|
|
325
386
|
render json: token_response, content_type: 'application/json'
|
|
326
387
|
rescue StandardError => e
|
|
@@ -329,42 +390,107 @@ module Mcp
|
|
|
329
390
|
end
|
|
330
391
|
|
|
331
392
|
def handle_refresh_token_grant
|
|
332
|
-
|
|
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
|
|
333
403
|
|
|
334
|
-
|
|
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
|
|
335
408
|
|
|
336
|
-
# OAuth 2.1 §4.
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
|
|
341
|
-
|
|
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')
|
|
342
427
|
end
|
|
343
428
|
|
|
429
|
+
return render_error('invalid_grant', 'Refresh token is invalid or expired') if record.expired?
|
|
430
|
+
|
|
344
431
|
# RFC 8707: a resource indicator, when supplied, must name this server.
|
|
345
432
|
return render_error('invalid_target', 'Invalid resource indicator') unless valid_requested_resource?
|
|
346
433
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
434
|
+
rotate_and_issue_from_refresh(record)
|
|
435
|
+
rescue StandardError => e
|
|
436
|
+
Rails.logger.error "[OAuth] Token refresh failed: #{e.message}"
|
|
437
|
+
render_error('server_error', 'Failed to issue tokens', status: :internal_server_error)
|
|
438
|
+
end
|
|
350
439
|
|
|
351
|
-
|
|
352
|
-
|
|
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
|
+
}
|
|
353
470
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
357
478
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
token_data,
|
|
361
|
-
base_url: request.base_url
|
|
362
|
-
)
|
|
479
|
+
token_response = Services::TokenService.generate_token_response(token_data, base_url: server_origin)
|
|
480
|
+
next if token_response[:refresh_token].present?
|
|
363
481
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
368
494
|
end
|
|
369
495
|
|
|
370
496
|
# Intersection of the originally granted scope and a requested subset.
|
|
@@ -387,6 +513,30 @@ module Mcp
|
|
|
387
513
|
requesting_client_id.present? && requesting_client_id == client_id
|
|
388
514
|
end
|
|
389
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
|
+
|
|
390
540
|
# === Client Registration ===
|
|
391
541
|
|
|
392
542
|
def build_client_registration
|
|
@@ -396,7 +546,11 @@ module Mcp
|
|
|
396
546
|
response_types: params[:response_types] || %w[code],
|
|
397
547
|
scope: params[:scope] || Mcp::Auth::ScopeRegistry.default_scope_string,
|
|
398
548
|
client_name: params[:client_name] || 'MCP Client',
|
|
399
|
-
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
|
|
400
554
|
}
|
|
401
555
|
end
|
|
402
556
|
|
|
@@ -408,14 +562,16 @@ module Mcp
|
|
|
408
562
|
def format_client_response(client)
|
|
409
563
|
{
|
|
410
564
|
client_id: client.client_id,
|
|
411
|
-
|
|
565
|
+
# Return the plaintext secret exactly once, at registration; only its
|
|
566
|
+
# digest is stored.
|
|
567
|
+
client_secret: client.plaintext_secret,
|
|
412
568
|
client_id_issued_at: client.created_at.to_i,
|
|
413
569
|
client_secret_expires_at: 0,
|
|
414
570
|
redirect_uris: client.redirect_uris,
|
|
415
571
|
grant_types: client.grant_types,
|
|
416
572
|
response_types: client.response_types,
|
|
417
573
|
scope: client.scope,
|
|
418
|
-
token_endpoint_auth_method:
|
|
574
|
+
token_endpoint_auth_method: client.token_endpoint_auth_method,
|
|
419
575
|
client_name: client.client_name,
|
|
420
576
|
client_uri: client.client_uri
|
|
421
577
|
}.compact
|
|
@@ -431,7 +587,7 @@ module Mcp
|
|
|
431
587
|
|
|
432
588
|
client = Mcp::Auth::OauthClient.find_by(client_id: client_id)
|
|
433
589
|
return nil unless client
|
|
434
|
-
return nil unless
|
|
590
|
+
return nil unless client.authenticate_secret(client_secret)
|
|
435
591
|
|
|
436
592
|
client
|
|
437
593
|
end
|
|
@@ -465,7 +621,8 @@ module Mcp
|
|
|
465
621
|
end
|
|
466
622
|
|
|
467
623
|
def revoke_access_for_client(token, client)
|
|
468
|
-
|
|
624
|
+
candidates = Mcp::Auth::SecretHashing.lookup_candidates(token)
|
|
625
|
+
access_token = Mcp::Auth::AccessToken.where(token: candidates, client_id: client.client_id).first
|
|
469
626
|
return false unless access_token
|
|
470
627
|
|
|
471
628
|
access_token.destroy
|
|
@@ -473,7 +630,8 @@ module Mcp
|
|
|
473
630
|
end
|
|
474
631
|
|
|
475
632
|
def revoke_refresh_for_client(token, client)
|
|
476
|
-
|
|
633
|
+
candidates = Mcp::Auth::SecretHashing.lookup_candidates(token)
|
|
634
|
+
refresh_token = Mcp::Auth::RefreshToken.where(token: candidates, client_id: client.client_id).first
|
|
477
635
|
return false unless refresh_token
|
|
478
636
|
|
|
479
637
|
refresh_token.destroy
|
|
@@ -525,6 +683,9 @@ module Mcp
|
|
|
525
683
|
|
|
526
684
|
def show_consent_screen
|
|
527
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)
|
|
528
689
|
@requested_scopes = parse_and_validate_scopes
|
|
529
690
|
@authorization_params = params.to_unsafe_h.slice(
|
|
530
691
|
:response_type, :client_id, :redirect_uri, :scope,
|
|
@@ -552,6 +713,14 @@ module Mcp
|
|
|
552
713
|
false
|
|
553
714
|
end
|
|
554
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
|
+
|
|
555
724
|
def get_client_name
|
|
556
725
|
client = Mcp::Auth::OauthClient.find_by_client_id(params[:client_id])
|
|
557
726
|
client&.client_name || params[:client_name] || params[:client_id] || 'Unknown Application'
|
|
@@ -566,15 +735,7 @@ module Mcp
|
|
|
566
735
|
all_available = Mcp::Auth::ScopeRegistry.available_scopes.keys
|
|
567
736
|
|
|
568
737
|
# Filter by user permissions if configured
|
|
569
|
-
|
|
570
|
-
all_available = all_available.select do |scope|
|
|
571
|
-
Mcp::Auth.configuration.validate_scope_for_user.call(
|
|
572
|
-
mcp_current_user,
|
|
573
|
-
current_org,
|
|
574
|
-
scope
|
|
575
|
-
)
|
|
576
|
-
end
|
|
577
|
-
end
|
|
738
|
+
all_available = all_available.select { |scope| scope_permitted_for_user?(scope) }
|
|
578
739
|
|
|
579
740
|
# Format all available scopes for display
|
|
580
741
|
# Mark as pre-selected if they were in the original request
|
|
@@ -588,6 +749,15 @@ module Mcp
|
|
|
588
749
|
end
|
|
589
750
|
end
|
|
590
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
|
+
|
|
591
761
|
# Get required scopes from requested scopes list
|
|
592
762
|
def get_required_scopes(requested_scopes)
|
|
593
763
|
validated = Mcp::Auth::ScopeRegistry.validate_scopes(requested_scopes)
|
|
@@ -610,6 +780,26 @@ module Mcp
|
|
|
610
780
|
head :no_content if request.method == 'OPTIONS'
|
|
611
781
|
end
|
|
612
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
|
+
|
|
613
803
|
def require_https
|
|
614
804
|
return if request.ssl? || request.local? || Rails.env.local?
|
|
615
805
|
|
|
@@ -617,8 +807,26 @@ module Mcp
|
|
|
617
807
|
end
|
|
618
808
|
|
|
619
809
|
def authorization_server_url
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
622
830
|
end
|
|
623
831
|
|
|
624
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
|