mcp-auth 0.3.0 → 0.5.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: 97784aa216cd18eac56baaf9b0e9520a8a38cc9181eefb5044fe4fef5aad826a
4
+ data.tar.gz: c36b3baf50130e7646f46592ca937fc834dd3ba1b3ff41de43f6114fbe14af3e
5
5
  SHA512:
6
- metadata.gz: c1326ad826abb70c5f888595aec6d47a2083857c98e9fd167af76d6c253fa4432430748be7b399fd902db4806bbf80df3169247e915be7bea6ba6682d5903bee
7
- data.tar.gz: b4d29c3b89881f5e2585342e5b0d20b6aaea2a9a3c5396d1752bc32fb16ae7677b37bb5f1898ca7e66330a9b02c7fdd84f508deff2196808629a63af18369bb2
6
+ metadata.gz: 88be7bed04fcd81edcf6911a1e067133096d4609be7b797b649722cd43abeba4d420785a90fe4656e54f76d5301cc386a6d92f54bcbc21a517e02d60013d86b2
7
+ data.tar.gz: 37866607f9bab28377ef68346d42908e99bdc17c086b30527495d30df196962a93377356738fdd490c82c2f9a5914ff0660b62e70ba0f50097f6d78a50a1df2d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-06-15
11
+
12
+ Security-hardening release. Closes five OAuth 2.1 / MCP authorization
13
+ vulnerabilities found in an adversarial audit of the authorization server and
14
+ protected-resource layer. Each fix ships with an RSpec test that fails before
15
+ and passes after.
16
+
17
+ ### Security (breaking where noted)
18
+ - **Consent can no longer be bypassed.** `GET /oauth/authorize` previously issued
19
+ an authorization code immediately when `approved=true` was present on the
20
+ request URL — a GET, so not even CSRF-protected — skipping the consent screen
21
+ entirely. The authorization endpoint now always renders consent; a code is
22
+ granted only via the CSRF-protected `POST /oauth/approve`. **Breaking:** clients
23
+ that appended `approved=true` to the authorize URL to auto-approve must go
24
+ through the consent/approve step.
25
+ - **Refresh tokens are bound to the issuing client** (OAuth 2.1 §4.3.1). The
26
+ refresh grant now rejects redemption unless the requesting `client_id` (Basic
27
+ auth or body) matches the client the token was issued to, and does not rotate
28
+ the token on a failed check. **Breaking:** a refresh request must include the
29
+ matching `client_id` — the documented flow already does.
30
+ - **Authorization codes are consumed atomically** (OAuth 2.1 §4.1.2). Consumption
31
+ now deletes the code in a single atomic operation and the token grant aborts
32
+ unless it won that deletion, eliminating a race that could mint two token sets
33
+ from one code.
34
+ - **Resource indicators are validated** (RFC 8707 / MCP authorization spec).
35
+ `authorize` and both token grants reject any `resource` that does not identify
36
+ this server (`invalid_target`), so the server can no longer mint a token whose
37
+ audience is some other — possibly attacker-controlled — resource. **Breaking:**
38
+ requests carrying a `resource` for a different host/path are rejected.
39
+ - **`api_key_secret` is no longer embedded in access tokens.** A bearer JWT is
40
+ decodable by anyone holding it and is stored at rest, so only the non-sensitive
41
+ `api_key_id` is now included; resolve the matching secret server-side from that
42
+ id. Any `api_key_secret` returned by `fetch_user_data` is ignored.
43
+
44
+ ### Changed
45
+ - README and the generated initializer document that `fetch_user_data` must not
46
+ return secrets (they are ignored and never written into the token).
47
+
48
+ ## [0.4.0] - 2026-05-29
49
+
50
+ Security-hardening release. Closes four OAuth correctness bugs and adds the
51
+ resource-server half of the MCP authorization spec.
52
+
53
+ ### Security (breaking where noted)
54
+ - **Authorization endpoint now validates `redirect_uri`** against the client's
55
+ registered URIs (RFC 6749 §3.1.2.3) and rejects unknown `client_id`s. An
56
+ unregistered/mismatched `redirect_uri` is answered with an error and is never
57
+ redirected to. **Breaking:** flows that relied on unvalidated redirect URIs
58
+ will now be rejected — register every redirect URI.
59
+ - **Access-token revocation now takes effect.** `validate_access_token` checks
60
+ that the stored token row still exists, so `POST /oauth/revoke` and an
61
+ expired/destroyed row immediately invalidate the JWT instead of it remaining
62
+ valid until natural expiry. Introspection reflects this too.
63
+ - **Token endpoint binds the authorization code to the client** (RFC 6749
64
+ §4.1.3): the requesting `client_id` (Basic auth or body) must match the code.
65
+ - **Audience binding honors `mcp_server_path`.** The default token `aud` is now
66
+ `base_url + mcp_server_path`, matching the published protected-resource
67
+ metadata (previously hard-coded to `/mcp`, breaking RFC 8707 on custom paths).
68
+ - **Audience matching is exact**, no longer a string prefix (which let
69
+ `https://api.example.com.evil.com` match `https://api.example.com`).
70
+ - HTTPS is now enforced on `register`, `revoke`, `introspect`, and `userinfo`
71
+ (in addition to `authorize`/`token`), except in dev/test/local.
72
+
73
+ ### Added
74
+ - **`Mcp::Auth::ProtectedResource`** controller concern — validates the incoming
75
+ Bearer token on your MCP endpoint, exposes the principal via
76
+ `Mcp::Auth::ControllerHelpers` (`mcp_user_id`, `mcp_scope`, …), and answers
77
+ 401 with the RFC 9728 `WWW-Authenticate: Bearer … resource_metadata="…"`
78
+ header the MCP spec requires. Includes `require_mcp_scope!` for per-action
79
+ scope enforcement.
80
+ - **OpenID Connect id_token issuance** — when the `openid` scope is granted, the
81
+ token response includes an `id_token` (with `email`/`profile` claims gated by
82
+ scope), making the advertised OIDC discovery real.
83
+ - **Signing-key rotation** — `token_signing_additional_public_keys` accepts extra
84
+ public keys that are honored for verification and published in JWKS, so a key
85
+ roll doesn't invalidate outstanding tokens. `TokenService.reset_signing_keys!`
86
+ clears the in-process key cache.
87
+ - Refresh grant supports **scope narrowing** (RFC 6749 §6) and the wired-up
88
+ `current_user_method` config option.
89
+ - Dynamic client registration now validates redirect URIs (RFC 7591/8252),
90
+ rejecting empty sets and dangerous schemes (`javascript:`/`data:`).
91
+
92
+ ### Changed
93
+ - Refresh-token rotation and authorization-code consumption now happen *before*
94
+ new tokens are minted, so a replayed code/refresh token can't double-issue.
95
+ - `store_access_token` failures now propagate instead of silently handing the
96
+ client an unrevocable token.
97
+ - `none` removed from advertised revocation/introspection auth methods (those
98
+ endpoints require client authentication).
99
+ - Migration template for `mcp_auth_oauth_clients` uses a portable `string`
100
+ primary key instead of Postgres-only `uuid`/`gen_random_uuid()`.
101
+
102
+ ### Migration
103
+
104
+ Mostly drop-in. Two things to check:
105
+ 1. Ensure all OAuth clients have their `redirect_uris` registered — the
106
+ authorization endpoint now enforces them.
107
+ 2. To protect your MCP endpoint, include the new concern:
108
+ ```ruby
109
+ class McpController < ApplicationController
110
+ include Mcp::Auth::ProtectedResource
111
+ before_action :authenticate_mcp_token!
112
+ end
113
+ ```
114
+
10
115
  ## [0.3.0] - 2026-05-25
11
116
 
12
117
  ### Added
@@ -110,7 +215,9 @@ keep `HS256` until refresh tokens cycle out.
110
215
  - Token audience validation to prevent confused deputy attacks
111
216
  - WWW-Authenticate header with resource metadata on 401 responses
112
217
 
113
- [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.3.0...HEAD
218
+ [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.5.0...HEAD
219
+ [0.5.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.4.0...v0.5.0
220
+ [0.4.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.3.0...v0.4.0
114
221
  [0.3.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.2.0...v0.3.0
115
222
  [0.2.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.1.0...v0.2.0
116
223
  [0.1.0]: https://github.com/SerhiiBorozenets/mcp-auth/releases/tag/v0.1.0
data/README.md CHANGED
@@ -186,18 +186,22 @@ Mcp::Auth.configure do |config|
186
186
  config.authorization_code_lifetime = 1800 # 30 minutes
187
187
 
188
188
  # User data fetcher - CUSTOMIZE THIS
189
+ #
190
+ # SECURITY: only NON-sensitive values are embedded into the access token (a
191
+ # bearer JWT is decodable by anyone holding it and is stored at rest). Return
192
+ # an api_key_id (an opaque reference) and resolve the matching secret
193
+ # server-side from that id at request time. Never return a raw secret here —
194
+ # any `api_key_secret` is intentionally ignored and NOT placed in the token.
189
195
  config.fetch_user_data = proc do |data|
190
196
  user = User.find(data[:user_id])
191
197
  org = Org.find(data[:org_id]) if data[:org_id]
192
-
193
- # Return user data + API key (if you have one)
198
+
194
199
  {
195
200
  email: user.email,
196
- api_key_id: org&.api_key&.id,
197
- api_key_secret: org&.api_key&.secret
201
+ api_key_id: org&.api_key&.id
198
202
  }
199
203
  rescue ActiveRecord::RecordNotFound
200
- { email: 'unknown@example.com', api_key_id: nil, api_key_secret: nil }
204
+ { email: 'unknown@example.com', api_key_id: nil }
201
205
  end
202
206
 
203
207
  # Methods for authentication
@@ -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,17 +165,65 @@ 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
+ valid_requested_resource? &&
170
+ registered_client_with_valid_redirect?
171
+ end
172
+
173
+ # RFC 8707 / MCP authorization spec: if the client sends a `resource`, it
174
+ # MUST identify this server. A token whose audience is some other resource
175
+ # must never be minted, so the request is rejected here — before any code
176
+ # is issued — rather than silently binding the token to a foreign audience.
177
+ def valid_requested_resource?
178
+ return true if params[:resource].blank?
179
+
180
+ allowed = Services::TokenService.resource_allowed?(params[:resource], canonical_resource_identifier)
181
+ Rails.logger.warn "[OAuth] Rejected unknown resource indicator: #{params[:resource]}" unless allowed
182
+ allowed
183
+ end
184
+
185
+ # Canonical resource identifier this server issues/accepts tokens for
186
+ # (base_url + configured mcp_server_path). Mirrors the value published in
187
+ # the protected-resource metadata and minted into the token `aud`.
188
+ def canonical_resource_identifier
189
+ path = Mcp::Auth.configuration&.mcp_server_path.presence || '/mcp'
190
+ path = "/#{path}" unless path.start_with?('/')
191
+ "#{request.base_url}#{path.chomp('/')}"
192
+ end
193
+
194
+ # OAuth 2.1 / RFC 6749 §3.1.2.3: the authorization endpoint MUST reject any
195
+ # redirect_uri that is not pre-registered for the client. This is the gate
196
+ # that prevents authorization-code interception via open redirect, so it is
197
+ # validated BEFORE the code is ever issued — and on failure we render an
198
+ # error instead of redirecting (we must never redirect to an unverified URI).
199
+ def registered_client_with_valid_redirect?
200
+ client = oauth_client
201
+ unless client
202
+ Rails.logger.warn "[OAuth] Unknown client_id: #{params[:client_id]}"
203
+ return false
204
+ end
205
+
206
+ return true if client.valid_redirect_uri?(params[:redirect_uri])
207
+
208
+ Rails.logger.warn "[OAuth] Unregistered redirect_uri for client=#{params[:client_id]}: #{params[:redirect_uri]}"
209
+ false
210
+ end
211
+
212
+ def oauth_client
213
+ return @oauth_client if defined?(@oauth_client)
214
+
215
+ @oauth_client = Mcp::Auth::OauthClient.find_by(client_id: params[:client_id])
174
216
  end
175
217
 
176
218
  # === Authorization Flow ===
177
219
 
220
+ # The authorization endpoint (GET/POST /oauth/authorize) MUST NOT issue a
221
+ # code on its own: doing so let any client skip consent by appending
222
+ # `approved=true` to the authorization URL (a GET, so not even CSRF
223
+ # protected). Approval is an explicit, CSRF-protected POST to
224
+ # /oauth/approve — so here we only ever render the consent screen.
178
225
  def handle_signed_in_user
179
- if params[:approved] == 'true'
180
- generate_and_redirect_with_code
181
- else
182
- show_consent_screen
183
- end
226
+ show_consent_screen
184
227
  end
185
228
 
186
229
  def redirect_to_login
@@ -203,13 +246,11 @@ module Mcp
203
246
  # Pass the params with approved scope to authorization service
204
247
  code = Services::AuthorizationService.generate_authorization_code(
205
248
  auth_params,
206
- user: current_user,
249
+ user: mcp_current_user,
207
250
  org: current_org
208
251
  )
209
252
 
210
- unless code
211
- return render_error('server_error', 'Failed to generate authorization code')
212
- end
253
+ return render_error('server_error', 'Failed to generate authorization code') unless code
213
254
 
214
255
  redirect_with_code(code)
215
256
  end
@@ -240,10 +281,19 @@ module Mcp
240
281
  def handle_authorization_code_grant
241
282
  code_data = Services::AuthorizationService.validate_authorization_code(params[:code])
242
283
 
243
- unless code_data
244
- return render_error('invalid_grant', 'Authorization code is invalid or expired')
284
+ return render_error('invalid_grant', 'Authorization code is invalid or expired') unless code_data
285
+
286
+ # RFC 6749 §4.1.3: the code MUST be bound to the client it was issued to.
287
+ # The requesting client identifies itself via HTTP Basic auth (confidential
288
+ # clients) or the client_id parameter (public clients using PKCE).
289
+ unless requesting_client_owns?(code_data[:client_id])
290
+ return render_error('invalid_grant', 'Authorization code was issued to a different client')
245
291
  end
246
292
 
293
+ # RFC 8707: a `resource` sent at the token endpoint (used as the audience
294
+ # fallback when the code carried none) must still identify this server.
295
+ return render_error('invalid_target', 'Invalid resource indicator') unless valid_requested_resource?
296
+
247
297
  # Validate PKCE
248
298
  unless Services::AuthorizationService.validate_pkce?(code_data[:code_challenge], params[:code_verifier])
249
299
  return render_error('invalid_grant', 'PKCE validation failed')
@@ -257,39 +307,84 @@ module Mcp
257
307
  # Use the APPROVED scope from the authorization code, not the original request
258
308
  Rails.logger.info "[OAuth] Token generation using scope from auth code: #{code_data[:scope]}"
259
309
 
310
+ # Consume the authorization code FIRST (one-time use). consume_* deletes
311
+ # the row atomically and only the request that actually removed it gets a
312
+ # truthy result, so a replayed/raced code can never yield a second set of
313
+ # tokens. Abort if we did not win the consumption.
314
+ unless Services::AuthorizationService.consume_authorization_code(params[:code])
315
+ return render_error('invalid_grant', 'Authorization code is invalid or expired')
316
+ end
317
+
260
318
  # Generate tokens with the APPROVED scope from authorization code
261
319
  token_data = code_data.merge(resource: code_data[:resource] || params[:resource])
262
320
  token_response = Services::TokenService.generate_token_response(
263
- token_data, # This includes the approved :scope from authorization code
321
+ token_data, # This includes the approved :scope from authorization code
264
322
  base_url: request.base_url
265
323
  )
266
324
 
267
- # Consume authorization code (one-time use)
268
- Services::AuthorizationService.consume_authorization_code(params[:code])
269
-
270
325
  render json: token_response, content_type: 'application/json'
326
+ rescue StandardError => e
327
+ Rails.logger.error "[OAuth] Token generation failed: #{e.message}"
328
+ render_error('server_error', 'Failed to issue tokens', status: :internal_server_error)
271
329
  end
272
330
 
273
331
  def handle_refresh_token_grant
274
332
  token_data = Services::TokenService.validate_refresh_token(params[:refresh_token])
275
333
 
276
- unless token_data
277
- return render_error('invalid_grant', 'Refresh token is invalid or expired')
334
+ return render_error('invalid_grant', 'Refresh token is invalid or expired') unless token_data
335
+
336
+ # OAuth 2.1 §4.3.1 / RFC 6749 §6: the authorization server MUST bind the
337
+ # refresh token to the client it was issued to and reject redemption by
338
+ # any other client. Without this a refresh token leaked to (or through) a
339
+ # second client could be exchanged for fresh access tokens.
340
+ unless requesting_client_owns?(token_data[:client_id])
341
+ return render_error('invalid_grant', 'Refresh token was issued to a different client')
278
342
  end
279
343
 
344
+ # RFC 8707: a resource indicator, when supplied, must name this server.
345
+ return render_error('invalid_target', 'Invalid resource indicator') unless valid_requested_resource?
346
+
347
+ # RFC 6749 §6: a client may request a NARROWER scope on refresh, never a
348
+ # wider one. Silently dropping unknown/extra scopes preserves least privilege.
349
+ token_data[:scope] = narrow_scope(token_data[:scope], params[:scope]) if params[:scope].present?
350
+
280
351
  # Include resource parameter if provided
281
352
  token_data[:resource] = params[:resource] if params[:resource]
282
353
 
354
+ # Rotate refresh token (OAuth 2.1 requirement) BEFORE issuing the new one
355
+ # so a replayed refresh token cannot mint a second token family.
356
+ Services::TokenService.revoke_refresh_token(params[:refresh_token])
357
+
283
358
  # Generate new tokens
284
359
  token_response = Services::TokenService.generate_token_response(
285
360
  token_data,
286
361
  base_url: request.base_url
287
362
  )
288
363
 
289
- # Rotate refresh token (OAuth 2.1 requirement)
290
- Services::TokenService.revoke_refresh_token(params[:refresh_token])
291
-
292
364
  render json: token_response, content_type: 'application/json'
365
+ rescue StandardError => e
366
+ Rails.logger.error "[OAuth] Token refresh failed: #{e.message}"
367
+ render_error('server_error', 'Failed to issue tokens', status: :internal_server_error)
368
+ end
369
+
370
+ # Intersection of the originally granted scope and a requested subset.
371
+ def narrow_scope(granted_scope, requested_scope)
372
+ granted = granted_scope.to_s.split
373
+ requested = requested_scope.to_s.split
374
+ (granted & requested).join(' ')
375
+ end
376
+
377
+ # client_id of the party making a token request: HTTP Basic auth wins for
378
+ # confidential clients, otherwise the public client_id parameter.
379
+ def requesting_client_id
380
+ basic_id, = extract_client_credentials_from_basic
381
+ basic_id.presence || params[:client_id]
382
+ end
383
+
384
+ # True only when the token-requesting client identifies itself AND that
385
+ # identity matches the client a grant (code/refresh token) was issued to.
386
+ def requesting_client_owns?(client_id)
387
+ requesting_client_id.present? && requesting_client_id == client_id
293
388
  end
294
389
 
295
390
  # === Client Registration ===
@@ -342,12 +437,21 @@ module Mcp
342
437
  end
343
438
 
344
439
  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
440
+ basic_id, basic_secret = extract_client_credentials_from_basic
441
+ return [basic_id, basic_secret] if basic_id.present?
442
+
443
+ [params[:client_id], params[:client_secret]]
444
+ end
445
+
446
+ # Returns [client_id, client_secret] from an HTTP Basic Authorization
447
+ # header, or [nil, nil] when the header is absent/not Basic.
448
+ def extract_client_credentials_from_basic
449
+ auth = request.authorization
450
+ return [nil, nil] unless auth&.start_with?('Basic ')
451
+
452
+ decoded = Base64.decode64(auth.split(' ', 2).last)
453
+ id, secret = decoded.split(':', 2)
454
+ [id, secret]
351
455
  end
352
456
 
353
457
  # RFC 7009: revoke token only if it belongs to the requesting client.
@@ -438,13 +542,13 @@ module Mcp
438
542
  config = Rails.application.config.mcp_auth
439
543
  config.use_custom_consent_view &&
440
544
  template_exists?(config.consent_view_path)
441
- rescue
545
+ rescue StandardError
442
546
  false
443
547
  end
444
548
 
445
549
  def template_exists?(path)
446
550
  lookup_context.exists?(path, [], false)
447
- rescue
551
+ rescue StandardError
448
552
  false
449
553
  end
450
554
 
@@ -465,7 +569,7 @@ module Mcp
465
569
  if Mcp::Auth.configuration.validate_scope_for_user
466
570
  all_available = all_available.select do |scope|
467
571
  Mcp::Auth.configuration.validate_scope_for_user.call(
468
- current_user,
572
+ mcp_current_user,
469
573
  current_org,
470
574
  scope
471
575
  )
@@ -507,7 +611,7 @@ module Mcp
507
611
  end
508
612
 
509
613
  def require_https
510
- return if request.ssl? || request.local? || Rails.env.development?
614
+ return if request.ssl? || request.local? || Rails.env.local?
511
615
 
512
616
  render_error('invalid_request', 'HTTPS required')
513
617
  end
@@ -529,6 +633,22 @@ module Mcp
529
633
  render json: error_response, status: status, content_type: 'application/json'
530
634
  end
531
635
 
636
+ # Resolve the signed-in user via the configured `current_user_method`
637
+ # (defaults to :current_user). Accepts either a symbol naming a method on
638
+ # the host ApplicationController or a proc evaluated in this context.
639
+ def mcp_current_user
640
+ method_name = Mcp::Auth.configuration&.current_user_method || :current_user
641
+ return instance_exec(&method_name) if method_name.respond_to?(:call)
642
+
643
+ send(method_name) if respond_to?(method_name, true)
644
+ rescue NoMethodError
645
+ nil
646
+ end
647
+
648
+ def mcp_user_signed_in?
649
+ mcp_current_user.present?
650
+ end
651
+
532
652
  def current_org
533
653
  # If current_org_method is nil in config, always return nil
534
654
  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
@@ -50,7 +50,12 @@ Mcp::Auth.configure do |config|
50
50
  # Expected return value: Hash with keys:
51
51
  # - :email (String) - User's email address
52
52
  # - :api_key_id (String/Integer, optional) - API key ID if using API keys
53
- # - :api_key_secret (String, optional) - API key secret if using API keys
53
+ #
54
+ # SECURITY: the access token is a bearer JWT — anyone holding it can decode
55
+ # its claims, and a copy is stored at rest for revocation. Only embed
56
+ # non-sensitive values. Return an api_key_id (an opaque reference) and look up
57
+ # the matching secret server-side at request time. A raw `api_key_secret`
58
+ # returned here is intentionally IGNORED and never written into the token.
54
59
  config.fetch_user_data = proc do |data|
55
60
  user = User.find(data[:user_id])
56
61
 
@@ -60,11 +65,10 @@ Mcp::Auth.configure do |config|
60
65
 
61
66
  {
62
67
  email: user.email,
63
- api_key_id: nil, # Set to your API key ID if applicable
64
- api_key_secret: nil # Set to your API key secret if applicable
68
+ api_key_id: nil # Set to your API key ID if applicable
65
69
  }
66
70
  rescue ActiveRecord::RecordNotFound
67
- { email: 'unknown@example.com', api_key_id: nil, api_key_secret: nil }
71
+ { email: 'unknown@example.com', api_key_id: nil }
68
72
  end
69
73
 
70
74
  # ============================================================================
@@ -193,6 +197,38 @@ Mcp::Auth.configure do |config|
193
197
  # - @authorization_params: Hash of OAuth parameters to preserve
194
198
  end
195
199
 
200
+ # ============================================================================
201
+ # JWT SIGNING (OPTIONAL)
202
+ # ============================================================================
203
+ #
204
+ # By default tokens are signed with HS256 using `oauth_secret`. To use
205
+ # asymmetric signing (recommended when token consumers should verify without
206
+ # the shared secret), set an algorithm and provide PEM-encoded keys:
207
+ #
208
+ # config.token_signing_algorithm = 'RS256' # or 'ES256'
209
+ # config.token_signing_private_key = ENV.fetch('MCP_JWT_PRIVATE_KEY')
210
+ # config.token_signing_public_key = ENV['MCP_JWT_PUBLIC_KEY'] # optional; derived if omitted
211
+ # config.token_signing_kid = 'main-2026' # optional explicit JWK key id
212
+ #
213
+ # Key rotation: list the previous public key(s) here so already-issued tokens
214
+ # keep verifying and both keys are published at /.well-known/jwks.json:
215
+ # config.token_signing_additional_public_keys = [ENV['MCP_JWT_PREVIOUS_PUBLIC_KEY']]
216
+
217
+ # ============================================================================
218
+ # PROTECTING YOUR MCP ENDPOINT (RESOURCE SERVER)
219
+ # ============================================================================
220
+ #
221
+ # Include the resource-server concern in the controller that serves your MCP
222
+ # endpoint. It validates the Bearer access token, exposes the principal via
223
+ # mcp_user_id / mcp_scope / mcp_email, and returns a spec-compliant 401 with a
224
+ # WWW-Authenticate header pointing at the protected-resource metadata.
225
+ #
226
+ # class McpController < ApplicationController
227
+ # include Mcp::Auth::ProtectedResource
228
+ # before_action :authenticate_mcp_token!
229
+ # before_action -> { require_mcp_scope!('mcp:read') }, only: :show
230
+ # end
231
+
196
232
  # Include controller helpers in ApplicationController
197
233
  Rails.application.config.to_prepare do
198
234
  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
 
@@ -52,7 +52,14 @@ module Mcp
52
52
  }
53
53
  end
54
54
 
55
- # Consume authorization code (one-time use)
55
+ # Consume authorization code (one-time use).
56
+ #
57
+ # OAuth 2.1 §4.1.2: an authorization code MUST be single-use. The
58
+ # delete is done as a single atomic DELETE ... WHERE that reports how
59
+ # many rows it removed, so when two requests race to redeem the same
60
+ # code exactly ONE sees `deleted == 1` and proceeds; the loser sees 0
61
+ # and gets nil. Returns the code's data on success, nil if the code was
62
+ # already consumed (or never existed).
56
63
  def consume_authorization_code(code)
57
64
  authorization_code = Mcp::Auth::AuthorizationCode.find_by(code: code)
58
65
  return nil unless authorization_code
@@ -69,8 +76,10 @@ module Mcp
69
76
  created_at: authorization_code.created_at.to_i
70
77
  }
71
78
 
72
- authorization_code.destroy
73
- Rails.logger.info "[AuthorizationService] Authorization code consumed"
79
+ deleted = Mcp::Auth::AuthorizationCode.where(id: authorization_code.id).delete_all
80
+ return nil unless deleted == 1
81
+
82
+ Rails.logger.info '[AuthorizationService] Authorization code consumed'
74
83
  code_data
75
84
  end
76
85
 
@@ -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)
@@ -54,8 +76,11 @@ module Mcp
54
76
  client_id: data[:client_id],
55
77
  email: user_data[:email],
56
78
  scope: data[:scope],
79
+ # Only a non-sensitive API key *identifier* is embedded. A bearer
80
+ # JWT is decodable by anyone holding it (and is stored at rest), so
81
+ # the matching secret MUST NOT be placed in the token — the resource
82
+ # server resolves the secret server-side from this id when needed.
57
83
  api_key_id: user_data[:api_key_id],
58
- api_key_secret: user_data[:api_key_secret],
59
84
  iat: Time.current.to_i,
60
85
  exp: exp_time
61
86
  }
@@ -124,7 +149,7 @@ module Mcp
124
149
  return false unless token_record
125
150
 
126
151
  token_record.destroy
127
- Rails.logger.info "[TokenService] Refresh token revoked"
152
+ Rails.logger.info '[TokenService] Refresh token revoked'
128
153
  true
129
154
  end
130
155
 
@@ -141,12 +166,48 @@ module Mcp
141
166
  }
142
167
 
143
168
  response[:refresh_token] = refresh_token if refresh_token
169
+
170
+ # OpenID Connect: only issue an id_token when the `openid` scope was granted.
171
+ if openid_scope?(data[:scope])
172
+ id_token = generate_id_token(data, base_url: base_url)
173
+ response[:id_token] = id_token if id_token
174
+ end
175
+
144
176
  response
145
177
  rescue StandardError => e
146
178
  Rails.logger.error "[TokenService] Failed to generate token response: #{e.message}"
147
179
  raise
148
180
  end
149
181
 
182
+ # OpenID Connect ID Token. Audience is the client_id (not the resource),
183
+ # per the OIDC core spec. Only the claims permitted by the granted
184
+ # profile/email scopes are included.
185
+ def generate_id_token(data, base_url:)
186
+ return nil if data[:client_id].blank?
187
+
188
+ user_data = fetch_user_data(data)
189
+ scopes = data[:scope].to_s.split
190
+
191
+ payload = {
192
+ iss: base_url,
193
+ sub: data[:user_id].to_s,
194
+ aud: data[:client_id],
195
+ iat: Time.current.to_i,
196
+ exp: Time.current.to_i + token_lifetime
197
+ }
198
+ if scopes.include?('email')
199
+ payload[:email] = user_data[:email]
200
+ payload[:email_verified] = true
201
+ end
202
+ payload[:name] = user_data[:email] if scopes.include?('profile')
203
+
204
+ jwt_headers = signing_kid ? { kid: signing_kid } : {}
205
+ JWT.encode(payload, signing_key, signing_algorithm, jwt_headers)
206
+ rescue StandardError => e
207
+ Rails.logger.error "[TokenService] Failed to generate id_token: #{e.message}"
208
+ nil
209
+ end
210
+
150
211
  # Public key used to sign new JWTs. Configured via Mcp::Auth.configure;
151
212
  # built lazily so apps that stay on HS256 don't have to set anything.
152
213
  def signing_public_key
@@ -176,12 +237,73 @@ module Mcp
176
237
  exported
177
238
  end
178
239
 
240
+ # All public JWKs to publish at the JWKS endpoint: the active key plus
241
+ # any additional rotation keys. During a key roll the previous key stays
242
+ # listed so already-issued tokens keep verifying. Empty for HS256.
243
+ def signing_jwks_export
244
+ return [] unless asymmetric_signing?
245
+
246
+ keys = [signing_jwk_export]
247
+ additional_public_keys.each do |pkey|
248
+ additional_jwk = JWT::JWK.new(pkey)
249
+ exported = additional_jwk.export
250
+ exported[:alg] = signing_algorithm
251
+ exported[:use] = 'sig'
252
+ keys << exported
253
+ end
254
+ keys.compact
255
+ end
256
+
257
+ # Clears memoized key material. Call after rotating signing keys at
258
+ # runtime (otherwise the previously loaded keys stay cached for the
259
+ # life of the process).
260
+ def reset_signing_keys!
261
+ @cached_private_key = nil
262
+ @cached_public_key = nil
263
+ @jwk = nil
264
+ end
265
+
266
+ # RFC 8707 §2 / MCP authorization spec: an authorization server MUST
267
+ # only honor resource indicators that name a resource it actually
268
+ # serves. A blank resource defaults to the canonical resource, so it is
269
+ # allowed; otherwise the request is accepted only when the requested
270
+ # resource matches this server's canonical resource (normalized, never
271
+ # a substring match). This stops a malicious client from minting tokens
272
+ # whose `aud` is some other — possibly attacker-controlled — resource.
273
+ def resource_allowed?(resource, canonical_resource)
274
+ return true if resource.blank?
275
+
276
+ audience_matches?(canonical_resource, resource)
277
+ end
278
+
179
279
  private
180
280
 
181
281
  def asymmetric_signing?
182
282
  Mcp::Auth.configuration&.asymmetric_signing? || false
183
283
  end
184
284
 
285
+ # Canonical resource URI used as the default token audience, derived from
286
+ # the configured mcp_server_path so it matches the published metadata.
287
+ def default_audience(base_url)
288
+ path = Mcp::Auth.configuration&.mcp_server_path.presence || '/mcp'
289
+ path = "/#{path}" unless path.start_with?('/')
290
+ path = path.chomp('/')
291
+ "#{base_url}#{path}"
292
+ end
293
+
294
+ def openid_scope?(scope)
295
+ scope.to_s.split.include?('openid')
296
+ end
297
+
298
+ # Public keys accepted for verification beyond the active one (rotation).
299
+ def additional_public_keys
300
+ Array(Mcp::Auth.configuration&.token_signing_additional_public_keys).filter_map do |raw|
301
+ next raw if raw.is_a?(OpenSSL::PKey::PKey)
302
+
303
+ OpenSSL::PKey.read(raw) if raw.present?
304
+ end
305
+ end
306
+
185
307
  def signing_algorithm
186
308
  Mcp::Auth.configuration&.token_signing_algorithm || 'HS256'
187
309
  end
@@ -192,12 +314,13 @@ module Mcp
192
314
  asymmetric_signing? ? cached_private_key : oauth_secret
193
315
  end
194
316
 
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
317
+ # Keys used to VERIFY incoming JWTs. HS256 verifies with the single
318
+ # shared secret; RS256/ES256 verifies against the active public key plus
319
+ # any configured rotation keys.
320
+ def verification_keys
321
+ return [oauth_secret] unless asymmetric_signing?
322
+
323
+ [cached_public_key, *additional_public_keys].compact
201
324
  end
202
325
 
203
326
  def cached_private_key
@@ -260,14 +383,20 @@ module Mcp
260
383
 
261
384
  # RFC 8707: Normalize resource URI (remove trailing slash, lowercase scheme/host)
262
385
  def normalize_resource_uri(uri)
263
- parsed = URI.parse(uri)
386
+ parsed = URI.parse(uri.to_s)
387
+
388
+ # A resource indicator must be an absolute URI with scheme + host.
389
+ # Anything else (relative path, mailto, garbage) is returned verbatim
390
+ # so callers compare it as an opaque string rather than crashing.
391
+ return uri.to_s if parsed.scheme.nil? || parsed.host.nil?
392
+
264
393
  normalized = "#{parsed.scheme.downcase}://#{parsed.host.downcase}"
265
394
  normalized += ":#{parsed.port}" if parsed.port && !default_port?(parsed)
266
395
  normalized += parsed.path.chomp('/') if parsed.path.present? && parsed.path != '/'
267
396
  normalized
268
- rescue URI::InvalidURIError => e
397
+ rescue URI::InvalidURIError, ArgumentError => e
269
398
  Rails.logger.warn "[TokenService] Invalid resource URI: #{uri} - #{e.message}"
270
- uri
399
+ uri.to_s
271
400
  end
272
401
 
273
402
  def default_port?(parsed_uri)
@@ -275,14 +404,16 @@ module Mcp
275
404
  (parsed_uri.scheme == 'https' && parsed_uri.port == 443)
276
405
  end
277
406
 
278
- # RFC 8707: Check if token audience matches requested resource
407
+ # RFC 8707: Check if token audience matches requested resource.
408
+ # `aud` may be a single value or an array (RFC 7519 §4.1.3). Comparison
409
+ # is on the normalized canonical URI — NOT a string prefix, which would
410
+ # let `https://api.example.com.evil.com` match `https://api.example.com`.
279
411
  def audience_matches?(token_audience, resource)
280
- normalized_audience = normalize_resource_uri(token_audience)
281
412
  normalized_resource = normalize_resource_uri(resource)
282
413
 
283
- # Exact match or audience is a prefix of resource
284
- normalized_audience == normalized_resource ||
285
- normalized_resource.start_with?(normalized_audience)
414
+ Array(token_audience).any? do |aud|
415
+ normalize_resource_uri(aud) == normalized_resource
416
+ end
286
417
  end
287
418
 
288
419
  def store_access_token(token, data, audience)
@@ -300,7 +431,11 @@ module Mcp
300
431
  )
301
432
  Rails.logger.info "[TokenService] Access token stored for user #{data[:user_id]}"
302
433
  rescue ActiveRecord::RecordInvalid => e
434
+ # Validation now depends on the stored row existing, so a token we
435
+ # failed to persist would be useless AND unrevocable. Fail loudly
436
+ # instead of handing the client a dead bearer token.
303
437
  Rails.logger.error "[TokenService] Failed to store access token: #{e.message}"
438
+ raise
304
439
  end
305
440
 
306
441
  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.5.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.5.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