mcp-auth 0.4.0 → 0.6.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: 52bce21e865d693725a8cf69feb1fcb72d875e12e947b6b0cb174ffdb6c7c400
4
- data.tar.gz: 142b04126e331eaafd075b71c6eb3aeccac11177cd38adca070a72dd09aa56bd
3
+ metadata.gz: 9fe9d6d6722194f34e61bc2b2fc1104997c41c6e54fe1d8493b556d1b2004119
4
+ data.tar.gz: f65dbf8e46cd6cedcdd39b2cdf963c33c165a533d0d6295f5d140bf3a6e0b4ef
5
5
  SHA512:
6
- metadata.gz: 493830d702b4331373da5afd18a037c1113ec773f7d19ce7e9bdb68d784dc2168f1d643c924f159fc22804cd1ec8f2ec6c30a9c7f1eaa80f88018c133f10a56d
7
- data.tar.gz: 9454e6a60a2d69cf48bb43412553710fd5cfd7b150b14162c7b1782170c7dd0cf1fac76d59ac8fe786245bc463c5918e4408eb99ca6a45608b100c5cbe23c12e
6
+ metadata.gz: 77d89518c11901729bc2001ce45b2e1ffb64c756fd267429b160e0fc2972c81f44261f8f4c208da3b1e3b4424efea5c752e0dd75a1c34e52866997a16b56a6a7
7
+ data.tar.gz: 23a62caa06957f5885433a6a4d780ebf3b85d2c1833d92a1a6583f940c35e9622a8848681cbbed2a11a05dd90f8bca74bcdbbf46b88c08e12035941e491da410
data/CHANGELOG.md CHANGED
@@ -7,6 +7,257 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-28
11
+
12
+ Second security-hardening round (audit follow-ups), delivered in two phases.
13
+ Phase 1 — code-level fixes, no migration:
14
+
15
+ ### Security (breaking where noted)
16
+ - **Authorization-code TTL was 30 HOURS, not 30 minutes.** The lifetime (a value
17
+ in seconds, e.g. `1800`) was applied with `.minutes`. Now applied as seconds,
18
+ and read from the single canonical config source so configured and default
19
+ deployments agree. **Breaking:** codes now expire in ~30 min as intended.
20
+ - **Issuer / audience / discovery URLs are pinned to the configured origin.**
21
+ `iss` and all discovery/JWKS URLs derive from `authorization_server_url`, and
22
+ the MCP resource (token `aud`, protected-resource metadata, 401 challenge)
23
+ from the new `mcp_server_url`, instead of the raw request Host — closing a
24
+ Host/`X-Forwarded-Host` header-injection vector. The two are separate so a
25
+ deployment with a distinct authorization server keeps its resource on the MCP
26
+ host. **When unset**, each falls back to the request origin, so the host app
27
+ MUST restrict permitted hosts via Rails `config.hosts`.
28
+ - **JWT validation hardened.** Access tokens now carry a `token_use` claim and an
29
+ id_token can no longer be replayed as an access token; decode enforces
30
+ `required_claims` (`iss`/`aud`/`sub`/`exp`, also checked explicitly so older
31
+ ruby-jwt versions that ignore that option are covered); `exp` is strict and
32
+ `nbf` allows a bounded clock-skew leeway;
33
+ a missing audience is no longer silently accepted.
34
+ - **Confidential-client auth at the token endpoint.** A `client_secret` presented
35
+ on a code/refresh request is now verified (constant-time); an invalid secret is
36
+ rejected. (Full *requirement* of a secret for confidential clients lands with
37
+ the `token_endpoint_auth_method` column in Phase 2.)
38
+ - **Refresh-token rotation is atomic.** Rotation is gated on a conditional delete,
39
+ so two concurrent redemptions of one refresh token can no longer each mint a
40
+ new token family.
41
+ - **Consent enforces least privilege.** A client can no longer be granted a scope
42
+ it never requested; approved scopes are intersected with the requested set
43
+ (plus the server's registered required scopes). If nothing the user approved
44
+ was requested, the flow ends with `invalid_scope` instead of falling back to
45
+ the original request.
46
+ - **A dedicated `oauth_secret` is required outside development/test.** The gem
47
+ no longer signs HS256 tokens with `Rails.application.secret_key_base` (key
48
+ separation): an unset secret, or one equal to `secret_key_base`, now raises.
49
+ The generated initializer no longer falls back to `secret_key_base`, and the
50
+ unused `MCP_OAUTH_PRIVATE_KEY` engine setting is removed. Dev/test still fall
51
+ back. **Breaking** — see Upgrade.
52
+
53
+ Phase 2 — secrets hashed at rest (adds a migration) + medium fixes:
54
+
55
+ ### Security
56
+ - **All persisted secrets are now hashed at rest.** Access-token JWTs, refresh
57
+ tokens, authorization codes, and client secrets are stored as SHA-256 digests
58
+ (prefixed `sha256$`); a database leak no longer yields usable credentials. The
59
+ plaintext client secret is returned exactly once at registration; tokens/codes
60
+ are returned once to the client and matched by digest thereafter.
61
+ - **Dynamic Client Registration rejects unsupported grant/response types**
62
+ (RFC 7591 §2) instead of storing arbitrary metadata.
63
+ - **Refresh-token reuse detection** (OAuth 2.1 §4.14.2). Rotation now marks the
64
+ presented token revoked (grouped by a `family_id`) instead of deleting it, so
65
+ an authenticated client replaying an already-rotated token is detected as theft
66
+ and the **entire family is revoked — both the refresh tokens and the access
67
+ tokens already issued to that principal** (immediate cut-off, not left valid
68
+ until expiry). Client authentication is checked *before* this reaction, so an
69
+ unauthenticated replay can't trigger family revocation. Rotation is atomic
70
+ (only the request that flips `revoked_at` wins), superseding the delete-based
71
+ race fix. The migration backfills a `family_id` for pre-existing tokens so
72
+ reuse detection covers them too. A configurable **rotation grace period**
73
+ (`refresh_token_reuse_grace_period`, default 10s) treats a rotated token
74
+ replayed moments later as a benign concurrent-refresh race (rejected softly,
75
+ family kept) rather than theft, so well-behaved MCP clients that fire several
76
+ refreshes when the access token expires aren't logged out.
77
+ - **Access tokens carry a unique `jti`** (RFC 7519) so two tokens issued in the
78
+ same second for the same principal/scope don't collide on the unique token
79
+ index (which previously raised on rapid/concurrent refreshes).
80
+ - **Confidential-client authentication is now enforced.** Clients carry a
81
+ `token_endpoint_auth_method`; a confidential client (`client_secret_basic` /
82
+ `client_secret_post`) MUST present a valid secret at the token endpoint, while
83
+ a public client (`none`, the default) relies on PKCE. **Existing clients
84
+ default to `none`, so nothing that worked before starts requiring a secret** —
85
+ a client opts into confidential auth explicitly at registration.
86
+
87
+ - **Refresh rotation and successor issuance are one transaction.** If minting
88
+ the new tokens fails (or yields no refresh token), the rotation rolls back and
89
+ the client can retry with the token it holds, instead of being left with no
90
+ valid refresh token (a forced logout).
91
+ - **`WWW-Authenticate` on a protected-resource 401 can be pinned.** The
92
+ `resource_metadata` URL derives from `mcp_server_url` when set, so a forged
93
+ Host header can't steer clients to attacker-controlled metadata.
94
+ - **Dangerous redirect-URI schemes are rejected at registration.**
95
+ `javascript:`, `data:`, `vbscript:`, `file:`, `about:` and `blob:` are refused
96
+ even when written with `://` (e.g. `javascript://%0aalert(1)`), which the
97
+ native-app-scheme check previously let through.
98
+
99
+ - **The per-user scope policy is enforced at approval.** `validate_scope_for_user`
100
+ only filtered what the consent screen showed; a user could POST a hidden scope
101
+ name to `/oauth/approve` and have it granted, and required scopes were re-added
102
+ even when the policy denied them. The policy now filters the granted set.
103
+ - **OAuth credentials are filtered from logs.** The engine adds `code`,
104
+ `code_verifier`, `client_secret`, `refresh_token`, `access_token`, `id_token`
105
+ and `token` to `filter_parameters` (Rails' defaults miss `code` and
106
+ `code_verifier`), and filters redirects carrying `?code=`.
107
+ - **Token responses are not cacheable** (RFC 6749 §5.1): `Cache-Control: no-store`
108
+ and `Pragma: no-cache` on token, register, introspect and userinfo.
109
+ - **The consent page can't be framed or read cross-origin.** `X-Frame-Options:
110
+ DENY` and `frame-ancestors 'none'` on authorize/approve, and CORS headers are no
111
+ longer sent there (RFC 9700 §2.6); the other endpoints keep CORS.
112
+
113
+ ### Security (DCR hardening)
114
+ - **Redirect-URI policy at registration.** Plain `http` is rejected except for
115
+ loopback hosts (`localhost`, `127.0.0.1`, `[::1]`, any port; RFC 8252 §7.3);
116
+ optional `allowed_redirect_uri_patterns` restricts registration to known
117
+ clients (Regexps must match the whole URI, anchored or not), and
118
+ `allow_loopback_redirects` can disable loopback. The policy applies to every
119
+ registered redirect URI regardless of grant types, and `/oauth/authorize`
120
+ refuses clients that didn't register the `authorization_code` grant.
121
+ **Breaking:** non-loopback `http://` redirect URIs no longer register.
122
+ - **Unknown scopes are rejected** at registration (`invalid_client_metadata`) and
123
+ at `/oauth/authorize` (`invalid_scope` redirect, RFC 6749 §4.1.2.1) instead of
124
+ being stored/echoed or silently dropped. OIDC scopes and `offline_access` are
125
+ accepted. `strict_scope_validation = false` restores the old narrowing.
126
+ - **`client_name` is sanitized** (control/bidi/zero-width characters stripped,
127
+ 100-char cap) and `client_uri` must be an http(s) URL, since both are
128
+ attacker-chosen at open registration and shown on the consent page.
129
+ - **Consent screen shows the redirect host** and flags applications whose host
130
+ isn't in `verified_redirect_hosts` as unverified (loopback is exempt).
131
+ **Existing installs:** the install generator copied the consent view into
132
+ your app (`app/views/mcp/auth/consent.html.erb`), and that copy overrides the
133
+ gem's, so you won't see these additions until you merge them in. Compare
134
+ your copy with the gem's `lib/generators/mcp/auth/templates/views/consent.html.erb`
135
+ (the "redirect-info" paragraph and the "Unverified application" box).
136
+
137
+ ### Fixed
138
+ - The generated initializer closed the `Mcp::Auth.configure` block before the
139
+ JWT-signing and `secret_dual_read` sections, so uncommenting
140
+ `config.secret_dual_read = false` (as the upgrade generator instructs) raised
141
+ `NameError` at boot.
142
+ - The secrets-hashing backfill migration processes rows in batches of 1000
143
+ (keyset-paginated by primary key) instead of loading each table into memory.
144
+ - Re-enabled a dead spec file (`spec/services/authorization_service.rb` →
145
+ `…_spec.rb`) that RSpec never ran, restoring ~130 lines of coverage.
146
+ - `mcp_auth:revoke_*` rake tasks now delete across the three tables inside a
147
+ transaction (no partial revocation on mid-way failure).
148
+
149
+ ### Added
150
+ - **`config.mcp_server_url`** — public origin of the MCP resource server. Pins the
151
+ token audience, protected-resource metadata, and 401 `resource_metadata` URL.
152
+ Optional (defaults to the request origin); set it whenever
153
+ `authorization_server_url` points at a different host.
154
+ - **`Mcp::Auth::ProtectedResource.www_authenticate(base_url, error:, description:)`**
155
+ — the RFC 9728 `WWW-Authenticate` challenge as a plain function, so a Rack
156
+ middleware guarding `/mcp` can emit the same header the controller concern
157
+ does (without it, spec-compliant MCP clients can't discover the metadata and
158
+ re-authorize).
159
+ - **Pending-migration guard.** If the gem is upgraded but its migrations haven't
160
+ run, mcp-auth now says so instead of failing with a cryptic `unknown attribute`:
161
+ a clear warning is logged at boot, the OAuth endpoints return an actionable
162
+ `server_error` ("run a pending migration"), and `rake mcp_auth:doctor` reports
163
+ schema drift and exits non-zero. Fully guarded — never breaks boot, CI, or
164
+ `db:*` tasks when the database is absent or unmigrated.
165
+ - **`rails generate mcp:auth:upgrade`** — copies the pending schema migration for
166
+ an existing install (no initializer/view overwrite prompts, unlike re-running
167
+ the full install generator); **`rails generate mcp:auth:hash_secrets`** copies
168
+ the secrets-hashing backfill, run once every server is upgraded.
169
+ - **`config.secret_dual_read`** (default `true`) — transitional dual-read: a
170
+ presented secret/token/code is matched against both its digest and any legacy
171
+ plaintext row not yet backfilled, so 0.6.0 keeps working before and during
172
+ the backfill. It does NOT make older versions read hashed rows (see Upgrade).
173
+ Set to `false` once every row is hashed to harden.
174
+
175
+ ### Upgrade
176
+
177
+ Two migrations, applied in **two separate steps** so no server ever sees a
178
+ schema or data format it can't handle:
179
+
180
+ - `add_mcp_auth_confidential_client_and_reuse`: **additive columns**
181
+ (`token_endpoint_auth_method` on clients; `family_id` + `revoked_at` on refresh
182
+ tokens). 0.5.0 ignores them; 0.6.0 **requires** them (its OAuth endpoints
183
+ return a 500 with a "run a pending migration" message until they exist).
184
+ - `hash_mcp_auth_secrets_at_rest`: a **data backfill** that hashes existing
185
+ secrets in place. 0.6.0 reads both forms (`secret_dual_read`); **0.5.0 cannot
186
+ read hashed rows**.
187
+
188
+ **Before deploying (HS256 users):** set a dedicated `MCP_HMAC_SECRET` (`rails
189
+ secret`). Outside development/test, token signing now fails if `oauth_secret` is
190
+ unset **or equal to `secret_key_base`**, which is exactly what initializers
191
+ generated by earlier versions (`ENV.fetch('MCP_HMAC_SECRET', secret_key_base)`)
192
+ fall back to. Change that line to `config.oauth_secret = ENV['MCP_HMAC_SECRET']`.
193
+ `rake mcp_auth:doctor` and a boot-time warning report the problem. Changing the
194
+ secret invalidates outstanding access tokens (clients refresh); refresh tokens
195
+ are unaffected.
196
+
197
+ ```bash
198
+ bundle update mcp-auth
199
+
200
+ # Step 1 — schema. Run BEFORE the new code serves traffic (e.g. release phase).
201
+ rails generate mcp:auth:upgrade
202
+ rails db:migrate
203
+ # ...deploy 0.6.0 to every server...
204
+ bin/rails mcp_auth:doctor # schema + signing-secret check
205
+
206
+ # Step 2 — once NO server runs <= 0.5.0 (a later deploy is fine):
207
+ rails generate mcp:auth:hash_secrets
208
+ rails db:migrate
209
+ # then: config.secret_dual_read = false
210
+ ```
211
+
212
+ The backfill hashes the plaintext already present in each column, so **existing
213
+ clients and tokens keep working without re-registration** — the client still
214
+ presents its original value and the gem re-hashes it to match. Existing clients
215
+ get `token_endpoint_auth_method = 'none'` (public), so none of them suddenly
216
+ requires a secret.
217
+
218
+ **During the step-1 rollout** (both versions serving), tokens and codes *issued
219
+ by* 0.6.0 servers are stored hashed, so a 0.5.0 server can't validate them until
220
+ it is replaced; keep that window short. After step 2, rolling back to ≤ 0.5.0
221
+ signs every client out. The hashing backfill is idempotent and irreversible.
222
+
223
+ ## [0.5.0] - 2026-06-15
224
+
225
+ Security-hardening release. Closes five OAuth 2.1 / MCP authorization
226
+ vulnerabilities found in an adversarial audit of the authorization server and
227
+ protected-resource layer. Each fix ships with an RSpec test that fails before
228
+ and passes after.
229
+
230
+ ### Security (breaking where noted)
231
+ - **Consent can no longer be bypassed.** `GET /oauth/authorize` previously issued
232
+ an authorization code immediately when `approved=true` was present on the
233
+ request URL — a GET, so not even CSRF-protected — skipping the consent screen
234
+ entirely. The authorization endpoint now always renders consent; a code is
235
+ granted only via the CSRF-protected `POST /oauth/approve`. **Breaking:** clients
236
+ that appended `approved=true` to the authorize URL to auto-approve must go
237
+ through the consent/approve step.
238
+ - **Refresh tokens are bound to the issuing client** (OAuth 2.1 §4.3.1). The
239
+ refresh grant now rejects redemption unless the requesting `client_id` (Basic
240
+ auth or body) matches the client the token was issued to, and does not rotate
241
+ the token on a failed check. **Breaking:** a refresh request must include the
242
+ matching `client_id` — the documented flow already does.
243
+ - **Authorization codes are consumed atomically** (OAuth 2.1 §4.1.2). Consumption
244
+ now deletes the code in a single atomic operation and the token grant aborts
245
+ unless it won that deletion, eliminating a race that could mint two token sets
246
+ from one code.
247
+ - **Resource indicators are validated** (RFC 8707 / MCP authorization spec).
248
+ `authorize` and both token grants reject any `resource` that does not identify
249
+ this server (`invalid_target`), so the server can no longer mint a token whose
250
+ audience is some other — possibly attacker-controlled — resource. **Breaking:**
251
+ requests carrying a `resource` for a different host/path are rejected.
252
+ - **`api_key_secret` is no longer embedded in access tokens.** A bearer JWT is
253
+ decodable by anyone holding it and is stored at rest, so only the non-sensitive
254
+ `api_key_id` is now included; resolve the matching secret server-side from that
255
+ id. Any `api_key_secret` returned by `fetch_user_data` is ignored.
256
+
257
+ ### Changed
258
+ - README and the generated initializer document that `fetch_user_data` must not
259
+ return secrets (they are ignored and never written into the token).
260
+
10
261
  ## [0.4.0] - 2026-05-29
11
262
 
12
263
  Security-hardening release. Closes four OAuth correctness bugs and adds the
@@ -158,7 +409,8 @@ keep `HS256` until refresh tokens cycle out.
158
409
  - Protected Resource Metadata (RFC 9728)
159
410
  - Resource Indicators support (RFC 8707) for token audience binding
160
411
  - OpenID Connect Discovery support
161
- - Automatic middleware for protecting `/mcp/*` routes
412
+ - Opt-in resource-server protection for MCP routes via the
413
+ `Mcp::Auth::ProtectedResource` concern
162
414
  - JWT access tokens with proper audience validation
163
415
  - Refresh token rotation for enhanced security
164
416
  - Database-backed token storage for revocation support
@@ -177,7 +429,10 @@ keep `HS256` until refresh tokens cycle out.
177
429
  - Token audience validation to prevent confused deputy attacks
178
430
  - WWW-Authenticate header with resource metadata on 401 responses
179
431
 
180
- [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.3.0...HEAD
432
+ [Unreleased]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.6.0...HEAD
433
+ [0.6.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.5.0...v0.6.0
434
+ [0.5.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.4.0...v0.5.0
435
+ [0.4.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.3.0...v0.4.0
181
436
  [0.3.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.2.0...v0.3.0
182
437
  [0.2.0]: https://github.com/SerhiiBorozenets/mcp-auth/compare/v0.1.0...v0.2.0
183
438
  [0.1.0]: https://github.com/SerhiiBorozenets/mcp-auth/releases/tag/v0.1.0
data/README.md CHANGED
@@ -135,6 +135,11 @@ rails generate mcp:auth:install
135
135
  rails db:migrate
136
136
  ```
137
137
 
138
+ **Upgrading an existing install?** Don't re-run the install generator. Use
139
+ `rails generate mcp:auth:upgrade` (schema, before deploying) and, once every
140
+ server runs the new version, `rails generate mcp:auth:hash_secrets`. See the
141
+ 0.6.0 "Upgrade" section in [CHANGELOG.md](CHANGELOG.md) for the exact order.
142
+
138
143
  ## Quick Start
139
144
 
140
145
  ### 1. Mount Routes
@@ -162,12 +167,17 @@ Edit `config/initializers/mcp_auth.rb`:
162
167
 
163
168
  ```ruby
164
169
  Mcp::Auth.configure do |config|
165
- # OAuth secret for JWT signing
166
- config.oauth_secret = ENV.fetch('MCP_HMAC_SECRET', Rails.application.secret_key_base)
170
+ # OAuth secret for JWT signing (HS256) — required outside development/test,
171
+ # and must not be secret_key_base. Generate with `rails secret`.
172
+ config.oauth_secret = ENV['MCP_HMAC_SECRET']
167
173
 
168
174
  # Authorization server URL (optional - defaults to same as resource server)
169
175
  config.authorization_server_url = ENV.fetch('MCP_AUTHORIZATION_SERVER_URL', nil)
170
176
 
177
+ # Public origin of the MCP resource server (optional - defaults to the request
178
+ # origin). Pins the token audience and protected-resource metadata URLs.
179
+ config.mcp_server_url = ENV.fetch('MCP_SERVER_URL', nil)
180
+
171
181
  # MCP Server Path - where your MCP server is mounted
172
182
  # Change this if your MCP server is NOT at '/mcp'
173
183
  config.mcp_server_path = ENV.fetch('MCP_SERVER_PATH', '/mcp')
@@ -186,18 +196,22 @@ Mcp::Auth.configure do |config|
186
196
  config.authorization_code_lifetime = 1800 # 30 minutes
187
197
 
188
198
  # User data fetcher - CUSTOMIZE THIS
199
+ #
200
+ # SECURITY: only NON-sensitive values are embedded into the access token (a
201
+ # bearer JWT is decodable by anyone holding it and is stored at rest). Return
202
+ # an api_key_id (an opaque reference) and resolve the matching secret
203
+ # server-side from that id at request time. Never return a raw secret here —
204
+ # any `api_key_secret` is intentionally ignored and NOT placed in the token.
189
205
  config.fetch_user_data = proc do |data|
190
206
  user = User.find(data[:user_id])
191
207
  org = Org.find(data[:org_id]) if data[:org_id]
192
-
193
- # Return user data + API key (if you have one)
208
+
194
209
  {
195
210
  email: user.email,
196
- api_key_id: org&.api_key&.id,
197
- api_key_secret: org&.api_key&.secret
211
+ api_key_id: org&.api_key&.id
198
212
  }
199
213
  rescue ActiveRecord::RecordNotFound
200
- { email: 'unknown@example.com', api_key_id: nil, api_key_secret: nil }
214
+ { email: 'unknown@example.com', api_key_id: nil }
201
215
  end
202
216
 
203
217
  # Methods for authentication
@@ -518,6 +532,10 @@ If you want to use a separate authorization server:
518
532
  ```ruby
519
533
  # config/initializers/mcp_auth.rb
520
534
  config.authorization_server_url = 'https://auth.example.com'
535
+ # Public origin of THIS app's MCP endpoint (the token audience). Optional —
536
+ # defaults to the request origin — but set it to pin the audience and metadata
537
+ # URLs against a forged Host header.
538
+ config.mcp_server_url = 'https://api.example.com'
521
539
  ```
522
540
 
523
541
  This is useful for: