mcp-auth 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4640ff06bc400ec8ec8b2869a90194a1ec6d89de1772ae23f4056cf4bfcb2d54
4
- data.tar.gz: 7f55acb2a222a430ecc1552b9073b71613a6b52f506f15b54224cc71fef66e24
3
+ metadata.gz: 52bce21e865d693725a8cf69feb1fcb72d875e12e947b6b0cb174ffdb6c7c400
4
+ data.tar.gz: 142b04126e331eaafd075b71c6eb3aeccac11177cd38adca070a72dd09aa56bd
5
5
  SHA512:
6
- metadata.gz: f7719d166eacb474ddd2769d4ea2dfeab40eea73592d5e284136f5036b06bb91bec299f6edf4527808ce0d5ec2cf5870e5a97c253bbb0f0a806851549f4c5306
7
- data.tar.gz: cf4b4977f5d8613a3d1102961a38129de1b8a52966efe130adb2399e38c4e378714bc0487a666e77c01748a4af57864f2b8813ca43ed6e086b18969e27276306
6
+ metadata.gz: 493830d702b4331373da5afd18a037c1113ec773f7d19ce7e9bdb68d784dc2168f1d643c924f159fc22804cd1ec8f2ec6c30a9c7f1eaa80f88018c133f10a56d
7
+ data.tar.gz: 9454e6a60a2d69cf48bb43412553710fd5cfd7b150b14162c7b1782170c7dd0cf1fac76d59ac8fe786245bc463c5918e4408eb99ca6a45608b100c5cbe23c12e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,120 @@ 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
+
77
+ ## [0.3.0] - 2026-05-25
78
+
79
+ ### Added
80
+ - **Asymmetric JWT signing** — `Mcp::Auth.configure` now accepts
81
+ `token_signing_algorithm` (`HS256` / `RS256` / `ES256`),
82
+ `token_signing_private_key` (PEM string or `OpenSSL::PKey`),
83
+ `token_signing_public_key` (optional — derived from the private key when
84
+ omitted), and `token_signing_kid` (optional explicit JWK key id;
85
+ auto-derived via JWT::JWK thumbprint when omitted).
86
+ - **JWKS publication** — `/.well-known/jwks.json` now returns the active
87
+ public key as a JWK when an asymmetric algorithm is configured. HMAC
88
+ keys are never exposed; HS256 keeps returning an empty key set.
89
+ - JWT headers now include `kid` for asymmetric algorithms, letting clients
90
+ pick the right verification key across rotations.
91
+ - `id_token_signing_alg_values_supported` in OIDC discovery metadata now
92
+ reflects the configured algorithm instead of being hard-coded to HS256.
93
+ - 18 new examples covering signing under each algorithm, JWKS shape, kid
94
+ override, and configuration validation.
95
+
96
+ ### Changed
97
+ - Default signing algorithm remains `HS256` — existing setups using
98
+ `oauth_secret` keep working without code changes.
99
+ - `TokenService` internals refactored so encode/decode pick the right key
100
+ for the configured algorithm; HMAC and asymmetric flows share one path.
101
+
102
+ ### Migration
103
+
104
+ To switch to asymmetric signing in your host app:
105
+
106
+ ```ruby
107
+ # config/initializers/mcp_auth.rb
108
+ Mcp::Auth.configure do |c|
109
+ c.token_signing_algorithm = 'RS256' # or 'ES256'
110
+ c.token_signing_private_key = ENV.fetch('MCP_TOKEN_PRIVATE_KEY') # PEM
111
+ # c.token_signing_public_key = ENV['MCP_TOKEN_PUBLIC_KEY'] # optional
112
+ # c.token_signing_kid = 'main-2026-05' # optional
113
+ end
114
+ ```
115
+
116
+ Generate the key once (RSA 2048 or EC P-256), store the private half in
117
+ your secrets manager / Rails credentials, and let the JWKS endpoint
118
+ serve the public half to resource servers.
119
+
120
+ Existing access tokens issued under `HS256` will no longer validate
121
+ after the switch — plan a brief re-auth window for active clients, or
122
+ keep `HS256` until refresh tokens cycle out.
123
+
10
124
  ## [0.2.0] - 2026-05-25
11
125
 
12
126
  ### Added
@@ -63,6 +177,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
63
177
  - Token audience validation to prevent confused deputy attacks
64
178
  - WWW-Authenticate header with resource metadata on 401 responses
65
179
 
66
- [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.2.0...HEAD
180
+ [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.3.0...HEAD
181
+ [0.3.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.2.0...v0.3.0
67
182
  [0.2.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.1.0...v0.2.0
68
183
  [0.1.0]: https://github.com/SerhiiBorozenets/mcp-auth/releases/tag/v0.1.0
@@ -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: #{params.inspect}"
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 user_signed_in?
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 user_signed_in?
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 "[OAuth] No scopes selected"
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 "[OAuth] Client registration request"
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: current_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
- return render_error('invalid_grant', 'Authorization code is invalid or expired')
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, # This includes the approved :scope from authorization code
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
- return render_error('invalid_grant', 'Refresh token is invalid or expired')
278
- end
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
- 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
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
- current_user,
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.development?
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
- revocation_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post none],
56
- introspection_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post none]
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'
@@ -78,16 +79,18 @@ module Mcp
78
79
  code_challenge_methods_supported: %w[S256],
79
80
  token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post none],
80
81
  subject_types_supported: %w[public],
81
- id_token_signing_alg_values_supported: %w[HS256]
82
+ id_token_signing_alg_values_supported: [Mcp::Auth.configuration&.token_signing_algorithm || 'HS256']
82
83
  }
83
84
 
84
85
  render json: metadata, status: :ok, content_type: 'application/json'
85
86
  end
86
87
 
87
- # JWKS endpoint (empty for HMAC)
88
+ # JWKS endpoint. Returns the active public key as a JWK when the
89
+ # configured signing algorithm is asymmetric (RS256/ES256). HMAC keys
90
+ # are NEVER published — for HS256 this stays an empty key set.
88
91
  def jwks
89
- keys = { keys: [] }
90
- render json: keys, status: :ok, content_type: 'application/json'
92
+ keys = Mcp::Auth::Services::TokenService.signing_jwks_export
93
+ render json: { keys: keys }, status: :ok, content_type: 'application/json'
91
94
  end
92
95
 
93
96
  private
@@ -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 = "mcp_auth_access_tokens"
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: "Mcp::Auth::OauthClient",
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 = "mcp_auth_oauth_clients"
7
- self.primary_key = "client_id"
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: "Mcp::Auth::AuthorizationCode",
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: "Mcp::Auth::AccessToken",
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: "Mcp::Auth::RefreshToken",
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.uuid :client_id, primary_key: true, null: false, default: -> { 'gen_random_uuid()' }
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
 
@@ -5,29 +5,33 @@ 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 = decode_with_known_keys(token)
15
+ return nil unless payload
14
16
 
15
17
  # Check expiration manually to ensure proper handling
16
- if payload['exp']
17
- return nil if payload['exp'] <= Time.current.to_i
18
- end
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)
19
25
 
20
26
  # Validate audience if resource provided (RFC 8707 compliance)
21
- if resource && payload['aud'].present?
22
- unless audience_matches?(payload['aud'], resource)
23
- Rails.logger.warn "[TokenService] Token audience mismatch: expected #{resource}, got #{payload['aud']}"
24
- return nil
25
- 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
26
30
  end
27
31
 
28
32
  payload.symbolize_keys
29
33
  rescue JWT::DecodeError, JWT::ExpiredSignature => e
30
- Rails.logger.debug "[TokenService] Token validation failed: #{e.message}"
34
+ Rails.logger.debug { "[TokenService] Token validation failed: #{e.message}" }
31
35
  nil
32
36
  rescue StandardError => e
33
37
  Rails.logger.error "[TokenService] Token validation error: #{e.message}"
@@ -35,12 +39,31 @@ module Mcp
35
39
  end
36
40
  end
37
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
+
38
58
  # Generate JWT access token with proper audience binding
39
59
  def generate_access_token(data, base_url:)
40
60
  user_data = fetch_user_data(data)
41
61
 
42
- # RFC 8707: Use provided resource or default to MCP API endpoint
43
- audience = normalize_resource_uri(data[:resource].presence || "#{base_url}/mcp")
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))
44
67
 
45
68
  # Calculate expiration time
46
69
  exp_time = data[:expires_at] ? data[:expires_at].to_i : (Time.current.to_i + token_lifetime)
@@ -59,7 +82,8 @@ module Mcp
59
82
  exp: exp_time
60
83
  }
61
84
 
62
- token = JWT.encode(payload, oauth_secret, 'HS256')
85
+ jwt_headers = signing_kid ? { kid: signing_kid } : {}
86
+ token = JWT.encode(payload, signing_key, signing_algorithm, jwt_headers)
63
87
 
64
88
  # Store token in database for revocation support
65
89
  store_access_token(token, data, audience)
@@ -122,7 +146,7 @@ module Mcp
122
146
  return false unless token_record
123
147
 
124
148
  token_record.destroy
125
- Rails.logger.info "[TokenService] Refresh token revoked"
149
+ Rails.logger.info '[TokenService] Refresh token revoked'
126
150
  true
127
151
  end
128
152
 
@@ -139,14 +163,195 @@ module Mcp
139
163
  }
140
164
 
141
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
+
142
173
  response
143
174
  rescue StandardError => e
144
175
  Rails.logger.error "[TokenService] Failed to generate token response: #{e.message}"
145
176
  raise
146
177
  end
147
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
+
208
+ # Public key used to sign new JWTs. Configured via Mcp::Auth.configure;
209
+ # built lazily so apps that stay on HS256 don't have to set anything.
210
+ def signing_public_key
211
+ return nil unless asymmetric_signing?
212
+
213
+ cached_public_key
214
+ end
215
+
216
+ # JWK identifier for the current signing key — included as `kid` in
217
+ # JWT headers and the JWKS entry. Falls back to JWT::JWK's
218
+ # auto-derived thumbprint when no explicit kid is configured.
219
+ def signing_kid
220
+ return nil unless asymmetric_signing?
221
+
222
+ Mcp::Auth.configuration&.token_signing_kid.presence || jwk.kid
223
+ end
224
+
225
+ # JWK for the active public key, suitable for the JWKS endpoint.
226
+ # Returns nil for HS256 (HMAC keys are never published).
227
+ def signing_jwk_export
228
+ return nil unless asymmetric_signing?
229
+
230
+ exported = jwk.export
231
+ exported[:kid] = signing_kid
232
+ exported[:alg] = signing_algorithm
233
+ exported[:use] = 'sig'
234
+ exported
235
+ end
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
+
148
263
  private
149
264
 
265
+ def asymmetric_signing?
266
+ Mcp::Auth.configuration&.asymmetric_signing? || false
267
+ end
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
+
291
+ def signing_algorithm
292
+ Mcp::Auth.configuration&.token_signing_algorithm || 'HS256'
293
+ end
294
+
295
+ # Key used to SIGN outgoing JWTs (HMAC secret for HS256, private key
296
+ # for RS256/ES256).
297
+ def signing_key
298
+ asymmetric_signing? ? cached_private_key : oauth_secret
299
+ end
300
+
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
308
+ end
309
+
310
+ def cached_private_key
311
+ @cached_private_key ||= load_private_key
312
+ end
313
+
314
+ def cached_public_key
315
+ @cached_public_key ||= load_public_key
316
+ end
317
+
318
+ def jwk
319
+ @jwk ||= JWT::JWK.new(cached_public_key)
320
+ end
321
+
322
+ def load_private_key
323
+ raw = Mcp::Auth.configuration&.token_signing_private_key
324
+ raise 'token_signing_private_key is not configured' if raw.blank?
325
+
326
+ raw.is_a?(OpenSSL::PKey::PKey) ? raw : OpenSSL::PKey.read(raw)
327
+ end
328
+
329
+ def load_public_key
330
+ raw = Mcp::Auth.configuration&.token_signing_public_key
331
+ if raw.present?
332
+ return raw if raw.is_a?(OpenSSL::PKey::PKey)
333
+
334
+ return OpenSSL::PKey.read(raw)
335
+ end
336
+
337
+ # Derive from the private key when an explicit public key isn't given.
338
+ private_key = cached_private_key
339
+ case private_key
340
+ when OpenSSL::PKey::RSA then private_key.public_key
341
+ when OpenSSL::PKey::EC then ec_public_key_from(private_key)
342
+ else
343
+ raise "Unsupported private key type for #{signing_algorithm}: #{private_key.class}"
344
+ end
345
+ end
346
+
347
+ # OpenSSL::PKey::EC#public_key returns the bare point, not a usable
348
+ # PKey instance. Build a public-only EC key so JWT::JWK can export it.
349
+ def ec_public_key_from(private_key)
350
+ pub = OpenSSL::PKey::EC.new(private_key.group)
351
+ pub.public_key = private_key.public_key
352
+ pub
353
+ end
354
+
150
355
  def oauth_secret
151
356
  secret = Mcp::Auth.configuration&.oauth_secret
152
357
  secret.presence || Rails.application.secret_key_base
@@ -162,14 +367,20 @@ module Mcp
162
367
 
163
368
  # RFC 8707: Normalize resource URI (remove trailing slash, lowercase scheme/host)
164
369
  def normalize_resource_uri(uri)
165
- 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
+
166
377
  normalized = "#{parsed.scheme.downcase}://#{parsed.host.downcase}"
167
378
  normalized += ":#{parsed.port}" if parsed.port && !default_port?(parsed)
168
379
  normalized += parsed.path.chomp('/') if parsed.path.present? && parsed.path != '/'
169
380
  normalized
170
- rescue URI::InvalidURIError => e
381
+ rescue URI::InvalidURIError, ArgumentError => e
171
382
  Rails.logger.warn "[TokenService] Invalid resource URI: #{uri} - #{e.message}"
172
- uri
383
+ uri.to_s
173
384
  end
174
385
 
175
386
  def default_port?(parsed_uri)
@@ -177,14 +388,16 @@ module Mcp
177
388
  (parsed_uri.scheme == 'https' && parsed_uri.port == 443)
178
389
  end
179
390
 
180
- # 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`.
181
395
  def audience_matches?(token_audience, resource)
182
- normalized_audience = normalize_resource_uri(token_audience)
183
396
  normalized_resource = normalize_resource_uri(resource)
184
397
 
185
- # Exact match or audience is a prefix of resource
186
- normalized_audience == normalized_resource ||
187
- normalized_resource.start_with?(normalized_audience)
398
+ Array(token_audience).any? do |aud|
399
+ normalize_resource_uri(aud) == normalized_resource
400
+ end
188
401
  end
189
402
 
190
403
  def store_access_token(token, data, audience)
@@ -202,7 +415,11 @@ module Mcp
202
415
  )
203
416
  Rails.logger.info "[TokenService] Access token stored for user #{data[:user_id]}"
204
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.
205
421
  Rails.logger.error "[TokenService] Failed to store access token: #{e.message}"
422
+ raise
206
423
  end
207
424
 
208
425
  def fetch_user_data(data)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mcp
4
4
  module Auth
5
- VERSION = "0.2.0"
5
+ VERSION = "0.4.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,15 @@ 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_private_key,
41
+ :token_signing_public_key,
42
+ :token_signing_additional_public_keys,
43
+ :token_signing_kid
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
38
48
 
39
49
  def initialize
40
50
  @oauth_secret = nil
@@ -50,6 +60,31 @@ module Mcp
50
60
  @mcp_server_path = '/mcp'
51
61
  @mcp_docs_url = nil
52
62
  @validate_scope_for_user = nil
63
+ # CP-9255 batch 2: JWT signing.
64
+ # Default HS256 keeps existing setups working (shared oauth_secret).
65
+ # Set algorithm to 'RS256' or 'ES256' and provide PEM-encoded keys
66
+ # via env or config to enable asymmetric signing + JWKS publication.
67
+ @token_signing_algorithm = 'HS256'
68
+ @token_signing_private_key = nil
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 = []
73
+ @token_signing_kid = nil
74
+ end
75
+
76
+ def token_signing_algorithm=(value)
77
+ value = value.to_s.upcase
78
+ unless SUPPORTED_SIGNING_ALGORITHMS.include?(value)
79
+ raise ArgumentError,
80
+ "Unsupported token_signing_algorithm: #{value.inspect}. " \
81
+ "Supported: #{SUPPORTED_SIGNING_ALGORITHMS.join(', ')}"
82
+ end
83
+ @token_signing_algorithm = value
84
+ end
85
+
86
+ def asymmetric_signing?
87
+ token_signing_algorithm != 'HS256'
53
88
  end
54
89
 
55
90
  # Register a custom scope for your application
@@ -104,6 +139,8 @@ module Mcp
104
139
  end
105
140
  end
106
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.
107
145
  require 'mcp/auth/scope_registry'
108
- require 'mcp/auth/services/token_service'
109
- 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.2.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