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.
@@ -8,10 +8,41 @@ module Mcp
8
8
 
9
9
  # Set defaults BEFORE validation
10
10
  before_validation :set_defaults, on: :create
11
+ before_validation :sanitize_client_name
12
+ # Hash the secret at rest just before persisting (covers both the
13
+ # auto-generated secret and one a caller sets explicitly).
14
+ before_save :hash_client_secret_at_rest
15
+
16
+ # The grant/response types this authorization server actually implements.
17
+ # Dynamic registration is rejected for anything outside these sets so a
18
+ # client cannot register metadata the server can't honor (RFC 7591 §2).
19
+ SUPPORTED_GRANT_TYPES = %w[authorization_code refresh_token].freeze
20
+ SUPPORTED_RESPONSE_TYPES = %w[code].freeze
21
+
22
+ # Token-endpoint authentication methods (RFC 7591 / RFC 8414). PUBLIC_AUTH_METHOD
23
+ # (`none`) is a public client (PKCE only, no client authentication); the
24
+ # others are CONFIDENTIAL clients that MUST present a valid client_secret.
25
+ # Defaults to `none` so existing/registered clients keep working — a client
26
+ # opts into confidential auth explicitly at registration.
27
+ PUBLIC_AUTH_METHOD = 'none'
28
+ TOKEN_ENDPOINT_AUTH_METHODS = [PUBLIC_AUTH_METHOD, 'client_secret_basic', 'client_secret_post'].freeze
29
+
30
+ # Schemes that must never receive an authorization code: they execute
31
+ # script or read local content rather than reaching a client
32
+ # (e.g. `javascript://%0aalert(1)` would otherwise pass the `://` check).
33
+ FORBIDDEN_REDIRECT_SCHEMES = %w[javascript data vbscript file about blob].freeze
34
+
35
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 [::1] ::1].freeze
36
+ MAX_CLIENT_NAME_LENGTH = 100
11
37
 
12
38
  validates :client_id, presence: true, uniqueness: true
13
39
  validates :client_secret, presence: true
40
+ validates :token_endpoint_auth_method, inclusion: { in: TOKEN_ENDPOINT_AUTH_METHODS }
14
41
  validate :validate_redirect_uris
42
+ validate :validate_grant_and_response_types
43
+ validate :validate_redirect_uri_policy
44
+ validate :validate_scope_known
45
+ validate :validate_client_uri
15
46
 
16
47
  serialize :redirect_uris, coder: JSON
17
48
  serialize :grant_types, coder: JSON
@@ -35,6 +66,11 @@ module Mcp
35
66
  primary_key: :client_id,
36
67
  dependent: :destroy
37
68
 
69
+ # The generated plaintext secret, available only on the in-memory instance
70
+ # right after creation (never persisted). The registration response returns
71
+ # this once; only its digest is stored.
72
+ attr_reader :plaintext_secret
73
+
38
74
  def self.find_by_client_id(client_id)
39
75
  find_by(client_id: client_id)
40
76
  end
@@ -47,14 +83,37 @@ module Mcp
47
83
  grant_types&.include?(grant_type)
48
84
  end
49
85
 
86
+ # Constant-time verification of a presented client_secret against the stored
87
+ # digest (secrets are hashed at rest).
88
+ def authenticate_secret(presented)
89
+ Mcp::Auth::SecretHashing.match?(client_secret, presented)
90
+ end
91
+
92
+ # Confidential clients MUST authenticate at the token endpoint; public
93
+ # clients (`none`) rely on PKCE.
94
+ def confidential?
95
+ token_endpoint_auth_method.to_s != PUBLIC_AUTH_METHOD
96
+ end
97
+
50
98
  private
51
99
 
52
100
  def set_defaults
53
101
  self.client_id ||= SecureRandom.uuid
54
- self.client_secret ||= SecureRandom.hex(32)
102
+ self.client_secret = SecureRandom.hex(32) if client_secret.blank?
55
103
  self.grant_types ||= %w[authorization_code refresh_token]
56
104
  self.response_types ||= %w[code]
57
105
  self.scope ||= Mcp::Auth::ScopeRegistry.default_scope_string
106
+ self.token_endpoint_auth_method = PUBLIC_AUTH_METHOD if token_endpoint_auth_method.blank?
107
+ end
108
+
109
+ # Digest the secret before it is written. The plaintext (generated or
110
+ # caller-supplied) is captured on the in-memory instance first so the
111
+ # registration response can return it exactly once.
112
+ def hash_client_secret_at_rest
113
+ return if client_secret.blank? || Mcp::Auth::SecretHashing.hashed?(client_secret)
114
+
115
+ @plaintext_secret ||= client_secret
116
+ self.client_secret = Mcp::Auth::SecretHashing.digest(client_secret)
58
117
  end
59
118
 
60
119
  # RFC 7591 / RFC 8252: a client using the authorization_code grant must
@@ -62,11 +121,11 @@ module Mcp
62
121
  # We reject scheme-only values (e.g. `javascript:`/`data:`) that would be
63
122
  # XSS-redirect vectors, while still allowing http(s) and native app schemes.
64
123
  def validate_redirect_uris
65
- return unless Array(grant_types).include?('authorization_code')
66
-
67
124
  uris = Array(redirect_uris)
68
125
  if uris.empty?
69
- errors.add(:redirect_uris, 'must include at least one redirect URI')
126
+ if Array(grant_types).include?('authorization_code')
127
+ errors.add(:redirect_uris, 'must include at least one redirect URI')
128
+ end
70
129
  return
71
130
  end
72
131
 
@@ -75,8 +134,99 @@ module Mcp
75
134
  end
76
135
  end
77
136
 
137
+ def validate_grant_and_response_types
138
+ (Array(grant_types) - SUPPORTED_GRANT_TYPES).each do |gt|
139
+ errors.add(:grant_types, "contains an unsupported grant type: #{gt}")
140
+ end
141
+ (Array(response_types) - SUPPORTED_RESPONSE_TYPES).each do |rt|
142
+ errors.add(:response_types, "contains an unsupported response type: #{rt}")
143
+ end
144
+ end
145
+
146
+ # The consent page shows client_name, which is attacker-chosen at open
147
+ # registration: strip control/format characters (bidi overrides, zero-width)
148
+ # and cap the length so it can't impersonate or spoof layout.
149
+ def sanitize_client_name
150
+ return if client_name.blank?
151
+
152
+ self.client_name = client_name.to_s.gsub(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/, ' ').squish.truncate(MAX_CLIENT_NAME_LENGTH)
153
+ end
154
+
155
+ # OAuth 2.1 / RFC 9700 §4.1.1: redirect URIs must be https, or http only on
156
+ # a loopback host; and, when configured, must match the allowlist.
157
+ # Runs for EVERY registered redirect URI, whatever the grant types: a client
158
+ # registered without authorization_code must not be able to slip an
159
+ # off-policy URI past this check. (/oauth/authorize also refuses clients
160
+ # that didn't register the authorization_code grant.)
161
+ def validate_redirect_uri_policy
162
+ Array(redirect_uris).each do |uri|
163
+ next unless valid_redirect_uri_format?(uri) # already reported
164
+
165
+ parsed = URI.parse(uri.to_s)
166
+ loopback = loopback_redirect?(parsed)
167
+ if parsed.is_a?(URI::HTTP) && !parsed.is_a?(URI::HTTPS) && !loopback
168
+ errors.add(:redirect_uris, "must use https (http is only allowed for loopback): #{uri}")
169
+ elsif loopback && config_value(:allow_loopback_redirects, true) == false
170
+ errors.add(:redirect_uris, "loopback redirect URIs are not allowed: #{uri}")
171
+ elsif !loopback && !redirect_uri_allowlisted?(uri)
172
+ errors.add(:redirect_uris, "is not an allowed redirect URI: #{uri}")
173
+ end
174
+ end
175
+ end
176
+
177
+ def loopback_redirect?(parsed)
178
+ parsed.is_a?(URI::HTTP) && LOOPBACK_HOSTS.include?(parsed.host.to_s.downcase)
179
+ end
180
+
181
+ def redirect_uri_allowlisted?(uri)
182
+ patterns = config_value(:allowed_redirect_uri_patterns, nil)
183
+ return true if patterns.nil?
184
+
185
+ Array(patterns).any? { |pat| pat.is_a?(Regexp) ? full_match?(pat, uri.to_s) : pat.to_s == uri.to_s }
186
+ end
187
+
188
+ # A Regexp must match the WHOLE URI, even if the operator forgot \A/\z —
189
+ # otherwise %r{https://claude\.ai/cb} would accept
190
+ # https://evil.example/?x=https://claude.ai/cb.
191
+ def full_match?(pattern, string)
192
+ match = pattern.match(string)
193
+ !match.nil? && match.begin(0).zero? && match.end(0) == string.length
194
+ end
195
+
196
+ # Reject (strict) or narrow (non-strict) scopes the server doesn't know, so
197
+ # `*` / `admin` can't be registered and echoed back.
198
+ def validate_scope_known
199
+ return if scope.blank?
200
+
201
+ unknown = Mcp::Auth::ScopeRegistry.unknown_scopes(scope)
202
+ return if unknown.empty?
203
+
204
+ if config_value(:strict_scope_validation, true) != false
205
+ errors.add(:scope, "contains unsupported scope(s): #{unknown.join(' ')}")
206
+ else
207
+ self.scope = (scope.split - unknown).join(' ')
208
+ end
209
+ end
210
+
211
+ def validate_client_uri
212
+ return if client_uri.blank?
213
+
214
+ parsed = URI.parse(client_uri.to_s)
215
+ errors.add(:client_uri, 'must be an http(s) URL') unless parsed.is_a?(URI::HTTP) && parsed.host.present?
216
+ rescue URI::InvalidURIError
217
+ errors.add(:client_uri, 'must be an http(s) URL')
218
+ end
219
+
220
+ # Boolean settings are compared against an explicit `false` by the callers,
221
+ # so nil (unset) means the documented default — matching the controller.
222
+ def config_value(key, default)
223
+ config = Mcp::Auth.configuration
224
+ config.respond_to?(key) ? config.public_send(key) : default
225
+ end
226
+
78
227
  def valid_redirect_uri_format?(uri)
79
228
  parsed = URI.parse(uri.to_s)
229
+ return false if FORBIDDEN_REDIRECT_SCHEMES.include?(parsed.scheme.to_s.downcase)
80
230
  return true if parsed.is_a?(URI::HTTP) && parsed.host.present? # http(s) with host
81
231
  return true if parsed.scheme.present? && uri.to_s.include?('://') # native app scheme
82
232
 
@@ -13,17 +13,28 @@ 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.
18
+ attr_accessor :plaintext_token
19
+
16
20
  validates :token, presence: true, uniqueness: true
17
21
  validates :client_id, presence: true
18
22
  validates :expires_at, presence: true
19
23
 
20
- scope :active, -> { where('expires_at > ?', Time.current) }
24
+ # A token is "live" only if it is neither expired nor revoked. Rotation
25
+ # marks the presented token revoked (rather than deleting it) so that a
26
+ # later replay of the same token can be DETECTED as reuse.
27
+ scope :active, -> { where(revoked_at: nil).where('expires_at > ?', Time.current) }
21
28
  scope :expired, -> { where('expires_at <= ?', Time.current) }
22
29
 
23
30
  def expired?
24
31
  expires_at <= Time.current
25
32
  end
26
33
 
34
+ def revoked?
35
+ revoked_at.present?
36
+ end
37
+
27
38
  def self.cleanup_expired
28
39
  expired.delete_all
29
40
  end
@@ -297,8 +297,21 @@
297
297
  <div class="client-info">
298
298
  <strong><%= @client_name %></strong>
299
299
  <p>wants to access your MCP server</p>
300
+ <p class="redirect-info">
301
+ You will be sent back to <strong><%= @redirect_host.presence || 'an unknown address' %></strong>
302
+ </p>
300
303
  </div>
301
304
 
305
+ <% unless @redirect_verified || @redirect_loopback %>
306
+ <div class="warning-box">
307
+ <div class="warning-icon">⚠️</div>
308
+ <div class="warning-text">
309
+ <strong>Unverified application.</strong> The name above was supplied by the application itself.
310
+ Only continue if you started this connection and recognize the address above.
311
+ </div>
312
+ </div>
313
+ <% end %>
314
+
302
315
  <% if @requested_scopes.any? { |s| s[:required] } %>
303
316
  <div class="warning-box">
304
317
  <div class="warning-icon">⚠️</div>
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Mcp
7
+ module Auth
8
+ module Generators
9
+ # Step 2 of upgrading an existing install: copies the data migration that
10
+ # hashes secrets already stored in plaintext (client secrets, tokens, codes)
11
+ # to sha256$ digests, in place. Run it only after EVERY server runs a
12
+ # version that reads hashed rows (>= 0.6.0): older versions look secrets
13
+ # up by their plaintext and would reject every client and token.
14
+ # Irreversible. Safe to re-run the generator (an existing migration is
15
+ # skipped) and the migration (already-hashed rows are left alone).
16
+ #
17
+ # rails generate mcp:auth:hash_secrets
18
+ # rails db:migrate
19
+ class HashSecretsGenerator < Rails::Generators::Base
20
+ include ActiveRecord::Generators::Migration
21
+
22
+ source_root File.expand_path('templates', __dir__)
23
+
24
+ desc 'Adds the migration that hashes existing MCP Auth secrets at rest (run after every server is upgraded).'
25
+
26
+ def copy_hash_migration
27
+ migration_template 'hash_mcp_auth_secrets_at_rest.rb.erb',
28
+ 'db/migrate/hash_mcp_auth_secrets_at_rest.rb',
29
+ migration_version: migration_version
30
+ end
31
+
32
+ def show_post_install_message
33
+ say "\nMCP Auth secrets-hashing migration added.", :green
34
+ say 'Only run it once NO server runs mcp-auth <= 0.5.0 (they cannot read hashed rows):'
35
+ say ' rails db:migrate'
36
+ say 'Irreversible: afterwards, rolling back to <= 0.5.0 signs every client out.'
37
+ say 'Then harden: config.secret_dual_read = false in config/initializers/mcp_auth.rb'
38
+ end
39
+
40
+ private
41
+
42
+ def migration_version
43
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -29,6 +29,18 @@ module Mcp
29
29
  migration_template "create_refresh_tokens.rb.erb",
30
30
  "db/migrate/create_mcp_auth_refresh_tokens.rb",
31
31
  migration_version: migration_version
32
+
33
+ # Hashes secrets at rest. On a fresh install this runs against empty
34
+ # tables (a no-op). Existing installs upgrade with mcp:auth:upgrade and,
35
+ # later, mcp:auth:hash_secrets instead of re-running this generator.
36
+ migration_template "hash_mcp_auth_secrets_at_rest.rb.erb",
37
+ "db/migrate/hash_mcp_auth_secrets_at_rest.rb",
38
+ migration_version: migration_version
39
+
40
+ # Confidential-client auth method + refresh-token reuse-detection columns.
41
+ migration_template "add_mcp_auth_confidential_client_and_reuse.rb.erb",
42
+ "db/migrate/add_mcp_auth_confidential_client_and_reuse.rb",
43
+ migration_version: migration_version
32
44
  end
33
45
 
34
46
  def copy_initializer
@@ -56,7 +68,7 @@ module Mcp
56
68
  say "MCP Auth has been installed!", :green
57
69
  say "="*80
58
70
  say "\nFiles created:"
59
- say " - db/migrate/*_create_mcp_auth_*.rb (4 migrations)"
71
+ say " - db/migrate/*_mcp_auth_*.rb (6 migrations)"
60
72
  say " - config/initializers/mcp_auth.rb"
61
73
  say " - app/views/mcp/auth/consent.html.erb"
62
74
  say "\nNext steps:"
@@ -0,0 +1,39 @@
1
+ class AddMcpAuthConfidentialClientAndReuse < ActiveRecord::Migration<%= migration_version %>
2
+ # Adds columns for:
3
+ # * confidential-client authentication — `token_endpoint_auth_method` on
4
+ # clients. Existing rows default to 'none' (public / PKCE), so no already-
5
+ # registered client is suddenly required to send a secret — a client opts
6
+ # into confidential auth explicitly at (re-)registration.
7
+ # * refresh-token reuse detection — `family_id` groups a rotation chain and
8
+ # `revoked_at` marks a consumed token, so replaying a rotated token is
9
+ # detected and the whole family is revoked.
10
+ def up
11
+ add_column :mcp_auth_oauth_clients, :token_endpoint_auth_method, :string,
12
+ null: false, default: 'none'
13
+
14
+ add_column :mcp_auth_refresh_tokens, :family_id, :string
15
+ add_column :mcp_auth_refresh_tokens, :revoked_at, :datetime
16
+ add_index :mcp_auth_refresh_tokens, :family_id
17
+
18
+ # Backfill a distinct family per existing refresh token. Without this, legacy
19
+ # tokens keep family_id = NULL, and reuse detection can't revoke their family
20
+ # (revoke_refresh_family(nil) is a no-op) — so a token stolen before its first
21
+ # rotation couldn't cut off the successor. One family-per-token is correct:
22
+ # each pre-existing token is the root of its own future rotation chain.
23
+ say_with_time 'Backfilling refresh-token family_id' do
24
+ count = 0
25
+ select_all('SELECT id FROM mcp_auth_refresh_tokens WHERE family_id IS NULL').each do |row|
26
+ execute("UPDATE mcp_auth_refresh_tokens SET family_id = #{quote(SecureRandom.uuid)} WHERE id = #{quote(row['id'])}")
27
+ count += 1
28
+ end
29
+ count
30
+ end
31
+ end
32
+
33
+ def down
34
+ remove_index :mcp_auth_refresh_tokens, :family_id
35
+ remove_column :mcp_auth_refresh_tokens, :revoked_at
36
+ remove_column :mcp_auth_refresh_tokens, :family_id
37
+ remove_column :mcp_auth_oauth_clients, :token_endpoint_auth_method
38
+ end
39
+ end
@@ -0,0 +1,72 @@
1
+ class HashMcpAuthSecretsAtRest < ActiveRecord::Migration<%= migration_version %>
2
+ # Backfill: convert secrets already stored in PLAINTEXT to one-way digests.
3
+ #
4
+ # Why this is safe for EXISTING clients/tokens
5
+ # --------------------------------------------
6
+ # At migration time the plaintext is still present in each column, so we hash
7
+ # it IN PLACE. Nothing needs to be re-issued: a client keeps working because it
8
+ # still holds the original value (client_secret / refresh token / auth code)
9
+ # and presents it as before — the gem now re-hashes what is presented and
10
+ # matches it against the stored digest. Access-token JWTs are likewise matched
11
+ # by digest for the revocation lookup; the client keeps sending the full JWT.
12
+ #
13
+ # The `sha256$` prefix makes each digest self-identifying, which:
14
+ # * makes this migration idempotent (a value already prefixed is skipped), and
15
+ # * disambiguates a digest from a plaintext SecureRandom.hex(32) — both are
16
+ # otherwise 64 hex characters.
17
+ #
18
+ # Hashing is done in Ruby (not a DB-specific SQL function) so it runs portably
19
+ # on SQLite, MySQL, and PostgreSQL.
20
+ #
21
+ # Irreversible by design.
22
+ PREFIX = 'sha256$'
23
+ BATCH_SIZE = 1000
24
+
25
+ def up
26
+ backfill_digest('mcp_auth_oauth_clients', 'client_secret', 'client_id')
27
+ backfill_digest('mcp_auth_access_tokens', 'token', 'id')
28
+ backfill_digest('mcp_auth_refresh_tokens', 'token', 'id')
29
+ backfill_digest('mcp_auth_authorization_codes', 'code', 'id')
30
+ end
31
+
32
+ def down
33
+ raise ActiveRecord::IrreversibleMigration,
34
+ 'Secrets are stored as one-way hashes and cannot be restored to plaintext.'
35
+ end
36
+
37
+ private
38
+
39
+ def backfill_digest(table, column, pk)
40
+ return unless data_source_exists?(table) && column_exists?(table, column)
41
+
42
+ say_with_time "Hashing #{table}.#{column} at rest" do
43
+ updated = 0
44
+ each_batch(table, column, pk) do |row|
45
+ value = row['val']
46
+ next if value.blank? || value.to_s.start_with?(PREFIX)
47
+
48
+ digest = "#{PREFIX}#{Digest::SHA256.hexdigest(value.to_s)}"
49
+ execute("UPDATE #{table} SET #{column} = #{quote(digest)} WHERE #{pk} = #{quote(row['pk'])}")
50
+ updated += 1
51
+ end
52
+ updated
53
+ end
54
+ end
55
+
56
+ # Keyset-paginate by primary key so a large table (access tokens accumulate)
57
+ # is never loaded into memory at once. The pk is never modified, so the
58
+ # cursor is stable while rows are rewritten.
59
+ def each_batch(table, column, pk, &block)
60
+ last_pk = nil
61
+ loop do
62
+ where = last_pk.nil? ? '' : "WHERE #{pk} > #{quote(last_pk)}"
63
+ rows = select_all(
64
+ "SELECT #{pk} AS pk, #{column} AS val FROM #{table} #{where} ORDER BY #{pk} LIMIT #{BATCH_SIZE}"
65
+ ).to_a
66
+ break if rows.empty?
67
+
68
+ rows.each(&block)
69
+ last_pk = rows.last['pk']
70
+ end
71
+ end
72
+ end
@@ -5,15 +5,24 @@ Mcp::Auth.configure do |config|
5
5
  # OAUTH CONFIGURATION
6
6
  # ============================================================================
7
7
 
8
- # OAuth secret for signing JWTs
9
- # Should be a secure random string in production (use: rails secret)
10
- config.oauth_secret = ENV.fetch('MCP_HMAC_SECRET', Rails.application.secret_key_base)
8
+ # OAuth secret for signing JWTs (HS256). REQUIRED outside development/test and
9
+ # must be DEDICATED — not Rails.application.secret_key_base, which also signs
10
+ # your cookies. Generate one with `rails secret`. (Dev/test fall back to
11
+ # secret_key_base when unset.) Not used with RS256/ES256 signing.
12
+ config.oauth_secret = ENV['MCP_HMAC_SECRET']
11
13
 
12
14
  # Authorization server URL (optional - defaults to same as resource server)
13
15
  # Set this if you're using a separate authorization server
14
16
  # Example: config.authorization_server_url = 'https://auth.example.com'
15
17
  config.authorization_server_url = ENV.fetch('MCP_AUTHORIZATION_SERVER_URL', nil)
16
18
 
19
+ # Public origin of the MCP resource server (optional - defaults to the request
20
+ # origin). The token audience, protected-resource metadata and 401 challenge
21
+ # are built from it. Set it to pin them against a forged Host header, and
22
+ # always set it when authorization_server_url points at a different host.
23
+ # Example: config.mcp_server_url = 'https://api.example.com'
24
+ config.mcp_server_url = ENV.fetch('MCP_SERVER_URL', nil)
25
+
17
26
  # ============================================================================
18
27
  # MCP SERVER CONFIGURATION
19
28
  # ============================================================================
@@ -40,6 +49,14 @@ Mcp::Auth.configure do |config|
40
49
  config.refresh_token_lifetime = 2_592_000 # 30 days
41
50
  config.authorization_code_lifetime = 1800 # 30 minutes
42
51
 
52
+ # Refresh-token rotation grace period (seconds). Refresh tokens rotate on every
53
+ # use and reuse is treated as theft (the whole token family is revoked). Real
54
+ # MCP clients often fire several refreshes at once when the access token
55
+ # expires, so a rotated token replayed WITHIN this window is treated as a benign
56
+ # race (rejected softly, family kept); a replay after it is treated as theft.
57
+ # Set to 0 to disable the grace and revoke on any replay.
58
+ config.refresh_token_reuse_grace_period = 10
59
+
43
60
  # ============================================================================
44
61
  # USER DATA FETCHER
45
62
  # ============================================================================
@@ -80,6 +97,32 @@ Mcp::Auth.configure do |config|
80
97
  config.current_user_method = :current_user
81
98
  config.current_org_method = :current_org
82
99
 
100
+ # ============================================================================
101
+ # DYNAMIC CLIENT REGISTRATION (DCR) POLICY
102
+ # ============================================================================
103
+
104
+ # /oauth/register is open (RFC 7591), so restrict where authorization codes can
105
+ # be sent. Plain http is always rejected except for loopback hosts.
106
+ #
107
+ # Restrict registration to known clients (Strings match exactly, Regexps must
108
+ # match the WHOLE URI, anchored or not; end a pattern with `.*` to allow any
109
+ # path, e.g. %r{https://app\.example\.com/.*}). nil = any https /
110
+ # native-scheme URI.
111
+ # config.allowed_redirect_uri_patterns = [
112
+ # %r{\Ahttps://claude\.ai/api/mcp/auth_callback\z},
113
+ # %r{\Ahttps://chatgpt\.com/connector_platform_oauth_redirect\z}
114
+ # ]
115
+
116
+ # RFC 8252 loopback redirects (localhost / 127.0.0.1 / [::1], any port).
117
+ # config.allow_loopback_redirects = true
118
+
119
+ # Redirect hosts shown as "verified" on the consent screen; others are flagged
120
+ # as unverified applications.
121
+ # config.verified_redirect_hosts = %w[claude.ai chatgpt.com]
122
+
123
+ # Reject unknown scopes (registration + /oauth/authorize invalid_scope).
124
+ # config.strict_scope_validation = true
125
+
83
126
  # ============================================================================
84
127
  # SCOPE CONFIGURATION
85
128
  # ============================================================================
@@ -195,7 +238,6 @@ Mcp::Auth.configure do |config|
195
238
  # * :required - Whether scope is required (true/false)
196
239
  # * :pre_selected - Whether scope was in the original request
197
240
  # - @authorization_params: Hash of OAuth parameters to preserve
198
- end
199
241
 
200
242
  # ============================================================================
201
243
  # JWT SIGNING (OPTIONAL)
@@ -214,6 +256,27 @@ end
214
256
  # keep verifying and both keys are published at /.well-known/jwks.json:
215
257
  # config.token_signing_additional_public_keys = [ENV['MCP_JWT_PREVIOUS_PUBLIC_KEY']]
216
258
 
259
+ # ============================================================================
260
+ # SECRETS HASHED AT REST — TRANSITIONAL DUAL-READ (OPTIONAL)
261
+ # ============================================================================
262
+ #
263
+ # Access tokens, refresh tokens, authorization codes, and client secrets are
264
+ # stored as one-way SHA-256 digests. While `secret_dual_read` is true (the
265
+ # default), a presented value is matched against BOTH its digest and any legacy
266
+ # PLAINTEXT row not yet backfilled, so this version keeps working before and
267
+ # while the backfill migration runs.
268
+ #
269
+ # Upgrading an existing install: `rails g mcp:auth:upgrade && rails db:migrate`
270
+ # (additive columns) before deploying; once EVERY server runs this version,
271
+ # `rails g mcp:auth:hash_secrets && rails db:migrate` hashes existing rows.
272
+ # Older versions (<= 0.5.0) cannot read hashed rows, so rolling back after that
273
+ # backfill signs every client out.
274
+ #
275
+ # Once every row is hashed, HARDEN by turning it off so plaintext-form matches
276
+ # are rejected:
277
+ # config.secret_dual_read = false
278
+ end
279
+
217
280
  # ============================================================================
218
281
  # PROTECTING YOUR MCP ENDPOINT (RESOURCE SERVER)
219
282
  # ============================================================================
@@ -297,8 +297,21 @@
297
297
  <div class="client-info">
298
298
  <strong><%%= @client_name %></strong>
299
299
  <p>wants to access your MCP server</p>
300
+ <p class="redirect-info">
301
+ You will be sent back to <strong><%%= @redirect_host.presence || 'an unknown address' %></strong>
302
+ </p>
300
303
  </div>
301
304
 
305
+ <%% unless @redirect_verified || @redirect_loopback %>
306
+ <div class="warning-box">
307
+ <div class="warning-icon">⚠️</div>
308
+ <div class="warning-text">
309
+ <strong>Unverified application.</strong> The name above was supplied by the application itself.
310
+ Only continue if you started this connection and recognize the address above.
311
+ </div>
312
+ </div>
313
+ <%% end %>
314
+
302
315
  <%% if @requested_scopes.any? { |s| s[:required] } %>
303
316
  <div class="warning-box">
304
317
  <div class="warning-icon">⚠️</div>
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Mcp
7
+ module Auth
8
+ module Generators
9
+ # Step 1 of upgrading an EXISTING install: copies the additive schema
10
+ # migration (new columns only) without touching the initializer or views
11
+ # (unlike the full install generator, which would prompt to overwrite them).
12
+ # Safe to re-run: `migration_template` skips a migration whose class already
13
+ # exists.
14
+ #
15
+ # The columns are purely additive, so older gem versions keep working with
16
+ # them. Run this migration BEFORE (or as part of) deploying the new version:
17
+ # the new code needs the columns and answers every OAuth endpoint with a 500
18
+ # until they exist. Hashing existing secrets is a separate, later step
19
+ # (`rails generate mcp:auth:hash_secrets`), because older versions cannot
20
+ # read hashed rows.
21
+ #
22
+ # rails generate mcp:auth:upgrade
23
+ # rails db:migrate
24
+ class UpgradeGenerator < Rails::Generators::Base
25
+ include ActiveRecord::Generators::Migration
26
+
27
+ source_root File.expand_path('templates', __dir__)
28
+
29
+ desc 'Adds pending MCP Auth schema migrations for an existing install (no initializer/view changes).'
30
+
31
+ def copy_pending_migrations
32
+ migration_template 'add_mcp_auth_confidential_client_and_reuse.rb.erb',
33
+ 'db/migrate/add_mcp_auth_confidential_client_and_reuse.rb',
34
+ migration_version: migration_version
35
+ end
36
+
37
+ def show_post_install_message
38
+ say "\nMCP Auth upgrade migration added (new columns only).", :green
39
+ say 'Next steps:'
40
+ say ' 1. Run it BEFORE the new gem version serves traffic (e.g. in your release'
41
+ say ' phase). The columns are additive, so servers still on the old version'
42
+ say ' keep working:'
43
+ say ' rails db:migrate'
44
+ say ' 2. Deploy this gem version to every server. If you sign with HS256, set a'
45
+ say ' dedicated MCP_HMAC_SECRET first (not secret_key_base).'
46
+ say ' 3. Once NO server runs the old version, hash existing secrets at rest:'
47
+ say ' rails generate mcp:auth:hash_secrets && rails db:migrate'
48
+ say ' 4. Then disable the transitional dual-read in config/initializers/mcp_auth.rb:'
49
+ say ' config.secret_dual_read = false'
50
+ end
51
+
52
+ private
53
+
54
+ def migration_version
55
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end