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 +4 -4
- data/CHANGELOG.md +74 -1
- data/app/controllers/mcp/auth/oauth_controller.rb +84 -28
- 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,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.
|
|
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
|
-
|
|
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
|
|
@@ -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
|