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 +4 -4
- data/CHANGELOG.md +49 -1
- data/app/controllers/mcp/auth/well_known_controller.rb +7 -4
- data/lib/mcp/auth/services/token_service.rb +101 -3
- data/lib/mcp/auth/version.rb +1 -1
- data/lib/mcp/auth.rb +29 -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: 46fb4e82f6f75e231441aacd12e4adfdfa14efffa64556ca4325f7ad3096eb1d
|
|
4
|
+
data.tar.gz: 6e9ba1314e2823c309651b5dae0c3ea1efda7e8140fe6266a4204603eab9b816
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
data/lib/mcp/auth/version.rb
CHANGED
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
|