mcp-auth 0.6.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 +20 -0
- data/README.md +195 -74
- data/app/controllers/mcp/auth/oauth_controller.rb +7 -2
- data/lib/mcp/auth/services/token_service.rb +10 -2
- data/lib/mcp/auth/version.rb +1 -1
- metadata +1 -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,26 @@ 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
|
+
|
|
10
30
|
## [0.6.0] - 2026-09-28
|
|
11
31
|
|
|
12
32
|
Second security-hardening round (audit follow-ups), delivered in two phases.
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ OAuth 2.1 authorization for Model Context Protocol (MCP) servers in Rails applic
|
|
|
4
4
|
|
|
5
5
|
## What is MCP Authorization?
|
|
6
6
|
|
|
7
|
-
The Model Context Protocol (MCP) is an open standard that enables AI assistants to securely connect to external data sources and tools. MCP Auth implements the [MCP Authorization specification](https://modelcontextprotocol.io/specification/
|
|
7
|
+
The Model Context Protocol (MCP) is an open standard that enables AI assistants to securely connect to external data sources and tools. MCP Auth implements the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization), providing OAuth 2.1-based authentication for MCP servers.
|
|
8
8
|
|
|
9
9
|
### Why OAuth for MCP?
|
|
10
10
|
|
|
@@ -115,7 +115,10 @@ sequenceDiagram
|
|
|
115
115
|
- ✅ **Token Revocation** - RFC 7009 support
|
|
116
116
|
- ✅ **Token Introspection** - RFC 7662 support
|
|
117
117
|
- ✅ **OpenID Connect** - Basic OIDC Discovery support
|
|
118
|
-
- ✅ **Refresh Token Rotation** - OAuth 2.1 security requirement
|
|
118
|
+
- ✅ **Refresh Token Rotation** - OAuth 2.1 security requirement, with reuse detection
|
|
119
|
+
- ✅ **Secrets Hashed at Rest** - tokens, codes and client secrets stored as SHA-256 digests
|
|
120
|
+
- ✅ **Registration Policy** - https-or-loopback redirect URIs, optional allowlist, unknown scopes rejected
|
|
121
|
+
- ✅ **RS256 / ES256 Signing** - optional asymmetric JWTs with a JWKS endpoint and key rotation
|
|
119
122
|
- ✅ **Fully Configurable** - Paths, URLs, lifetimes, and user data
|
|
120
123
|
- ✅ **Beautiful Consent Screen** - Customizable UI
|
|
121
124
|
|
|
@@ -241,6 +244,10 @@ class ApplicationController < ActionController::Base
|
|
|
241
244
|
end
|
|
242
245
|
```
|
|
243
246
|
|
|
247
|
+
**Login redirect:** when a user who isn't signed in reaches `/oauth/authorize`,
|
|
248
|
+
MCP Auth redirects to `main_app.new_user_session_path`, the route Devise defines.
|
|
249
|
+
Without Devise, define a route with that name that leads to your login page.
|
|
250
|
+
|
|
244
251
|
### 4. Set Environment Variables
|
|
245
252
|
|
|
246
253
|
```bash
|
|
@@ -282,16 +289,35 @@ MCP Auth provides these endpoints automatically:
|
|
|
282
289
|
|
|
283
290
|
### Protecting Your MCP Server
|
|
284
291
|
|
|
285
|
-
MCP Auth
|
|
292
|
+
MCP Auth does **not** protect any route by itself. Include the resource-server
|
|
293
|
+
concern in the controller that serves your MCP endpoint:
|
|
286
294
|
|
|
287
295
|
```ruby
|
|
288
|
-
|
|
289
|
-
|
|
296
|
+
class McpController < ApplicationController
|
|
297
|
+
include Mcp::Auth::ProtectedResource
|
|
290
298
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
299
|
+
before_action :authenticate_mcp_token!
|
|
300
|
+
before_action -> { require_mcp_scope!('mcp:write') }, only: :create
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`authenticate_mcp_token!` checks the Bearer token's signature, expiry,
|
|
305
|
+
revocation status and audience (`mcp_server_url` or the request origin, plus
|
|
306
|
+
`mcp_server_path`). On failure it returns a 401 whose `WWW-Authenticate` header
|
|
307
|
+
points MCP clients at the protected-resource metadata (RFC 9728).
|
|
308
|
+
`require_mcp_scope!` returns a 403 `insufficient_scope` when a scope is missing.
|
|
309
|
+
|
|
310
|
+
If your MCP server is mounted as Rack middleware (not a controller), validate
|
|
311
|
+
the token yourself and build the same header:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
payload = Mcp::Auth::Services::TokenService.validate_access_token(
|
|
315
|
+
token, resource: 'https://example.com/mcp'
|
|
316
|
+
)
|
|
317
|
+
unless payload
|
|
318
|
+
headers = { 'WWW-Authenticate' => Mcp::Auth::ProtectedResource.www_authenticate('https://example.com') }
|
|
319
|
+
return [401, headers, ['Unauthorized']]
|
|
320
|
+
end
|
|
295
321
|
```
|
|
296
322
|
|
|
297
323
|
### OAuth 2.1 Authorization Flow
|
|
@@ -320,10 +346,17 @@ Response:
|
|
|
320
346
|
"redirect_uris": ["https://client.example.com/callback"],
|
|
321
347
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
322
348
|
"response_types": ["code"],
|
|
323
|
-
"scope": "mcp:read mcp:write"
|
|
349
|
+
"scope": "mcp:read mcp:write",
|
|
350
|
+
"token_endpoint_auth_method": "none",
|
|
351
|
+
"client_name": "My MCP Client"
|
|
324
352
|
}
|
|
325
353
|
```
|
|
326
354
|
|
|
355
|
+
The `client_secret` is returned only once; only its hash is stored. Clients are
|
|
356
|
+
public (`none`, PKCE only) unless they register `token_endpoint_auth_method` as
|
|
357
|
+
`client_secret_basic` or `client_secret_post`, in which case the token endpoint
|
|
358
|
+
requires the secret.
|
|
359
|
+
|
|
327
360
|
#### 2. Authorization Request with PKCE
|
|
328
361
|
|
|
329
362
|
Generate PKCE parameters:
|
|
@@ -401,7 +434,8 @@ curl -X POST https://example.com/oauth/token \
|
|
|
401
434
|
|
|
402
435
|
### Helper Methods in Controllers
|
|
403
436
|
|
|
404
|
-
|
|
437
|
+
These read what `authenticate_mcp_token!` stored for the request, so they are
|
|
438
|
+
only set in controllers that run it (see above):
|
|
405
439
|
|
|
406
440
|
```ruby
|
|
407
441
|
class MyController < ApplicationController
|
|
@@ -466,63 +500,109 @@ config.mcp_docs_url = 'https://docs.example.com/mcp-api'
|
|
|
466
500
|
|
|
467
501
|
### Custom Consent Screen
|
|
468
502
|
|
|
469
|
-
|
|
503
|
+
`rails generate mcp:auth:install` copies the consent page to
|
|
504
|
+
`app/views/mcp/auth/consent.html.erb`. That copy overrides the gem's view, so
|
|
505
|
+
edit it to match your branding. Optionally render it inside your app layout:
|
|
470
506
|
|
|
471
507
|
```ruby
|
|
472
|
-
#
|
|
473
|
-
config.use_custom_consent_view = true
|
|
474
|
-
|
|
475
|
-
# 2. Edit app/views/mcp/auth/consent.html.erb
|
|
508
|
+
# config/initializers/mcp_auth.rb
|
|
509
|
+
config.use_custom_consent_view = true # render with layout 'application'
|
|
510
|
+
config.consent_view_path = 'mcp/auth/consent' # the template to render
|
|
476
511
|
```
|
|
477
512
|
|
|
478
|
-
|
|
513
|
+
Variables available in the view:
|
|
479
514
|
|
|
480
|
-
- `@client_name` - Name
|
|
481
|
-
- `@
|
|
482
|
-
- `@
|
|
515
|
+
- `@client_name` - Name the client registered (self-asserted; not verified)
|
|
516
|
+
- `@redirect_host` - Host the authorization code will be sent to. **Show it.**
|
|
517
|
+
- `@redirect_verified` / `@redirect_loopback` - Whether that host is in
|
|
518
|
+
`verified_redirect_hosts`, or is a loopback address
|
|
519
|
+
- `@requested_scopes` - Array of hashes: `:key`, `:name`, `:description`,
|
|
520
|
+
`:required`, `:pre_selected`
|
|
521
|
+
- `@authorization_params` - OAuth parameters to send back unchanged
|
|
483
522
|
|
|
484
|
-
|
|
523
|
+
The approve form must POST the user's chosen scopes as `scopes[]` (required
|
|
524
|
+
scopes included) together with `approved=true`; with no scopes the request is
|
|
525
|
+
rejected. A minimal example:
|
|
485
526
|
|
|
486
527
|
```erb
|
|
487
|
-
|
|
488
|
-
<
|
|
489
|
-
|
|
490
|
-
<
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
528
|
+
<h1><%= @client_name %> wants to access your account</h1>
|
|
529
|
+
<p>You will be sent back to <strong><%= @redirect_host %></strong></p>
|
|
530
|
+
<% unless @redirect_verified || @redirect_loopback %>
|
|
531
|
+
<p><strong>Unverified application.</strong> Only continue if you started this connection.</p>
|
|
532
|
+
<% end %>
|
|
533
|
+
|
|
534
|
+
<%= form_with url: oauth_approve_path, method: :post, local: true do |f| %>
|
|
535
|
+
<% @authorization_params.each do |key, value| %>
|
|
536
|
+
<%= f.hidden_field key, value: value, id: nil %>
|
|
537
|
+
<% end %>
|
|
538
|
+
<% @requested_scopes.each do |scope| %>
|
|
539
|
+
<label>
|
|
540
|
+
<%= check_box_tag 'scopes[]', scope[:key], scope[:required] || scope[:pre_selected],
|
|
541
|
+
id: nil, disabled: scope[:required] %>
|
|
542
|
+
<%= scope[:name] %> — <%= scope[:description] %>
|
|
543
|
+
</label>
|
|
544
|
+
<%# disabled checkboxes aren't submitted, so send required scopes separately %>
|
|
545
|
+
<%= hidden_field_tag 'scopes[]', scope[:key], id: nil if scope[:required] %>
|
|
546
|
+
<% end %>
|
|
547
|
+
<%= f.button 'Allow', name: 'approved', value: 'true' %>
|
|
548
|
+
<% end %>
|
|
549
|
+
|
|
550
|
+
<%= form_with url: oauth_approve_path, method: :post, local: true do |f| %>
|
|
551
|
+
<% @authorization_params.each do |key, value| %>
|
|
552
|
+
<%= f.hidden_field key, value: value, id: nil %>
|
|
553
|
+
<% end %>
|
|
554
|
+
<%= f.hidden_field :approved, value: 'false' %>
|
|
555
|
+
<%= f.button 'Deny' %>
|
|
556
|
+
<% end %>
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
### Scopes and Per-User Policy
|
|
560
|
+
|
|
561
|
+
Register the scopes your MCP server understands (requests naming any other
|
|
562
|
+
scope are rejected unless `strict_scope_validation = false`):
|
|
563
|
+
|
|
564
|
+
```ruby
|
|
565
|
+
config.register_scope 'mcp:read', name: 'Read Access', description: 'Read your data', required: true
|
|
566
|
+
config.register_scope 'mcp:write', name: 'Write Access', description: 'Modify data on your behalf'
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
To restrict which scopes a given user may grant, set a policy. It hides
|
|
570
|
+
scopes on the consent screen **and** is enforced when the code is issued:
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
config.validate_scope_for_user = proc do |user, org, scope|
|
|
574
|
+
scope != 'mcp:admin' || user.admin?
|
|
575
|
+
end
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
### Client Registration Policy
|
|
579
|
+
|
|
580
|
+
`/oauth/register` is open (RFC 7591), so limit what a registered client can do:
|
|
581
|
+
|
|
582
|
+
```ruby
|
|
583
|
+
# Only these redirect URIs can register. Strings match exactly; Regexps must
|
|
584
|
+
# match the WHOLE URI (end with .* to allow any path).
|
|
585
|
+
config.allowed_redirect_uri_patterns = [
|
|
586
|
+
%r{https://claude\.ai/api/mcp/auth_callback}
|
|
587
|
+
]
|
|
588
|
+
config.allow_loopback_redirects = true # localhost / 127.0.0.1 / [::1], any port
|
|
589
|
+
config.verified_redirect_hosts = %w[claude.ai] # others show "Unverified application"
|
|
590
|
+
config.strict_scope_validation = true # reject unknown scopes
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Plain `http://` redirect URIs are always rejected except on loopback hosts.
|
|
594
|
+
|
|
595
|
+
### Token Signing (RS256 / ES256)
|
|
596
|
+
|
|
597
|
+
Tokens are signed with HS256 and `oauth_secret` by default. To let other
|
|
598
|
+
services verify tokens without the shared secret, use asymmetric keys. The
|
|
599
|
+
public keys are published at `/.well-known/jwks.json`:
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
config.token_signing_algorithm = 'RS256' # or 'ES256'
|
|
603
|
+
config.token_signing_private_key = ENV.fetch('MCP_JWT_PRIVATE_KEY')
|
|
604
|
+
# Key rotation: keep previous public keys so issued tokens still verify
|
|
605
|
+
config.token_signing_additional_public_keys = [ENV['MCP_JWT_PREVIOUS_PUBLIC_KEY']].compact
|
|
526
606
|
```
|
|
527
607
|
|
|
528
608
|
### Separate Authorization Server
|
|
@@ -573,18 +653,21 @@ RSpec.describe 'MCP API', type: :request do
|
|
|
573
653
|
let(:user) { create(:user) }
|
|
574
654
|
let(:org) { create(:org) }
|
|
575
655
|
|
|
656
|
+
# Request specs use the host www.example.com, and the token audience must
|
|
657
|
+
# match the resource your controller expects (mcp_server_url, or the request
|
|
658
|
+
# origin, plus mcp_server_path).
|
|
576
659
|
let(:access_token) do
|
|
577
660
|
token_data = {
|
|
578
661
|
client_id: 'test-client',
|
|
579
662
|
scope: 'mcp:read mcp:write',
|
|
580
663
|
user_id: user.id,
|
|
581
664
|
org_id: org.id,
|
|
582
|
-
resource: 'http://
|
|
665
|
+
resource: 'http://www.example.com/mcp'
|
|
583
666
|
}
|
|
584
|
-
|
|
667
|
+
|
|
585
668
|
Mcp::Auth::Services::TokenService.generate_access_token(
|
|
586
669
|
token_data,
|
|
587
|
-
base_url: 'http://
|
|
670
|
+
base_url: 'http://www.example.com'
|
|
588
671
|
)
|
|
589
672
|
end
|
|
590
673
|
|
|
@@ -620,6 +703,9 @@ rake mcp_auth:revoke_client_tokens[CLIENT_ID]
|
|
|
620
703
|
|
|
621
704
|
# Revoke all tokens for a specific user
|
|
622
705
|
rake mcp_auth:revoke_user_tokens[USER_ID]
|
|
706
|
+
|
|
707
|
+
# Check the database schema and signing secret (exits non-zero on problems)
|
|
708
|
+
rake mcp_auth:doctor
|
|
623
709
|
```
|
|
624
710
|
|
|
625
711
|
### Scheduled Cleanup
|
|
@@ -645,6 +731,9 @@ rails secret
|
|
|
645
731
|
export MCP_HMAC_SECRET="your-256-bit-secret-here"
|
|
646
732
|
```
|
|
647
733
|
|
|
734
|
+
Outside development/test, HS256 signing fails if this secret is unset or equal
|
|
735
|
+
to `secret_key_base`. `rake mcp_auth:doctor` checks it.
|
|
736
|
+
|
|
648
737
|
### 2. Always Use HTTPS in Production
|
|
649
738
|
|
|
650
739
|
```ruby
|
|
@@ -652,7 +741,18 @@ export MCP_HMAC_SECRET="your-256-bit-secret-here"
|
|
|
652
741
|
config.force_ssl = true
|
|
653
742
|
```
|
|
654
743
|
|
|
655
|
-
MCP Auth
|
|
744
|
+
MCP Auth rejects plain-HTTP requests to the OAuth endpoints outside development/test.
|
|
745
|
+
|
|
746
|
+
### Pin Your Public URLs
|
|
747
|
+
|
|
748
|
+
Discovery metadata, the token issuer and the token audience come from
|
|
749
|
+
`authorization_server_url` and `mcp_server_url`. When they're unset, the request's
|
|
750
|
+
Host header is used, so set them or restrict `config.hosts`:
|
|
751
|
+
|
|
752
|
+
```ruby
|
|
753
|
+
config.authorization_server_url = 'https://example.com'
|
|
754
|
+
config.mcp_server_url = 'https://example.com'
|
|
755
|
+
```
|
|
656
756
|
|
|
657
757
|
### 3. Keep Token Lifetimes Short
|
|
658
758
|
|
|
@@ -660,20 +760,21 @@ MCP Auth automatically enforces HTTPS for OAuth endpoints in production.
|
|
|
660
760
|
# Recommended settings
|
|
661
761
|
config.access_token_lifetime = 3600 # 1 hour
|
|
662
762
|
config.refresh_token_lifetime = 2_592_000 # 30 days
|
|
663
|
-
config.authorization_code_lifetime =
|
|
763
|
+
config.authorization_code_lifetime = 600 # 10 minutes (OAuth 2.1 guidance; default 1800)
|
|
664
764
|
```
|
|
665
765
|
|
|
666
766
|
### 4. Validate Redirect URIs
|
|
667
767
|
|
|
668
|
-
|
|
768
|
+
Redirect URIs are matched exactly at `/oauth/authorize`. Restrict which ones can
|
|
769
|
+
register with `allowed_redirect_uri_patterns` (see Client Registration Policy).
|
|
669
770
|
|
|
670
771
|
### 5. Monitor Failed Authentications
|
|
671
772
|
|
|
672
773
|
Check logs regularly for suspicious activity:
|
|
673
774
|
|
|
674
775
|
```bash
|
|
675
|
-
grep "
|
|
676
|
-
grep "
|
|
776
|
+
grep "\[OAuth\] Error" log/production.log # every OAuth error response
|
|
777
|
+
grep "Refresh-token reuse detected" log/production.log
|
|
677
778
|
```
|
|
678
779
|
|
|
679
780
|
### 6. Implement Rate Limiting
|
|
@@ -682,12 +783,30 @@ Use rack-attack or similar to prevent brute force attacks:
|
|
|
682
783
|
|
|
683
784
|
```ruby
|
|
684
785
|
# config/initializers/rack_attack.rb
|
|
685
|
-
Rack::Attack.throttle('oauth/token', limit:
|
|
786
|
+
Rack::Attack.throttle('oauth/token', limit: 20, period: 1.minute) do |req|
|
|
686
787
|
req.ip if req.path == '/oauth/token' && req.post?
|
|
687
788
|
end
|
|
789
|
+
|
|
790
|
+
# Registration is open, so throttle it hard
|
|
791
|
+
Rack::Attack.throttle('oauth/register', limit: 5, period: 1.hour) do |req|
|
|
792
|
+
req.ip if req.path == '/oauth/register' && req.post?
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
Rack::Attack.throttle('oauth/other', limit: 60, period: 1.minute) do |req|
|
|
796
|
+
req.ip if req.path.start_with?('/oauth/authorize', '/oauth/revoke', '/oauth/introspect')
|
|
797
|
+
end
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
### 7. After Upgrading to 0.6.0
|
|
801
|
+
|
|
802
|
+
Once `rails g mcp:auth:hash_secrets && rails db:migrate` has run, turn off the
|
|
803
|
+
transitional plaintext lookup:
|
|
804
|
+
|
|
805
|
+
```ruby
|
|
806
|
+
config.secret_dual_read = false
|
|
688
807
|
```
|
|
689
808
|
|
|
690
|
-
###
|
|
809
|
+
### 8. Regular Token Cleanup
|
|
691
810
|
|
|
692
811
|
Run cleanup task daily to remove expired tokens:
|
|
693
812
|
|
|
@@ -736,7 +855,8 @@ resource: 'https://example.com/mcp' # Must match mcp_server_path
|
|
|
736
855
|
**Problem**: Valid-looking tokens are rejected
|
|
737
856
|
|
|
738
857
|
**Solutions**:
|
|
739
|
-
- Check `oauth_secret` is set correctly and consistently
|
|
858
|
+
- Check `oauth_secret` is set correctly and consistently on every server
|
|
859
|
+
- Check `mcp_server_url` (or the request host) and `mcp_server_path` match the token's `aud`
|
|
740
860
|
- Ensure server clocks are synchronized (JWT exp validation is time-sensitive)
|
|
741
861
|
- Verify token hasn't expired (check `exp` claim)
|
|
742
862
|
- Check token audience matches your MCP server
|
|
@@ -748,9 +868,10 @@ ruby -rjwt -e "puts JWT.decode('YOUR_TOKEN', nil, false).inspect"
|
|
|
748
868
|
|
|
749
869
|
### "Missing template" Errors
|
|
750
870
|
|
|
751
|
-
**Problem**: Missing template
|
|
871
|
+
**Problem**: Missing template errors when the consent screen renders
|
|
752
872
|
|
|
753
|
-
**Solution**:
|
|
873
|
+
**Solution**: With `use_custom_consent_view = true` the page renders inside your
|
|
874
|
+
`application` layout, so that layout must exist. Otherwise, regenerate the view:
|
|
754
875
|
|
|
755
876
|
```bash
|
|
756
877
|
rails generate mcp:auth:install
|
|
@@ -803,7 +924,7 @@ MCP Auth implements the following specifications:
|
|
|
803
924
|
- [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) - Authorization Server Metadata
|
|
804
925
|
- [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) - Resource Indicators for OAuth 2.0
|
|
805
926
|
- [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) - OAuth 2.0 Protected Resource Metadata
|
|
806
|
-
- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/
|
|
927
|
+
- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization) - Model Context Protocol Authorization
|
|
807
928
|
|
|
808
929
|
## Development
|
|
809
930
|
|
|
@@ -462,16 +462,21 @@ module Mcp
|
|
|
462
462
|
scope = record.scope
|
|
463
463
|
scope = narrow_scope(scope, params[:scope]) if params[:scope].present?
|
|
464
464
|
|
|
465
|
+
# A token issued before family_id existed (or not yet backfilled) starts its
|
|
466
|
+
# family here, and the rotation stamps the same id on it, so replaying it
|
|
467
|
+
# later revokes this successor's family instead of nothing.
|
|
468
|
+
family_id = record.family_id.presence || SecureRandom.uuid
|
|
469
|
+
|
|
465
470
|
token_data = {
|
|
466
471
|
client_id: record.client_id, scope: scope, user_id: record.user_id,
|
|
467
|
-
org_id: record.org_id, family_id:
|
|
472
|
+
org_id: record.org_id, family_id: family_id,
|
|
468
473
|
resource: params[:resource].presence || canonical_resource_identifier
|
|
469
474
|
}
|
|
470
475
|
|
|
471
476
|
outcome = nil
|
|
472
477
|
token_response = nil
|
|
473
478
|
Mcp::Auth::RefreshToken.transaction do
|
|
474
|
-
unless Services::TokenService.rotate_refresh_token(record)
|
|
479
|
+
unless Services::TokenService.rotate_refresh_token(record, family_id: family_id)
|
|
475
480
|
outcome = :lost_race
|
|
476
481
|
raise ActiveRecord::Rollback
|
|
477
482
|
end
|
|
@@ -192,10 +192,18 @@ module Mcp
|
|
|
192
192
|
# Atomically consume a refresh token by flipping revoked_at from NULL.
|
|
193
193
|
# Returns true only for the request that actually performed the flip, so
|
|
194
194
|
# concurrent redemptions of the same token can't both rotate.
|
|
195
|
-
|
|
195
|
+
#
|
|
196
|
+
# A token with no family yet (issued before family_id existed and not
|
|
197
|
+
# backfilled) is stamped with `family_id`, the family its successor is
|
|
198
|
+
# issued in, in the same atomic update: otherwise a later replay of it
|
|
199
|
+
# would find no family to revoke. An existing family is never changed.
|
|
200
|
+
def rotate_refresh_token(record, family_id: nil)
|
|
201
|
+
changes = { revoked_at: Time.current }
|
|
202
|
+
changes[:family_id] = family_id if record.family_id.blank? && family_id.present?
|
|
203
|
+
|
|
196
204
|
Mcp::Auth::RefreshToken
|
|
197
205
|
.where(id: record.id, revoked_at: nil)
|
|
198
|
-
.update_all(
|
|
206
|
+
.update_all(changes) == 1
|
|
199
207
|
end
|
|
200
208
|
|
|
201
209
|
# Revoke every still-live token in a family (used on reuse detection).
|
data/lib/mcp/auth/version.rb
CHANGED