auth: allow passing custom http.Client to AuthorizationCodeHandler (#840)
This commit is contained in:
parent
724dd47aa3
commit
d836ceb87d
2 changed files with 173 additions and 51 deletions
|
|
@ -101,6 +101,19 @@ type AuthorizationCodeHandlerConfig struct {
|
|||
// It should return the authorization code and state once the Authorization Server
|
||||
// redirects back to the RedirectURL.
|
||||
AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error)
|
||||
|
||||
// Client is an optional HTTP client to use for HTTP requests.
|
||||
// It is used for the following requests:
|
||||
// - Fetching Protected Resource Metadata
|
||||
// - Fetching Authorization Server Metadata
|
||||
// - Registering a client dynamically
|
||||
// - Exchanging an authorization code for an access token
|
||||
// - Refreshing an access token
|
||||
// Custom clients can include additional security configurations,
|
||||
// such as SSRF protections, see
|
||||
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
|
||||
// If not provided, http.DefaultClient will be used.
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
|
||||
|
|
@ -166,6 +179,9 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
|||
// it should have been set by now. Otherwise, it is required.
|
||||
return nil, errors.New("RedirectURL is required")
|
||||
}
|
||||
if config.Client == nil {
|
||||
config.Client = http.DefaultClient
|
||||
}
|
||||
return &AuthorizationCodeHandler{config: config}, nil
|
||||
}
|
||||
|
||||
|
|
@ -280,7 +296,7 @@ func (h *AuthorizationCodeHandler) getProtectedResourceMetadata(ctx context.Cont
|
|||
// Use MCP server URL as the resource URI per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri.
|
||||
for _, url := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) {
|
||||
prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, http.DefaultClient)
|
||||
prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, h.config.Client)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
|
|
@ -359,7 +375,7 @@ func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL {
|
|||
func (h *AuthorizationCodeHandler) getAuthServerMetadata(ctx context.Context, prm *oauthex.ProtectedResourceMetadata) (*oauthex.AuthServerMeta, error) {
|
||||
authServerURL := prm.AuthorizationServers[0]
|
||||
for _, u := range authorizationServerMetadataURLs(authServerURL) {
|
||||
asm, err := oauthex.GetAuthServerMeta(ctx, u, authServerURL, http.DefaultClient)
|
||||
asm, err := oauthex.GetAuthServerMeta(ctx, u, authServerURL, h.config.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get authorization server metadata: %w", err)
|
||||
}
|
||||
|
|
@ -488,7 +504,7 @@ func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm *
|
|||
// 3. Attempt to use dynamic client registration.
|
||||
dcrCfg := h.config.DynamicClientRegistrationConfig
|
||||
if dcrCfg != nil && asm.RegistrationEndpoint != "" {
|
||||
regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, http.DefaultClient)
|
||||
regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, h.config.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register client: %w", err)
|
||||
}
|
||||
|
|
@ -542,10 +558,11 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
|
|||
oauth2.VerifierOption(authResult.usedCodeVerifier),
|
||||
oauth2.SetAuthURLParam("resource", resourceURL),
|
||||
}
|
||||
token, err := cfg.Exchange(ctx, authResult.Code, opts...)
|
||||
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client)
|
||||
token, err := cfg.Exchange(clientCtx, authResult.Code, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token exchange failed: %w", err)
|
||||
}
|
||||
h.tokenSource = cfg.TokenSource(ctx, token)
|
||||
h.tokenSource = cfg.TokenSource(clientCtx, token)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,8 +125,11 @@ func TestAuthorize_ForbiddenUnhandledError(t *testing.T) {
|
|||
"WWW-Authenticate",
|
||||
"Bearer error=invalid_token",
|
||||
)
|
||||
handler := &AuthorizationCodeHandler{} // No config needed for this test.
|
||||
err := handler.Authorize(t.Context(), req, resp)
|
||||
handler, err := NewAuthorizationCodeHandler(validConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
err = handler.Authorize(t.Context(), req, resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Authorize() failed: %v", err)
|
||||
}
|
||||
|
|
@ -200,16 +203,7 @@ func TestNewAuthorizationCodeHandler_Success(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestNewAuthorizationCodeHandler_Error(t *testing.T) {
|
||||
validConfig := func() *AuthorizationCodeHandlerConfig {
|
||||
return &AuthorizationCodeHandlerConfig{
|
||||
ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://example.com/client"},
|
||||
RedirectURL: "https://example.com/callback",
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
// Ensure the base config is valid
|
||||
// Ensure the base config is valid.
|
||||
if _, err := NewAuthorizationCodeHandler(validConfig()); err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
|
|
@ -324,7 +318,11 @@ func TestNewAuthorizationCodeHandler_Error(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetProtectedResourceMetadata_Success(t *testing.T) {
|
||||
handler := &AuthorizationCodeHandler{} // No config needed for this method
|
||||
handler, err := NewAuthorizationCodeHandler(validConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler() error = %v", err)
|
||||
}
|
||||
|
||||
pathForChallenge := "/protected-resource"
|
||||
|
||||
tests := []struct {
|
||||
|
|
@ -398,8 +396,11 @@ func TestGetProtectedResourceMetadata_Success(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetProtectedResourceMetadata_Backcompat(t *testing.T) {
|
||||
handler, err := NewAuthorizationCodeHandler(validConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler() error = %v", err)
|
||||
}
|
||||
var challenges []oauthex.Challenge
|
||||
handler := &AuthorizationCodeHandler{} // No config needed for this method
|
||||
got, err := handler.getProtectedResourceMetadata(t.Context(), challenges, "http://localhost:1234/resource")
|
||||
if err != nil {
|
||||
t.Fatalf("getProtectedResourceMetadata() error = %v", err)
|
||||
|
|
@ -423,8 +424,11 @@ func TestGetProtectedResourceMetadata_Error(t *testing.T) {
|
|||
ScopesSupported: []string{"read", "write"},
|
||||
}
|
||||
mux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(metadata))
|
||||
handler, err := NewAuthorizationCodeHandler(validConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler() error = %v", err)
|
||||
}
|
||||
var challenges []oauthex.Challenge
|
||||
handler := &AuthorizationCodeHandler{} // No config needed for this method
|
||||
got, err := handler.getProtectedResourceMetadata(t.Context(), challenges, server.URL+"/resource")
|
||||
if err == nil || !strings.Contains(err.Error(), "authorization servers") {
|
||||
t.Errorf("getProtectedResourceMetadata() = %v, want error containing \"authorization servers\"", err)
|
||||
|
|
@ -435,7 +439,10 @@ func TestGetProtectedResourceMetadata_Error(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetAuthServerMetadata(t *testing.T) {
|
||||
handler := &AuthorizationCodeHandler{} // No config needed for this method
|
||||
handler, err := NewAuthorizationCodeHandler(validConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler() error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -563,11 +570,11 @@ func TestHandleRegistration(t *testing.T) {
|
|||
ClientIDMetadataDocumentSupported: true,
|
||||
},
|
||||
handlerConfig: &AuthorizationCodeHandlerConfig{
|
||||
ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://client.example.com"},
|
||||
ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://client.example.com/metadata.json"},
|
||||
},
|
||||
want: &resolvedClientConfig{
|
||||
registrationType: registrationTypeClientIDMetadataDocument,
|
||||
clientID: "https://client.example.com",
|
||||
clientID: "https://client.example.com/metadata.json",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -597,7 +604,7 @@ func TestHandleRegistration(t *testing.T) {
|
|||
{
|
||||
name: "NoneSupported",
|
||||
handlerConfig: &AuthorizationCodeHandlerConfig{
|
||||
ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://client.example.com"},
|
||||
ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://client.example.com/metadata.json"},
|
||||
},
|
||||
wantError: true,
|
||||
},
|
||||
|
|
@ -607,7 +614,14 @@ func TestHandleRegistration(t *testing.T) {
|
|||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{RegistrationConfig: tt.serverConfig})
|
||||
s.Start(t)
|
||||
handler := &AuthorizationCodeHandler{config: tt.handlerConfig}
|
||||
tt.handlerConfig.AuthorizationCodeFetcher = func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
tt.handlerConfig.RedirectURL = "https://example.com/callback"
|
||||
handler, err := NewAuthorizationCodeHandler(tt.handlerConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler() error = %v, want nil", err)
|
||||
}
|
||||
asm, err := handler.getAuthServerMetadata(t.Context(), &oauthex.ProtectedResourceMetadata{
|
||||
AuthorizationServers: []string{s.URL()},
|
||||
})
|
||||
|
|
@ -644,11 +658,20 @@ func TestDynamicRegistration(t *testing.T) {
|
|||
},
|
||||
})
|
||||
s.Start(t)
|
||||
handler := &AuthorizationCodeHandler{config: &AuthorizationCodeHandlerConfig{
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{
|
||||
Metadata: &oauthex.ClientRegistrationMetadata{},
|
||||
Metadata: &oauthex.ClientRegistrationMetadata{
|
||||
RedirectURIs: []string{"https://example.com/callback"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
RedirectURL: "https://example.com/callback",
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler() error = %v", err)
|
||||
}
|
||||
asm, err := handler.getAuthServerMetadata(t.Context(), &oauthex.ProtectedResourceMetadata{
|
||||
AuthorizationServers: []string{s.URL()},
|
||||
})
|
||||
|
|
@ -672,3 +695,15 @@ func TestDynamicRegistration(t *testing.T) {
|
|||
t.Errorf("handleRegistration() authStyle = %v, want %v", got.authStyle, oauth2.AuthStyleInHeader)
|
||||
}
|
||||
}
|
||||
|
||||
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
|
||||
// Values that are relevant to the test should be set explicitly.
|
||||
func validConfig() *AuthorizationCodeHandlerConfig {
|
||||
return &AuthorizationCodeHandlerConfig{
|
||||
ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://example.com/client"},
|
||||
RedirectURL: "https://example.com/callback",
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue