mcp-auth 0.5.0 → 0.6.1

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