mcp-auth 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52bce21e865d693725a8cf69feb1fcb72d875e12e947b6b0cb174ffdb6c7c400
4
- data.tar.gz: 142b04126e331eaafd075b71c6eb3aeccac11177cd38adca070a72dd09aa56bd
3
+ metadata.gz: 97784aa216cd18eac56baaf9b0e9520a8a38cc9181eefb5044fe4fef5aad826a
4
+ data.tar.gz: c36b3baf50130e7646f46592ca937fc834dd3ba1b3ff41de43f6114fbe14af3e
5
5
  SHA512:
6
- metadata.gz: 493830d702b4331373da5afd18a037c1113ec773f7d19ce7e9bdb68d784dc2168f1d643c924f159fc22804cd1ec8f2ec6c30a9c7f1eaa80f88018c133f10a56d
7
- data.tar.gz: 9454e6a60a2d69cf48bb43412553710fd5cfd7b150b14162c7b1782170c7dd0cf1fac76d59ac8fe786245bc463c5918e4408eb99ca6a45608b100c5cbe23c12e
6
+ metadata.gz: 88be7bed04fcd81edcf6911a1e067133096d4609be7b797b649722cd43abeba4d420785a90fe4656e54f76d5301cc386a6d92f54bcbc21a517e02d60013d86b2
7
+ data.tar.gz: 37866607f9bab28377ef68346d42908e99bdc17c086b30527495d30df196962a93377356738fdd490c82c2f9a5914ff0660b62e70ba0f50097f6d78a50a1df2d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-06-15
11
+
12
+ Security-hardening release. Closes five OAuth 2.1 / MCP authorization
13
+ vulnerabilities found in an adversarial audit of the authorization server and
14
+ protected-resource layer. Each fix ships with an RSpec test that fails before
15
+ and passes after.
16
+
17
+ ### Security (breaking where noted)
18
+ - **Consent can no longer be bypassed.** `GET /oauth/authorize` previously issued
19
+ an authorization code immediately when `approved=true` was present on the
20
+ request URL — a GET, so not even CSRF-protected — skipping the consent screen
21
+ entirely. The authorization endpoint now always renders consent; a code is
22
+ granted only via the CSRF-protected `POST /oauth/approve`. **Breaking:** clients
23
+ that appended `approved=true` to the authorize URL to auto-approve must go
24
+ through the consent/approve step.
25
+ - **Refresh tokens are bound to the issuing client** (OAuth 2.1 §4.3.1). The
26
+ refresh grant now rejects redemption unless the requesting `client_id` (Basic
27
+ auth or body) matches the client the token was issued to, and does not rotate
28
+ the token on a failed check. **Breaking:** a refresh request must include the
29
+ matching `client_id` — the documented flow already does.
30
+ - **Authorization codes are consumed atomically** (OAuth 2.1 §4.1.2). Consumption
31
+ now deletes the code in a single atomic operation and the token grant aborts
32
+ unless it won that deletion, eliminating a race that could mint two token sets
33
+ from one code.
34
+ - **Resource indicators are validated** (RFC 8707 / MCP authorization spec).
35
+ `authorize` and both token grants reject any `resource` that does not identify
36
+ this server (`invalid_target`), so the server can no longer mint a token whose
37
+ audience is some other — possibly attacker-controlled — resource. **Breaking:**
38
+ requests carrying a `resource` for a different host/path are rejected.
39
+ - **`api_key_secret` is no longer embedded in access tokens.** A bearer JWT is
40
+ decodable by anyone holding it and is stored at rest, so only the non-sensitive
41
+ `api_key_id` is now included; resolve the matching secret server-side from that
42
+ id. Any `api_key_secret` returned by `fetch_user_data` is ignored.
43
+
44
+ ### Changed
45
+ - README and the generated initializer document that `fetch_user_data` must not
46
+ return secrets (they are ignored and never written into the token).
47
+
10
48
  ## [0.4.0] - 2026-05-29
11
49
 
12
50
  Security-hardening release. Closes four OAuth correctness bugs and adds the
@@ -177,7 +215,9 @@ keep `HS256` until refresh tokens cycle out.
177
215
  - Token audience validation to prevent confused deputy attacks
178
216
  - WWW-Authenticate header with resource metadata on 401 responses
179
217
 
180
- [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.3.0...HEAD
218
+ [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.5.0...HEAD
219
+ [0.5.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.4.0...v0.5.0
220
+ [0.4.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.3.0...v0.4.0
181
221
  [0.3.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.2.0...v0.3.0
182
222
  [0.2.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.1.0...v0.2.0
183
223
  [0.1.0]: https://github.com/SerhiiBorozenets/mcp-auth/releases/tag/v0.1.0
data/README.md CHANGED
@@ -186,18 +186,22 @@ Mcp::Auth.configure do |config|
186
186
  config.authorization_code_lifetime = 1800 # 30 minutes
187
187
 
188
188
  # User data fetcher - CUSTOMIZE THIS
189
+ #
190
+ # SECURITY: only NON-sensitive values are embedded into the access token (a
191
+ # bearer JWT is decodable by anyone holding it and is stored at rest). Return
192
+ # an api_key_id (an opaque reference) and resolve the matching secret
193
+ # server-side from that id at request time. Never return a raw secret here —
194
+ # any `api_key_secret` is intentionally ignored and NOT placed in the token.
189
195
  config.fetch_user_data = proc do |data|
190
196
  user = User.find(data[:user_id])
191
197
  org = Org.find(data[:org_id]) if data[:org_id]
192
-
193
- # Return user data + API key (if you have one)
198
+
194
199
  {
195
200
  email: user.email,
196
- api_key_id: org&.api_key&.id,
197
- api_key_secret: org&.api_key&.secret
201
+ api_key_id: org&.api_key&.id
198
202
  }
199
203
  rescue ActiveRecord::RecordNotFound
200
- { email: 'unknown@example.com', api_key_id: nil, api_key_secret: nil }
204
+ { email: 'unknown@example.com', api_key_id: nil }
201
205
  end
202
206
 
203
207
  # Methods for authentication
@@ -166,9 +166,31 @@ module Mcp
166
166
  params[:redirect_uri].present? &&
167
167
  params[:code_challenge].present? &&
168
168
  params[:code_challenge_method] == 'S256' &&
169
+ valid_requested_resource? &&
169
170
  registered_client_with_valid_redirect?
170
171
  end
171
172
 
173
+ # RFC 8707 / MCP authorization spec: if the client sends a `resource`, it
174
+ # MUST identify this server. A token whose audience is some other resource
175
+ # must never be minted, so the request is rejected here — before any code
176
+ # is issued — rather than silently binding the token to a foreign audience.
177
+ def valid_requested_resource?
178
+ return true if params[:resource].blank?
179
+
180
+ allowed = Services::TokenService.resource_allowed?(params[:resource], canonical_resource_identifier)
181
+ Rails.logger.warn "[OAuth] Rejected unknown resource indicator: #{params[:resource]}" unless allowed
182
+ allowed
183
+ end
184
+
185
+ # 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`.
188
+ def canonical_resource_identifier
189
+ path = Mcp::Auth.configuration&.mcp_server_path.presence || '/mcp'
190
+ path = "/#{path}" unless path.start_with?('/')
191
+ "#{request.base_url}#{path.chomp('/')}"
192
+ end
193
+
172
194
  # OAuth 2.1 / RFC 6749 §3.1.2.3: the authorization endpoint MUST reject any
173
195
  # redirect_uri that is not pre-registered for the client. This is the gate
174
196
  # that prevents authorization-code interception via open redirect, so it is
@@ -195,12 +217,13 @@ module Mcp
195
217
 
196
218
  # === Authorization Flow ===
197
219
 
220
+ # The authorization endpoint (GET/POST /oauth/authorize) MUST NOT issue a
221
+ # code on its own: doing so let any client skip consent by appending
222
+ # `approved=true` to the authorization URL (a GET, so not even CSRF
223
+ # protected). Approval is an explicit, CSRF-protected POST to
224
+ # /oauth/approve — so here we only ever render the consent screen.
198
225
  def handle_signed_in_user
199
- if params[:approved] == 'true'
200
- generate_and_redirect_with_code
201
- else
202
- show_consent_screen
203
- end
226
+ show_consent_screen
204
227
  end
205
228
 
206
229
  def redirect_to_login
@@ -263,10 +286,14 @@ module Mcp
263
286
  # RFC 6749 §4.1.3: the code MUST be bound to the client it was issued to.
264
287
  # The requesting client identifies itself via HTTP Basic auth (confidential
265
288
  # clients) or the client_id parameter (public clients using PKCE).
266
- unless requesting_client_id.present? && requesting_client_id == code_data[:client_id]
289
+ unless requesting_client_owns?(code_data[:client_id])
267
290
  return render_error('invalid_grant', 'Authorization code was issued to a different client')
268
291
  end
269
292
 
293
+ # RFC 8707: a `resource` sent at the token endpoint (used as the audience
294
+ # fallback when the code carried none) must still identify this server.
295
+ return render_error('invalid_target', 'Invalid resource indicator') unless valid_requested_resource?
296
+
270
297
  # Validate PKCE
271
298
  unless Services::AuthorizationService.validate_pkce?(code_data[:code_challenge], params[:code_verifier])
272
299
  return render_error('invalid_grant', 'PKCE validation failed')
@@ -280,10 +307,13 @@ module Mcp
280
307
  # Use the APPROVED scope from the authorization code, not the original request
281
308
  Rails.logger.info "[OAuth] Token generation using scope from auth code: #{code_data[:scope]}"
282
309
 
283
- # Consume the authorization code FIRST (one-time use). Doing this before
284
- # token generation guarantees a replayed code can never yield a second
285
- # set of tokens even if two requests race.
286
- Services::AuthorizationService.consume_authorization_code(params[:code])
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
+ unless Services::AuthorizationService.consume_authorization_code(params[:code])
315
+ return render_error('invalid_grant', 'Authorization code is invalid or expired')
316
+ end
287
317
 
288
318
  # Generate tokens with the APPROVED scope from authorization code
289
319
  token_data = code_data.merge(resource: code_data[:resource] || params[:resource])
@@ -303,6 +333,17 @@ module Mcp
303
333
 
304
334
  return render_error('invalid_grant', 'Refresh token is invalid or expired') unless token_data
305
335
 
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')
342
+ end
343
+
344
+ # RFC 8707: a resource indicator, when supplied, must name this server.
345
+ return render_error('invalid_target', 'Invalid resource indicator') unless valid_requested_resource?
346
+
306
347
  # RFC 6749 §6: a client may request a NARROWER scope on refresh, never a
307
348
  # wider one. Silently dropping unknown/extra scopes preserves least privilege.
308
349
  token_data[:scope] = narrow_scope(token_data[:scope], params[:scope]) if params[:scope].present?
@@ -340,6 +381,12 @@ module Mcp
340
381
  basic_id.presence || params[:client_id]
341
382
  end
342
383
 
384
+ # True only when the token-requesting client identifies itself AND that
385
+ # identity matches the client a grant (code/refresh token) was issued to.
386
+ def requesting_client_owns?(client_id)
387
+ requesting_client_id.present? && requesting_client_id == client_id
388
+ end
389
+
343
390
  # === Client Registration ===
344
391
 
345
392
  def build_client_registration
@@ -50,7 +50,12 @@ Mcp::Auth.configure do |config|
50
50
  # Expected return value: Hash with keys:
51
51
  # - :email (String) - User's email address
52
52
  # - :api_key_id (String/Integer, optional) - API key ID if using API keys
53
- # - :api_key_secret (String, optional) - API key secret if using API keys
53
+ #
54
+ # SECURITY: the access token is a bearer JWT — anyone holding it can decode
55
+ # its claims, and a copy is stored at rest for revocation. Only embed
56
+ # non-sensitive values. Return an api_key_id (an opaque reference) and look up
57
+ # the matching secret server-side at request time. A raw `api_key_secret`
58
+ # returned here is intentionally IGNORED and never written into the token.
54
59
  config.fetch_user_data = proc do |data|
55
60
  user = User.find(data[:user_id])
56
61
 
@@ -60,11 +65,10 @@ Mcp::Auth.configure do |config|
60
65
 
61
66
  {
62
67
  email: user.email,
63
- api_key_id: nil, # Set to your API key ID if applicable
64
- api_key_secret: nil # Set to your API key secret if applicable
68
+ api_key_id: nil # Set to your API key ID if applicable
65
69
  }
66
70
  rescue ActiveRecord::RecordNotFound
67
- { email: 'unknown@example.com', api_key_id: nil, api_key_secret: nil }
71
+ { email: 'unknown@example.com', api_key_id: nil }
68
72
  end
69
73
 
70
74
  # ============================================================================
@@ -52,7 +52,14 @@ module Mcp
52
52
  }
53
53
  end
54
54
 
55
- # Consume authorization code (one-time use)
55
+ # Consume authorization code (one-time use).
56
+ #
57
+ # OAuth 2.1 §4.1.2: an authorization code MUST be single-use. The
58
+ # delete is done as a single atomic DELETE ... WHERE that reports how
59
+ # many rows it removed, so when two requests race to redeem the same
60
+ # code exactly ONE sees `deleted == 1` and proceeds; the loser sees 0
61
+ # and gets nil. Returns the code's data on success, nil if the code was
62
+ # already consumed (or never existed).
56
63
  def consume_authorization_code(code)
57
64
  authorization_code = Mcp::Auth::AuthorizationCode.find_by(code: code)
58
65
  return nil unless authorization_code
@@ -69,8 +76,10 @@ module Mcp
69
76
  created_at: authorization_code.created_at.to_i
70
77
  }
71
78
 
72
- authorization_code.destroy
73
- Rails.logger.info "[AuthorizationService] Authorization code consumed"
79
+ deleted = Mcp::Auth::AuthorizationCode.where(id: authorization_code.id).delete_all
80
+ return nil unless deleted == 1
81
+
82
+ Rails.logger.info '[AuthorizationService] Authorization code consumed'
74
83
  code_data
75
84
  end
76
85
 
@@ -76,8 +76,11 @@ module Mcp
76
76
  client_id: data[:client_id],
77
77
  email: user_data[:email],
78
78
  scope: data[:scope],
79
+ # Only a non-sensitive API key *identifier* is embedded. A bearer
80
+ # JWT is decodable by anyone holding it (and is stored at rest), so
81
+ # the matching secret MUST NOT be placed in the token — the resource
82
+ # server resolves the secret server-side from this id when needed.
79
83
  api_key_id: user_data[:api_key_id],
80
- api_key_secret: user_data[:api_key_secret],
81
84
  iat: Time.current.to_i,
82
85
  exp: exp_time
83
86
  }
@@ -260,6 +263,19 @@ module Mcp
260
263
  @jwk = nil
261
264
  end
262
265
 
266
+ # RFC 8707 §2 / MCP authorization spec: an authorization server MUST
267
+ # only honor resource indicators that name a resource it actually
268
+ # serves. A blank resource defaults to the canonical resource, so it is
269
+ # allowed; otherwise the request is accepted only when the requested
270
+ # resource matches this server's canonical resource (normalized, never
271
+ # a substring match). This stops a malicious client from minting tokens
272
+ # whose `aud` is some other — possibly attacker-controlled — resource.
273
+ def resource_allowed?(resource, canonical_resource)
274
+ return true if resource.blank?
275
+
276
+ audience_matches?(canonical_resource, resource)
277
+ end
278
+
263
279
  private
264
280
 
265
281
  def asymmetric_signing?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mcp
4
4
  module Auth
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp-auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Serhii Borozenets