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.
@@ -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
- before_action :set_cors_headers
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
- if params[:approved] == 'true'
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 requesting_client_id.present? && requesting_client_id == code_data[:client_id]
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
- # Use the APPROVED scope from the authorization code, not the original request
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
- # Consume the authorization code FIRST (one-time use). Doing this before
284
- # token generation guarantees a replayed code can never yield a second
285
- # set of tokens even if two requests race.
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
- # Generate tokens with the APPROVED scope from authorization code
289
- token_data = code_data.merge(resource: code_data[:resource] || params[:resource])
290
- token_response = Services::TokenService.generate_token_response(
291
- token_data, # This includes the approved :scope from authorization code
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
- token_data = Services::TokenService.validate_refresh_token(params[:refresh_token])
303
-
304
- return render_error('invalid_grant', 'Refresh token is invalid or expired') unless token_data
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
- # RFC 6749 §6: a client may request a NARROWER scope on refresh, never a
307
- # wider one. Silently dropping unknown/extra scopes preserves least privilege.
308
- token_data[:scope] = narrow_scope(token_data[:scope], params[:scope]) if params[:scope].present?
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
- # Include resource parameter if provided
311
- token_data[:resource] = params[:resource] if params[:resource]
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
- # Rotate refresh token (OAuth 2.1 requirement) BEFORE issuing the new one
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
- # Generate new tokens
318
- token_response = Services::TokenService.generate_token_response(
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
- render json: token_response, content_type: 'application/json'
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
- client_secret: client.client_secret,
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: 'client_secret_basic',
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 ActiveSupport::SecurityUtils.secure_compare(client.client_secret.to_s, client_secret.to_s)
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
- access_token = Mcp::Auth::AccessToken.find_by(token: token, client_id: client.client_id)
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
- refresh_token = Mcp::Auth::RefreshToken.find_by(token: token, client_id: client.client_id)
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
- if Mcp::Auth.configuration.validate_scope_for_user
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
- config_url = Rails.application.config.mcp_auth.authorization_server_url
574
- config_url.presence || "#{request.scheme}://#{request.host_with_port}"
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
- base_url = "#{request.scheme}://#{request.host_with_port}"
121
- "#{base_url}#{mcp_path}"
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 base URL
133
- return "#{request.base_url}#{docs_url}"
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
- "#{request.base_url}#{mcp_path}/docs"
142
+ "#{resource_origin}#{mcp_path}/docs"
142
143
  end
143
144
 
144
145
  def authorization_server_url
145
- config_url = Mcp::Auth.configuration&.authorization_server_url
146
- config_url.presence || "#{request.scheme}://#{request.host_with_port}"
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