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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f3afa0eeb6e176bc47801df4caf7b5298646f86c53c2607b2ac28781abe1f83
4
- data.tar.gz: 0bbb349fcb1ac5b8ad142946f742a1e75bb9ebdcf11f041f2c9eaf081b8b9d5f
3
+ metadata.gz: 4640ff06bc400ec8ec8b2869a90194a1ec6d89de1772ae23f4056cf4bfcb2d54
4
+ data.tar.gz: 7f55acb2a222a430ecc1552b9073b71613a6b52f506f15b54224cc71fef66e24
5
5
  SHA512:
6
- metadata.gz: b51073154b563e332913f9a08773acd618858b9a6e067ddb4145ddc73a2a5da86c830c93b75a87fc496e6c7e35ac8e064f18cd3fbabec8920fb3f17dea5a8545
7
- data.tar.gz: 2f003c85cfa923ea611550c0d2c0f7a86b7c8c7e98ec1d64d2de2c25e50a295b1577209ef3cc500f0168c53135de56763580c94e946d9a6e31c16c72f77f283d
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.1.0...HEAD
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
- token = params[:token]
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
- # Try refresh token first
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
- token = params[:token]
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
- # Try as JWT access token
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: :bad_request, content_type: 'application/json'
529
+ render json: error_response, status: status, content_type: 'application/json'
474
530
  end
475
531
 
476
532
  def current_org
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mcp
4
4
  module Auth
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Serhii Borozenets