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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fe9d6d6722194f34e61bc2b2fc1104997c41c6e54fe1d8493b556d1b2004119
4
- data.tar.gz: f65dbf8e46cd6cedcdd39b2cdf963c33c165a533d0d6295f5d140bf3a6e0b4ef
3
+ metadata.gz: 18c18025068378717391f57735fb8534c082607e8207683b1913518e6f259a89
4
+ data.tar.gz: 780753579ba6291c75f16a60eb547328bcdaae67237fc58ada4286ab663e7815
5
5
  SHA512:
6
- metadata.gz: 77d89518c11901729bc2001ce45b2e1ffb64c756fd267429b160e0fc2972c81f44261f8f4c208da3b1e3b4424efea5c752e0dd75a1c34e52866997a16b56a6a7
7
- data.tar.gz: 23a62caa06957f5885433a6a4d780ebf3b85d2c1833d92a1a6583f940c35e9622a8848681cbbed2a11a05dd90f8bca74bcdbbf46b88c08e12035941e491da410
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/2025-06-18/basic/authorization), providing OAuth 2.1-based authentication for MCP servers.
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 automatically protects routes matching your configured `mcp_server_path`:
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
- # If mcp_server_path = '/mcp'
289
- # All routes starting with /mcp/* require OAuth tokens
296
+ class McpController < ApplicationController
297
+ include Mcp::Auth::ProtectedResource
290
298
 
291
- GET /mcp/tools # Protected ✅
292
- GET /mcp/resources # Protected ✅
293
- GET /mcp/prompts # Protected ✅
294
- GET /other/endpoint # Not protected ❌
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
- Access authentication data in your controllers:
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
- Customize the OAuth consent screen to match your branding:
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
- # 1. Enable custom consent view in config/initializers/mcp_auth.rb
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
- Available variables in the view:
513
+ Variables available in the view:
479
514
 
480
- - `@client_name` - Name of the OAuth client requesting access
481
- - `@requested_scopes` - Array of human-readable scope descriptions
482
- - `@authorization_params` - OAuth parameters (client_id, redirect_uri, etc.)
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
- Example custom consent view:
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
- <!DOCTYPE html>
488
- <html>
489
- <head>
490
- <title>Authorization Request</title>
491
- <style>
492
- /* Your custom styles */
493
- </style>
494
- </head>
495
- <body>
496
- <div class="consent-container">
497
- <h1><%= @client_name %> wants to access your account</h1>
498
-
499
- <p>This application is requesting permission to:</p>
500
- <ul>
501
- <% @requested_scopes.each do |scope| %>
502
- <li><%= scope %></li>
503
- <% end %>
504
- </ul>
505
-
506
- <%= form_tag oauth_approve_path, method: :post do %>
507
- <% @authorization_params.each do |key, value| %>
508
- <%= hidden_field_tag key, value %>
509
- <% end %>
510
-
511
- <%= hidden_field_tag :approved, true %>
512
- <%= submit_tag "Allow Access", class: "btn-primary" %>
513
- <% end %>
514
-
515
- <%= form_tag oauth_approve_path, method: :post do %>
516
- <% @authorization_params.each do |key, value| %>
517
- <%= hidden_field_tag key, value %>
518
- <% end %>
519
-
520
- <%= hidden_field_tag :approved, false %>
521
- <%= submit_tag "Deny", class: "btn-secondary" %>
522
- <% end %>
523
- </div>
524
- </body>
525
- </html>
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://localhost:3000/mcp'
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://localhost:3000'
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 automatically enforces HTTPS for OAuth endpoints in production.
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 = 1800 # 30 minutes
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
- Only register trusted redirect URIs for your OAuth clients. MCP Auth validates exact URI matches.
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 "Token validation failed" log/production.log
676
- grep "Authorization code is invalid" log/production.log
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: 5, period: 1.minute) do |req|
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
- ### 7. Regular Token Cleanup
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 layouts/mcp_auth or consent view errors
871
+ **Problem**: Missing template errors when the consent screen renders
752
872
 
753
- **Solution**: Run the generator to create views:
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/2025-06-18/basic/authorization) - Model Context Protocol Authorization
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: record.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
- def rotate_refresh_token(record)
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(revoked_at: Time.current) == 1
206
+ .update_all(changes) == 1
199
207
  end
200
208
 
201
209
  # Revoke every still-live token in a family (used on reuse detection).
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mcp
4
4
  module Auth
5
- VERSION = "0.6.0"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
  end
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.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Serhii Borozenets