mcp-auth 0.2.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: 4640ff06bc400ec8ec8b2869a90194a1ec6d89de1772ae23f4056cf4bfcb2d54
4
- data.tar.gz: 7f55acb2a222a430ecc1552b9073b71613a6b52f506f15b54224cc71fef66e24
3
+ metadata.gz: 46fb4e82f6f75e231441aacd12e4adfdfa14efffa64556ca4325f7ad3096eb1d
4
+ data.tar.gz: 6e9ba1314e2823c309651b5dae0c3ea1efda7e8140fe6266a4204603eab9b816
5
5
  SHA512:
6
- metadata.gz: f7719d166eacb474ddd2769d4ea2dfeab40eea73592d5e284136f5036b06bb91bec299f6edf4527808ce0d5ec2cf5870e5a97c253bbb0f0a806851549f4c5306
7
- data.tar.gz: cf4b4977f5d8613a3d1102961a38129de1b8a52966efe130adb2399e38c4e378714bc0487a666e77c01748a4af57864f2b8813ca43ed6e086b18969e27276306
6
+ metadata.gz: c1326ad826abb70c5f888595aec6d47a2083857c98e9fd167af76d6c253fa4432430748be7b399fd902db4806bbf80df3169247e915be7bea6ba6682d5903bee
7
+ data.tar.gz: b4d29c3b89881f5e2585342e5b0d20b6aaea2a9a3c5396d1752bc32fb16ae7677b37bb5f1898ca7e66330a9b02c7fdd84f508deff2196808629a63af18369bb2
data/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@ 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
+
10
57
  ## [0.2.0] - 2026-05-25
11
58
 
12
59
  ### Added
@@ -63,6 +110,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
63
110
  - Token audience validation to prevent confused deputy attacks
64
111
  - WWW-Authenticate header with resource metadata on 401 responses
65
112
 
66
- [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.2.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
67
115
  [0.2.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.1.0...v0.2.0
68
116
  [0.1.0]: https://github.com/SerhiiBorozenets/mcp-auth/releases/tag/v0.1.0
@@ -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.2.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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Serhii Borozenets