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 +4 -4
- data/CHANGELOG.md +41 -1
- data/README.md +9 -5
- data/app/controllers/mcp/auth/oauth_controller.rb +57 -10
- data/lib/generators/mcp/auth/templates/initializer.rb +8 -4
- data/lib/mcp/auth/services/authorization_service.rb +12 -3
- data/lib/mcp/auth/services/token_service.rb +17 -1
- data/lib/mcp/auth/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97784aa216cd18eac56baaf9b0e9520a8a38cc9181eefb5044fe4fef5aad826a
|
|
4
|
+
data.tar.gz: c36b3baf50130e7646f46592ca937fc834dd3ba1b3ff41de43f6114fbe14af3e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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).
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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.
|
|
73
|
-
|
|
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?
|
data/lib/mcp/auth/version.rb
CHANGED