mcp-auth 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +217 -2
- data/README.md +16 -2
- data/app/controllers/mcp/auth/oauth_controller.rb +264 -56
- data/app/controllers/mcp/auth/well_known_controller.rb +23 -8
- data/app/models/mcp/auth/access_token.rb +5 -0
- data/app/models/mcp/auth/authorization_code.rb +4 -0
- data/app/models/mcp/auth/oauth_client.rb +154 -4
- data/app/models/mcp/auth/refresh_token.rb +12 -1
- data/app/views/mcp/auth/consent.html.erb +13 -0
- data/lib/generators/mcp/auth/hash_secrets_generator.rb +48 -0
- data/lib/generators/mcp/auth/install_generator.rb +13 -1
- data/lib/generators/mcp/auth/templates/add_mcp_auth_confidential_client_and_reuse.rb.erb +39 -0
- data/lib/generators/mcp/auth/templates/hash_mcp_auth_secrets_at_rest.rb.erb +72 -0
- data/lib/generators/mcp/auth/templates/initializer.rb +67 -4
- data/lib/generators/mcp/auth/templates/views/consent.html.erb +13 -0
- data/lib/generators/mcp/auth/upgrade_generator.rb +60 -0
- data/lib/mcp/auth/engine.rb +35 -1
- data/lib/mcp/auth/protected_resource.rb +29 -6
- data/lib/mcp/auth/schema_guard.rb +67 -0
- data/lib/mcp/auth/scope_registry.rb +12 -0
- data/lib/mcp/auth/secret_hashing.rb +83 -0
- data/lib/mcp/auth/services/authorization_service.rb +18 -8
- data/lib/mcp/auth/services/token_service.rb +144 -23
- data/lib/mcp/auth/version.rb +1 -1
- data/lib/mcp/auth.rb +51 -4
- data/lib/tasks/mcp_auth_tasks.rake +40 -6
- metadata +18 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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/*
|
|
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
|
-
#
|
|
10
|
-
|
|
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
|