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.
@@ -21,12 +21,46 @@ module Mcp
21
21
 
22
22
  initializer "mcp_auth.configure" do
23
23
  config.mcp_auth = ActiveSupport::OrderedOptions.new
24
- config.mcp_auth.oauth_secret = ENV.fetch('MCP_OAUTH_PRIVATE_KEY', nil)
25
24
  config.mcp_auth.authorization_server_url = ENV.fetch('MCP_AUTHORIZATION_SERVER_URL', nil)
26
25
  config.mcp_auth.access_token_lifetime = 3600 # 1 hour
27
26
  config.mcp_auth.refresh_token_lifetime = 2_592_000 # 30 days
28
27
  config.mcp_auth.authorization_code_lifetime = 1800 # 30 minutes
29
28
  end
29
+
30
+ # Keep OAuth credentials out of the host app's logs. Rails' default filters
31
+ # (:token, :secret, ...) miss the authorization `code` and the PKCE
32
+ # `code_verifier`, which together are enough to redeem a code; the
33
+ # redirect back to the client (`Redirected to ...?code=...`) is filtered too.
34
+ # Anchored regexes, not symbols: Rails matches symbols as SUBSTRINGS across
35
+ # the whole app (and ActiveRecord filter_attributes), so `:code` would also
36
+ # mask `postal_code`, `promo_code`, ...
37
+ OAUTH_FILTER_PARAMETERS = [
38
+ /\Acode\z/, /\Acode_verifier\z/, /\Aclient_secret\z/, /\Arefresh_token\z/,
39
+ /\Aaccess_token\z/, /\Aid_token\z/, /\Atoken\z/
40
+ ].freeze
41
+
42
+ initializer 'mcp_auth.filter_parameters' do |app|
43
+ app.config.filter_parameters |= OAUTH_FILTER_PARAMETERS
44
+ app.config.filter_redirect << /[?&]code=/
45
+ end
46
+
47
+ # Surface a pending mcp-auth migration at boot so an operator who upgraded
48
+ # the gem without running migrations gets a clear, actionable warning
49
+ # instead of a cryptic runtime error on the first OAuth request. Never
50
+ # raises (missing_columns is fully guarded), so it can't break boot/CI.
51
+ config.after_initialize do
52
+ missing = Mcp::Auth::SchemaGuard.missing_columns
53
+ Rails.logger.warn("[mcp-auth] #{Mcp::Auth::SchemaGuard.guidance(missing)}") if missing.any?
54
+
55
+ # Same idea for the signing secret: warn at boot rather than only failing
56
+ # on the first token request (outside dev/test, where it's a hard error).
57
+ unless Rails.env.development? || Rails.env.test?
58
+ problem = Mcp::Auth::Services::TokenService.oauth_secret_problem
59
+ Rails.logger.warn("[mcp-auth] #{problem}") if problem
60
+ end
61
+ rescue StandardError => e
62
+ Rails.logger.debug { "[mcp-auth] boot checks skipped: #{e.class}" }
63
+ end
30
64
  end
31
65
  end
32
66
  end
@@ -20,6 +20,21 @@ module Mcp
20
20
  extend ActiveSupport::Concern
21
21
  include Mcp::Auth::ControllerHelpers
22
22
 
23
+ # Build the RFC 9728 §5.1 / MCP-spec `WWW-Authenticate` header value for a
24
+ # 401 from a protected MCP resource. Exposed as a plain function (no request
25
+ # object needed) so it can be used from a Rack middleware guarding /mcp, not
26
+ # only from this controller concern — a 401 without this header leaves
27
+ # spec-compliant MCP clients (e.g. MCP Inspector) unable to discover the
28
+ # protected-resource metadata and refresh/re-authorize.
29
+ #
30
+ # headers['WWW-Authenticate'] =
31
+ # Mcp::Auth::ProtectedResource.www_authenticate(request.base_url)
32
+ def self.www_authenticate(base_url, error: 'invalid_token',
33
+ description: 'The access token is missing, invalid, or expired')
34
+ metadata_url = "#{base_url}/.well-known/oauth-protected-resource"
35
+ %(Bearer error="#{error}", error_description="#{description}", resource_metadata="#{metadata_url}")
36
+ end
37
+
23
38
  # Validates the Bearer access token (signature, expiry, revocation status,
24
39
  # and — when a resource is configured — the RFC 8707 audience). On success
25
40
  # the decoded claims are stashed in request.env for ControllerHelpers and
@@ -66,21 +81,29 @@ module Mcp
66
81
  header.split(' ', 2).last.presence
67
82
  end
68
83
 
69
- # Canonical resource identifier for this server (base_url + mcp_server_path),
70
- # matching the audience minted into access tokens.
84
+ # Canonical resource identifier for this server (MCP server origin +
85
+ # mcp_server_path), matching the audience minted into access tokens.
71
86
  def mcp_resource_identifier
72
87
  path = Mcp::Auth.configuration&.mcp_server_path.presence || '/mcp'
73
88
  path = "/#{path}" unless path.start_with?('/')
74
- "#{request.base_url}#{path.chomp('/')}"
89
+ "#{mcp_server_origin}#{path.chomp('/')}"
90
+ end
91
+
92
+ # The configured mcp_server_url when set, else the request origin (the host
93
+ # app MUST then restrict hosts via `config.hosts`). Deliberately NOT
94
+ # authorization_server_url: with a separate authorization server, the MCP
95
+ # resource (and its metadata) still lives on this app's host.
96
+ def mcp_server_origin
97
+ Mcp::Auth.configuration&.mcp_server_url.presence || request.base_url
75
98
  end
76
99
 
77
100
  # RFC 9728 §5.1 / MCP authorization spec: a 401 MUST advertise the
78
101
  # protected-resource metadata document via WWW-Authenticate so clients can
79
- # bootstrap the OAuth flow.
102
+ # bootstrap the OAuth flow. Set mcp_server_url to pin the metadata URL so
103
+ # a forged Host header can't steer clients to attacker-controlled metadata.
80
104
  def render_mcp_unauthorized(error, description, status: :unauthorized)
81
- metadata_url = "#{request.base_url}/.well-known/oauth-protected-resource"
82
105
  response.headers['WWW-Authenticate'] =
83
- %(Bearer error="#{error}", error_description="#{description}", resource_metadata="#{metadata_url}")
106
+ Mcp::Auth::ProtectedResource.www_authenticate(mcp_server_origin, error: error, description: description)
84
107
  render json: { error: error, error_description: description }, status: status
85
108
  end
86
109
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcp
4
+ module Auth
5
+ # Raised when the loaded gem needs database columns the app hasn't migrated
6
+ # yet. Carries an actionable message naming the exact command to run.
7
+ class PendingMigrationError < Mcp::Auth::Error; end
8
+
9
+ # Detects the common upgrade hazard: a new gem version ships migrations, the
10
+ # app is deployed WITHOUT running them, and the code then blows up at runtime
11
+ # with a cryptic `unknown attribute` / `UndefinedColumn`. This turns that into
12
+ # an explicit, self-explaining condition — a loud boot warning and a clear
13
+ # error at the OAuth endpoints — so the operator knows to run `rails db:migrate`.
14
+ module SchemaGuard
15
+ # Columns each release added beyond the original schema, keyed by model.
16
+ # Extend this whenever a future migration adds a column the code relies on.
17
+ REQUIRED_COLUMNS = {
18
+ 'Mcp::Auth::OauthClient' => %w[token_endpoint_auth_method],
19
+ 'Mcp::Auth::RefreshToken' => %w[family_id revoked_at]
20
+ }.freeze
21
+
22
+ module_function
23
+
24
+ # "table.column" entries the code needs but the database is missing. Empty
25
+ # when the schema is current. NEVER raises and returns [] when the database
26
+ # isn't available or a table doesn't exist yet (fresh install, `db:create`,
27
+ # asset precompile, CI without a database) so it can't break boot or tasks.
28
+ def missing_columns
29
+ REQUIRED_COLUMNS.flat_map do |model_name, columns|
30
+ model = model_name.constantize
31
+ next [] unless model.table_exists?
32
+
33
+ (columns - model.column_names).map { |column| "#{model.table_name}.#{column}" }
34
+ end
35
+ rescue StandardError => e
36
+ # ActiveRecord::NoDatabaseError, ConnectionNotEstablished, StatementInvalid,
37
+ # etc. — we simply can't check here, so treat as "nothing to report".
38
+ Rails.logger.debug { "[mcp-auth] schema check skipped: #{e.class}" } if defined?(Rails)
39
+ []
40
+ end
41
+
42
+ # Memoized fast path for the per-request guard. Columns are only ever ADDED
43
+ # by a migration + restart, so once the schema is current for this process
44
+ # it stays current — we can cache the affirmative and skip the check on
45
+ # every subsequent request. A negative result is NOT cached, so the guard
46
+ # keeps reporting the problem until it's actually resolved.
47
+ def up_to_date?
48
+ return true if @up_to_date
49
+
50
+ @up_to_date = missing_columns.empty?
51
+ end
52
+
53
+ # Raise unless the schema is current (used where a hard failure is wanted).
54
+ def check!
55
+ missing = missing_columns
56
+ return true if missing.empty?
57
+
58
+ raise PendingMigrationError, guidance(missing)
59
+ end
60
+
61
+ def guidance(missing = missing_columns)
62
+ "mcp-auth #{Mcp::Auth::VERSION} needs a database migration — missing #{missing.join(', ')}. " \
63
+ 'Run `rails g mcp:auth:upgrade && rails db:migrate`, then restart.'
64
+ end
65
+ end
66
+ end
67
+ end
@@ -60,6 +60,18 @@ module Mcp
60
60
  @custom_scopes = {}
61
61
  end
62
62
 
63
+ # Scopes that may appear in a request without being rejected as unknown:
64
+ # registered scopes plus the standard OIDC scopes and offline_access.
65
+ def recognized_scope?(scope)
66
+ scope_exists?(scope) || (STANDARD_OIDC_SCOPES + %w[offline_access]).include?(scope.to_s)
67
+ end
68
+
69
+ # Tokens of a scope string (or array) that are not recognized.
70
+ def unknown_scopes(requested_scopes)
71
+ scopes = requested_scopes.is_a?(String) ? requested_scopes.split : Array(requested_scopes)
72
+ scopes.reject { |scope| recognized_scope?(scope) }
73
+ end
74
+
63
75
  # Check if a scope exists
64
76
  def scope_exists?(scope)
65
77
  available_scopes.key?(scope.to_s)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Mcp
6
+ module Auth
7
+ # One-way hashing for the secrets we persist: client secrets, access tokens,
8
+ # refresh tokens, and authorization codes.
9
+ #
10
+ # We only ever *verify* these values against a copy the caller presents later
11
+ # — we never need to read the original back. That makes a one-way hash
12
+ # strictly safer than reversible encryption: a database leak yields useless
13
+ # digests, and there is no decryption key that (if it also leaked) could turn
14
+ # them back into working credentials.
15
+ #
16
+ # The values are high-entropy (>= 256-bit SecureRandom, or signed JWTs), so a
17
+ # single unsalted SHA-256 is sufficient — there is no dictionary/brute-force
18
+ # risk the way there is for low-entropy passwords (which would need bcrypt/
19
+ # argon2). SHA-256 is also deterministic, which lets us look a token up
20
+ # directly by its digest through the existing unique index.
21
+ #
22
+ # Digests are stored with a `sha256$` prefix so they are self-identifying:
23
+ # this makes the backfill migration idempotent (a value already carrying the
24
+ # prefix is left alone) and leaves room to introduce a different scheme later
25
+ # without ambiguity.
26
+ module SecretHashing
27
+ PREFIX = 'sha256$'
28
+
29
+ module_function
30
+
31
+ # Digest a plaintext value for storage/lookup. ALWAYS hashes non-blank
32
+ # input — including a value that already carries the prefix. This is a
33
+ # security property, not a nicety: a presented credential is only ever the
34
+ # raw secret, so if we instead returned an already-`sha256$`-prefixed input
35
+ # unchanged, an attacker who read the stored digest from the database could
36
+ # replay that digest verbatim and it would match the row (defeating
37
+ # hashing-at-rest). Re-hashing a prefixed value means the stored digest is
38
+ # never itself a valid credential. (The backfill migration achieves
39
+ # idempotency with its own prefix guard, not via this method.)
40
+ def digest(value)
41
+ return value if value.blank?
42
+
43
+ "#{PREFIX}#{Digest::SHA256.hexdigest(value.to_s)}"
44
+ end
45
+
46
+ def hashed?(value)
47
+ value.to_s.start_with?(PREFIX)
48
+ end
49
+
50
+ # Whether transitional dual-read is active (see Configuration#secret_dual_read).
51
+ # Defaults to true when configuration is absent so an un-configured app
52
+ # still upgrades safely.
53
+ def dual_read?
54
+ config = Mcp::Auth.configuration
55
+ config.nil? || config.secret_dual_read != false
56
+ end
57
+
58
+ # The stored-column values a presented secret could legitimately match:
59
+ # always its digest, plus — while dual-read is enabled — the raw plaintext,
60
+ # so a legacy row not yet backfilled by the migration still resolves. Use
61
+ # with `where(column: candidates)`.
62
+ def lookup_candidates(value)
63
+ return [] if value.blank?
64
+
65
+ candidates = [digest(value)]
66
+ candidates << value.to_s if dual_read? && !hashed?(value)
67
+ candidates.uniq
68
+ end
69
+
70
+ # Constant-time verification of a presented plaintext against a stored
71
+ # value. Matches the digest form, and — under dual-read — a legacy plaintext
72
+ # row as well.
73
+ def match?(stored, presented)
74
+ return false if stored.blank? || presented.blank?
75
+
76
+ return true if ActiveSupport::SecurityUtils.secure_compare(stored.to_s, digest(presented.to_s))
77
+
78
+ dual_read? && !hashed?(stored) &&
79
+ ActiveSupport::SecurityUtils.secure_compare(stored.to_s, presented.to_s)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -5,15 +5,17 @@ module Mcp
5
5
  module Services
6
6
  class AuthorizationService
7
7
  class << self
8
- # Generate authorization code with PKCE support
8
+ # Generate authorization code with PKCE support. The code is returned to
9
+ # the client in plaintext, but only its digest is persisted (hashed at
10
+ # rest).
9
11
  def generate_authorization_code(params, user:, org:)
10
12
  code = SecureRandom.hex(32)
11
13
 
12
14
  # Use provided scope or default to all registered scopes
13
15
  scope = params[:scope].presence || Mcp::Auth::ScopeRegistry.default_scope_string
14
16
 
15
- authorization_code = Mcp::Auth::AuthorizationCode.create!(
16
- code: code,
17
+ Mcp::Auth::AuthorizationCode.create!(
18
+ code: Mcp::Auth::SecretHashing.digest(code),
17
19
  client_id: params[:client_id],
18
20
  redirect_uri: params[:redirect_uri],
19
21
  code_challenge: params[:code_challenge],
@@ -22,11 +24,11 @@ module Mcp
22
24
  scope: scope,
23
25
  user: user,
24
26
  org: org,
25
- expires_at: authorization_code_lifetime.minutes.from_now
27
+ expires_at: authorization_code_lifetime.seconds.from_now
26
28
  )
27
29
 
28
30
  Rails.logger.info "[AuthorizationService] Authorization code generated for user #{user.id}"
29
- authorization_code.code
31
+ code
30
32
  rescue ActiveRecord::RecordInvalid => e
31
33
  Rails.logger.error "[AuthorizationService] Failed to create authorization code: #{e.message}"
32
34
  nil
@@ -36,7 +38,9 @@ module Mcp
36
38
  def validate_authorization_code(code)
37
39
  return nil if code.blank?
38
40
 
39
- authorization_code = Mcp::Auth::AuthorizationCode.active.find_by(code: code)
41
+ authorization_code = Mcp::Auth::AuthorizationCode.active
42
+ .where(code: Mcp::Auth::SecretHashing.lookup_candidates(code))
43
+ .first
40
44
  return nil unless authorization_code
41
45
 
42
46
  {
@@ -61,7 +65,8 @@ module Mcp
61
65
  # and gets nil. Returns the code's data on success, nil if the code was
62
66
  # already consumed (or never existed).
63
67
  def consume_authorization_code(code)
64
- authorization_code = Mcp::Auth::AuthorizationCode.find_by(code: code)
68
+ authorization_code = Mcp::Auth::AuthorizationCode
69
+ .where(code: Mcp::Auth::SecretHashing.lookup_candidates(code)).first
65
70
  return nil unless authorization_code
66
71
 
67
72
  code_data = {
@@ -101,8 +106,13 @@ module Mcp
101
106
 
102
107
  private
103
108
 
109
+ # Authorization-code TTL in SECONDS (matching every other lifetime in
110
+ # the gem). Read from the single canonical config source so an app that
111
+ # configures via Mcp::Auth.configure and one that relies on defaults
112
+ # agree; 1800s = 30 minutes. (Historically this was read from a second
113
+ # config object and applied as `.minutes`, yielding 30-HOUR codes.)
104
114
  def authorization_code_lifetime
105
- Rails.application.config.mcp_auth.authorization_code_lifetime || 30
115
+ Mcp::Auth.configuration&.authorization_code_lifetime || 1800
106
116
  end
107
117
  end
108
118
  end
@@ -4,6 +4,14 @@ module Mcp
4
4
  module Auth
5
5
  module Services
6
6
  class TokenService
7
+ # Clock-skew tolerance (seconds) for `nbf` only, so a token minted by a
8
+ # server whose clock runs slightly ahead isn't rejected as "not yet
9
+ # valid". Expiry is deliberately strict — see validate_access_token.
10
+ CLOCK_SKEW_LEEWAY_SECONDS = 30
11
+
12
+ # Claims every access token must carry.
13
+ REQUIRED_CLAIMS = %w[iss aud sub exp].freeze
14
+
7
15
  class << self
8
16
  # Validate access token with optional resource verification (RFC 8707).
9
17
  # Supports HS256, RS256, and ES256 — algorithm comes from configuration.
@@ -14,17 +22,36 @@ module Mcp
14
22
  payload = decode_with_known_keys(token)
15
23
  return nil unless payload
16
24
 
17
- # Check expiration manually to ensure proper handling
25
+ # Required claims, checked here too: ruby-jwt versions older than the
26
+ # `required_claims` decode option silently ignore it, and the gemspec
27
+ # allows them. Without this, a token lacking `aud` would skip the
28
+ # audience check below on those versions.
29
+ return nil unless REQUIRED_CLAIMS.all? { |claim| payload[claim].present? }
30
+
31
+ # Expiry is strict (no leeway): this server validates tokens it
32
+ # issued itself, so there's no cross-machine skew to absorb.
18
33
  return nil if payload['exp'] && (payload['exp'] <= Time.current.to_i)
19
34
 
35
+ # Token-use separation: an id_token is signed with the same key/alg
36
+ # as an access token, so cryptographically it would otherwise verify
37
+ # here. Reject anything not minted as an access token. (Legacy tokens
38
+ # without the claim are still accepted; the DB row check below is the
39
+ # second gate — id_tokens are never stored.)
40
+ token_use = payload['token_use']
41
+ return nil unless token_use.nil? || token_use == 'access'
42
+
20
43
  # Revocation check (RFC 7009): a JWT remains cryptographically valid
21
44
  # until it expires, so a stored-and-still-present row is what makes
22
45
  # `revoke` actually take effect. Without this, destroyed tokens would
23
- # keep validating until natural expiry.
24
- return nil unless Mcp::Auth::AccessToken.active.exists?(token: token)
25
-
26
- # Validate audience if resource provided (RFC 8707 compliance)
27
- if resource && payload['aud'].present? && !audience_matches?(payload['aud'], resource)
46
+ # keep validating until natural expiry. The row is keyed by the
47
+ # token's digest (tokens are hashed at rest).
48
+ candidates = SecretHashing.lookup_candidates(token)
49
+ return nil unless Mcp::Auth::AccessToken.active.where(token: candidates).exists?
50
+
51
+ # Validate audience if a resource was provided (RFC 8707). `aud` is a
52
+ # required claim (enforced at decode), so a token lacking it never
53
+ # reaches here — no silent skip on a missing audience.
54
+ if resource && !audience_matches?(payload['aud'], resource)
28
55
  Rails.logger.warn "[TokenService] Token audience mismatch: expected #{resource}, got #{payload['aud']}"
29
56
  return nil
30
57
  end
@@ -46,7 +73,7 @@ module Mcp
46
73
  def decode_with_known_keys(token)
47
74
  last_error = nil
48
75
  verification_keys.each do |key|
49
- return JWT.decode(token, key, true, { algorithm: signing_algorithm }).first
76
+ return JWT.decode(token, key, true, decode_options).first
50
77
  rescue JWT::DecodeError => e
51
78
  last_error = e
52
79
  end
@@ -55,6 +82,23 @@ module Mcp
55
82
  nil
56
83
  end
57
84
 
85
+ # Decode options shared across verification keys. The algorithm is
86
+ # pinned to a single value (blocks `alg:none` and RS↔HS confusion);
87
+ # `exp` is verified strictly and `nbf` with a small clock-skew leeway;
88
+ # and the
89
+ # core claims are required so a token missing `iss`/`aud`/`sub`/`exp`
90
+ # is rejected outright rather than silently passing later checks.
91
+ def decode_options
92
+ {
93
+ algorithm: signing_algorithm,
94
+ verify_expiration: true,
95
+ verify_not_before: true,
96
+ exp_leeway: 0,
97
+ nbf_leeway: CLOCK_SKEW_LEEWAY_SECONDS,
98
+ required_claims: REQUIRED_CLAIMS
99
+ }
100
+ end
101
+
58
102
  # Generate JWT access token with proper audience binding
59
103
  def generate_access_token(data, base_url:)
60
104
  user_data = fetch_user_data(data)
@@ -76,6 +120,15 @@ module Mcp
76
120
  client_id: data[:client_id],
77
121
  email: user_data[:email],
78
122
  scope: data[:scope],
123
+ # Unique token id (RFC 7519 jti). Guarantees every access token is
124
+ # distinct even when two are issued in the same second for the same
125
+ # principal/scope (e.g. rapid or concurrent refreshes) — without it
126
+ # the JWTs would be byte-identical and collide on the unique token
127
+ # index at storage time.
128
+ jti: SecureRandom.uuid,
129
+ # Marks this JWT as an access token so it can't be replayed as an
130
+ # id_token (or vice versa); verified in validate_access_token.
131
+ token_use: 'access',
79
132
  # Only a non-sensitive API key *identifier* is embedded. A bearer
80
133
  # JWT is decodable by anyone holding it (and is stored at rest), so
81
134
  # the matching secret MUST NOT be placed in the token — the resource
@@ -97,7 +150,11 @@ module Mcp
97
150
  raise
98
151
  end
99
152
 
100
- # Generate refresh token
153
+ # Generate refresh token. The opaque value is returned to the client in
154
+ # plaintext, but only its digest is persisted (hashed at rest). Tokens
155
+ # descend within a `family_id`: the first issuance starts a new family;
156
+ # a rotation reuses the presented token's family so a later replay can
157
+ # be detected as reuse.
101
158
  def generate_refresh_token(data)
102
159
  refresh_token = SecureRandom.hex(32)
103
160
 
@@ -106,11 +163,12 @@ module Mcp
106
163
 
107
164
  begin
108
165
  Mcp::Auth::RefreshToken.create!(
109
- token: refresh_token,
166
+ token: SecretHashing.digest(refresh_token),
110
167
  client_id: data[:client_id],
111
168
  scope: data[:scope],
112
169
  user_id: data[:user_id],
113
170
  org_id: data[:org_id],
171
+ family_id: data[:family_id].presence || SecureRandom.uuid,
114
172
  expires_at: expires_at
115
173
  )
116
174
 
@@ -122,13 +180,54 @@ module Mcp
122
180
  end
123
181
  end
124
182
 
183
+ # Look up the refresh-token record for a presented value in ANY state
184
+ # (active, expired, or revoked) so the caller can implement reuse
185
+ # detection. Honors dual-read (digest or legacy plaintext).
186
+ def find_refresh_token(raw)
187
+ return nil if raw.blank?
188
+
189
+ Mcp::Auth::RefreshToken.where(token: SecretHashing.lookup_candidates(raw)).first
190
+ end
191
+
192
+ # Atomically consume a refresh token by flipping revoked_at from NULL.
193
+ # Returns true only for the request that actually performed the flip, so
194
+ # concurrent redemptions of the same token can't both rotate.
195
+ def rotate_refresh_token(record)
196
+ Mcp::Auth::RefreshToken
197
+ .where(id: record.id, revoked_at: nil)
198
+ .update_all(revoked_at: Time.current) == 1
199
+ end
200
+
201
+ # Revoke every still-live token in a family (used on reuse detection).
202
+ def revoke_refresh_family(family_id)
203
+ return 0 if family_id.blank?
204
+
205
+ Mcp::Auth::RefreshToken
206
+ .where(family_id: family_id, revoked_at: nil)
207
+ .update_all(revoked_at: Time.current)
208
+ end
209
+
210
+ # Delete the access tokens for a (user, client) pair. Called on
211
+ # refresh-token-reuse (theft) detection so the already-issued access
212
+ # token(s) are cut off immediately, not left valid until natural expiry.
213
+ # Access tokens aren't grouped by family_id, so we scope by the
214
+ # principal that the compromised family belonged to.
215
+ def revoke_access_tokens_for(user_id:, client_id:)
216
+ Mcp::Auth::AccessToken.where(user_id: user_id, client_id: client_id).delete_all
217
+ end
218
+
125
219
  # Validate refresh token
126
220
  def validate_refresh_token(refresh_token)
127
221
  return nil if refresh_token.blank?
128
222
 
129
- token_record = Mcp::Auth::RefreshToken.find_by(token: refresh_token)
223
+ token_record = Mcp::Auth::RefreshToken.where(token: SecretHashing.lookup_candidates(refresh_token)).first
130
224
  return nil unless token_record
131
225
 
226
+ # A rotated/revoked token is no longer active (RFC 7662): rotation now
227
+ # marks `revoked_at` instead of deleting the row, so introspection must
228
+ # exclude it explicitly rather than relying on the row being gone.
229
+ return nil if token_record.revoked?
230
+
132
231
  # Check if token is expired
133
232
  return nil if token_record.expires_at < Time.current
134
233
 
@@ -141,18 +240,6 @@ module Mcp
141
240
  }
142
241
  end
143
242
 
144
- # Revoke refresh token (RFC 7009)
145
- def revoke_refresh_token(refresh_token)
146
- return false if refresh_token.blank?
147
-
148
- token_record = Mcp::Auth::RefreshToken.find_by(token: refresh_token)
149
- return false unless token_record
150
-
151
- token_record.destroy
152
- Rails.logger.info '[TokenService] Refresh token revoked'
153
- true
154
- end
155
-
156
243
  # Generate complete token response
157
244
  def generate_token_response(data, base_url:)
158
245
  access_token = generate_access_token(data, base_url: base_url)
@@ -192,6 +279,7 @@ module Mcp
192
279
  iss: base_url,
193
280
  sub: data[:user_id].to_s,
194
281
  aud: data[:client_id],
282
+ token_use: 'id', # distinguishes this from an access token
195
283
  iat: Time.current.to_i,
196
284
  exp: Time.current.to_i + token_lifetime
197
285
  }
@@ -368,11 +456,44 @@ module Mcp
368
456
  pub
369
457
  end
370
458
 
459
+ # HMAC secret for HS256 signing. A dedicated secret MUST be configured:
460
+ # silently reusing Rails' secret_key_base (which also signs cookies and
461
+ # every MessageVerifier) breaks key separation. Outside development/test
462
+ # a missing secret — or one that IS secret_key_base, e.g. via an
463
+ # `ENV.fetch('MCP_HMAC_SECRET', secret_key_base)` initializer — is a hard
464
+ # error; in dev/test we fall back so the gem still boots.
371
465
  def oauth_secret
372
466
  secret = Mcp::Auth.configuration&.oauth_secret
467
+ problem = oauth_secret_problem(secret)
468
+ return secret if problem.nil?
469
+
470
+ raise Mcp::Auth::Error, problem unless local_env?
471
+
373
472
  secret.presence || Rails.application.secret_key_base
374
473
  end
375
474
 
475
+ # Why the configured HS256 secret is unacceptable outside dev/test, or
476
+ # nil when it is fine. Public so boot checks / `mcp_auth:doctor` can
477
+ # report it before the first token request fails.
478
+ def oauth_secret_problem(secret = Mcp::Auth.configuration&.oauth_secret)
479
+ return nil if asymmetric_signing? # RS256/ES256 don't use oauth_secret
480
+
481
+ if secret.blank?
482
+ 'Mcp::Auth.configuration.oauth_secret must be set (e.g. ENV["MCP_HMAC_SECRET"]) — refusing to ' \
483
+ 'sign tokens with Rails.application.secret_key_base outside development/test (key separation).'
484
+ elsif defined?(Rails) && Rails.application &&
485
+ ActiveSupport::SecurityUtils.secure_compare(secret.to_s, Rails.application.secret_key_base.to_s)
486
+ 'Mcp::Auth.configuration.oauth_secret must not be Rails.application.secret_key_base — set a ' \
487
+ 'dedicated secret (e.g. ENV["MCP_HMAC_SECRET"], generate with `rails secret`).'
488
+ end
489
+ end
490
+
491
+ public :oauth_secret_problem
492
+
493
+ def local_env?
494
+ defined?(Rails) && (Rails.env.development? || Rails.env.test?)
495
+ end
496
+
376
497
  def token_lifetime
377
498
  Mcp::Auth.configuration&.access_token_lifetime || 3600
378
499
  end
@@ -421,7 +542,7 @@ module Mcp
421
542
  expires_at = data[:expires_at] || token_lifetime.seconds.from_now
422
543
 
423
544
  Mcp::Auth::AccessToken.create!(
424
- token: token,
545
+ token: SecretHashing.digest(token),
425
546
  client_id: data[:client_id],
426
547
  resource: audience,
427
548
  scope: data[:scope],
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mcp
4
4
  module Auth
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end