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.
|
// See [AuthorizationCodeFetcher] for details.
|
||||||
AuthorizationCodeFetcher AuthorizationCodeFetcher
|
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.
|
// Client is an optional HTTP client to use for HTTP requests.
|
||||||
// It is used for the following requests:
|
// It is used for the following requests:
|
||||||
// - Fetching Protected Resource Metadata
|
// - Fetching Protected Resource Metadata
|
||||||
|
|
@ -263,6 +280,14 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
scps = prm.ScopesSupported
|
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{
|
cfg := &oauth2.Config{
|
||||||
ClientID: resolvedClientConfig.clientID,
|
ClientID: resolvedClientConfig.clientID,
|
||||||
ClientSecret: resolvedClientConfig.clientSecret,
|
ClientSecret: resolvedClientConfig.clientSecret,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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.
|
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
|
||||||
// Values that are relevant to the test should be set explicitly.
|
// Values that are relevant to the test should be set explicitly.
|
||||||
func validConfig() *AuthorizationCodeHandlerConfig {
|
func validConfig() *AuthorizationCodeHandlerConfig {
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,9 @@ type Config struct {
|
||||||
// ClientCredentialsConfig enables RFC 6749 Section 4.4 client credentials
|
// ClientCredentialsConfig enables RFC 6749 Section 4.4 client credentials
|
||||||
// grant at the /token endpoint.
|
// grant at the /token endpoint.
|
||||||
ClientCredentialsConfig *ClientCredentialsConfig
|
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.
|
// 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",
|
AuthorizationEndpoint: s.URL() + s.config.IssuerPath + "/authorize",
|
||||||
TokenEndpoint: s.URL() + s.config.IssuerPath + "/token",
|
TokenEndpoint: s.URL() + s.config.IssuerPath + "/token",
|
||||||
RegistrationEndpoint: registrationEndpoint,
|
RegistrationEndpoint: registrationEndpoint,
|
||||||
|
ScopesSupported: s.config.ScopesSupported,
|
||||||
ResponseTypesSupported: []string{"code"},
|
ResponseTypesSupported: []string{"code"},
|
||||||
CodeChallengeMethodsSupported: []string{"S256"},
|
CodeChallengeMethodsSupported: []string{"S256"},
|
||||||
ClientIDMetadataDocumentSupported: cimdSupported,
|
ClientIDMetadataDocumentSupported: cimdSupported,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue