From 438cd4384c23b7bac80b8db58c6db878d6a140f0 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 6 May 2026 15:43:26 +0200 Subject: [PATCH] mcp: OIDC-Flavored Refresh Token Guidance (#939) ## Description Implemets SEP-2207 (https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance) Fixes #940 --- auth/authorization_code.go | 25 ++++ auth/authorization_code_test.go | 126 ++++++++++++++++++ .../oauthtest/fake_authorization_server.go | 4 + 3 files changed, 155 insertions(+) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 758b88e..9b41e36 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -90,6 +90,23 @@ type AuthorizationCodeHandlerConfig struct { // See [AuthorizationCodeFetcher] for details. AuthorizationCodeFetcher AuthorizationCodeFetcher + // RequestRefreshToken indicates that the client intends to use refresh + // tokens and is capable of storing them securely. + // + // When true and the Authorization Server metadata contains "offline_access" + // in its scopes_supported, the client adds "offline_access" to the + // requested scopes. + // + // When using Dynamic Client Registration, callers should include + // "refresh_token" in [DynamicClientRegistrationConfig].Metadata.GrantTypes + // directly to advertise refresh token support to the Authorization Server. + // + // When using Client ID Metadata Document, the document hosted at the + // Client ID URL should include "refresh_token" in its grant_types. + // + // See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance. + RequestRefreshToken bool + // Client is an optional HTTP client to use for HTTP requests. // It is used for the following requests: // - Fetching Protected Resource Metadata @@ -263,6 +280,14 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ scps = prm.ScopesSupported } + // SEP-2207: when the client desires refresh tokens and the Authorization + // Server advertises offline_access support, add it to the requested scopes. + if h.config.RequestRefreshToken && + slices.Contains(asm.ScopesSupported, "offline_access") && + !slices.Contains(scps, "offline_access") { + scps = append(scps, "offline_access") + } + cfg := &oauth2.Config{ ClientID: resolvedClientConfig.clientID, ClientSecret: resolvedClientConfig.clientSecret, diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index 92b6faf..d30cbf3 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -10,6 +10,8 @@ import ( "net/http" "net/http/httptest" "net/http/httputil" + "net/url" + "slices" "strings" "testing" @@ -738,6 +740,130 @@ func TestApplicationTypeInference(t *testing.T) { } } +func TestAuthorize_OfflineAccessScope(t *testing.T) { + tests := []struct { + name string + requestRefreshToken bool + asScopesSupported []string + challengeScopes string + wantOfflineAccess bool + }{ + { + name: "AddedWhenASSupportsAndClientRequests", + requestRefreshToken: true, + asScopesSupported: []string{"openid", "offline_access"}, + wantOfflineAccess: true, + }, + { + name: "NotAddedWhenClientDoesNotRequest", + requestRefreshToken: false, + asScopesSupported: []string{"openid", "offline_access"}, + wantOfflineAccess: false, + }, + { + name: "NotAddedWhenASDoesNotSupport", + requestRefreshToken: true, + asScopesSupported: []string{"openid"}, + wantOfflineAccess: false, + }, + { + name: "NotDuplicatedWhenAlreadyInScopes", + requestRefreshToken: true, + asScopesSupported: []string{"openid", "offline_access"}, + challengeScopes: "read offline_access", + wantOfflineAccess: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + ScopesSupported: tt.asScopesSupported, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + var capturedAuthURL string + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test_client_secret", + }, + }, + RequestRefreshToken: tt.requestRefreshToken, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + capturedAuthURL = args.URL + return nil, fmt.Errorf("stop after capturing URL") + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + wwwAuth := "Bearer resource_metadata=" + resourceServer.URL + "/.well-known/oauth-protected-resource/resource" + if tt.challengeScopes != "" { + wwwAuth += fmt.Sprintf(", scope=%q", tt.challengeScopes) + } + resp.Header.Set("WWW-Authenticate", wwwAuth) + + // Authorize will fail at the fetcher, but we only care about the URL. + handler.Authorize(context.Background(), req, resp) + + if capturedAuthURL == "" { + t.Fatal("AuthorizationCodeFetcher was not called") + } + u, err := url.Parse(capturedAuthURL) + if err != nil { + t.Fatalf("failed to parse captured auth URL: %v", err) + } + scopes := strings.Fields(u.Query().Get("scope")) + hasOfflineAccess := slices.Contains(scopes, "offline_access") + if hasOfflineAccess != tt.wantOfflineAccess { + t.Errorf("offline_access in scopes = %v, want %v (scopes: %v)", hasOfflineAccess, tt.wantOfflineAccess, scopes) + } + + // When offline_access was already present in challenge scopes, + // verify it appears exactly once. + if tt.wantOfflineAccess { + count := 0 + for _, s := range scopes { + if s == "offline_access" { + count++ + } + } + if count != 1 { + t.Errorf("offline_access appears %d times in scopes, want 1", count) + } + } + }) + } +} + // validConfig for test to create an AuthorizationCodeHandler using its constructor. // Values that are relevant to the test should be set explicitly. func validConfig() *AuthorizationCodeHandlerConfig { diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index 33c25e8..94d725a 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -82,6 +82,9 @@ type Config struct { // ClientCredentialsConfig enables RFC 6749 Section 4.4 client credentials // grant at the /token endpoint. ClientCredentialsConfig *ClientCredentialsConfig + // ScopesSupported is an optional list of scopes to advertise in the + // authorization server metadata. + ScopesSupported []string } // FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing. @@ -166,6 +169,7 @@ func (s *FakeAuthorizationServer) handleMetadata(w http.ResponseWriter, r *http. AuthorizationEndpoint: s.URL() + s.config.IssuerPath + "/authorize", TokenEndpoint: s.URL() + s.config.IssuerPath + "/token", RegistrationEndpoint: registrationEndpoint, + ScopesSupported: s.config.ScopesSupported, ResponseTypesSupported: []string{"code"}, CodeChallengeMethodsSupported: []string{"S256"}, ClientIDMetadataDocumentSupported: cimdSupported,