mcp-auth 0.1.0 → 0.3.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: 46fb4e82f6f75e231441aacd12e4adfdfa14efffa64556ca4325f7ad3096eb1d
4
+ data.tar.gz: 6e9ba1314e2823c309651b5dae0c3ea1efda7e8140fe6266a4204603eab9b816
5
5
  SHA512:
6
- metadata.gz: b51073154b563e332913f9a08773acd618858b9a6e067ddb4145ddc73a2a5da86c830c93b75a87fc496e6c7e35ac8e064f18cd3fbabec8920fb3f17dea5a8545
7
- data.tar.gz: 2f003c85cfa923ea611550c0d2c0f7a86b7c8c7e98ec1d64d2de2c25e50a295b1577209ef3cc500f0168c53135de56763580c94e946d9a6e31c16c72f77f283d
6
+ metadata.gz: c1326ad826abb70c5f888595aec6d47a2083857c98e9fd167af76d6c253fa4432430748be7b399fd902db4806bbf80df3169247e915be7bea6ba6682d5903bee
7
+ data.tar.gz: b4d29c3b89881f5e2585342e5b0d20b6aaea2a9a3c5396d1752bc32fb16ae7677b37bb5f1898ca7e66330a9b02c7fdd84f508deff2196808629a63af18369bb2
data/CHANGELOG.md CHANGED
@@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-05-25
11
+
12
+ ### Added
13
+ - **Asymmetric JWT signing** — `Mcp::Auth.configure` now accepts
14
+ `token_signing_algorithm` (`HS256` / `RS256` / `ES256`),
15
+ `token_signing_private_key` (PEM string or `OpenSSL::PKey`),
16
+ `token_signing_public_key` (optional — derived from the private key when
17
+ omitted), and `token_signing_kid` (optional explicit JWK key id;
18
+ auto-derived via JWT::JWK thumbprint when omitted).
19
+ - **JWKS publication** — `/.well-known/jwks.json` now returns the active
20
+ public key as a JWK when an asymmetric algorithm is configured. HMAC
21
+ keys are never exposed; HS256 keeps returning an empty key set.
22
+ - JWT headers now include `kid` for asymmetric algorithms, letting clients
23
+ pick the right verification key across rotations.
24
+ - `id_token_signing_alg_values_supported` in OIDC discovery metadata now
25
+ reflects the configured algorithm instead of being hard-coded to HS256.
26
+ - 18 new examples covering signing under each algorithm, JWKS shape, kid
27
+ override, and configuration validation.
28
+
29
+ ### Changed
30
+ - Default signing algorithm remains `HS256` — existing setups using
31
+ `oauth_secret` keep working without code changes.
32
+ - `TokenService` internals refactored so encode/decode pick the right key
33
+ for the configured algorithm; HMAC and asymmetric flows share one path.
34
+
35
+ ### Migration
36
+
37
+ To switch to asymmetric signing in your host app:
38
+
39
+ ```ruby
40
+ # config/initializers/mcp_auth.rb
41
+ Mcp::Auth.configure do |c|
42
+ c.token_signing_algorithm = 'RS256' # or 'ES256'
43
+ c.token_signing_private_key = ENV.fetch('MCP_TOKEN_PRIVATE_KEY') # PEM
44
+ # c.token_signing_public_key = ENV['MCP_TOKEN_PUBLIC_KEY'] # optional
45
+ # c.token_signing_kid = 'main-2026-05' # optional
46
+ end
47
+ ```
48
+
49
+ Generate the key once (RSA 2048 or EC P-256), store the private half in
50
+ your secrets manager / Rails credentials, and let the JWKS endpoint
51
+ serve the public half to resource servers.
52
+
53
+ Existing access tokens issued under `HS256` will no longer validate
54
+ after the switch — plan a brief re-auth window for active clients, or
55
+ keep `HS256` until refresh tokens cycle out.
56
+
57
+ ## [0.2.0] - 2026-05-25
58
+
59
+ ### Added
60
+ - **RFC 9207** — `iss` parameter on authorization-error redirects (success
61
+ redirects already included it). Becomes a MUST in the MCP 2026-07-28
62
+ spec release candidate.
63
+ - **RFC 7009** — `POST /oauth/revoke` now requires client authentication
64
+ (HTTP Basic or form body) and only revokes tokens owned by the
65
+ authenticated client. Honors the optional `token_type_hint` parameter.
66
+ - **RFC 7662** — `POST /oauth/introspect` now requires client
67
+ authentication. Tokens not owned by the authenticated client are
68
+ reported as `{active: false}` to prevent token-scanning attacks.
69
+ - Spec coverage for `revoke` + `introspect` endpoints (13 examples).
70
+
71
+ ### Changed
72
+ - `revoke` and `introspect` now return HTTP 401 with
73
+ `{error: "invalid_client"}` when client authentication fails. Previously
74
+ they accepted unauthenticated requests. **This is a breaking change for
75
+ callers that did not authenticate** — update clients to send credentials
76
+ via HTTP Basic auth (preferred) or `client_id` + `client_secret` form
77
+ params.
78
+ - `render_error` now accepts a `status:` keyword argument
79
+ (default `:bad_request`).
80
+
10
81
  ## [0.1.0] - 2025-01-10
11
82
 
12
83
  ### Added
@@ -39,5 +110,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
39
110
  - Token audience validation to prevent confused deputy attacks
40
111
  - WWW-Authenticate header with resource metadata on 401 responses
41
112
 
42
- [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.1.0...HEAD
113
+ [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.3.0...HEAD
114
+ [0.3.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.2.0...v0.3.0
115
+ [0.2.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.1.0...v0.2.0
43
116
  [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
@@ -78,16 +78,19 @@ module Mcp
78
78
  code_challenge_methods_supported: %w[S256],
79
79
  token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post none],
80
80
  subject_types_supported: %w[public],
81
- id_token_signing_alg_values_supported: %w[HS256]
81
+ id_token_signing_alg_values_supported: [Mcp::Auth.configuration&.token_signing_algorithm || 'HS256']
82
82
  }
83
83
 
84
84
  render json: metadata, status: :ok, content_type: 'application/json'
85
85
  end
86
86
 
87
- # JWKS endpoint (empty for HMAC)
87
+ # JWKS endpoint. Returns the active public key as a JWK when the
88
+ # configured signing algorithm is asymmetric (RS256/ES256). HMAC keys
89
+ # are NEVER published — for HS256 this stays an empty key set.
88
90
  def jwks
89
- keys = { keys: [] }
90
- render json: keys, status: :ok, content_type: 'application/json'
91
+ jwk = Mcp::Auth::Services::TokenService.signing_jwk_export
92
+ keys = jwk ? [jwk] : []
93
+ render json: { keys: keys }, status: :ok, content_type: 'application/json'
91
94
  end
92
95
 
93
96
  private
@@ -5,12 +5,13 @@ module Mcp
5
5
  module Services
6
6
  class TokenService
7
7
  class << self
8
- # Validate access token with optional resource verification (RFC 8707)
8
+ # Validate access token with optional resource verification (RFC 8707).
9
+ # Supports HS256, RS256, and ES256 — algorithm comes from configuration.
9
10
  def validate_access_token(token, resource: nil)
10
11
  return nil if token.blank?
11
12
 
12
13
  begin
13
- payload = JWT.decode(token, oauth_secret, true, { algorithm: 'HS256' }).first
14
+ payload = JWT.decode(token, verification_key, true, { algorithm: signing_algorithm }).first
14
15
 
15
16
  # Check expiration manually to ensure proper handling
16
17
  if payload['exp']
@@ -59,7 +60,8 @@ module Mcp
59
60
  exp: exp_time
60
61
  }
61
62
 
62
- token = JWT.encode(payload, oauth_secret, 'HS256')
63
+ jwt_headers = signing_kid ? { kid: signing_kid } : {}
64
+ token = JWT.encode(payload, signing_key, signing_algorithm, jwt_headers)
63
65
 
64
66
  # Store token in database for revocation support
65
67
  store_access_token(token, data, audience)
@@ -145,8 +147,104 @@ module Mcp
145
147
  raise
146
148
  end
147
149
 
150
+ # Public key used to sign new JWTs. Configured via Mcp::Auth.configure;
151
+ # built lazily so apps that stay on HS256 don't have to set anything.
152
+ def signing_public_key
153
+ return nil unless asymmetric_signing?
154
+
155
+ cached_public_key
156
+ end
157
+
158
+ # JWK identifier for the current signing key — included as `kid` in
159
+ # JWT headers and the JWKS entry. Falls back to JWT::JWK's
160
+ # auto-derived thumbprint when no explicit kid is configured.
161
+ def signing_kid
162
+ return nil unless asymmetric_signing?
163
+
164
+ Mcp::Auth.configuration&.token_signing_kid.presence || jwk.kid
165
+ end
166
+
167
+ # JWK for the active public key, suitable for the JWKS endpoint.
168
+ # Returns nil for HS256 (HMAC keys are never published).
169
+ def signing_jwk_export
170
+ return nil unless asymmetric_signing?
171
+
172
+ exported = jwk.export
173
+ exported[:kid] = signing_kid
174
+ exported[:alg] = signing_algorithm
175
+ exported[:use] = 'sig'
176
+ exported
177
+ end
178
+
148
179
  private
149
180
 
181
+ def asymmetric_signing?
182
+ Mcp::Auth.configuration&.asymmetric_signing? || false
183
+ end
184
+
185
+ def signing_algorithm
186
+ Mcp::Auth.configuration&.token_signing_algorithm || 'HS256'
187
+ end
188
+
189
+ # Key used to SIGN outgoing JWTs (HMAC secret for HS256, private key
190
+ # for RS256/ES256).
191
+ def signing_key
192
+ asymmetric_signing? ? cached_private_key : oauth_secret
193
+ end
194
+
195
+ # Key used to VERIFY incoming JWTs (HMAC secret for HS256, public key
196
+ # for RS256/ES256). For asymmetric algorithms callers may eventually
197
+ # want per-token key lookup via the JWT `kid` header, but for now we
198
+ # only have one active key so a single value is fine.
199
+ def verification_key
200
+ asymmetric_signing? ? cached_public_key : oauth_secret
201
+ end
202
+
203
+ def cached_private_key
204
+ @cached_private_key ||= load_private_key
205
+ end
206
+
207
+ def cached_public_key
208
+ @cached_public_key ||= load_public_key
209
+ end
210
+
211
+ def jwk
212
+ @jwk ||= JWT::JWK.new(cached_public_key)
213
+ end
214
+
215
+ def load_private_key
216
+ raw = Mcp::Auth.configuration&.token_signing_private_key
217
+ raise 'token_signing_private_key is not configured' if raw.blank?
218
+
219
+ raw.is_a?(OpenSSL::PKey::PKey) ? raw : OpenSSL::PKey.read(raw)
220
+ end
221
+
222
+ def load_public_key
223
+ raw = Mcp::Auth.configuration&.token_signing_public_key
224
+ if raw.present?
225
+ return raw if raw.is_a?(OpenSSL::PKey::PKey)
226
+
227
+ return OpenSSL::PKey.read(raw)
228
+ end
229
+
230
+ # Derive from the private key when an explicit public key isn't given.
231
+ private_key = cached_private_key
232
+ case private_key
233
+ when OpenSSL::PKey::RSA then private_key.public_key
234
+ when OpenSSL::PKey::EC then ec_public_key_from(private_key)
235
+ else
236
+ raise "Unsupported private key type for #{signing_algorithm}: #{private_key.class}"
237
+ end
238
+ end
239
+
240
+ # OpenSSL::PKey::EC#public_key returns the bare point, not a usable
241
+ # PKey instance. Build a public-only EC key so JWT::JWK can export it.
242
+ def ec_public_key_from(private_key)
243
+ pub = OpenSSL::PKey::EC.new(private_key.group)
244
+ pub.public_key = private_key.public_key
245
+ pub
246
+ end
247
+
150
248
  def oauth_secret
151
249
  secret = Mcp::Auth.configuration&.oauth_secret
152
250
  secret.presence || Rails.application.secret_key_base
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mcp
4
4
  module Auth
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/mcp/auth.rb CHANGED
@@ -22,6 +22,8 @@ module Mcp
22
22
  end
23
23
 
24
24
  class Configuration
25
+ SUPPORTED_SIGNING_ALGORITHMS = %w[HS256 RS256 ES256].freeze
26
+
25
27
  attr_accessor :oauth_secret,
26
28
  :authorization_server_url,
27
29
  :access_token_lifetime,
@@ -34,7 +36,11 @@ module Mcp
34
36
  :use_custom_consent_view,
35
37
  :mcp_server_path,
36
38
  :mcp_docs_url,
37
- :validate_scope_for_user
39
+ :validate_scope_for_user,
40
+ :token_signing_algorithm,
41
+ :token_signing_private_key,
42
+ :token_signing_public_key,
43
+ :token_signing_kid
38
44
 
39
45
  def initialize
40
46
  @oauth_secret = nil
@@ -50,6 +56,28 @@ module Mcp
50
56
  @mcp_server_path = '/mcp'
51
57
  @mcp_docs_url = nil
52
58
  @validate_scope_for_user = nil
59
+ # CP-9255 batch 2: JWT signing.
60
+ # Default HS256 keeps existing setups working (shared oauth_secret).
61
+ # Set algorithm to 'RS256' or 'ES256' and provide PEM-encoded keys
62
+ # via env or config to enable asymmetric signing + JWKS publication.
63
+ @token_signing_algorithm = 'HS256'
64
+ @token_signing_private_key = nil
65
+ @token_signing_public_key = nil
66
+ @token_signing_kid = nil
67
+ end
68
+
69
+ def token_signing_algorithm=(value)
70
+ value = value.to_s.upcase
71
+ unless SUPPORTED_SIGNING_ALGORITHMS.include?(value)
72
+ raise ArgumentError,
73
+ "Unsupported token_signing_algorithm: #{value.inspect}. " \
74
+ "Supported: #{SUPPORTED_SIGNING_ALGORITHMS.join(', ')}"
75
+ end
76
+ @token_signing_algorithm = value
77
+ end
78
+
79
+ def asymmetric_signing?
80
+ token_signing_algorithm != 'HS256'
53
81
  end
54
82
 
55
83
  # Register a custom scope for your application
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Serhii Borozenets