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 +4 -4
- data/CHANGELOG.md +237 -2
- data/README.md +211 -76
- data/app/controllers/mcp/auth/oauth_controller.rb +269 -56
- data/app/controllers/mcp/auth/well_known_controller.rb +23 -8
- data/app/models/mcp/auth/access_token.rb +5 -0
- data/app/models/mcp/auth/authorization_code.rb +4 -0
- data/app/models/mcp/auth/oauth_client.rb +154 -4
- data/app/models/mcp/auth/refresh_token.rb +12 -1
- data/app/views/mcp/auth/consent.html.erb +13 -0
- data/lib/generators/mcp/auth/hash_secrets_generator.rb +48 -0
- data/lib/generators/mcp/auth/install_generator.rb +13 -1
- data/lib/generators/mcp/auth/templates/add_mcp_auth_confidential_client_and_reuse.rb.erb +39 -0
- data/lib/generators/mcp/auth/templates/hash_mcp_auth_secrets_at_rest.rb.erb +72 -0
- data/lib/generators/mcp/auth/templates/initializer.rb +67 -4
- data/lib/generators/mcp/auth/templates/views/consent.html.erb +13 -0
- data/lib/generators/mcp/auth/upgrade_generator.rb +60 -0
- data/lib/mcp/auth/engine.rb +35 -1
- data/lib/mcp/auth/protected_resource.rb +29 -6
- data/lib/mcp/auth/schema_guard.rb +67 -0
- data/lib/mcp/auth/scope_registry.rb +12 -0
- data/lib/mcp/auth/secret_hashing.rb +83 -0
- data/lib/mcp/auth/services/authorization_service.rb +18 -8
- data/lib/mcp/auth/services/token_service.rb +152 -23
- data/lib/mcp/auth/version.rb +1 -1
- data/lib/mcp/auth.rb +51 -4
- data/lib/tasks/mcp_auth_tasks.rake +40 -6
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18c18025068378717391f57735fb8534c082607e8207683b1913518e6f259a89
|
|
4
|
+
data.tar.gz: 780753579ba6291c75f16a60eb547328bcdaae67237fc58ada4286ab663e7815
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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.
|
|
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
|