mcp-auth 0.1.0 → 0.2.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 +26 -1
- data/app/controllers/mcp/auth/oauth_controller.rb +84 -28
- 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: 4640ff06bc400ec8ec8b2869a90194a1ec6d89de1772ae23f4056cf4bfcb2d54
|
|
4
|
+
data.tar.gz: 7f55acb2a222a430ecc1552b9073b71613a6b52f506f15b54224cc71fef66e24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f7719d166eacb474ddd2769d4ea2dfeab40eea73592d5e284136f5036b06bb91bec299f6edf4527808ce0d5ec2cf5870e5a97c253bbb0f0a806851549f4c5306
|
|
7
|
+
data.tar.gz: cf4b4977f5d8613a3d1102961a38129de1b8a52966efe130adb2399e38c4e378714bc0487a666e77c01748a4af57864f2b8813ca43ed6e086b18969e27276306
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-05-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **RFC 9207** — `iss` parameter on authorization-error redirects (success
|
|
14
|
+
redirects already included it). Becomes a MUST in the MCP 2026-07-28
|
|
15
|
+
spec release candidate.
|
|
16
|
+
- **RFC 7009** — `POST /oauth/revoke` now requires client authentication
|
|
17
|
+
(HTTP Basic or form body) and only revokes tokens owned by the
|
|
18
|
+
authenticated client. Honors the optional `token_type_hint` parameter.
|
|
19
|
+
- **RFC 7662** — `POST /oauth/introspect` now requires client
|
|
20
|
+
authentication. Tokens not owned by the authenticated client are
|
|
21
|
+
reported as `{active: false}` to prevent token-scanning attacks.
|
|
22
|
+
- Spec coverage for `revoke` + `introspect` endpoints (13 examples).
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- `revoke` and `introspect` now return HTTP 401 with
|
|
26
|
+
`{error: "invalid_client"}` when client authentication fails. Previously
|
|
27
|
+
they accepted unauthenticated requests. **This is a breaking change for
|
|
28
|
+
callers that did not authenticate** — update clients to send credentials
|
|
29
|
+
via HTTP Basic auth (preferred) or `client_id` + `client_secret` form
|
|
30
|
+
params.
|
|
31
|
+
- `render_error` now accepts a `status:` keyword argument
|
|
32
|
+
(default `:bad_request`).
|
|
33
|
+
|
|
10
34
|
## [0.1.0] - 2025-01-10
|
|
11
35
|
|
|
12
36
|
### Added
|
|
@@ -39,5 +63,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
39
63
|
- Token audience validation to prevent confused deputy attacks
|
|
40
64
|
- WWW-Authenticate header with resource metadata on 401 responses
|
|
41
65
|
|
|
42
|
-
[Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.
|
|
66
|
+
[Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.2.0...HEAD
|
|
67
|
+
[0.2.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.1.0...v0.2.0
|
|
43
68
|
[0.1.0]: https://github.com/SerhiiBorozenets/mcp-auth/releases/tag/v0.1.0
|
|
@@ -99,49 +99,38 @@ module Mcp
|
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
# RFC 7009: Token Revocation
|
|
102
|
+
# Requires client authentication; only revokes tokens that belong to the
|
|
103
|
+
# authenticated client (otherwise still returns 200 to avoid leaking which
|
|
104
|
+
# tokens exist — per RFC 7009 §2.2).
|
|
102
105
|
def revoke
|
|
103
|
-
|
|
106
|
+
client = authenticate_client
|
|
107
|
+
return render_error('invalid_client', 'Client authentication failed', status: :unauthorized) unless client
|
|
104
108
|
|
|
109
|
+
token = params[:token]
|
|
105
110
|
if token.blank?
|
|
106
111
|
return render_error('invalid_request', 'Token parameter is required')
|
|
107
112
|
end
|
|
108
113
|
|
|
109
|
-
|
|
110
|
-
revoked = Services::TokenService.revoke_refresh_token(token)
|
|
111
|
-
|
|
112
|
-
# Try access token if not found
|
|
113
|
-
unless revoked
|
|
114
|
-
access_token = Mcp::Auth::AccessToken.find_by(token: token)
|
|
115
|
-
if access_token
|
|
116
|
-
access_token.destroy
|
|
117
|
-
revoked = true
|
|
118
|
-
end
|
|
119
|
-
end
|
|
114
|
+
revoked = revoke_token_for_client(token, client, hint: params[:token_type_hint])
|
|
120
115
|
|
|
121
|
-
# RFC 7009: Always return 200 OK
|
|
122
|
-
Rails.logger.info "[OAuth] Token revocation: #{revoked ? 'success' : 'not found'}"
|
|
116
|
+
# RFC 7009: Always return 200 OK regardless of whether the token was found.
|
|
117
|
+
Rails.logger.info "[OAuth] Token revocation by client=#{client.client_id}: #{revoked ? 'success' : 'not found / not owned'}"
|
|
123
118
|
head :ok
|
|
124
119
|
end
|
|
125
120
|
|
|
126
121
|
# RFC 7662: Token Introspection
|
|
122
|
+
# Requires client authentication. Tokens not owned by the authenticated
|
|
123
|
+
# client are reported as `{active: false}` to prevent token-scanning.
|
|
127
124
|
def introspect
|
|
128
|
-
|
|
125
|
+
client = authenticate_client
|
|
126
|
+
return render_error('invalid_client', 'Client authentication failed', status: :unauthorized) unless client
|
|
129
127
|
|
|
128
|
+
token = params[:token]
|
|
130
129
|
if token.blank?
|
|
131
130
|
return render json: { active: false }, content_type: 'application/json'
|
|
132
131
|
end
|
|
133
132
|
|
|
134
|
-
|
|
135
|
-
payload = Services::TokenService.validate_access_token(token)
|
|
136
|
-
|
|
137
|
-
response = if payload
|
|
138
|
-
build_access_token_introspection(payload)
|
|
139
|
-
else
|
|
140
|
-
# Try as refresh token
|
|
141
|
-
refresh_data = Services::TokenService.validate_refresh_token(token)
|
|
142
|
-
refresh_data ? build_refresh_token_introspection(refresh_data) : { active: false }
|
|
143
|
-
end
|
|
144
|
-
|
|
133
|
+
response = introspect_token_for_client(token, client)
|
|
145
134
|
render json: response, content_type: 'application/json'
|
|
146
135
|
end
|
|
147
136
|
|
|
@@ -239,6 +228,8 @@ module Mcp
|
|
|
239
228
|
redirect_uri = URI.parse(params[:redirect_uri])
|
|
240
229
|
query_params = { error: error, error_description: description }
|
|
241
230
|
query_params[:state] = params[:state] if params[:state]
|
|
231
|
+
# RFC 9207: iss MUST be included in authorization responses, including errors.
|
|
232
|
+
query_params[:iss] = authorization_server_url
|
|
242
233
|
|
|
243
234
|
redirect_uri.query = URI.encode_www_form(query_params)
|
|
244
235
|
redirect_to redirect_uri.to_s, allow_other_host: true
|
|
@@ -335,6 +326,71 @@ module Mcp
|
|
|
335
326
|
}.compact
|
|
336
327
|
end
|
|
337
328
|
|
|
329
|
+
# === Client Authentication (RFC 6749 §2.3, used by revoke + introspect) ===
|
|
330
|
+
|
|
331
|
+
# Accepts client credentials from either HTTP Basic auth or form params.
|
|
332
|
+
# Returns the OauthClient instance on success, nil on failure.
|
|
333
|
+
def authenticate_client
|
|
334
|
+
client_id, client_secret = extract_client_credentials
|
|
335
|
+
return nil if client_id.blank? || client_secret.blank?
|
|
336
|
+
|
|
337
|
+
client = Mcp::Auth::OauthClient.find_by(client_id: client_id)
|
|
338
|
+
return nil unless client
|
|
339
|
+
return nil unless ActiveSupport::SecurityUtils.secure_compare(client.client_secret.to_s, client_secret.to_s)
|
|
340
|
+
|
|
341
|
+
client
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def extract_client_credentials
|
|
345
|
+
if (auth = request.authorization) && auth.start_with?('Basic ')
|
|
346
|
+
decoded = Base64.decode64(auth.split(' ', 2).last)
|
|
347
|
+
decoded.split(':', 2)
|
|
348
|
+
else
|
|
349
|
+
[params[:client_id], params[:client_secret]]
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# RFC 7009: revoke token only if it belongs to the requesting client.
|
|
354
|
+
# token_type_hint is a hint, not a constraint — try both regardless.
|
|
355
|
+
def revoke_token_for_client(token, client, hint: nil)
|
|
356
|
+
if hint == 'refresh_token'
|
|
357
|
+
revoke_refresh_for_client(token, client) || revoke_access_for_client(token, client)
|
|
358
|
+
else
|
|
359
|
+
revoke_access_for_client(token, client) || revoke_refresh_for_client(token, client)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def revoke_access_for_client(token, client)
|
|
364
|
+
access_token = Mcp::Auth::AccessToken.find_by(token: token, client_id: client.client_id)
|
|
365
|
+
return false unless access_token
|
|
366
|
+
|
|
367
|
+
access_token.destroy
|
|
368
|
+
true
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def revoke_refresh_for_client(token, client)
|
|
372
|
+
refresh_token = Mcp::Auth::RefreshToken.find_by(token: token, client_id: client.client_id)
|
|
373
|
+
return false unless refresh_token
|
|
374
|
+
|
|
375
|
+
refresh_token.destroy
|
|
376
|
+
true
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# RFC 7662: only describe tokens owned by the authenticated client.
|
|
380
|
+
def introspect_token_for_client(token, client)
|
|
381
|
+
payload = Services::TokenService.validate_access_token(token)
|
|
382
|
+
if payload && payload[:client_id] == client.client_id
|
|
383
|
+
build_access_token_introspection(payload)
|
|
384
|
+
else
|
|
385
|
+
refresh_data = Services::TokenService.validate_refresh_token(token)
|
|
386
|
+
if refresh_data && refresh_data[:client_id] == client.client_id
|
|
387
|
+
build_refresh_token_introspection(refresh_data)
|
|
388
|
+
else
|
|
389
|
+
{ active: false }
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
338
394
|
# === Token Introspection ===
|
|
339
395
|
|
|
340
396
|
def build_access_token_introspection(payload)
|
|
@@ -463,14 +519,14 @@ module Mcp
|
|
|
463
519
|
|
|
464
520
|
# === Error Handling ===
|
|
465
521
|
|
|
466
|
-
def render_error(error, description)
|
|
522
|
+
def render_error(error, description, status: :bad_request)
|
|
467
523
|
error_response = {
|
|
468
524
|
error: error,
|
|
469
525
|
error_description: description
|
|
470
526
|
}
|
|
471
527
|
|
|
472
528
|
Rails.logger.error "[OAuth] Error: #{error_response.inspect}"
|
|
473
|
-
render json: error_response, status:
|
|
529
|
+
render json: error_response, status: status, content_type: 'application/json'
|
|
474
530
|
end
|
|
475
531
|
|
|
476
532
|
def current_org
|
data/lib/mcp/auth/version.rb
CHANGED