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.
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
 
@@ -135,6 +138,11 @@ rails generate mcp:auth:install
135
138
  rails db:migrate
136
139
  ```
137
140
 
141
+ **Upgrading an existing install?** Don't re-run the install generator. Use
142
+ `rails generate mcp:auth:upgrade` (schema, before deploying) and, once every
143
+ server runs the new version, `rails generate mcp:auth:hash_secrets`. See the
144
+ 0.6.0 "Upgrade" section in [CHANGELOG.md](CHANGELOG.md) for the exact order.
145
+
138
146
  ## Quick Start
139
147
 
140
148
  ### 1. Mount Routes
@@ -162,12 +170,17 @@ Edit `config/initializers/mcp_auth.rb`:
162
170
 
163
171
  ```ruby
164
172
  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)
173
+ # OAuth secret for JWT signing (HS256) — required outside development/test,
174
+ # and must not be secret_key_base. Generate with `rails secret`.
175
+ config.oauth_secret = ENV['MCP_HMAC_SECRET']
167
176
 
168
177
  # Authorization server URL (optional - defaults to same as resource server)
169
178
  config.authorization_server_url = ENV.fetch('MCP_AUTHORIZATION_SERVER_URL', nil)
170
179
 
180
+ # Public origin of the MCP resource server (optional - defaults to the request
181
+ # origin). Pins the token audience and protected-resource metadata URLs.
182
+ config.mcp_server_url = ENV.fetch('MCP_SERVER_URL', nil)
183
+
171
184
  # MCP Server Path - where your MCP server is mounted
172
185
  # Change this if your MCP server is NOT at '/mcp'
173
186
  config.mcp_server_path = ENV.fetch('MCP_SERVER_PATH', '/mcp')
@@ -231,6 +244,10 @@ class ApplicationController < ActionController::Base
231
244
  end
232
245
  ```
233
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
+
234
251
  ### 4. Set Environment Variables
235
252
 
236
253
  ```bash
@@ -272,16 +289,35 @@ MCP Auth provides these endpoints automatically:
272
289
 
273
290
  ### Protecting Your MCP Server
274
291
 
275
- 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:
276
294
 
277
295
  ```ruby
278
- # If mcp_server_path = '/mcp'
279
- # All routes starting with /mcp/* require OAuth tokens
296
+ class McpController < ApplicationController
297
+ include Mcp::Auth::ProtectedResource
298
+
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.
280
309
 
281
- GET /mcp/tools # Protected ✅
282
- GET /mcp/resources # Protected ✅
283
- GET /mcp/prompts # Protected ✅
284
- GET /other/endpoint # Not protected ❌
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
285
321
  ```
286
322
 
287
323
  ### OAuth 2.1 Authorization Flow
@@ -310,10 +346,17 @@ Response:
310
346
  "redirect_uris": ["https://client.example.com/callback"],
311
347
  "grant_types": ["authorization_code", "refresh_token"],
312
348
  "response_types": ["code"],
313
- "scope": "mcp:read mcp:write"
349
+ "scope": "mcp:read mcp:write",
350
+ "token_endpoint_auth_method": "none",
351
+ "client_name": "My MCP Client"
314
352
  }
315
353
  ```
316
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
+
317
360
  #### 2. Authorization Request with PKCE
318
361
 
319
362
  Generate PKCE parameters:
@@ -391,7 +434,8 @@ curl -X POST https://example.com/oauth/token \
391
434
 
392
435
  ### Helper Methods in Controllers
393
436
 
394
- 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):
395
439
 
396
440
  ```ruby
397
441
  class MyController < ApplicationController
@@ -456,63 +500,109 @@ config.mcp_docs_url = 'https://docs.example.com/mcp-api'
456
500
 
457
501
  ### Custom Consent Screen
458
502
 
459
- 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:
460
506
 
461
507
  ```ruby
462
- # 1. Enable custom consent view in config/initializers/mcp_auth.rb
463
- config.use_custom_consent_view = true
464
-
465
- # 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
466
511
  ```
467
512
 
468
- Available variables in the view:
513
+ Variables available in the view:
469
514
 
470
- - `@client_name` - Name of the OAuth client requesting access
471
- - `@requested_scopes` - Array of human-readable scope descriptions
472
- - `@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
473
522
 
474
- 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:
475
526
 
476
527
  ```erb
477
- <!DOCTYPE html>
478
- <html>
479
- <head>
480
- <title>Authorization Request</title>
481
- <style>
482
- /* Your custom styles */
483
- </style>
484
- </head>
485
- <body>
486
- <div class="consent-container">
487
- <h1><%= @client_name %> wants to access your account</h1>
488
-
489
- <p>This application is requesting permission to:</p>
490
- <ul>
491
- <% @requested_scopes.each do |scope| %>
492
- <li><%= scope %></li>
493
- <% end %>
494
- </ul>
495
-
496
- <%= form_tag oauth_approve_path, method: :post do %>
497
- <% @authorization_params.each do |key, value| %>
498
- <%= hidden_field_tag key, value %>
499
- <% end %>
500
-
501
- <%= hidden_field_tag :approved, true %>
502
- <%= submit_tag "Allow Access", class: "btn-primary" %>
503
- <% end %>
504
-
505
- <%= form_tag oauth_approve_path, method: :post do %>
506
- <% @authorization_params.each do |key, value| %>
507
- <%= hidden_field_tag key, value %>
508
- <% end %>
509
-
510
- <%= hidden_field_tag :approved, false %>
511
- <%= submit_tag "Deny", class: "btn-secondary" %>
512
- <% end %>
513
- </div>
514
- </body>
515
- </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
516
606
  ```
517
607
 
518
608
  ### Separate Authorization Server
@@ -522,6 +612,10 @@ If you want to use a separate authorization server:
522
612
  ```ruby
523
613
  # config/initializers/mcp_auth.rb
524
614
  config.authorization_server_url = 'https://auth.example.com'
615
+ # Public origin of THIS app's MCP endpoint (the token audience). Optional —
616
+ # defaults to the request origin — but set it to pin the audience and metadata
617
+ # URLs against a forged Host header.
618
+ config.mcp_server_url = 'https://api.example.com'
525
619
  ```
526
620
 
527
621
  This is useful for:
@@ -559,18 +653,21 @@ RSpec.describe 'MCP API', type: :request do
559
653
  let(:user) { create(:user) }
560
654
  let(:org) { create(:org) }
561
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).
562
659
  let(:access_token) do
563
660
  token_data = {
564
661
  client_id: 'test-client',
565
662
  scope: 'mcp:read mcp:write',
566
663
  user_id: user.id,
567
664
  org_id: org.id,
568
- resource: 'http://localhost:3000/mcp'
665
+ resource: 'http://www.example.com/mcp'
569
666
  }
570
-
667
+
571
668
  Mcp::Auth::Services::TokenService.generate_access_token(
572
669
  token_data,
573
- base_url: 'http://localhost:3000'
670
+ base_url: 'http://www.example.com'
574
671
  )
575
672
  end
576
673
 
@@ -606,6 +703,9 @@ rake mcp_auth:revoke_client_tokens[CLIENT_ID]
606
703
 
607
704
  # Revoke all tokens for a specific user
608
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
609
709
  ```
610
710
 
611
711
  ### Scheduled Cleanup
@@ -631,6 +731,9 @@ rails secret
631
731
  export MCP_HMAC_SECRET="your-256-bit-secret-here"
632
732
  ```
633
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
+
634
737
  ### 2. Always Use HTTPS in Production
635
738
 
636
739
  ```ruby
@@ -638,7 +741,18 @@ export MCP_HMAC_SECRET="your-256-bit-secret-here"
638
741
  config.force_ssl = true
639
742
  ```
640
743
 
641
- 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
+ ```
642
756
 
643
757
  ### 3. Keep Token Lifetimes Short
644
758
 
@@ -646,20 +760,21 @@ MCP Auth automatically enforces HTTPS for OAuth endpoints in production.
646
760
  # Recommended settings
647
761
  config.access_token_lifetime = 3600 # 1 hour
648
762
  config.refresh_token_lifetime = 2_592_000 # 30 days
649
- config.authorization_code_lifetime = 1800 # 30 minutes
763
+ config.authorization_code_lifetime = 600 # 10 minutes (OAuth 2.1 guidance; default 1800)
650
764
  ```
651
765
 
652
766
  ### 4. Validate Redirect URIs
653
767
 
654
- 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).
655
770
 
656
771
  ### 5. Monitor Failed Authentications
657
772
 
658
773
  Check logs regularly for suspicious activity:
659
774
 
660
775
  ```bash
661
- grep "Token validation failed" log/production.log
662
- 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
663
778
  ```
664
779
 
665
780
  ### 6. Implement Rate Limiting
@@ -668,12 +783,30 @@ Use rack-attack or similar to prevent brute force attacks:
668
783
 
669
784
  ```ruby
670
785
  # config/initializers/rack_attack.rb
671
- 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|
672
787
  req.ip if req.path == '/oauth/token' && req.post?
673
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
674
807
  ```
675
808
 
676
- ### 7. Regular Token Cleanup
809
+ ### 8. Regular Token Cleanup
677
810
 
678
811
  Run cleanup task daily to remove expired tokens:
679
812
 
@@ -722,7 +855,8 @@ resource: 'https://example.com/mcp' # Must match mcp_server_path
722
855
  **Problem**: Valid-looking tokens are rejected
723
856
 
724
857
  **Solutions**:
725
- - 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`
726
860
  - Ensure server clocks are synchronized (JWT exp validation is time-sensitive)
727
861
  - Verify token hasn't expired (check `exp` claim)
728
862
  - Check token audience matches your MCP server
@@ -734,9 +868,10 @@ ruby -rjwt -e "puts JWT.decode('YOUR_TOKEN', nil, false).inspect"
734
868
 
735
869
  ### "Missing template" Errors
736
870
 
737
- **Problem**: Missing template layouts/mcp_auth or consent view errors
871
+ **Problem**: Missing template errors when the consent screen renders
738
872
 
739
- **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:
740
875
 
741
876
  ```bash
742
877
  rails generate mcp:auth:install
@@ -789,7 +924,7 @@ MCP Auth implements the following specifications:
789
924
  - [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) - Authorization Server Metadata
790
925
  - [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) - Resource Indicators for OAuth 2.0
791
926
  - [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) - OAuth 2.0 Protected Resource Metadata
792
- - [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
793
928
 
794
929
  ## Development
795
930