mcp: OIDC-Flavored Refresh Token Guidance (#939)
## Description Implemets SEP-2207 (https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance) Fixes #940
This commit is contained in:
parent
9604499eed
commit
438cd4384c
3 changed files with 155 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue