mcp-auth 0.5.0 → 0.6.1
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 +237 -2
- data/README.md +211 -76
- data/app/controllers/mcp/auth/oauth_controller.rb +269 -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 +152 -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,112 @@ 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
|
+
# A token issued before family_id existed (or not yet backfilled) starts its
|
|
466
|
+
# family here, and the rotation stamps the same id on it, so replaying it
|
|
467
|
+
# later revokes this successor's family instead of nothing.
|
|
468
|
+
family_id = record.family_id.presence || SecureRandom.uuid
|
|
469
|
+
|
|
470
|
+
token_data = {
|
|
471
|
+
client_id: record.client_id, scope: scope, user_id: record.user_id,
|
|
472
|
+
org_id: record.org_id, family_id: family_id,
|
|
473
|
+
resource: params[:resource].presence || canonical_resource_identifier
|
|
474
|
+
}
|
|
353
475
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
476
|
+
outcome = nil
|
|
477
|
+
token_response = nil
|
|
478
|
+
Mcp::Auth::RefreshToken.transaction do
|
|
479
|
+
unless Services::TokenService.rotate_refresh_token(record, family_id: family_id)
|
|
480
|
+
outcome = :lost_race
|
|
481
|
+
raise ActiveRecord::Rollback
|
|
482
|
+
end
|
|
357
483
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
token_data,
|
|
361
|
-
base_url: request.base_url
|
|
362
|
-
)
|
|
484
|
+
token_response = Services::TokenService.generate_token_response(token_data, base_url: server_origin)
|
|
485
|
+
next if token_response[:refresh_token].present?
|
|
363
486
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
487
|
+
outcome = :no_successor
|
|
488
|
+
raise ActiveRecord::Rollback
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
case outcome
|
|
492
|
+
when :lost_race
|
|
493
|
+
render_error('invalid_grant', 'Refresh token is invalid or expired')
|
|
494
|
+
when :no_successor
|
|
495
|
+
render_error('server_error', 'Failed to issue tokens', status: :internal_server_error)
|
|
496
|
+
else
|
|
497
|
+
render json: token_response, content_type: 'application/json'
|
|
498
|
+
end
|
|
368
499
|
end
|
|
369
500
|
|
|
370
501
|
# Intersection of the originally granted scope and a requested subset.
|
|
@@ -387,6 +518,30 @@ module Mcp
|
|
|
387
518
|
requesting_client_id.present? && requesting_client_id == client_id
|
|
388
519
|
end
|
|
389
520
|
|
|
521
|
+
# client_secret presented on a token request, from HTTP Basic auth or the
|
|
522
|
+
# client_secret form param (nil if the client presents none).
|
|
523
|
+
def presented_client_secret
|
|
524
|
+
_, basic_secret = extract_client_credentials_from_basic
|
|
525
|
+
basic_secret.presence || params[:client_secret].presence
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Token-endpoint client authentication (RFC 6749 §2.3):
|
|
529
|
+
# * CONFIDENTIAL clients (token_endpoint_auth_method != 'none') MUST
|
|
530
|
+
# present a valid client_secret.
|
|
531
|
+
# * PUBLIC clients ('none') present no secret and rely on PKCE — but if
|
|
532
|
+
# one IS presented it must still be valid.
|
|
533
|
+
def client_authentication_valid?(client_id)
|
|
534
|
+
client = Mcp::Auth::OauthClient.find_by(client_id: client_id)
|
|
535
|
+
return false unless client
|
|
536
|
+
|
|
537
|
+
secret = presented_client_secret
|
|
538
|
+
if client.confidential?
|
|
539
|
+
secret.present? && client.authenticate_secret(secret)
|
|
540
|
+
else
|
|
541
|
+
secret.blank? || client.authenticate_secret(secret)
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
390
545
|
# === Client Registration ===
|
|
391
546
|
|
|
392
547
|
def build_client_registration
|
|
@@ -396,7 +551,11 @@ module Mcp
|
|
|
396
551
|
response_types: params[:response_types] || %w[code],
|
|
397
552
|
scope: params[:scope] || Mcp::Auth::ScopeRegistry.default_scope_string,
|
|
398
553
|
client_name: params[:client_name] || 'MCP Client',
|
|
399
|
-
client_uri: params[:client_uri]
|
|
554
|
+
client_uri: params[:client_uri],
|
|
555
|
+
# Default to a PUBLIC (PKCE) client unless the caller opts into a
|
|
556
|
+
# confidential method; an unsupported value is rejected by the model.
|
|
557
|
+
token_endpoint_auth_method: params[:token_endpoint_auth_method].presence ||
|
|
558
|
+
Mcp::Auth::OauthClient::PUBLIC_AUTH_METHOD
|
|
400
559
|
}
|
|
401
560
|
end
|
|
402
561
|
|
|
@@ -408,14 +567,16 @@ module Mcp
|
|
|
408
567
|
def format_client_response(client)
|
|
409
568
|
{
|
|
410
569
|
client_id: client.client_id,
|
|
411
|
-
|
|
570
|
+
# Return the plaintext secret exactly once, at registration; only its
|
|
571
|
+
# digest is stored.
|
|
572
|
+
client_secret: client.plaintext_secret,
|
|
412
573
|
client_id_issued_at: client.created_at.to_i,
|
|
413
574
|
client_secret_expires_at: 0,
|
|
414
575
|
redirect_uris: client.redirect_uris,
|
|
415
576
|
grant_types: client.grant_types,
|
|
416
577
|
response_types: client.response_types,
|
|
417
578
|
scope: client.scope,
|
|
418
|
-
token_endpoint_auth_method:
|
|
579
|
+
token_endpoint_auth_method: client.token_endpoint_auth_method,
|
|
419
580
|
client_name: client.client_name,
|
|
420
581
|
client_uri: client.client_uri
|
|
421
582
|
}.compact
|
|
@@ -431,7 +592,7 @@ module Mcp
|
|
|
431
592
|
|
|
432
593
|
client = Mcp::Auth::OauthClient.find_by(client_id: client_id)
|
|
433
594
|
return nil unless client
|
|
434
|
-
return nil unless
|
|
595
|
+
return nil unless client.authenticate_secret(client_secret)
|
|
435
596
|
|
|
436
597
|
client
|
|
437
598
|
end
|
|
@@ -465,7 +626,8 @@ module Mcp
|
|
|
465
626
|
end
|
|
466
627
|
|
|
467
628
|
def revoke_access_for_client(token, client)
|
|
468
|
-
|
|
629
|
+
candidates = Mcp::Auth::SecretHashing.lookup_candidates(token)
|
|
630
|
+
access_token = Mcp::Auth::AccessToken.where(token: candidates, client_id: client.client_id).first
|
|
469
631
|
return false unless access_token
|
|
470
632
|
|
|
471
633
|
access_token.destroy
|
|
@@ -473,7 +635,8 @@ module Mcp
|
|
|
473
635
|
end
|
|
474
636
|
|
|
475
637
|
def revoke_refresh_for_client(token, client)
|
|
476
|
-
|
|
638
|
+
candidates = Mcp::Auth::SecretHashing.lookup_candidates(token)
|
|
639
|
+
refresh_token = Mcp::Auth::RefreshToken.where(token: candidates, client_id: client.client_id).first
|
|
477
640
|
return false unless refresh_token
|
|
478
641
|
|
|
479
642
|
refresh_token.destroy
|
|
@@ -525,6 +688,9 @@ module Mcp
|
|
|
525
688
|
|
|
526
689
|
def show_consent_screen
|
|
527
690
|
@client_name = get_client_name
|
|
691
|
+
@redirect_host = URI.parse(params[:redirect_uri].to_s).host.to_s rescue ''
|
|
692
|
+
@redirect_loopback = Mcp::Auth::OauthClient::LOOPBACK_HOSTS.include?(@redirect_host.downcase)
|
|
693
|
+
@redirect_verified = verified_redirect_host?(@redirect_host)
|
|
528
694
|
@requested_scopes = parse_and_validate_scopes
|
|
529
695
|
@authorization_params = params.to_unsafe_h.slice(
|
|
530
696
|
:response_type, :client_id, :redirect_uri, :scope,
|
|
@@ -552,6 +718,14 @@ module Mcp
|
|
|
552
718
|
false
|
|
553
719
|
end
|
|
554
720
|
|
|
721
|
+
# Exact host or '*.example.com' wildcard from config.verified_redirect_hosts.
|
|
722
|
+
def verified_redirect_host?(host)
|
|
723
|
+
Array(Mcp::Auth.configuration&.verified_redirect_hosts).any? do |entry|
|
|
724
|
+
entry = entry.to_s.downcase
|
|
725
|
+
entry.start_with?('*.') ? host.downcase.end_with?(entry[1..]) : host.downcase == entry
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
|
|
555
729
|
def get_client_name
|
|
556
730
|
client = Mcp::Auth::OauthClient.find_by_client_id(params[:client_id])
|
|
557
731
|
client&.client_name || params[:client_name] || params[:client_id] || 'Unknown Application'
|
|
@@ -566,15 +740,7 @@ module Mcp
|
|
|
566
740
|
all_available = Mcp::Auth::ScopeRegistry.available_scopes.keys
|
|
567
741
|
|
|
568
742
|
# 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
|
|
743
|
+
all_available = all_available.select { |scope| scope_permitted_for_user?(scope) }
|
|
578
744
|
|
|
579
745
|
# Format all available scopes for display
|
|
580
746
|
# Mark as pre-selected if they were in the original request
|
|
@@ -588,6 +754,15 @@ module Mcp
|
|
|
588
754
|
end
|
|
589
755
|
end
|
|
590
756
|
|
|
757
|
+
# Whether the host app's per-user scope policy allows `scope` for the
|
|
758
|
+
# signed-in user (always true when no policy is configured).
|
|
759
|
+
def scope_permitted_for_user?(scope)
|
|
760
|
+
policy = Mcp::Auth.configuration&.validate_scope_for_user
|
|
761
|
+
return true unless policy
|
|
762
|
+
|
|
763
|
+
policy.call(mcp_current_user, current_org, scope) ? true : false
|
|
764
|
+
end
|
|
765
|
+
|
|
591
766
|
# Get required scopes from requested scopes list
|
|
592
767
|
def get_required_scopes(requested_scopes)
|
|
593
768
|
validated = Mcp::Auth::ScopeRegistry.validate_scopes(requested_scopes)
|
|
@@ -610,6 +785,26 @@ module Mcp
|
|
|
610
785
|
head :no_content if request.method == 'OPTIONS'
|
|
611
786
|
end
|
|
612
787
|
|
|
788
|
+
def set_no_store_headers
|
|
789
|
+
response.headers['Cache-Control'] = 'no-store'
|
|
790
|
+
response.headers['Pragma'] = 'no-cache'
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def set_anti_framing_headers
|
|
794
|
+
response.headers['X-Frame-Options'] = 'DENY'
|
|
795
|
+
response.headers['Content-Security-Policy'] = "frame-ancestors 'none'"
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# Fail with a clear, actionable error (not a cryptic `unknown attribute`)
|
|
799
|
+
# when the gem was upgraded but its migrations haven't been run.
|
|
800
|
+
def require_current_schema
|
|
801
|
+
return if Mcp::Auth::SchemaGuard.up_to_date? # memoized; cheap after warmup
|
|
802
|
+
|
|
803
|
+
Rails.logger.error "[OAuth] #{Mcp::Auth::SchemaGuard.guidance}"
|
|
804
|
+
render_error('server_error', 'Server database schema is out of date; a pending migration must be run',
|
|
805
|
+
status: :internal_server_error)
|
|
806
|
+
end
|
|
807
|
+
|
|
613
808
|
def require_https
|
|
614
809
|
return if request.ssl? || request.local? || Rails.env.local?
|
|
615
810
|
|
|
@@ -617,8 +812,26 @@ module Mcp
|
|
|
617
812
|
end
|
|
618
813
|
|
|
619
814
|
def authorization_server_url
|
|
620
|
-
|
|
621
|
-
|
|
815
|
+
server_origin
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# The pinned public origin of this server. Prefer the explicitly configured
|
|
819
|
+
# authorization_server_url so issued-token `iss`/`aud`, the `iss` returned
|
|
820
|
+
# on the authorization redirect, and the canonical resource cannot be
|
|
821
|
+
# poisoned by a forged Host / X-Forwarded-Host header. Falls back to the
|
|
822
|
+
# request origin ONLY when unconfigured — in which case the host app MUST
|
|
823
|
+
# restrict permitted hosts via Rails `config.hosts`.
|
|
824
|
+
def server_origin
|
|
825
|
+
configured = Mcp::Auth.configuration&.authorization_server_url
|
|
826
|
+
configured.presence || "#{request.scheme}://#{request.host_with_port}"
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# Origin of the MCP RESOURCE server (the token audience). Distinct from
|
|
830
|
+
# server_origin: with a separate authorization server, the MCP endpoint
|
|
831
|
+
# still lives on this app's host. Pinned via mcp_server_url when set; else
|
|
832
|
+
# the request origin (restrict hosts via `config.hosts`).
|
|
833
|
+
def resource_origin
|
|
834
|
+
Mcp::Auth.configuration&.mcp_server_url.presence || request.base_url
|
|
622
835
|
end
|
|
623
836
|
|
|
624
837
|
# === 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
|