Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions auth/authorization_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
guglielmo-san marked this conversation as resolved.
// "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
Comment thread
guglielmo-san marked this conversation as resolved.

// Client is an optional HTTP client to use for HTTP requests.
// It is used for the following requests:
// - Fetching Protected Resource Metadata
Expand Down Expand Up @@ -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,
Expand Down
126 changes: 126 additions & 0 deletions auth/authorization_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions internal/oauthtest/fake_authorization_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading