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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46fb4e82f6f75e231441aacd12e4adfdfa14efffa64556ca4325f7ad3096eb1d
4
- data.tar.gz: 6e9ba1314e2823c309651b5dae0c3ea1efda7e8140fe6266a4204603eab9b816
3
+ metadata.gz: 52bce21e865d693725a8cf69feb1fcb72d875e12e947b6b0cb174ffdb6c7c400
4
+ data.tar.gz: 142b04126e331eaafd075b71c6eb3aeccac11177cd38adca070a72dd09aa56bd
5
5
  SHA512:
6
- metadata.gz: c1326ad826abb70c5f888595aec6d47a2083857c98e9fd167af76d6c253fa4432430748be7b399fd902db4806bbf80df3169247e915be7bea6ba6682d5903bee
7
- data.tar.gz: b4d29c3b89881f5e2585342e5b0d20b6aaea2a9a3c5396d1752bc32fb16ae7677b37bb5f1898ca7e66330a9b02c7fdd84f508deff2196808629a63af18369bb2
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: #{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'
@@ -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
- jwk = Mcp::Auth::Services::TokenService.signing_jwk_export
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 = "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
 
@@ -11,24 +11,27 @@ module Mcp
11
11
  return nil if token.blank?
12
12
 
13
13
  begin
14
- payload = JWT.decode(token, verification_key, true, { algorithm: signing_algorithm }).first
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
- return nil if payload['exp'] <= Time.current.to_i
19
- 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)
20
25
 
21
26
  # Validate audience if resource provided (RFC 8707 compliance)
22
- if resource && payload['aud'].present?
23
- unless audience_matches?(payload['aud'], resource)
24
- Rails.logger.warn "[TokenService] Token audience mismatch: expected #{resource}, got #{payload['aud']}"
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 MCP API endpoint
44
- 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))
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 "[TokenService] Refresh token revoked"
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
- # Key used to VERIFY incoming JWTs (HMAC secret for HS256, public key
196
- # for RS256/ES256). For asymmetric algorithms callers may eventually
197
- # want per-token key lookup via the JWT `kid` header, but for now we
198
- # only have one active key so a single value is fine.
199
- def verification_key
200
- asymmetric_signing? ? cached_public_key : oauth_secret
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
- # Exact match or audience is a prefix of resource
284
- normalized_audience == normalized_resource ||
285
- normalized_resource.start_with?(normalized_audience)
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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mcp
4
4
  module Auth
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
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/services/token_service'
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.3.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