mcp-auth 0.3.0 → 0.4.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 +67 -0
- data/app/controllers/mcp/auth/oauth_controller.rb +124 -51
- data/app/controllers/mcp/auth/well_known_controller.rb +5 -5
- data/app/models/mcp/auth/access_token.rb +4 -2
- data/app/models/mcp/auth/oauth_client.rb +35 -6
- data/lib/generators/mcp/auth/templates/create_oauth_clients.rb.erb +5 -1
- data/lib/generators/mcp/auth/templates/initializer.rb +32 -0
- data/lib/mcp/auth/protected_resource.rb +88 -0
- data/lib/mcp/auth/scope_registry.rb +6 -3
- data/lib/mcp/auth/services/token_service.rb +146 -27
- data/lib/mcp/auth/version.rb +1 -1
- data/lib/mcp/auth.rb +12 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 52bce21e865d693725a8cf69feb1fcb72d875e12e947b6b0cb174ffdb6c7c400
|
|
4
|
+
data.tar.gz: 142b04126e331eaafd075b71c6eb3aeccac11177cd38adca070a72dd09aa56bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 493830d702b4331373da5afd18a037c1113ec773f7d19ce7e9bdb68d784dc2168f1d643c924f159fc22804cd1ec8f2ec6c30a9c7f1eaa80f88018c133f10a56d
|
|
7
|
+
data.tar.gz: 9454e6a60a2d69cf48bb43412553710fd5cfd7b150b14162c7b1782170c7dd0cf1fac76d59ac8fe786245bc463c5918e4408eb99ca6a45608b100c5cbe23c12e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-05-29
|
|
11
|
+
|
|
12
|
+
Security-hardening release. Closes four OAuth correctness bugs and adds the
|
|
13
|
+
resource-server half of the MCP authorization spec.
|
|
14
|
+
|
|
15
|
+
### Security (breaking where noted)
|
|
16
|
+
- **Authorization endpoint now validates `redirect_uri`** against the client's
|
|
17
|
+
registered URIs (RFC 6749 §3.1.2.3) and rejects unknown `client_id`s. An
|
|
18
|
+
unregistered/mismatched `redirect_uri` is answered with an error and is never
|
|
19
|
+
redirected to. **Breaking:** flows that relied on unvalidated redirect URIs
|
|
20
|
+
will now be rejected — register every redirect URI.
|
|
21
|
+
- **Access-token revocation now takes effect.** `validate_access_token` checks
|
|
22
|
+
that the stored token row still exists, so `POST /oauth/revoke` and an
|
|
23
|
+
expired/destroyed row immediately invalidate the JWT instead of it remaining
|
|
24
|
+
valid until natural expiry. Introspection reflects this too.
|
|
25
|
+
- **Token endpoint binds the authorization code to the client** (RFC 6749
|
|
26
|
+
§4.1.3): the requesting `client_id` (Basic auth or body) must match the code.
|
|
27
|
+
- **Audience binding honors `mcp_server_path`.** The default token `aud` is now
|
|
28
|
+
`base_url + mcp_server_path`, matching the published protected-resource
|
|
29
|
+
metadata (previously hard-coded to `/mcp`, breaking RFC 8707 on custom paths).
|
|
30
|
+
- **Audience matching is exact**, no longer a string prefix (which let
|
|
31
|
+
`https://api.example.com.evil.com` match `https://api.example.com`).
|
|
32
|
+
- HTTPS is now enforced on `register`, `revoke`, `introspect`, and `userinfo`
|
|
33
|
+
(in addition to `authorize`/`token`), except in dev/test/local.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **`Mcp::Auth::ProtectedResource`** controller concern — validates the incoming
|
|
37
|
+
Bearer token on your MCP endpoint, exposes the principal via
|
|
38
|
+
`Mcp::Auth::ControllerHelpers` (`mcp_user_id`, `mcp_scope`, …), and answers
|
|
39
|
+
401 with the RFC 9728 `WWW-Authenticate: Bearer … resource_metadata="…"`
|
|
40
|
+
header the MCP spec requires. Includes `require_mcp_scope!` for per-action
|
|
41
|
+
scope enforcement.
|
|
42
|
+
- **OpenID Connect id_token issuance** — when the `openid` scope is granted, the
|
|
43
|
+
token response includes an `id_token` (with `email`/`profile` claims gated by
|
|
44
|
+
scope), making the advertised OIDC discovery real.
|
|
45
|
+
- **Signing-key rotation** — `token_signing_additional_public_keys` accepts extra
|
|
46
|
+
public keys that are honored for verification and published in JWKS, so a key
|
|
47
|
+
roll doesn't invalidate outstanding tokens. `TokenService.reset_signing_keys!`
|
|
48
|
+
clears the in-process key cache.
|
|
49
|
+
- Refresh grant supports **scope narrowing** (RFC 6749 §6) and the wired-up
|
|
50
|
+
`current_user_method` config option.
|
|
51
|
+
- Dynamic client registration now validates redirect URIs (RFC 7591/8252),
|
|
52
|
+
rejecting empty sets and dangerous schemes (`javascript:`/`data:`).
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
- Refresh-token rotation and authorization-code consumption now happen *before*
|
|
56
|
+
new tokens are minted, so a replayed code/refresh token can't double-issue.
|
|
57
|
+
- `store_access_token` failures now propagate instead of silently handing the
|
|
58
|
+
client an unrevocable token.
|
|
59
|
+
- `none` removed from advertised revocation/introspection auth methods (those
|
|
60
|
+
endpoints require client authentication).
|
|
61
|
+
- Migration template for `mcp_auth_oauth_clients` uses a portable `string`
|
|
62
|
+
primary key instead of Postgres-only `uuid`/`gen_random_uuid()`.
|
|
63
|
+
|
|
64
|
+
### Migration
|
|
65
|
+
|
|
66
|
+
Mostly drop-in. Two things to check:
|
|
67
|
+
1. Ensure all OAuth clients have their `redirect_uris` registered — the
|
|
68
|
+
authorization endpoint now enforces them.
|
|
69
|
+
2. To protect your MCP endpoint, include the new concern:
|
|
70
|
+
```ruby
|
|
71
|
+
class McpController < ApplicationController
|
|
72
|
+
include Mcp::Auth::ProtectedResource
|
|
73
|
+
before_action :authenticate_mcp_token!
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
10
77
|
## [0.3.0] - 2026-05-25
|
|
11
78
|
|
|
12
79
|
### Added
|
|
@@ -6,17 +6,17 @@ module Mcp
|
|
|
6
6
|
skip_before_action :verify_authenticity_token, only: %i[token register revoke introspect userinfo]
|
|
7
7
|
before_action :set_cors_headers
|
|
8
8
|
before_action :handle_options_request
|
|
9
|
-
before_action :require_https, only: %i[authorize token]
|
|
9
|
+
before_action :require_https, only: %i[authorize approve token register revoke introspect userinfo]
|
|
10
10
|
|
|
11
11
|
# OAuth 2.1 Authorization endpoint (GET/POST)
|
|
12
12
|
def authorize
|
|
13
|
-
Rails.logger.info "[OAuth] Authorization request:
|
|
13
|
+
Rails.logger.info "[OAuth] Authorization request for client=#{params[:client_id]} scope=#{params[:scope]}"
|
|
14
14
|
|
|
15
15
|
unless valid_authorization_params?
|
|
16
16
|
return render_error('invalid_request', 'Missing or invalid required parameters')
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
if
|
|
19
|
+
if mcp_user_signed_in?
|
|
20
20
|
handle_signed_in_user
|
|
21
21
|
else
|
|
22
22
|
redirect_to_login
|
|
@@ -25,13 +25,9 @@ module Mcp
|
|
|
25
25
|
|
|
26
26
|
# Consent approval endpoint
|
|
27
27
|
def approve
|
|
28
|
-
unless
|
|
29
|
-
return redirect_to main_app.new_user_session_path
|
|
30
|
-
end
|
|
28
|
+
return redirect_to main_app.new_user_session_path unless mcp_user_signed_in?
|
|
31
29
|
|
|
32
|
-
unless valid_authorization_params?
|
|
33
|
-
return render_error('invalid_request', 'Missing required parameters')
|
|
34
|
-
end
|
|
30
|
+
return render_error('invalid_request', 'Missing required parameters') unless valid_authorization_params?
|
|
35
31
|
|
|
36
32
|
if params[:approved] == 'true'
|
|
37
33
|
# Get selected scopes from checkboxes
|
|
@@ -41,7 +37,7 @@ module Mcp
|
|
|
41
37
|
|
|
42
38
|
# Validate selected scopes
|
|
43
39
|
if selected_scopes.blank?
|
|
44
|
-
Rails.logger.warn
|
|
40
|
+
Rails.logger.warn '[OAuth] No scopes selected'
|
|
45
41
|
return render_error('invalid_request', 'At least one scope must be selected')
|
|
46
42
|
end
|
|
47
43
|
|
|
@@ -58,8 +54,15 @@ module Mcp
|
|
|
58
54
|
return render_error('invalid_request', 'Required scopes must be selected')
|
|
59
55
|
end
|
|
60
56
|
approved_scopes = Mcp::Auth::ScopeRegistry.validate_scopes(selected_scopes)
|
|
57
|
+
|
|
58
|
+
# Preserve standard OpenID Connect scopes that were originally requested.
|
|
59
|
+
# They gate identity claims (already governed by the userinfo/id_token
|
|
60
|
+
# endpoints) rather than application resources, so they are not rendered
|
|
61
|
+
# as individual consent checkboxes but must survive the approval step.
|
|
62
|
+
oidc_scopes = requested_scopes & Mcp::Auth::ScopeRegistry::STANDARD_OIDC_SCOPES
|
|
63
|
+
approved_scope_string = (approved_scopes + oidc_scopes).uniq.join(' ')
|
|
64
|
+
|
|
61
65
|
# Generate authorization code with ONLY approved scopes
|
|
62
|
-
approved_scope_string = approved_scopes.join(' ')
|
|
63
66
|
generate_and_redirect_with_code(approved_scope_string)
|
|
64
67
|
else
|
|
65
68
|
redirect_with_error('access_denied', 'User denied the request')
|
|
@@ -80,7 +83,7 @@ module Mcp
|
|
|
80
83
|
|
|
81
84
|
# RFC 7591: Dynamic Client Registration
|
|
82
85
|
def register
|
|
83
|
-
Rails.logger.info
|
|
86
|
+
Rails.logger.info '[OAuth] Client registration request'
|
|
84
87
|
|
|
85
88
|
begin
|
|
86
89
|
client_data = build_client_registration
|
|
@@ -107,9 +110,7 @@ module Mcp
|
|
|
107
110
|
return render_error('invalid_client', 'Client authentication failed', status: :unauthorized) unless client
|
|
108
111
|
|
|
109
112
|
token = params[:token]
|
|
110
|
-
if token.blank?
|
|
111
|
-
return render_error('invalid_request', 'Token parameter is required')
|
|
112
|
-
end
|
|
113
|
+
return render_error('invalid_request', 'Token parameter is required') if token.blank?
|
|
113
114
|
|
|
114
115
|
revoked = revoke_token_for_client(token, client, hint: params[:token_type_hint])
|
|
115
116
|
|
|
@@ -126,9 +127,7 @@ module Mcp
|
|
|
126
127
|
return render_error('invalid_client', 'Client authentication failed', status: :unauthorized) unless client
|
|
127
128
|
|
|
128
129
|
token = params[:token]
|
|
129
|
-
if token.blank?
|
|
130
|
-
return render json: { active: false }, content_type: 'application/json'
|
|
131
|
-
end
|
|
130
|
+
return render json: { active: false }, content_type: 'application/json' if token.blank?
|
|
132
131
|
|
|
133
132
|
response = introspect_token_for_client(token, client)
|
|
134
133
|
render json: response, content_type: 'application/json'
|
|
@@ -138,16 +137,12 @@ module Mcp
|
|
|
138
137
|
def userinfo
|
|
139
138
|
auth_header = request.headers['Authorization']
|
|
140
139
|
|
|
141
|
-
unless auth_header&.start_with?('Bearer ')
|
|
142
|
-
return render json: { error: 'invalid_token' }, status: :unauthorized
|
|
143
|
-
end
|
|
140
|
+
return render json: { error: 'invalid_token' }, status: :unauthorized unless auth_header&.start_with?('Bearer ')
|
|
144
141
|
|
|
145
142
|
token = auth_header.split(' ', 2).last
|
|
146
143
|
payload = Services::TokenService.validate_access_token(token)
|
|
147
144
|
|
|
148
|
-
unless payload
|
|
149
|
-
return render json: { error: 'invalid_token' }, status: :unauthorized
|
|
150
|
-
end
|
|
145
|
+
return render json: { error: 'invalid_token' }, status: :unauthorized unless payload
|
|
151
146
|
|
|
152
147
|
user_info = {
|
|
153
148
|
sub: payload[:sub],
|
|
@@ -170,7 +165,32 @@ module Mcp
|
|
|
170
165
|
params[:client_id].present? &&
|
|
171
166
|
params[:redirect_uri].present? &&
|
|
172
167
|
params[:code_challenge].present? &&
|
|
173
|
-
params[:code_challenge_method] == 'S256'
|
|
168
|
+
params[:code_challenge_method] == 'S256' &&
|
|
169
|
+
registered_client_with_valid_redirect?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# OAuth 2.1 / RFC 6749 §3.1.2.3: the authorization endpoint MUST reject any
|
|
173
|
+
# redirect_uri that is not pre-registered for the client. This is the gate
|
|
174
|
+
# that prevents authorization-code interception via open redirect, so it is
|
|
175
|
+
# validated BEFORE the code is ever issued — and on failure we render an
|
|
176
|
+
# error instead of redirecting (we must never redirect to an unverified URI).
|
|
177
|
+
def registered_client_with_valid_redirect?
|
|
178
|
+
client = oauth_client
|
|
179
|
+
unless client
|
|
180
|
+
Rails.logger.warn "[OAuth] Unknown client_id: #{params[:client_id]}"
|
|
181
|
+
return false
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
return true if client.valid_redirect_uri?(params[:redirect_uri])
|
|
185
|
+
|
|
186
|
+
Rails.logger.warn "[OAuth] Unregistered redirect_uri for client=#{params[:client_id]}: #{params[:redirect_uri]}"
|
|
187
|
+
false
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def oauth_client
|
|
191
|
+
return @oauth_client if defined?(@oauth_client)
|
|
192
|
+
|
|
193
|
+
@oauth_client = Mcp::Auth::OauthClient.find_by(client_id: params[:client_id])
|
|
174
194
|
end
|
|
175
195
|
|
|
176
196
|
# === Authorization Flow ===
|
|
@@ -203,13 +223,11 @@ module Mcp
|
|
|
203
223
|
# Pass the params with approved scope to authorization service
|
|
204
224
|
code = Services::AuthorizationService.generate_authorization_code(
|
|
205
225
|
auth_params,
|
|
206
|
-
user:
|
|
226
|
+
user: mcp_current_user,
|
|
207
227
|
org: current_org
|
|
208
228
|
)
|
|
209
229
|
|
|
210
|
-
unless code
|
|
211
|
-
return render_error('server_error', 'Failed to generate authorization code')
|
|
212
|
-
end
|
|
230
|
+
return render_error('server_error', 'Failed to generate authorization code') unless code
|
|
213
231
|
|
|
214
232
|
redirect_with_code(code)
|
|
215
233
|
end
|
|
@@ -240,8 +258,13 @@ module Mcp
|
|
|
240
258
|
def handle_authorization_code_grant
|
|
241
259
|
code_data = Services::AuthorizationService.validate_authorization_code(params[:code])
|
|
242
260
|
|
|
243
|
-
unless code_data
|
|
244
|
-
|
|
261
|
+
return render_error('invalid_grant', 'Authorization code is invalid or expired') unless code_data
|
|
262
|
+
|
|
263
|
+
# RFC 6749 §4.1.3: the code MUST be bound to the client it was issued to.
|
|
264
|
+
# The requesting client identifies itself via HTTP Basic auth (confidential
|
|
265
|
+
# clients) or the client_id parameter (public clients using PKCE).
|
|
266
|
+
unless requesting_client_id.present? && requesting_client_id == code_data[:client_id]
|
|
267
|
+
return render_error('invalid_grant', 'Authorization code was issued to a different client')
|
|
245
268
|
end
|
|
246
269
|
|
|
247
270
|
# Validate PKCE
|
|
@@ -257,39 +280,64 @@ module Mcp
|
|
|
257
280
|
# Use the APPROVED scope from the authorization code, not the original request
|
|
258
281
|
Rails.logger.info "[OAuth] Token generation using scope from auth code: #{code_data[:scope]}"
|
|
259
282
|
|
|
283
|
+
# Consume the authorization code FIRST (one-time use). Doing this before
|
|
284
|
+
# token generation guarantees a replayed code can never yield a second
|
|
285
|
+
# set of tokens even if two requests race.
|
|
286
|
+
Services::AuthorizationService.consume_authorization_code(params[:code])
|
|
287
|
+
|
|
260
288
|
# Generate tokens with the APPROVED scope from authorization code
|
|
261
289
|
token_data = code_data.merge(resource: code_data[:resource] || params[:resource])
|
|
262
290
|
token_response = Services::TokenService.generate_token_response(
|
|
263
|
-
token_data,
|
|
291
|
+
token_data, # This includes the approved :scope from authorization code
|
|
264
292
|
base_url: request.base_url
|
|
265
293
|
)
|
|
266
294
|
|
|
267
|
-
# Consume authorization code (one-time use)
|
|
268
|
-
Services::AuthorizationService.consume_authorization_code(params[:code])
|
|
269
|
-
|
|
270
295
|
render json: token_response, content_type: 'application/json'
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
Rails.logger.error "[OAuth] Token generation failed: #{e.message}"
|
|
298
|
+
render_error('server_error', 'Failed to issue tokens', status: :internal_server_error)
|
|
271
299
|
end
|
|
272
300
|
|
|
273
301
|
def handle_refresh_token_grant
|
|
274
302
|
token_data = Services::TokenService.validate_refresh_token(params[:refresh_token])
|
|
275
303
|
|
|
276
|
-
unless token_data
|
|
277
|
-
|
|
278
|
-
|
|
304
|
+
return render_error('invalid_grant', 'Refresh token is invalid or expired') unless token_data
|
|
305
|
+
|
|
306
|
+
# RFC 6749 §6: a client may request a NARROWER scope on refresh, never a
|
|
307
|
+
# wider one. Silently dropping unknown/extra scopes preserves least privilege.
|
|
308
|
+
token_data[:scope] = narrow_scope(token_data[:scope], params[:scope]) if params[:scope].present?
|
|
279
309
|
|
|
280
310
|
# Include resource parameter if provided
|
|
281
311
|
token_data[:resource] = params[:resource] if params[:resource]
|
|
282
312
|
|
|
313
|
+
# Rotate refresh token (OAuth 2.1 requirement) BEFORE issuing the new one
|
|
314
|
+
# so a replayed refresh token cannot mint a second token family.
|
|
315
|
+
Services::TokenService.revoke_refresh_token(params[:refresh_token])
|
|
316
|
+
|
|
283
317
|
# Generate new tokens
|
|
284
318
|
token_response = Services::TokenService.generate_token_response(
|
|
285
319
|
token_data,
|
|
286
320
|
base_url: request.base_url
|
|
287
321
|
)
|
|
288
322
|
|
|
289
|
-
# Rotate refresh token (OAuth 2.1 requirement)
|
|
290
|
-
Services::TokenService.revoke_refresh_token(params[:refresh_token])
|
|
291
|
-
|
|
292
323
|
render json: token_response, content_type: 'application/json'
|
|
324
|
+
rescue StandardError => e
|
|
325
|
+
Rails.logger.error "[OAuth] Token refresh failed: #{e.message}"
|
|
326
|
+
render_error('server_error', 'Failed to issue tokens', status: :internal_server_error)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Intersection of the originally granted scope and a requested subset.
|
|
330
|
+
def narrow_scope(granted_scope, requested_scope)
|
|
331
|
+
granted = granted_scope.to_s.split
|
|
332
|
+
requested = requested_scope.to_s.split
|
|
333
|
+
(granted & requested).join(' ')
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# client_id of the party making a token request: HTTP Basic auth wins for
|
|
337
|
+
# confidential clients, otherwise the public client_id parameter.
|
|
338
|
+
def requesting_client_id
|
|
339
|
+
basic_id, = extract_client_credentials_from_basic
|
|
340
|
+
basic_id.presence || params[:client_id]
|
|
293
341
|
end
|
|
294
342
|
|
|
295
343
|
# === Client Registration ===
|
|
@@ -342,12 +390,21 @@ module Mcp
|
|
|
342
390
|
end
|
|
343
391
|
|
|
344
392
|
def extract_client_credentials
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
393
|
+
basic_id, basic_secret = extract_client_credentials_from_basic
|
|
394
|
+
return [basic_id, basic_secret] if basic_id.present?
|
|
395
|
+
|
|
396
|
+
[params[:client_id], params[:client_secret]]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Returns [client_id, client_secret] from an HTTP Basic Authorization
|
|
400
|
+
# header, or [nil, nil] when the header is absent/not Basic.
|
|
401
|
+
def extract_client_credentials_from_basic
|
|
402
|
+
auth = request.authorization
|
|
403
|
+
return [nil, nil] unless auth&.start_with?('Basic ')
|
|
404
|
+
|
|
405
|
+
decoded = Base64.decode64(auth.split(' ', 2).last)
|
|
406
|
+
id, secret = decoded.split(':', 2)
|
|
407
|
+
[id, secret]
|
|
351
408
|
end
|
|
352
409
|
|
|
353
410
|
# RFC 7009: revoke token only if it belongs to the requesting client.
|
|
@@ -438,13 +495,13 @@ module Mcp
|
|
|
438
495
|
config = Rails.application.config.mcp_auth
|
|
439
496
|
config.use_custom_consent_view &&
|
|
440
497
|
template_exists?(config.consent_view_path)
|
|
441
|
-
rescue
|
|
498
|
+
rescue StandardError
|
|
442
499
|
false
|
|
443
500
|
end
|
|
444
501
|
|
|
445
502
|
def template_exists?(path)
|
|
446
503
|
lookup_context.exists?(path, [], false)
|
|
447
|
-
rescue
|
|
504
|
+
rescue StandardError
|
|
448
505
|
false
|
|
449
506
|
end
|
|
450
507
|
|
|
@@ -465,7 +522,7 @@ module Mcp
|
|
|
465
522
|
if Mcp::Auth.configuration.validate_scope_for_user
|
|
466
523
|
all_available = all_available.select do |scope|
|
|
467
524
|
Mcp::Auth.configuration.validate_scope_for_user.call(
|
|
468
|
-
|
|
525
|
+
mcp_current_user,
|
|
469
526
|
current_org,
|
|
470
527
|
scope
|
|
471
528
|
)
|
|
@@ -507,7 +564,7 @@ module Mcp
|
|
|
507
564
|
end
|
|
508
565
|
|
|
509
566
|
def require_https
|
|
510
|
-
return if request.ssl? || request.local? || Rails.env.
|
|
567
|
+
return if request.ssl? || request.local? || Rails.env.local?
|
|
511
568
|
|
|
512
569
|
render_error('invalid_request', 'HTTPS required')
|
|
513
570
|
end
|
|
@@ -529,6 +586,22 @@ module Mcp
|
|
|
529
586
|
render json: error_response, status: status, content_type: 'application/json'
|
|
530
587
|
end
|
|
531
588
|
|
|
589
|
+
# Resolve the signed-in user via the configured `current_user_method`
|
|
590
|
+
# (defaults to :current_user). Accepts either a symbol naming a method on
|
|
591
|
+
# the host ApplicationController or a proc evaluated in this context.
|
|
592
|
+
def mcp_current_user
|
|
593
|
+
method_name = Mcp::Auth.configuration&.current_user_method || :current_user
|
|
594
|
+
return instance_exec(&method_name) if method_name.respond_to?(:call)
|
|
595
|
+
|
|
596
|
+
send(method_name) if respond_to?(method_name, true)
|
|
597
|
+
rescue NoMethodError
|
|
598
|
+
nil
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def mcp_user_signed_in?
|
|
602
|
+
mcp_current_user.present?
|
|
603
|
+
end
|
|
604
|
+
|
|
532
605
|
def current_org
|
|
533
606
|
# If current_org_method is nil in config, always return nil
|
|
534
607
|
return nil if Mcp::Auth.configuration.current_org_method.nil?
|
|
@@ -51,9 +51,10 @@ module Mcp
|
|
|
51
51
|
require_pushed_authorization_requests: false,
|
|
52
52
|
require_signed_request_object: false,
|
|
53
53
|
|
|
54
|
-
# Token revocation and introspection
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
# Token revocation and introspection require client authentication
|
|
55
|
+
# (RFC 7009 §2.1 / RFC 7662 §2.1) — `none` is intentionally not offered.
|
|
56
|
+
revocation_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post],
|
|
57
|
+
introspection_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post]
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
render json: metadata, status: :ok, content_type: 'application/json'
|
|
@@ -88,8 +89,7 @@ module Mcp
|
|
|
88
89
|
# configured signing algorithm is asymmetric (RS256/ES256). HMAC keys
|
|
89
90
|
# are NEVER published — for HS256 this stays an empty key set.
|
|
90
91
|
def jwks
|
|
91
|
-
|
|
92
|
-
keys = jwk ? [jwk] : []
|
|
92
|
+
keys = Mcp::Auth::Services::TokenService.signing_jwks_export
|
|
93
93
|
render json: { keys: keys }, status: :ok, content_type: 'application/json'
|
|
94
94
|
end
|
|
95
95
|
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Mcp
|
|
2
4
|
module Auth
|
|
3
5
|
class AccessToken < ActiveRecord::Base
|
|
4
|
-
self.table_name =
|
|
6
|
+
self.table_name = 'mcp_auth_access_tokens'
|
|
5
7
|
|
|
6
8
|
belongs_to :user
|
|
7
9
|
belongs_to :org, optional: true
|
|
8
10
|
belongs_to :oauth_client,
|
|
9
|
-
class_name:
|
|
11
|
+
class_name: 'Mcp::Auth::OauthClient',
|
|
10
12
|
foreign_key: :client_id,
|
|
11
13
|
primary_key: :client_id,
|
|
12
14
|
optional: true
|
|
@@ -3,33 +3,34 @@
|
|
|
3
3
|
module Mcp
|
|
4
4
|
module Auth
|
|
5
5
|
class OauthClient < ActiveRecord::Base
|
|
6
|
-
self.table_name =
|
|
7
|
-
self.primary_key =
|
|
6
|
+
self.table_name = 'mcp_auth_oauth_clients'
|
|
7
|
+
self.primary_key = 'client_id'
|
|
8
8
|
|
|
9
9
|
# Set defaults BEFORE validation
|
|
10
10
|
before_validation :set_defaults, on: :create
|
|
11
11
|
|
|
12
12
|
validates :client_id, presence: true, uniqueness: true
|
|
13
13
|
validates :client_secret, presence: true
|
|
14
|
+
validate :validate_redirect_uris
|
|
14
15
|
|
|
15
16
|
serialize :redirect_uris, coder: JSON
|
|
16
17
|
serialize :grant_types, coder: JSON
|
|
17
18
|
serialize :response_types, coder: JSON
|
|
18
19
|
|
|
19
20
|
has_many :authorization_codes,
|
|
20
|
-
class_name:
|
|
21
|
+
class_name: 'Mcp::Auth::AuthorizationCode',
|
|
21
22
|
foreign_key: :client_id,
|
|
22
23
|
primary_key: :client_id,
|
|
23
24
|
dependent: :destroy
|
|
24
25
|
|
|
25
26
|
has_many :access_tokens,
|
|
26
|
-
class_name:
|
|
27
|
+
class_name: 'Mcp::Auth::AccessToken',
|
|
27
28
|
foreign_key: :client_id,
|
|
28
29
|
primary_key: :client_id,
|
|
29
30
|
dependent: :destroy
|
|
30
31
|
|
|
31
32
|
has_many :refresh_tokens,
|
|
32
|
-
class_name:
|
|
33
|
+
class_name: 'Mcp::Auth::RefreshToken',
|
|
33
34
|
foreign_key: :client_id,
|
|
34
35
|
primary_key: :client_id,
|
|
35
36
|
dependent: :destroy
|
|
@@ -55,6 +56,34 @@ module Mcp
|
|
|
55
56
|
self.response_types ||= %w[code]
|
|
56
57
|
self.scope ||= Mcp::Auth::ScopeRegistry.default_scope_string
|
|
57
58
|
end
|
|
59
|
+
|
|
60
|
+
# RFC 7591 / RFC 8252: a client using the authorization_code grant must
|
|
61
|
+
# register at least one redirect URI, and each must be an absolute URI.
|
|
62
|
+
# We reject scheme-only values (e.g. `javascript:`/`data:`) that would be
|
|
63
|
+
# XSS-redirect vectors, while still allowing http(s) and native app schemes.
|
|
64
|
+
def validate_redirect_uris
|
|
65
|
+
return unless Array(grant_types).include?('authorization_code')
|
|
66
|
+
|
|
67
|
+
uris = Array(redirect_uris)
|
|
68
|
+
if uris.empty?
|
|
69
|
+
errors.add(:redirect_uris, 'must include at least one redirect URI')
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
uris.each do |uri|
|
|
74
|
+
errors.add(:redirect_uris, "contains an invalid redirect URI: #{uri}") unless valid_redirect_uri_format?(uri)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def valid_redirect_uri_format?(uri)
|
|
79
|
+
parsed = URI.parse(uri.to_s)
|
|
80
|
+
return true if parsed.is_a?(URI::HTTP) && parsed.host.present? # http(s) with host
|
|
81
|
+
return true if parsed.scheme.present? && uri.to_s.include?('://') # native app scheme
|
|
82
|
+
|
|
83
|
+
false
|
|
84
|
+
rescue URI::InvalidURIError
|
|
85
|
+
false
|
|
86
|
+
end
|
|
58
87
|
end
|
|
59
88
|
end
|
|
60
|
-
end
|
|
89
|
+
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
class CreateMcpAuthOauthClients < ActiveRecord::Migration<%= migration_version %>
|
|
2
2
|
def up
|
|
3
|
+
# client_id is a string primary key (the model generates a UUID via
|
|
4
|
+
# SecureRandom.uuid in a before_validation hook). Using :string instead of
|
|
5
|
+
# the Postgres-only :uuid / gen_random_uuid() keeps the gem portable across
|
|
6
|
+
# SQLite, MySQL, and Postgres.
|
|
3
7
|
create_table :mcp_auth_oauth_clients, id: false do |t|
|
|
4
|
-
t.
|
|
8
|
+
t.string :client_id, primary_key: true, null: false
|
|
5
9
|
t.string :client_secret, null: false
|
|
6
10
|
t.text :redirect_uris
|
|
7
11
|
t.text :grant_types
|
|
@@ -193,6 +193,38 @@ Mcp::Auth.configure do |config|
|
|
|
193
193
|
# - @authorization_params: Hash of OAuth parameters to preserve
|
|
194
194
|
end
|
|
195
195
|
|
|
196
|
+
# ============================================================================
|
|
197
|
+
# JWT SIGNING (OPTIONAL)
|
|
198
|
+
# ============================================================================
|
|
199
|
+
#
|
|
200
|
+
# By default tokens are signed with HS256 using `oauth_secret`. To use
|
|
201
|
+
# asymmetric signing (recommended when token consumers should verify without
|
|
202
|
+
# the shared secret), set an algorithm and provide PEM-encoded keys:
|
|
203
|
+
#
|
|
204
|
+
# config.token_signing_algorithm = 'RS256' # or 'ES256'
|
|
205
|
+
# config.token_signing_private_key = ENV.fetch('MCP_JWT_PRIVATE_KEY')
|
|
206
|
+
# config.token_signing_public_key = ENV['MCP_JWT_PUBLIC_KEY'] # optional; derived if omitted
|
|
207
|
+
# config.token_signing_kid = 'main-2026' # optional explicit JWK key id
|
|
208
|
+
#
|
|
209
|
+
# Key rotation: list the previous public key(s) here so already-issued tokens
|
|
210
|
+
# keep verifying and both keys are published at /.well-known/jwks.json:
|
|
211
|
+
# config.token_signing_additional_public_keys = [ENV['MCP_JWT_PREVIOUS_PUBLIC_KEY']]
|
|
212
|
+
|
|
213
|
+
# ============================================================================
|
|
214
|
+
# PROTECTING YOUR MCP ENDPOINT (RESOURCE SERVER)
|
|
215
|
+
# ============================================================================
|
|
216
|
+
#
|
|
217
|
+
# Include the resource-server concern in the controller that serves your MCP
|
|
218
|
+
# endpoint. It validates the Bearer access token, exposes the principal via
|
|
219
|
+
# mcp_user_id / mcp_scope / mcp_email, and returns a spec-compliant 401 with a
|
|
220
|
+
# WWW-Authenticate header pointing at the protected-resource metadata.
|
|
221
|
+
#
|
|
222
|
+
# class McpController < ApplicationController
|
|
223
|
+
# include Mcp::Auth::ProtectedResource
|
|
224
|
+
# before_action :authenticate_mcp_token!
|
|
225
|
+
# before_action -> { require_mcp_scope!('mcp:read') }, only: :show
|
|
226
|
+
# end
|
|
227
|
+
|
|
196
228
|
# Include controller helpers in ApplicationController
|
|
197
229
|
Rails.application.config.to_prepare do
|
|
198
230
|
ApplicationController.include Mcp::Auth::ControllerHelpers
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcp
|
|
4
|
+
module Auth
|
|
5
|
+
# Resource-server side of the MCP authorization spec. Include this in the
|
|
6
|
+
# controller that serves your MCP endpoint to validate the incoming Bearer
|
|
7
|
+
# token and expose the authenticated principal via Mcp::Auth::ControllerHelpers
|
|
8
|
+
# (mcp_user_id, mcp_scope, ...).
|
|
9
|
+
#
|
|
10
|
+
# class McpController < ApplicationController
|
|
11
|
+
# include Mcp::Auth::ProtectedResource
|
|
12
|
+
# before_action :authenticate_mcp_token!
|
|
13
|
+
# before_action -> { require_mcp_scope!('mcp:read') }, only: :show
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# On a missing/invalid/expired token it answers 401 with the RFC 9728
|
|
17
|
+
# WWW-Authenticate header so MCP clients can discover the authorization
|
|
18
|
+
# server from the protected-resource metadata.
|
|
19
|
+
module ProtectedResource
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
include Mcp::Auth::ControllerHelpers
|
|
22
|
+
|
|
23
|
+
# Validates the Bearer access token (signature, expiry, revocation status,
|
|
24
|
+
# and — when a resource is configured — the RFC 8707 audience). On success
|
|
25
|
+
# the decoded claims are stashed in request.env for ControllerHelpers and
|
|
26
|
+
# the payload is returned; on failure it renders 401 and halts the action.
|
|
27
|
+
def authenticate_mcp_token!
|
|
28
|
+
token = mcp_bearer_token
|
|
29
|
+
payload = token && Services::TokenService.validate_access_token(token, resource: mcp_resource_identifier)
|
|
30
|
+
|
|
31
|
+
unless payload
|
|
32
|
+
render_mcp_unauthorized('invalid_token', 'The access token is missing, invalid, or expired')
|
|
33
|
+
return false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
request.env['mcp.user_id'] = payload[:sub]
|
|
37
|
+
request.env['mcp.org_id'] = payload[:org]
|
|
38
|
+
request.env['mcp.email'] = payload[:email]
|
|
39
|
+
request.env['mcp.token'] = token
|
|
40
|
+
request.env['mcp.scope'] = payload[:scope]
|
|
41
|
+
request.env['mcp.api_key'] = payload[:api_key_id]
|
|
42
|
+
payload
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Enforce that the validated token carries every given scope. Renders 403
|
|
46
|
+
# insufficient_scope and returns false when any is missing.
|
|
47
|
+
def require_mcp_scope!(*required)
|
|
48
|
+
granted = mcp_scope.to_s.split
|
|
49
|
+
missing = required.map(&:to_s) - granted
|
|
50
|
+
return true if missing.empty?
|
|
51
|
+
|
|
52
|
+
render_mcp_unauthorized(
|
|
53
|
+
'insufficient_scope',
|
|
54
|
+
"Missing required scope: #{missing.join(' ')}",
|
|
55
|
+
status: :forbidden
|
|
56
|
+
)
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def mcp_bearer_token
|
|
63
|
+
header = request.authorization || request.headers['Authorization']
|
|
64
|
+
return nil unless header&.start_with?('Bearer ')
|
|
65
|
+
|
|
66
|
+
header.split(' ', 2).last.presence
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Canonical resource identifier for this server (base_url + mcp_server_path),
|
|
70
|
+
# matching the audience minted into access tokens.
|
|
71
|
+
def mcp_resource_identifier
|
|
72
|
+
path = Mcp::Auth.configuration&.mcp_server_path.presence || '/mcp'
|
|
73
|
+
path = "/#{path}" unless path.start_with?('/')
|
|
74
|
+
"#{request.base_url}#{path.chomp('/')}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# RFC 9728 §5.1 / MCP authorization spec: a 401 MUST advertise the
|
|
78
|
+
# protected-resource metadata document via WWW-Authenticate so clients can
|
|
79
|
+
# bootstrap the OAuth flow.
|
|
80
|
+
def render_mcp_unauthorized(error, description, status: :unauthorized)
|
|
81
|
+
metadata_url = "#{request.base_url}/.well-known/oauth-protected-resource"
|
|
82
|
+
response.headers['WWW-Authenticate'] =
|
|
83
|
+
%(Bearer error="#{error}", error_description="#{description}", resource_metadata="#{metadata_url}")
|
|
84
|
+
render json: { error: error, error_description: description }, status: status
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -14,6 +14,11 @@ module Mcp
|
|
|
14
14
|
# required: false
|
|
15
15
|
# )
|
|
16
16
|
class ScopeRegistry
|
|
17
|
+
# Standard OpenID Connect scopes. These are not application resource scopes
|
|
18
|
+
# (so they are not registered or shown as consent checkboxes) but are
|
|
19
|
+
# recognized when present so OIDC discovery/id_token issuance works.
|
|
20
|
+
STANDARD_OIDC_SCOPES = %w[openid profile email].freeze
|
|
21
|
+
|
|
17
22
|
class << self
|
|
18
23
|
# Custom scopes registered by the application
|
|
19
24
|
def custom_scopes
|
|
@@ -72,9 +77,7 @@ module Mcp
|
|
|
72
77
|
# Validate and filter requested scopes
|
|
73
78
|
def validate_scopes(requested_scopes)
|
|
74
79
|
# If no scopes requested, return all required scopes
|
|
75
|
-
if requested_scopes.blank?
|
|
76
|
-
return available_scopes.select { |_, meta| meta[:required] }.keys
|
|
77
|
-
end
|
|
80
|
+
return available_scopes.select { |_, meta| meta[:required] }.keys if requested_scopes.blank?
|
|
78
81
|
|
|
79
82
|
scopes = requested_scopes.is_a?(String) ? requested_scopes.split : requested_scopes
|
|
80
83
|
|
|
@@ -11,24 +11,27 @@ module Mcp
|
|
|
11
11
|
return nil if token.blank?
|
|
12
12
|
|
|
13
13
|
begin
|
|
14
|
-
payload =
|
|
14
|
+
payload = decode_with_known_keys(token)
|
|
15
|
+
return nil unless payload
|
|
15
16
|
|
|
16
17
|
# Check expiration manually to ensure proper handling
|
|
17
|
-
if payload['exp']
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
return nil if payload['exp'] && (payload['exp'] <= Time.current.to_i)
|
|
19
|
+
|
|
20
|
+
# Revocation check (RFC 7009): a JWT remains cryptographically valid
|
|
21
|
+
# until it expires, so a stored-and-still-present row is what makes
|
|
22
|
+
# `revoke` actually take effect. Without this, destroyed tokens would
|
|
23
|
+
# keep validating until natural expiry.
|
|
24
|
+
return nil unless Mcp::Auth::AccessToken.active.exists?(token: token)
|
|
20
25
|
|
|
21
26
|
# Validate audience if resource provided (RFC 8707 compliance)
|
|
22
|
-
if resource && payload['aud'].present?
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return nil
|
|
26
|
-
end
|
|
27
|
+
if resource && payload['aud'].present? && !audience_matches?(payload['aud'], resource)
|
|
28
|
+
Rails.logger.warn "[TokenService] Token audience mismatch: expected #{resource}, got #{payload['aud']}"
|
|
29
|
+
return nil
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
payload.symbolize_keys
|
|
30
33
|
rescue JWT::DecodeError, JWT::ExpiredSignature => e
|
|
31
|
-
Rails.logger.debug "[TokenService] Token validation failed: #{e.message}"
|
|
34
|
+
Rails.logger.debug { "[TokenService] Token validation failed: #{e.message}" }
|
|
32
35
|
nil
|
|
33
36
|
rescue StandardError => e
|
|
34
37
|
Rails.logger.error "[TokenService] Token validation error: #{e.message}"
|
|
@@ -36,12 +39,31 @@ module Mcp
|
|
|
36
39
|
end
|
|
37
40
|
end
|
|
38
41
|
|
|
42
|
+
# Decode against every key we currently trust. For HS256 that is the
|
|
43
|
+
# single shared secret; for RS256/ES256 it is the active public key plus
|
|
44
|
+
# any additional public keys configured for rotation, so tokens signed
|
|
45
|
+
# with the previous key keep validating across a key roll.
|
|
46
|
+
def decode_with_known_keys(token)
|
|
47
|
+
last_error = nil
|
|
48
|
+
verification_keys.each do |key|
|
|
49
|
+
return JWT.decode(token, key, true, { algorithm: signing_algorithm }).first
|
|
50
|
+
rescue JWT::DecodeError => e
|
|
51
|
+
last_error = e
|
|
52
|
+
end
|
|
53
|
+
raise last_error if last_error
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
39
58
|
# Generate JWT access token with proper audience binding
|
|
40
59
|
def generate_access_token(data, base_url:)
|
|
41
60
|
user_data = fetch_user_data(data)
|
|
42
61
|
|
|
43
|
-
# RFC 8707: Use provided resource or default to
|
|
44
|
-
|
|
62
|
+
# RFC 8707: Use provided resource or default to the configured MCP
|
|
63
|
+
# endpoint. The default MUST mirror the canonical resource published in
|
|
64
|
+
# the protected-resource metadata, which is built from mcp_server_path —
|
|
65
|
+
# otherwise audience validation breaks for any non-default path.
|
|
66
|
+
audience = normalize_resource_uri(data[:resource].presence || default_audience(base_url))
|
|
45
67
|
|
|
46
68
|
# Calculate expiration time
|
|
47
69
|
exp_time = data[:expires_at] ? data[:expires_at].to_i : (Time.current.to_i + token_lifetime)
|
|
@@ -124,7 +146,7 @@ module Mcp
|
|
|
124
146
|
return false unless token_record
|
|
125
147
|
|
|
126
148
|
token_record.destroy
|
|
127
|
-
Rails.logger.info
|
|
149
|
+
Rails.logger.info '[TokenService] Refresh token revoked'
|
|
128
150
|
true
|
|
129
151
|
end
|
|
130
152
|
|
|
@@ -141,12 +163,48 @@ module Mcp
|
|
|
141
163
|
}
|
|
142
164
|
|
|
143
165
|
response[:refresh_token] = refresh_token if refresh_token
|
|
166
|
+
|
|
167
|
+
# OpenID Connect: only issue an id_token when the `openid` scope was granted.
|
|
168
|
+
if openid_scope?(data[:scope])
|
|
169
|
+
id_token = generate_id_token(data, base_url: base_url)
|
|
170
|
+
response[:id_token] = id_token if id_token
|
|
171
|
+
end
|
|
172
|
+
|
|
144
173
|
response
|
|
145
174
|
rescue StandardError => e
|
|
146
175
|
Rails.logger.error "[TokenService] Failed to generate token response: #{e.message}"
|
|
147
176
|
raise
|
|
148
177
|
end
|
|
149
178
|
|
|
179
|
+
# OpenID Connect ID Token. Audience is the client_id (not the resource),
|
|
180
|
+
# per the OIDC core spec. Only the claims permitted by the granted
|
|
181
|
+
# profile/email scopes are included.
|
|
182
|
+
def generate_id_token(data, base_url:)
|
|
183
|
+
return nil if data[:client_id].blank?
|
|
184
|
+
|
|
185
|
+
user_data = fetch_user_data(data)
|
|
186
|
+
scopes = data[:scope].to_s.split
|
|
187
|
+
|
|
188
|
+
payload = {
|
|
189
|
+
iss: base_url,
|
|
190
|
+
sub: data[:user_id].to_s,
|
|
191
|
+
aud: data[:client_id],
|
|
192
|
+
iat: Time.current.to_i,
|
|
193
|
+
exp: Time.current.to_i + token_lifetime
|
|
194
|
+
}
|
|
195
|
+
if scopes.include?('email')
|
|
196
|
+
payload[:email] = user_data[:email]
|
|
197
|
+
payload[:email_verified] = true
|
|
198
|
+
end
|
|
199
|
+
payload[:name] = user_data[:email] if scopes.include?('profile')
|
|
200
|
+
|
|
201
|
+
jwt_headers = signing_kid ? { kid: signing_kid } : {}
|
|
202
|
+
JWT.encode(payload, signing_key, signing_algorithm, jwt_headers)
|
|
203
|
+
rescue StandardError => e
|
|
204
|
+
Rails.logger.error "[TokenService] Failed to generate id_token: #{e.message}"
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
150
208
|
# Public key used to sign new JWTs. Configured via Mcp::Auth.configure;
|
|
151
209
|
# built lazily so apps that stay on HS256 don't have to set anything.
|
|
152
210
|
def signing_public_key
|
|
@@ -176,12 +234,60 @@ module Mcp
|
|
|
176
234
|
exported
|
|
177
235
|
end
|
|
178
236
|
|
|
237
|
+
# All public JWKs to publish at the JWKS endpoint: the active key plus
|
|
238
|
+
# any additional rotation keys. During a key roll the previous key stays
|
|
239
|
+
# listed so already-issued tokens keep verifying. Empty for HS256.
|
|
240
|
+
def signing_jwks_export
|
|
241
|
+
return [] unless asymmetric_signing?
|
|
242
|
+
|
|
243
|
+
keys = [signing_jwk_export]
|
|
244
|
+
additional_public_keys.each do |pkey|
|
|
245
|
+
additional_jwk = JWT::JWK.new(pkey)
|
|
246
|
+
exported = additional_jwk.export
|
|
247
|
+
exported[:alg] = signing_algorithm
|
|
248
|
+
exported[:use] = 'sig'
|
|
249
|
+
keys << exported
|
|
250
|
+
end
|
|
251
|
+
keys.compact
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Clears memoized key material. Call after rotating signing keys at
|
|
255
|
+
# runtime (otherwise the previously loaded keys stay cached for the
|
|
256
|
+
# life of the process).
|
|
257
|
+
def reset_signing_keys!
|
|
258
|
+
@cached_private_key = nil
|
|
259
|
+
@cached_public_key = nil
|
|
260
|
+
@jwk = nil
|
|
261
|
+
end
|
|
262
|
+
|
|
179
263
|
private
|
|
180
264
|
|
|
181
265
|
def asymmetric_signing?
|
|
182
266
|
Mcp::Auth.configuration&.asymmetric_signing? || false
|
|
183
267
|
end
|
|
184
268
|
|
|
269
|
+
# Canonical resource URI used as the default token audience, derived from
|
|
270
|
+
# the configured mcp_server_path so it matches the published metadata.
|
|
271
|
+
def default_audience(base_url)
|
|
272
|
+
path = Mcp::Auth.configuration&.mcp_server_path.presence || '/mcp'
|
|
273
|
+
path = "/#{path}" unless path.start_with?('/')
|
|
274
|
+
path = path.chomp('/')
|
|
275
|
+
"#{base_url}#{path}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def openid_scope?(scope)
|
|
279
|
+
scope.to_s.split.include?('openid')
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Public keys accepted for verification beyond the active one (rotation).
|
|
283
|
+
def additional_public_keys
|
|
284
|
+
Array(Mcp::Auth.configuration&.token_signing_additional_public_keys).filter_map do |raw|
|
|
285
|
+
next raw if raw.is_a?(OpenSSL::PKey::PKey)
|
|
286
|
+
|
|
287
|
+
OpenSSL::PKey.read(raw) if raw.present?
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
185
291
|
def signing_algorithm
|
|
186
292
|
Mcp::Auth.configuration&.token_signing_algorithm || 'HS256'
|
|
187
293
|
end
|
|
@@ -192,12 +298,13 @@ module Mcp
|
|
|
192
298
|
asymmetric_signing? ? cached_private_key : oauth_secret
|
|
193
299
|
end
|
|
194
300
|
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
301
|
+
# Keys used to VERIFY incoming JWTs. HS256 verifies with the single
|
|
302
|
+
# shared secret; RS256/ES256 verifies against the active public key plus
|
|
303
|
+
# any configured rotation keys.
|
|
304
|
+
def verification_keys
|
|
305
|
+
return [oauth_secret] unless asymmetric_signing?
|
|
306
|
+
|
|
307
|
+
[cached_public_key, *additional_public_keys].compact
|
|
201
308
|
end
|
|
202
309
|
|
|
203
310
|
def cached_private_key
|
|
@@ -260,14 +367,20 @@ module Mcp
|
|
|
260
367
|
|
|
261
368
|
# RFC 8707: Normalize resource URI (remove trailing slash, lowercase scheme/host)
|
|
262
369
|
def normalize_resource_uri(uri)
|
|
263
|
-
parsed = URI.parse(uri)
|
|
370
|
+
parsed = URI.parse(uri.to_s)
|
|
371
|
+
|
|
372
|
+
# A resource indicator must be an absolute URI with scheme + host.
|
|
373
|
+
# Anything else (relative path, mailto, garbage) is returned verbatim
|
|
374
|
+
# so callers compare it as an opaque string rather than crashing.
|
|
375
|
+
return uri.to_s if parsed.scheme.nil? || parsed.host.nil?
|
|
376
|
+
|
|
264
377
|
normalized = "#{parsed.scheme.downcase}://#{parsed.host.downcase}"
|
|
265
378
|
normalized += ":#{parsed.port}" if parsed.port && !default_port?(parsed)
|
|
266
379
|
normalized += parsed.path.chomp('/') if parsed.path.present? && parsed.path != '/'
|
|
267
380
|
normalized
|
|
268
|
-
rescue URI::InvalidURIError => e
|
|
381
|
+
rescue URI::InvalidURIError, ArgumentError => e
|
|
269
382
|
Rails.logger.warn "[TokenService] Invalid resource URI: #{uri} - #{e.message}"
|
|
270
|
-
uri
|
|
383
|
+
uri.to_s
|
|
271
384
|
end
|
|
272
385
|
|
|
273
386
|
def default_port?(parsed_uri)
|
|
@@ -275,14 +388,16 @@ module Mcp
|
|
|
275
388
|
(parsed_uri.scheme == 'https' && parsed_uri.port == 443)
|
|
276
389
|
end
|
|
277
390
|
|
|
278
|
-
# RFC 8707: Check if token audience matches requested resource
|
|
391
|
+
# RFC 8707: Check if token audience matches requested resource.
|
|
392
|
+
# `aud` may be a single value or an array (RFC 7519 §4.1.3). Comparison
|
|
393
|
+
# is on the normalized canonical URI — NOT a string prefix, which would
|
|
394
|
+
# let `https://api.example.com.evil.com` match `https://api.example.com`.
|
|
279
395
|
def audience_matches?(token_audience, resource)
|
|
280
|
-
normalized_audience = normalize_resource_uri(token_audience)
|
|
281
396
|
normalized_resource = normalize_resource_uri(resource)
|
|
282
397
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
398
|
+
Array(token_audience).any? do |aud|
|
|
399
|
+
normalize_resource_uri(aud) == normalized_resource
|
|
400
|
+
end
|
|
286
401
|
end
|
|
287
402
|
|
|
288
403
|
def store_access_token(token, data, audience)
|
|
@@ -300,7 +415,11 @@ module Mcp
|
|
|
300
415
|
)
|
|
301
416
|
Rails.logger.info "[TokenService] Access token stored for user #{data[:user_id]}"
|
|
302
417
|
rescue ActiveRecord::RecordInvalid => e
|
|
418
|
+
# Validation now depends on the stored row existing, so a token we
|
|
419
|
+
# failed to persist would be useless AND unrevocable. Fail loudly
|
|
420
|
+
# instead of handing the client a dead bearer token.
|
|
303
421
|
Rails.logger.error "[TokenService] Failed to store access token: #{e.message}"
|
|
422
|
+
raise
|
|
304
423
|
end
|
|
305
424
|
|
|
306
425
|
def fetch_user_data(data)
|
data/lib/mcp/auth/version.rb
CHANGED
data/lib/mcp/auth.rb
CHANGED
|
@@ -37,11 +37,15 @@ module Mcp
|
|
|
37
37
|
:mcp_server_path,
|
|
38
38
|
:mcp_docs_url,
|
|
39
39
|
:validate_scope_for_user,
|
|
40
|
-
:token_signing_algorithm,
|
|
41
40
|
:token_signing_private_key,
|
|
42
41
|
:token_signing_public_key,
|
|
42
|
+
:token_signing_additional_public_keys,
|
|
43
43
|
:token_signing_kid
|
|
44
44
|
|
|
45
|
+
# token_signing_algorithm has a validating writer defined below, so only
|
|
46
|
+
# the reader is generated here.
|
|
47
|
+
attr_reader :token_signing_algorithm
|
|
48
|
+
|
|
45
49
|
def initialize
|
|
46
50
|
@oauth_secret = nil
|
|
47
51
|
@authorization_server_url = nil
|
|
@@ -63,6 +67,9 @@ module Mcp
|
|
|
63
67
|
@token_signing_algorithm = 'HS256'
|
|
64
68
|
@token_signing_private_key = nil
|
|
65
69
|
@token_signing_public_key = nil
|
|
70
|
+
# Additional public keys (PEM strings or OpenSSL::PKey instances) accepted
|
|
71
|
+
# for verification and published in the JWKS during key rotation.
|
|
72
|
+
@token_signing_additional_public_keys = []
|
|
66
73
|
@token_signing_kid = nil
|
|
67
74
|
end
|
|
68
75
|
|
|
@@ -132,6 +139,8 @@ module Mcp
|
|
|
132
139
|
end
|
|
133
140
|
end
|
|
134
141
|
|
|
142
|
+
# Loaded after the module body so they can reference Mcp::Auth::Configuration
|
|
143
|
+
# and Mcp::Auth::ControllerHelpers defined above. The services are already
|
|
144
|
+
# required at the top of this file.
|
|
135
145
|
require 'mcp/auth/scope_registry'
|
|
136
|
-
require 'mcp/auth/
|
|
137
|
-
require 'mcp/auth/services/authorization_service'
|
|
146
|
+
require 'mcp/auth/protected_resource'
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Serhii Borozenets
|
|
@@ -221,6 +221,7 @@ files:
|
|
|
221
221
|
- lib/generators/mcp/auth/templates/views/consent.html.erb
|
|
222
222
|
- lib/mcp/auth.rb
|
|
223
223
|
- lib/mcp/auth/engine.rb
|
|
224
|
+
- lib/mcp/auth/protected_resource.rb
|
|
224
225
|
- lib/mcp/auth/scope_registry.rb
|
|
225
226
|
- lib/mcp/auth/services/authorization_service.rb
|
|
226
227
|
- lib/mcp/auth/services/token_service.rb
|