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.
@@ -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
@@ -183,12 +218,22 @@ module Mcp
183
218
  end
184
219
 
185
220
  # Canonical resource identifier this server issues/accepts tokens for
186
- # (base_url + configured mcp_server_path). Mirrors the value published in
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
- "#{request.base_url}#{path.chomp('/')}"
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
- # 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)
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
- # Generate tokens with the APPROVED scope from authorization code
319
- token_data = code_data.merge(resource: code_data[:resource] || params[:resource])
320
- token_response = Services::TokenService.generate_token_response(
321
- token_data, # This includes the approved :scope from authorization code
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
- token_data = Services::TokenService.validate_refresh_token(params[:refresh_token])
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
- return render_error('invalid_grant', 'Refresh token is invalid or expired') unless token_data
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.3.1 / RFC 6749 §6: the authorization server MUST bind the
337
- # refresh token to the client it was issued to and reject redemption by
338
- # any other client. Without this a refresh token leaked to (or through) a
339
- # second client could be exchanged for fresh access tokens.
340
- unless requesting_client_owns?(token_data[:client_id])
341
- return render_error('invalid_grant', 'Refresh token was issued to a different client')
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
- # RFC 6749 §6: a client may request a NARROWER scope on refresh, never a
348
- # wider one. Silently dropping unknown/extra scopes preserves least privilege.
349
- token_data[:scope] = narrow_scope(token_data[:scope], params[:scope]) if params[:scope].present?
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
- # Include resource parameter if provided
352
- token_data[:resource] = params[:resource] if params[:resource]
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
- # Rotate refresh token (OAuth 2.1 requirement) BEFORE issuing the new one
355
- # so a replayed refresh token cannot mint a second token family.
356
- Services::TokenService.revoke_refresh_token(params[:refresh_token])
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
- # Generate new tokens
359
- token_response = Services::TokenService.generate_token_response(
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
- render json: token_response, content_type: 'application/json'
365
- rescue StandardError => e
366
- Rails.logger.error "[OAuth] Token refresh failed: #{e.message}"
367
- render_error('server_error', 'Failed to issue tokens', status: :internal_server_error)
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
- client_secret: client.client_secret,
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: 'client_secret_basic',
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 ActiveSupport::SecurityUtils.secure_compare(client.client_secret.to_s, client_secret.to_s)
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
- access_token = Mcp::Auth::AccessToken.find_by(token: token, client_id: client.client_id)
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
- refresh_token = Mcp::Auth::RefreshToken.find_by(token: token, client_id: client.client_id)
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
- if Mcp::Auth.configuration.validate_scope_for_user
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
- config_url = Rails.application.config.mcp_auth.authorization_server_url
621
- config_url.presence || "#{request.scheme}://#{request.host_with_port}"
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
- 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