diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..cc09cda
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,9 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+.idea/*
diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml
new file mode 100644
index 0000000..c298633
--- /dev/null
+++ b/.idea/dictionaries/project.xml
@@ -0,0 +1,7 @@
+
+
+
+ pkce
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..6404f20
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/trpc-mcp-go.iml b/.idea/trpc-mcp-go.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/trpc-mcp-go.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 08fac5c..f655171 100644
--- a/README.md
+++ b/README.md
@@ -769,6 +769,187 @@ weatherHandler := mcp.NewTypedToolHandler(func(ctx context.Context, req *mcp.Cal
See [`examples/schema-generation/`](examples/schema-generation/) for a complete example.
+## OAuth 2.1 Authentication Support
+The library provides comprehensive OAuth 2.1 authentication support for securing MCP servers and enabling authenticated client access.
+
+### Feature
+
+- **OAuth 2.1 Authorization Code Flow:** Full RFC-compliant authorization code flow with PKCE support
+- **JWT Token Handling:** HMAC-signed JWT access and refresh tokens with automatic validation
+- **Token Introspection:** RFC 7662 compliant token introspection for real-time validation
+- **Metadata Endpoints:** OAuth 2.1 and OpenID Connect discovery endpoints
+- **Audit Logging:** Comprehensive security logging with sensitive data protection
+- **Bearer Token Protection:** Automatic token verification for MCP endpoints
+
+### Server Configuration
+
+#### Basic OAuth Server Setup
+
+```go
+// Create OAuth provider (or use built-in proxy provider)
+provider := providers.NewProxyOAuthServerProvider(providers.ProxyOptions{
+ Endpoints: providers.ProxyEndpoints{
+ AuthorizationURL: "https://your-oauth-server.com/authorize",
+ TokenURL: "https://your-oauth-server.com/token",
+ RevocationURL: "https://your-oauth-server.com/revoke",
+ RegistrationURL: "https://your-oauth-server.com/register",
+ },
+ VerifyAccessToken: yourTokenVerificationFunc,
+ GetClient: yourClientLookupFunc,
+})
+
+// Configure token verifier with introspection
+verifier, err := server.NewTokenVerifier(ctx, server.TokenVerifierConfig{
+ Introspection: &server.IntrospectionConfig{
+ Endpoint: "https://your-oauth-server.com/introspect",
+ Timeout: 5 * time.Second,
+ CacheTTL: 30 * time.Second,
+ NegativeCacheTTL: 10 * time.Second,
+ UseOnJWTFail: true,
+ },
+})
+
+// Create MCP server with OAuth protection
+mcpServer := mcp.NewServer(
+ "Secure-MCP-Server", "1.0.0",
+
+ // OAuth routes for client authorization
+ mcp.WithOAuthRoutes(mcp.OAuthRoutesConfig{
+ Provider: provider,
+ IssuerURL: mustURL("https://your-oauth-server.com"),
+ BaseURL: mustURL("https://your-mcp-server.com"),
+ ScopesSupported: []string{"mcp.read", "mcp.write"},
+ }),
+
+ // OAuth metadata endpoints
+ mcp.WithOAuthMetadata(mcp.OAuthMetadataConfig{
+ ResourceServerURL: mustURL("https://your-mcp-server.com"),
+ ScopesSupported: []string{"mcp.read", "mcp.write"},
+ ResourceName: &"MCP Server",
+ }),
+
+ // Bearer token authentication
+ mcp.WithBearerAuth(&mcp.BearerAuthConfig{
+ Enabled: true,
+ RequiredScopes: []string{"mcp.read", "mcp.write"},
+ Verifier: verifier,
+ }),
+
+ // Security audit logging
+ mcp.WithAudit(&mcp.AuditConfig{
+ Enabled: true,
+ Level: "basic",
+ HashSensitiveData: true,
+ IncludeRequestBody: false,
+ IncludeResponseBody: false,
+ EndpointPatterns: []string{"/mcp/", "/authorize", "/token"},
+ }),
+)
+```
+
+### Client Configuration
+
+#### OAuth-Enabled Client
+
+```go
+// Configure OAuth authentication flow
+authFlow := mcp.AuthFlowConfig{
+ ServerURL: "https://your-mcp-server.com",
+ ClientMetadata: auth.OAuthClientMetadata{
+ ClientName: &"my-mcp-client",
+ GrantTypes: []string{"authorization_code", "refresh_token"},
+ TokenEndpointAuthMethod: "client_secret_post",
+ RedirectURIs: []string{"http://localhost:8080/callback"},
+ Scope: &"mcp.read mcp.write",
+ },
+ ResourceMetadataURL: &"https://your-mcp-server.com/.well-known/oauth-protected-resource",
+ RedirectURL: "http://localhost:8080/callback",
+ Scope: &"mcp.read mcp.write",
+ OnRedirect: func(u *url.URL) error {
+ fmt.Printf("Please authorize at: %s\n", u.String())
+ // Open browser or handle redirect
+ return nil
+ },
+}
+
+// Create authenticated MCP client
+client, err := mcp.NewClient(
+ "https://your-mcp-server.com/mcp",
+ mcp.Implementation{Name: "My-Client", Version: "1.0.0"},
+ mcp.WithAuthFlow(authFlow),
+)
+if err != nil {
+ log.Fatal(err)
+}
+
+// Complete authorization flow
+ctx := context.Background()
+_, err = client.Initialize(ctx, &mcp.InitializeRequest{})
+if err != nil {
+ log.Fatal(err)
+}
+
+// Handle authorization callback (typically in HTTP handler)
+authCode := "received_from_redirect"
+if err := client.CompleteAuthFlow(ctx, authCode); err != nil {
+ log.Fatal(err)
+}
+
+// Use authenticated client normally
+result, err := client.CallTool(ctx, &mcp.CallToolRequest{
+ Params: mcp.CallToolParams{Name: "secure_tool"},
+})
+```
+
+### Security Features
+
+#### Token Verification
+
+The library supports multiple token verification methods:
+
+- **JWT Verification:** Direct HMAC/RSA signature validation
+- **Token Introspection:** RFC 7662 compliant real-time validation
+- **Hybrid Mode:** JWT first with introspection fallback
+- **Caching:** Configurable positive/negative caching for performance
+
+#### Audit Logger
+
+Comprehensive security logging with configurable levels:
+
+```go
+mcp.WithAudit(&mcp.AuditConfig{
+ Enabled: true,
+ Level: "detailed", // "basic" or "detailed"
+ HashSensitiveData: true, // Hash tokens/secrets in logs
+ IncludeRequestBody: true, // Log request payloads
+ IncludeResponseBody: false, // Log response payloads
+ EndpointPatterns: []string{"/mcp/", "/oauth/"},
+ ExcludePatterns: []string{"/health", "/metrics"},
+})
+```
+
+### OAuth Endpoints
+
+When OAuth is enabled, the server automatically provides:
+
+| Endpoint | Description |
+|-------------------------------------------|-------------|
+| `/.well-known/oauth-protected-resource` | OAuth 2.1 Resource Server Metadata (RFC 8705) |
+| `/.well-known/oauth-authorization-server` | OAuth 2.1 Authorization Server Metadata (RFC 8414) |
+| `/authorize` | Authorization endpoint for client redirects |
+| `/token` | Token endpoint for code/refresh exchange |
+| `/register` | Dynamic client registration (RFC 7591) |
+| `/revoke` | Token revocation endpoint (RFC 7009) |
+
+### Complete Example
+
+See the OAuth authentication example at [`examples/auth/`](examples/auth) for a complete working implementation including:
+
+- Mock OAuth 2.1 server setup
+- MCP server with OAuth protection
+- MCP client with authorization flow
+- Token handling and refresh
+- Security audit logging
## Example Patterns
diff --git a/client.go b/client.go
index 0f3d485..641874b 100644
--- a/client.go
+++ b/client.go
@@ -14,7 +14,8 @@ import (
"reflect"
"sync"
"sync/atomic"
-
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/client"
"trpc.group/trpc-go/trpc-mcp-go/internal/errors"
"trpc.group/trpc-go/trpc-mcp-go/internal/retry"
)
@@ -117,6 +118,15 @@ type Client struct {
state State // State.
transportOptions []transportOption
+ // OAuth provider
+ oauthProvider client.OAuthClientProvider
+ // OAuth token management
+ accessToken string
+ refreshToken string
+
+ // OAuth flow configuration
+ authFlowConfig *AuthFlowConfig
+
// transport configuration.
transportConfig *transportConfig
@@ -159,6 +169,30 @@ func NewClient(serverURL string, clientInfo Implementation, options ...ClientOpt
option(client)
}
+ // Handle OAuth authentication if configured
+ if client.oauthProvider != nil {
+ if tokens, err := client.oauthProvider.Tokens(); err == nil && tokens != nil {
+ client.accessToken = tokens.AccessToken
+ if tokens.RefreshToken != nil {
+ client.refreshToken = *tokens.RefreshToken
+ }
+ if client.accessToken != "" {
+ // Ensure a headers map exists
+ if client.transportConfig.httpHeaders == nil {
+ client.transportConfig.httpHeaders = make(http.Header)
+ }
+ client.transportConfig.httpHeaders.Set("Authorization", "Bearer "+client.accessToken)
+ // Also push into transportOptions so the streamable transport sees it
+ client.transportOptions = append(client.transportOptions, withTransportHTTPHeaders(client.transportConfig.httpHeaders))
+ }
+ } else if err != nil {
+ // Optional: Surface a warning via logger if available
+ if client.logger != nil {
+ client.logger.Warnf("OAuth provider returned no tokens at client initialization: %v", err)
+ }
+ }
+ }
+
// Create transport layer if not previously set via options.
if client.transport == nil {
client.transport = newStreamableHTTPClientTransport(client.transportConfig, client.transportOptions...)
@@ -177,6 +211,38 @@ func NewClient(serverURL string, clientInfo Implementation, options ...ClientOpt
return client, nil
}
+// AuthFlowConfig configures OAuth 2.0 authentication flow
+type AuthFlowConfig struct {
+ // ServerURL is the OAuth authorization server URL (required)
+ ServerURL string
+
+ // ClientMetadata contains OAuth client configuration
+ ClientMetadata auth.OAuthClientMetadata
+
+ // RedirectURL is the OAuth redirect URI (required)
+ RedirectURL string
+
+ // OnRedirect handles the authorization redirect (required)
+ // This function should redirect the user to the authorization URL
+ OnRedirect func(*url.URL) error
+
+ // Scope defines the requested access permissions (optional)
+ Scope *string
+
+ // CustomFetchFunc allows custom HTTP client behavior (optional)
+ CustomFetchFunc auth.FetchFunc
+
+ // ResourceMetadataURL for discovering protected resource metadata (optional)
+ ResourceMetadataURL *string
+
+ // AuthorizationCode can be provided if you already have one (optional)
+ // This is useful for handling the redirect callback
+ AuthorizationCode *string
+
+ // State for CSRF protection (optional)
+ State *string
+}
+
// transportConfig includes transport layer configuration.
type transportConfig struct {
serverURL *url.URL // server URL
@@ -198,6 +264,8 @@ type transportConfig struct {
// These options are typically not used by the default handler, but may be used by custom
// implementations that replace the default NewHTTPReqHandler function for extensibility.
httpReqHandlerOptions []HTTPReqHandlerOption
+
+ oauthProvider client.OAuthClientProvider
}
// newDefaultTransportConfig creates a default transport configuration.
@@ -210,6 +278,7 @@ func newDefaultTransportConfig() *transportConfig {
httpReqHandlerOptions: []HTTPReqHandlerOption{},
enableGetSSE: true,
path: "",
+ oauthProvider: nil,
}
}
@@ -324,6 +393,18 @@ func (c *Client) Initialize(ctx context.Context, initReq *InitializeRequest) (*I
return nil, errors.ErrAlreadyInitialized
}
+ // If auth flow is configured, execute it first
+ if c.authFlowConfig != nil {
+ if err := c.executeAuthFlow(ctx); err != nil {
+ return nil, fmt.Errorf("authentication failed: %w", err)
+ }
+
+ // Ensure transport uses the latest token
+ if err := c.updateClientTokens(); err != nil {
+ return nil, fmt.Errorf("failed to update tokens: %w", err)
+ }
+ }
+
// Create request.
requestID := c.requestID.Add(1)
req := newJSONRPCRequest(requestID, MethodInitialize, map[string]interface{}{
@@ -667,3 +748,362 @@ func (c *Client) SendRootsListChangedNotification(ctx context.Context) error {
func isZeroStruct(x interface{}) bool {
return reflect.ValueOf(x).IsZero()
}
+
+// WithAuthFlow creates a client option that configures and executes the complete OAuth flow
+func WithAuthFlow(config AuthFlowConfig) ClientOption {
+ return func(c *Client) {
+ // Validate required configuration
+ if config.ServerURL == "" {
+ panic("AuthFlowConfig.ServerURL is required")
+ }
+ if config.RedirectURL == "" {
+ panic("AuthFlowConfig.RedirectURL is required")
+ }
+ if config.OnRedirect == nil {
+ panic("AuthFlowConfig.OnRedirect is required")
+ }
+
+ // Store config for later use
+ c.authFlowConfig = &config
+
+ // Create internal OAuth provider
+ provider := client.NewInMemoryOAuthClientProvider(
+ config.RedirectURL,
+ config.ClientMetadata,
+ config.OnRedirect,
+ )
+
+ c.oauthProvider = provider
+ c.transportConfig.oauthProvider = provider
+ c.transportOptions = append(c.transportOptions, withTransportOAuthProvider(provider))
+
+ }
+}
+
+// executeAuthFlow runs the complete OAuth authentication flow using internal methods
+func (c *Client) executeAuthFlow(ctx context.Context) error {
+ if c.authFlowConfig == nil {
+ return fmt.Errorf("auth flow not configured")
+ }
+
+ // Build auth options for internal flow
+ authOptions := auth.AuthOptions{
+ ServerUrl: c.authFlowConfig.ServerURL,
+ Scope: c.authFlowConfig.Scope,
+ FetchFn: c.authFlowConfig.CustomFetchFunc,
+ ResourceMetadataUrl: c.authFlowConfig.ResourceMetadataURL,
+ }
+
+ // Execute the complete internal authentication flow
+ result, err := c.authInternal(authOptions)
+ if err != nil {
+ return fmt.Errorf("OAuth flow failed: %w", err)
+ }
+
+ // Handle authentication result
+ return c.handleAuthResult(result)
+}
+
+// authInternal orchestrates the complete OAuth flow using internal methods
+func (c *Client) authInternal(options auth.AuthOptions) (*client.AuthResult, error) {
+ // Discover protected resource metadata (if available)
+ var resourceMetadata *auth.OAuthProtectedResourceMetadata
+ var authorizationServerUrl string
+
+ metadata, err := c.discoverProtectedResource(options.ServerUrl, options)
+ if err == nil {
+ resourceMetadata = metadata
+ if len(resourceMetadata.AuthorizationServers) > 0 {
+ authorizationServerUrl = resourceMetadata.AuthorizationServers[0]
+ }
+ }
+
+ if authorizationServerUrl == "" {
+ authorizationServerUrl = options.ServerUrl
+ }
+
+ // Select resource URL
+ resource, err := c.selectResourceURL(options.ServerUrl, resourceMetadata)
+ if err != nil {
+ return nil, fmt.Errorf("failed to select resource URL: %w", err)
+ }
+
+ // Discover authorization server metadata
+ serverMetadata, err := c.discoverAuthServer(authorizationServerUrl)
+ if err != nil {
+ return nil, fmt.Errorf("failed to discover authorization server: %w", err)
+ }
+
+ // Handle client registration if needed
+ clientInfo, err := c.handleClientRegistration(authorizationServerUrl, serverMetadata, options)
+ if err != nil {
+ return nil, fmt.Errorf("client registration failed: %w", err)
+ }
+
+ // Try token refresh if refresh token exists
+ if result, err := c.tryTokenRefresh(authorizationServerUrl, serverMetadata, clientInfo, resource, options); err == nil {
+ return result, nil
+ }
+
+ // Exchange the authorization code for a token
+ if options.AuthorizationCode != nil && *options.AuthorizationCode != "" {
+ cv, err := c.oauthProvider.CodeVerifier()
+ if err != nil || cv == "" {
+ return nil, fmt.Errorf("missing code_verifier: %w", err)
+ }
+
+ var addClientAuth func(http.Header, url.Values, string) error
+ if ap, ok := c.oauthProvider.(client.OAuthClientAuthProvider); ok {
+ addClientAuth = ap.AddClientAuthentication
+ }
+
+ tokens, err := client.ExchangeAuthorization(authorizationServerUrl, client.ExchangeAuthorizationOptions{
+ Metadata: serverMetadata,
+ ClientInformation: clientInfo,
+ AuthorizationCode: *options.AuthorizationCode,
+ CodeVerifier: cv,
+ RedirectURI: c.oauthProvider.RedirectURL(),
+ Resource: resource,
+ AddClientAuthentication: addClientAuth,
+ FetchFn: options.FetchFn,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := c.oauthProvider.SaveTokens(*tokens); err != nil {
+ return nil, fmt.Errorf("failed to save tokens: %w", err)
+ }
+ res := client.AuthResultAuthorized
+ return &res, nil
+ }
+
+ // Start authorization flow
+ return c.startAuthorizationFlow(authorizationServerUrl, serverMetadata, clientInfo, resource, options)
+}
+
+// discoverProtectedResource discovers OAuth protected resource metadata
+func (c *Client) discoverProtectedResource(serverUrl string, options auth.AuthOptions) (*auth.OAuthProtectedResourceMetadata, error) {
+ discoveryOptions := &auth.DiscoveryOptions{
+ ResourceMetadataUrl: options.ResourceMetadataUrl,
+ }
+
+ return client.DiscoverOAuthProtectedResourceMetadata(serverUrl, discoveryOptions, options.FetchFn)
+}
+
+// discoverAuthServer discovers authorization server metadata
+func (c *Client) discoverAuthServer(authServerUrl string) (auth.AuthorizationServerMetadata, error) {
+ return client.DiscoverAuthorizationServerMetadata(context.Background(), authServerUrl, nil)
+}
+
+// handleClientRegistration handles dynamic client registration if needed
+func (c *Client) handleClientRegistration(authServerUrl string, serverMetadata auth.AuthorizationServerMetadata, options auth.AuthOptions) (*auth.OAuthClientInformation, error) {
+ clientInfo := c.oauthProvider.ClientInformation()
+
+ if clientInfo == nil {
+ // Need to register client
+ if _, ok := c.oauthProvider.(client.OAuthClientInfoProvider); !ok {
+ return nil, fmt.Errorf("OAuth client information must be saveable for dynamic registration")
+ }
+
+ fullInfo, err := client.RegisterClient(context.Background(), authServerUrl, client.RegisterClientOptions{
+ Metadata: serverMetadata,
+ ClientMetadata: c.oauthProvider.ClientMetadata(),
+ FetchFn: options.FetchFn,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to register client: %w", err)
+ }
+
+ if clientInfoProvider, ok := c.oauthProvider.(client.OAuthClientInfoProvider); ok {
+ if err := clientInfoProvider.SaveClientInformation(*fullInfo); err != nil {
+ return nil, fmt.Errorf("failed to save client information: %w", err)
+ }
+ }
+
+ clientInfo = &auth.OAuthClientInformation{
+ ClientID: fullInfo.ClientID,
+ ClientSecret: fullInfo.ClientSecret,
+ }
+ }
+
+ return clientInfo, nil
+}
+
+// selectResourceURL selects the appropriate resource URL
+func (c *Client) selectResourceURL(serverUrl string, resourceMetadata *auth.OAuthProtectedResourceMetadata) (*url.URL, error) {
+ defaultResource, err := auth.ResourceURLFromServerURL(serverUrl)
+ if err != nil {
+ return nil, err
+ }
+
+ // Use custom validator if available
+ if validator, ok := c.oauthProvider.(client.OAuthResourceValidator); ok {
+ return validator.ValidateResourceURL(defaultResource, resourceMetadata)
+ }
+
+ // Include resource param only when metadata exists
+ if resourceMetadata == nil {
+ return nil, nil
+ }
+
+ // Check metadata resource compatibility
+ allowed, err := auth.CheckResourceAllowed(auth.CheckResourceAllowedParams{
+ RequestedResource: defaultResource,
+ ConfiguredResource: resourceMetadata.Resource,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to validate resource: %w", err)
+ }
+ if !allowed {
+ return nil, fmt.Errorf("protected resource mismatch")
+ }
+
+ return url.Parse(resourceMetadata.Resource)
+}
+
+// tryTokenRefresh attempts to refresh existing tokens
+func (c *Client) tryTokenRefresh(authServerUrl string, serverMetadata auth.AuthorizationServerMetadata, clientInfo *auth.OAuthClientInformation, resource *url.URL, options auth.AuthOptions) (*client.AuthResult, error) {
+ tokens, err := c.oauthProvider.Tokens()
+ if err != nil || tokens == nil || tokens.RefreshToken == nil || *tokens.RefreshToken == "" {
+ return nil, fmt.Errorf("no refresh token available")
+ }
+
+ var addClientAuth func(http.Header, url.Values, string) error
+ if authProvider, ok := c.oauthProvider.(client.OAuthClientAuthProvider); ok {
+ addClientAuth = authProvider.AddClientAuthentication
+ }
+
+ newTokens, err := client.RefreshAuthorization(authServerUrl, client.RefreshAuthorizationOptions{
+ Metadata: serverMetadata,
+ ClientInformation: clientInfo,
+ RefreshToken: *tokens.RefreshToken,
+ Resource: resource,
+ AddClientAuthentication: addClientAuth,
+ FetchFn: options.FetchFn,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if err := c.oauthProvider.SaveTokens(*newTokens); err != nil {
+ return nil, fmt.Errorf("failed to save refreshed tokens: %w", err)
+ }
+
+ result := client.AuthResultAuthorized
+ return &result, nil
+}
+
+// startAuthorizationFlow starts the authorization code flow
+func (c *Client) startAuthorizationFlow(authServerUrl string, serverMetadata auth.AuthorizationServerMetadata, clientInfo *auth.OAuthClientInformation, resource *url.URL, options auth.AuthOptions) (*client.AuthResult, error) {
+ var state *string
+ if c.authFlowConfig.State != nil {
+ state = c.authFlowConfig.State
+ } else if stateProvider, ok := c.oauthProvider.(client.OAuthStateProvider); ok {
+ stateValue, err := stateProvider.State()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get state: %w", err)
+ }
+ state = &stateValue
+ }
+
+ scope := options.Scope
+ if scope == nil {
+ clientMetadata := c.oauthProvider.ClientMetadata()
+ if clientMetadata.Scope != nil {
+ scope = clientMetadata.Scope
+ }
+ }
+
+ authResult, err := client.StartAuthorization(authServerUrl, client.StartAuthorizationOptions{
+ Metadata: serverMetadata,
+ ClientInformation: *clientInfo,
+ State: state,
+ RedirectURL: c.oauthProvider.RedirectURL(),
+ Scope: scope,
+ Resource: resource,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to start authorization: %w", err)
+ }
+
+ if err := c.oauthProvider.SaveCodeVerifier(authResult.CodeVerifier); err != nil {
+ return nil, fmt.Errorf("failed to save code verifier: %w", err)
+ }
+
+ if err := c.oauthProvider.RedirectToAuthorization(authResult.AuthorizationURL); err != nil {
+ return nil, fmt.Errorf("failed to redirect to authorization: %w", err)
+ }
+
+ result := client.AuthResultRedirect
+ return &result, nil
+}
+
+// handleAuthResult processes the authentication result
+func (c *Client) handleAuthResult(result *client.AuthResult) error {
+ switch *result {
+ case client.AuthResultAuthorized:
+ return c.updateClientTokens()
+ case client.AuthResultRedirect:
+ // User needs to complete authorization, this is normal
+ return nil
+ default:
+ return fmt.Errorf("unknown authentication result: %s", *result)
+ }
+}
+
+// updateClientTokens updates HTTP headers with new tokens
+func (c *Client) updateClientTokens() error {
+ tokens, err := c.oauthProvider.Tokens()
+ if err != nil {
+ return fmt.Errorf("failed to get tokens: %w", err)
+ }
+
+ if tokens != nil && tokens.AccessToken != "" {
+ c.accessToken = tokens.AccessToken
+ if tokens.RefreshToken != nil {
+ c.refreshToken = *tokens.RefreshToken
+ }
+
+ // Update HTTP headers
+ if c.transportConfig.httpHeaders == nil {
+ c.transportConfig.httpHeaders = make(http.Header)
+ }
+ c.transportConfig.httpHeaders.Set("Authorization", "Bearer "+c.accessToken)
+
+ // Update existing transport
+ if c.transport != nil {
+ if streamableTransport, ok := c.transport.(*streamableHTTPClientTransport); ok {
+ if streamableTransport.httpHeaders == nil {
+ streamableTransport.httpHeaders = make(http.Header)
+ }
+ streamableTransport.httpHeaders.Set("Authorization", "Bearer "+c.accessToken)
+ }
+ }
+ }
+
+ return nil
+}
+
+// CompleteAuthFlow completes the OAuth flow with authorization code
+func (c *Client) CompleteAuthFlow(ctx context.Context, authorizationCode string) error {
+ if c.authFlowConfig == nil {
+ return fmt.Errorf("auth flow not configured")
+ }
+
+ // Set the authorization code in options
+ authOptions := auth.AuthOptions{
+ ServerUrl: c.authFlowConfig.ServerURL,
+ Scope: c.authFlowConfig.Scope,
+ FetchFn: c.authFlowConfig.CustomFetchFunc,
+ ResourceMetadataUrl: c.authFlowConfig.ResourceMetadataURL,
+ AuthorizationCode: &authorizationCode,
+ }
+
+ // Execute the flow with the authorization code
+ result, err := c.authInternal(authOptions)
+ if err != nil {
+ return fmt.Errorf("failed to complete auth flow: %w", err)
+ }
+
+ return c.handleAuthResult(result)
+}
diff --git a/e2e/oauth_integration_test.go b/e2e/oauth_integration_test.go
new file mode 100644
index 0000000..08aca07
--- /dev/null
+++ b/e2e/oauth_integration_test.go
@@ -0,0 +1,693 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package e2e
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v4"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ mcp "trpc.group/trpc-go/trpc-mcp-go"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/providers"
+)
+
+const (
+ testHMACSecret = "test-oauth-secret"
+ testClientID = "test-client"
+ testScope = "mcp.read mcp.write"
+)
+
+// TestOAuth2Integration tests the complete OAuth 2.1 flow with MCP server
+func TestOAuth2Integration(t *testing.T) {
+ // Start mock OAuth authorization server
+ oauthServer := startMockOAuthServer(t)
+ defer oauthServer.Close()
+
+ // Create OAuth Provider
+ provider := createTestOAuthProvider(oauthServer.URL)
+
+ // Start MCP server with OAuth authentication
+ mcpServerURL, cleanup := startOAuthMCPServer(t, provider)
+ defer cleanup()
+
+ // Test OAuth flows
+ t.Run("BearerTokenAuth", func(t *testing.T) {
+ testBearerTokenAuth(t, oauthServer.URL, mcpServerURL)
+ })
+
+ t.Run("InvalidToken", func(t *testing.T) {
+ testInvalidToken(t, mcpServerURL)
+ })
+
+ // Test authorization code flow
+ t.Run("AuthorizationCodeFlow", func(t *testing.T) {
+ testSimpleAuthorizationCodeFlow(t, oauthServer.URL, mcpServerURL)
+ })
+
+ t.Run("TokenRefresh", func(t *testing.T) {
+ testTokenRefresh(t, oauthServer.URL, mcpServerURL)
+ })
+}
+
+// startMockOAuthServer starts a mock OAuth authorization server for testing
+func startMockOAuthServer(t *testing.T) *httptest.Server {
+ t.Helper()
+
+ mux := http.NewServeMux()
+
+ // Authorization endpoint
+ mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Simulate user authorization by redirecting directly to callback URL
+ redirectURI := r.URL.Query().Get("redirect_uri")
+ state := r.URL.Query().Get("state")
+ code := "test-auth-code-" + fmt.Sprintf("%d", time.Now().Unix())
+
+ callbackURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
+ http.Redirect(w, r, callbackURL, http.StatusFound)
+ })
+
+ // Token endpoint
+ mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Invalid form", http.StatusBadRequest)
+ return
+ }
+
+ grantType := r.FormValue("grant_type")
+ code := r.FormValue("code")
+ refreshToken := r.FormValue("refresh_token")
+
+ var tokenResponse map[string]interface{}
+
+ switch grantType {
+ case "authorization_code":
+ if code == "" {
+ http.Error(w, "Missing authorization code", http.StatusBadRequest)
+ return
+ }
+ tokenResponse = createTokenResponse(t, "access_token", "refresh_token")
+
+ case "refresh_token":
+ if refreshToken == "" {
+ http.Error(w, "Missing refresh token", http.StatusBadRequest)
+ return
+ }
+ // Validate refresh token
+ if !strings.HasPrefix(refreshToken, "test-refresh-token-") {
+ http.Error(w, "Invalid refresh token", http.StatusBadRequest)
+ return
+ }
+ tokenResponse = createTokenResponse(t, "new_access_token", "new_refresh_token")
+
+ default:
+ http.Error(w, "Unsupported grant type", http.StatusBadRequest)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(tokenResponse)
+ })
+
+ // Client registration endpoint
+ mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ clientInfo := map[string]interface{}{
+ "client_id": testClientID,
+ "client_secret": "",
+ "redirect_uris": []string{"http://localhost:5173/callback"},
+ "grant_types": []string{"authorization_code", "refresh_token"},
+ "scope": testScope,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(clientInfo)
+ })
+
+ // OAuth authorization server metadata endpoint
+ mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Dynamically get server URL
+ baseURL := "http://" + r.Host
+ metadata := map[string]interface{}{
+ "issuer": baseURL,
+ "authorization_endpoint": baseURL + "/authorize",
+ "token_endpoint": baseURL + "/token",
+ "registration_endpoint": baseURL + "/register",
+ "response_types_supported": []string{"code"},
+ "grant_types_supported": []string{"authorization_code", "refresh_token"},
+ "code_challenge_methods_supported": []string{"S256"},
+ "token_endpoint_auth_methods_supported": []string{"client_secret_post", "client_secret_basic"},
+ "scopes_supported": []string{"mcp.read", "mcp.write"},
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(metadata)
+ })
+
+ // OpenID Connect configuration endpoint (for compatibility)
+ mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Dynamically get server URL
+ baseURL := "http://" + r.Host
+ // Return same content as OAuth metadata
+ metadata := map[string]interface{}{
+ "issuer": baseURL,
+ "authorization_endpoint": baseURL + "/authorize",
+ "token_endpoint": baseURL + "/token",
+ "registration_endpoint": baseURL + "/register",
+ "response_types_supported": []string{"code"},
+ "grant_types_supported": []string{"authorization_code", "refresh_token"},
+ "code_challenge_methods_supported": []string{"S256"},
+ "token_endpoint_auth_methods_supported": []string{"client_secret_post", "client_secret_basic"},
+ "scopes_supported": []string{"mcp.read", "mcp.write"},
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(metadata)
+ })
+
+ server := httptest.NewServer(mux)
+ t.Logf("Mock OAuth server started at: %s", server.URL)
+ return server
+}
+
+// createTestOAuthProvider creates an OAuth provider for testing
+func createTestOAuthProvider(oauthServerURL string) server.OAuthServerProvider {
+ return providers.NewProxyOAuthServerProvider(providers.ProxyOptions{
+ Endpoints: providers.ProxyEndpoints{
+ AuthorizationURL: oauthServerURL + "/authorize",
+ TokenURL: oauthServerURL + "/token",
+ RegistrationURL: oauthServerURL + "/register",
+ },
+ VerifyAccessToken: func(token string) (*server.AuthInfo, error) {
+ return verifyTestJWT(token)
+ },
+ GetClient: func(clientID string) (*auth.OAuthClientInformationFull, error) {
+ return &auth.OAuthClientInformationFull{
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"http://localhost:5173/callback"},
+ ResponseTypes: []string{"code"},
+ GrantTypes: []string{"authorization_code", "refresh_token"},
+ ClientName: stringPtr("test-client"),
+ Scope: stringPtr(testScope),
+ },
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: clientID,
+ ClientSecret: "",
+ },
+ }, nil
+ },
+ })
+}
+
+// startOAuthMCPServer starts an MCP server with OAuth authentication enabled
+func startOAuthMCPServer(t *testing.T, provider server.OAuthServerProvider) (string, func()) {
+ t.Helper()
+
+ // Create MCP server using standardized approach
+ server := mcp.NewServer(
+ "OAuth-Test-Server",
+ "1.0.0",
+ mcp.WithServerPath("/mcp"),
+ mcp.WithOAuthRoutes(mcp.OAuthRoutesConfig{
+ Provider: provider,
+ IssuerURL: mustParseURL("http://localhost:3030"),
+ BaseURL: mustParseURL("http://localhost:3000"),
+ ScopesSupported: []string{"mcp.read", "mcp.write"},
+ }),
+ mcp.WithBearerAuth(&mcp.BearerAuthConfig{
+ Enabled: true,
+ RequiredScopes: []string{"mcp.read", "mcp.write"},
+ Issuer: "http://localhost:3030",
+ Audience: []string{"http://localhost:3000"},
+ Verifier: server.TokenVerifierFunc(func(ctx context.Context, token string) (server.AuthInfo, error) {
+ authInfo, err := verifyTestJWT(token)
+ if err != nil {
+ return server.AuthInfo{}, err
+ }
+ return *authInfo, nil
+ }),
+ }),
+ )
+
+ // Register test tools using standardized approach
+ RegisterTestTools(server)
+
+ // Create HTTP test server
+ httpServer := httptest.NewServer(server.HTTPHandler())
+ serverURL := httpServer.URL + "/mcp"
+
+ t.Logf("OAuth MCP server started at: %s", serverURL)
+
+ cleanup := func() {
+ t.Log("Closing OAuth MCP server")
+ httpServer.Close()
+ }
+
+ return serverURL, cleanup
+}
+
+// testBearerTokenAuth tests Bearer Token authentication using standardized approach
+func testBearerTokenAuth(t *testing.T, oauthServerURL, mcpServerURL string) {
+ t.Helper()
+
+ // Create client directly with a valid JWT Token
+ validToken := createTestJWT(t, "access_token")
+
+ // Create HTTP headers with Bearer Token
+ headers := make(http.Header)
+ headers.Set("Authorization", "Bearer "+validToken)
+
+ // Use standardized client creation
+ client := CreateTestClient(t, mcpServerURL, func(c *mcp.Client) {
+ // Apply OAuth headers
+ mcp.WithHTTPHeaders(headers)(c)
+ })
+ defer CleanupClient(t, client)
+
+ // Initialize client using standardized approach
+ InitializeClient(t, client)
+
+ // Test tool invocation using standardized approach
+ content := ExecuteTestTool(t, client, "basic-greet", map[string]interface{}{
+ "name": "bearer-test",
+ })
+
+ require.Len(t, content, 1)
+ textContent, ok := content[0].(mcp.TextContent)
+ assert.True(t, ok)
+ assert.Contains(t, textContent.Text, "Hello, bearer-test")
+}
+
+// testInvalidToken tests invalid token handling using standardized approach
+func testInvalidToken(t *testing.T, mcpServerURL string) {
+ t.Helper()
+
+ // Create client with invalid token
+ invalidToken := "invalid.jwt.token"
+
+ // Create HTTP headers with invalid Bearer Token
+ headers := make(http.Header)
+ headers.Set("Authorization", "Bearer "+invalidToken)
+
+ // Use standardized client creation
+ client := CreateTestClient(t, mcpServerURL, func(c *mcp.Client) {
+ // Apply OAuth headers
+ mcp.WithHTTPHeaders(headers)(c)
+ })
+ defer CleanupClient(t, client)
+
+ // Try to initialize client, should fail
+ ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
+ defer cancel()
+
+ _, err := client.Initialize(ctx, &mcp.InitializeRequest{
+ Params: mcp.InitializeParams{
+ ProtocolVersion: mcp.ProtocolVersion_2025_03_26,
+ ClientInfo: mcp.Implementation{
+ Name: "Invalid-Token-Client",
+ Version: "1.0.0",
+ },
+ },
+ })
+
+ // Should return authentication error
+ assert.Error(t, err)
+ // Check if it contains authentication-related error message
+ errorMsg := err.Error()
+ assert.True(t,
+ strings.Contains(errorMsg, "unauthorized") ||
+ strings.Contains(errorMsg, "401") ||
+ strings.Contains(errorMsg, "authentication") ||
+ strings.Contains(errorMsg, "auth"),
+ "Expected authentication error, got: %s", errorMsg)
+}
+
+// testSimpleAuthorizationCodeFlow tests the simplified authorization code flow
+func testSimpleAuthorizationCodeFlow(t *testing.T, oauthServerURL, mcpServerURL string) {
+ t.Helper()
+
+ // Test authorization endpoint functionality
+ t.Run("AuthorizationEndpoint", func(t *testing.T) {
+ // Start a simple callback server
+ callbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Logf("Callback received: %s", r.URL.RawQuery)
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("OK"))
+ }))
+ defer callbackServer.Close()
+
+ // Build authorization URL with URL encoding
+ params := url.Values{}
+ params.Set("client_id", testClientID)
+ params.Set("response_type", "code")
+ params.Set("redirect_uri", callbackServer.URL+"/callback")
+ params.Set("scope", testScope)
+ params.Set("state", "test-state")
+
+ authURL := oauthServerURL + "/authorize?" + params.Encode()
+ t.Logf("Testing authorization URL: %s", authURL)
+
+ // Create HTTP client that doesn't follow redirects
+ client := &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse // Don't follow redirects
+ },
+ }
+
+ // Access authorization endpoint
+ resp, err := client.Get(authURL)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ // Should redirect to callback URL
+ assert.Equal(t, http.StatusFound, resp.StatusCode)
+
+ // Check redirect URL
+ location := resp.Header.Get("Location")
+ t.Logf("Redirect location: %s", location)
+ assert.Contains(t, location, "code=")
+ assert.Contains(t, location, "state=test-state")
+ })
+
+ // Test token endpoint functionality
+ t.Run("TokenEndpoint", func(t *testing.T) {
+ // Simulate authorization code exchange for tokens
+ formData := url.Values{}
+ formData.Set("grant_type", "authorization_code")
+ formData.Set("code", "test-auth-code-123")
+ formData.Set("redirect_uri", "http://localhost:5173/callback")
+
+ resp, err := http.PostForm(oauthServerURL+"/token", formData)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ // Should return success
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ // Validate response content
+ var tokenResp map[string]interface{}
+ err = json.NewDecoder(resp.Body).Decode(&tokenResp)
+ require.NoError(t, err)
+
+ assert.Contains(t, tokenResp, "access_token")
+ assert.Contains(t, tokenResp, "refresh_token")
+ assert.Equal(t, "Bearer", tokenResp["token_type"])
+ assert.Equal(t, testScope, tokenResp["scope"])
+ })
+
+ // Test OAuth metadata endpoint
+ t.Run("OAuthMetadata", func(t *testing.T) {
+ // Test OAuth authorization server metadata
+ resp, err := http.Get(oauthServerURL + "/.well-known/oauth-authorization-server")
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var metadata map[string]interface{}
+ err = json.NewDecoder(resp.Body).Decode(&metadata)
+ require.NoError(t, err)
+
+ assert.Equal(t, oauthServerURL, metadata["issuer"])
+ assert.Contains(t, metadata, "authorization_endpoint")
+ assert.Contains(t, metadata, "token_endpoint")
+ assert.Contains(t, metadata, "scopes_supported")
+ })
+}
+
+// testTokenRefresh tests token refresh functionality
+func testTokenRefresh(t *testing.T, oauthServerURL, mcpServerURL string) {
+ t.Helper()
+
+ // Create a client with a refresh token
+ refreshToken := "test-refresh-token-" + fmt.Sprintf("%d", time.Now().Unix())
+
+ // Create OAuth Provider that supports token refresh
+ provider := providers.NewProxyOAuthServerProvider(providers.ProxyOptions{
+ Endpoints: providers.ProxyEndpoints{
+ AuthorizationURL: oauthServerURL + "/authorize",
+ TokenURL: oauthServerURL + "/token",
+ RegistrationURL: oauthServerURL + "/register",
+ },
+ VerifyAccessToken: func(token string) (*server.AuthInfo, error) {
+ return verifyTestJWT(token)
+ },
+ GetClient: func(clientID string) (*auth.OAuthClientInformationFull, error) {
+ return &auth.OAuthClientInformationFull{
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"http://localhost:5173/callback"},
+ ResponseTypes: []string{"code"},
+ GrantTypes: []string{"authorization_code", "refresh_token"},
+ ClientName: stringPtr("test-client"),
+ Scope: stringPtr(testScope),
+ },
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: clientID,
+ ClientSecret: "",
+ },
+ }, nil
+ },
+ })
+
+ // Start MCP server with OAuth
+ mcpServerURL, cleanup := startOAuthMCPServer(t, provider)
+ defer cleanup()
+
+ // Test token refresh flow
+ t.Run("RefreshTokenFlow", func(t *testing.T) {
+ // Simulate refresh token request
+ refreshReq := map[string]string{
+ "grant_type": "refresh_token",
+ "refresh_token": refreshToken,
+ }
+
+ // Send refresh request to OAuth server
+ formData := url.Values{}
+ for k, v := range refreshReq {
+ formData.Set(k, v)
+ }
+ resp, err := http.PostForm(oauthServerURL+"/token", formData)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ // Validate response
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var tokenResp map[string]interface{}
+ err = json.NewDecoder(resp.Body).Decode(&tokenResp)
+ require.NoError(t, err)
+
+ // Validate returned tokens
+ assert.Contains(t, tokenResp, "access_token")
+ assert.Contains(t, tokenResp, "refresh_token")
+ assert.Equal(t, "Bearer", tokenResp["token_type"])
+ assert.Equal(t, testScope, tokenResp["scope"])
+
+ // Validate new access token is valid
+ newAccessToken, ok := tokenResp["access_token"].(string)
+ require.True(t, ok)
+
+ // Create client with new access token using standardized approach
+ headers := make(http.Header)
+ headers.Set("Authorization", "Bearer "+newAccessToken)
+
+ client := CreateTestClient(t, mcpServerURL, func(c *mcp.Client) {
+ mcp.WithHTTPHeaders(headers)(c)
+ })
+ defer CleanupClient(t, client)
+
+ // Initialize client using standardized approach
+ InitializeClient(t, client)
+
+ // Test tool invocation using standardized approach
+ content := ExecuteTestTool(t, client, "basic-greet", map[string]interface{}{
+ "name": "refresh-test",
+ })
+
+ require.Len(t, content, 1)
+ textContent, ok := content[0].(mcp.TextContent)
+ assert.True(t, ok)
+ assert.Contains(t, textContent.Text, "Hello, refresh-test")
+ })
+
+ // Test invalid refresh token
+ t.Run("InvalidRefreshToken", func(t *testing.T) {
+ invalidRefreshReq := map[string]string{
+ "grant_type": "refresh_token",
+ "refresh_token": "invalid-refresh-token",
+ }
+
+ formData := url.Values{}
+ for k, v := range invalidRefreshReq {
+ formData.Set(k, v)
+ }
+ resp, err := http.PostForm(oauthServerURL+"/token", formData)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ // Should return error (400 Bad Request)
+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
+ })
+}
+
+// createTokenResponse creates a test token response with JWT access token
+func createTokenResponse(t *testing.T, accessToken, refreshToken string) map[string]interface{} {
+ t.Helper()
+
+ return map[string]interface{}{
+ "access_token": createTestJWT(t, accessToken),
+ "refresh_token": refreshToken,
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "scope": testScope,
+ }
+}
+
+// createTestJWT creates a test JWT token with specified token type
+func createTestJWT(t *testing.T, tokenType string) string {
+ t.Helper()
+
+ claims := jwt.MapClaims{
+ "iss": "http://localhost:3030",
+ "aud": []string{"http://localhost:3000"},
+ "sub": testClientID,
+ "scope": "mcp.read mcp.write", // Ensure correct scope
+ "iat": time.Now().Unix(),
+ "exp": time.Now().Add(time.Hour).Unix(),
+ "token_type": tokenType,
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ signedToken, err := token.SignedString([]byte(testHMACSecret))
+ require.NoError(t, err)
+ return signedToken
+}
+
+// verifyTestJWT verifies and parses a test JWT token
+func verifyTestJWT(tokenString string) (*server.AuthInfo, error) {
+ token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
+ if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+ }
+ return []byte(testHMACSecret), nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
+ // Scopes
+ scopes := []string{}
+ if scope, ok := claims["scope"].(string); ok {
+ scopes = strings.Fields(scope)
+ }
+
+ // ExpiresAt
+ var expiresAtPtr *int64
+ if v, ok := claims["exp"].(float64); ok {
+ vv := int64(v)
+ expiresAtPtr = &vv
+ }
+
+ // Audience -> Resource (first value)
+ var resourceURL *url.URL
+ if audVal, ok := claims["aud"]; ok {
+ switch v := audVal.(type) {
+ case string:
+ if u, err := url.Parse(v); err == nil {
+ resourceURL = u
+ }
+ case []interface{}:
+ if len(v) > 0 {
+ if s, ok := v[0].(string); ok {
+ if u, err := url.Parse(s); err == nil {
+ resourceURL = u
+ }
+ }
+ }
+ case []string:
+ if len(v) > 0 {
+ if u, err := url.Parse(v[0]); err == nil {
+ resourceURL = u
+ }
+ }
+ }
+ }
+
+ // ClientID
+ clientID, _ := claims["sub"].(string)
+
+ // Extra (include iss and client_id)
+ extra := map[string]interface{}{}
+ if iss, ok := claims["iss"].(string); ok {
+ extra["iss"] = iss
+ }
+ if clientID != "" {
+ extra["client_id"] = clientID
+ }
+
+ return &server.AuthInfo{
+ ClientID: clientID,
+ Scopes: scopes,
+ ExpiresAt: expiresAtPtr,
+ Resource: resourceURL,
+ Extra: extra,
+ }, nil
+ }
+
+ return nil, fmt.Errorf("invalid token")
+}
+
+// stringPtr returns a pointer to the given string
+func stringPtr(s string) *string {
+ return &s
+}
+
+// mustParseURL parses a URL string and panics if parsing fails
+func mustParseURL(s string) *url.URL {
+ u, err := url.Parse(s)
+ if err != nil {
+ panic(err)
+ }
+ return u
+}
diff --git a/examples/auth/README.md b/examples/auth/README.md
new file mode 100644
index 0000000..00e95ee
--- /dev/null
+++ b/examples/auth/README.md
@@ -0,0 +1,48 @@
+# OAuth 2.1 Authentication Example
+
+This example demonstrates how to use **trpc-mcp-go** to implement an OAuth 2.1 authentication flow between a client and a server.
+
+## Features
+
+- **OAuth 2.1 Authorization Code Flow**
+ - Client registration and redirect handling
+ - Token exchange (authorization code & refresh token)
+ - Token verification with HMAC-signed JWTs
+
+- **MCP Integration**
+ - MCP server with OAuth-protected routes
+ - MCP client with integrated authorization flow
+ - Example of authenticated tool invocation
+
+- **Infrastructure**
+ - Mock OAuth 2.1 server for local testing
+ - Support for access & refresh tokens
+ - Token introspection and metadata endpoints
+
+## Quick Start
+
+### 1. Start the OAuth Authentication Server
+```bash
+cd server
+go run main.go
+```
+
+- Runs a mock OAuth 2.1 server on `http://localhost:3030`
+- Starts an MCP server with OAuth-protected endpoints on `http://localhost:3000/mcp`
+
+### 2. Start the OAuth Client
+```bash
+cd client
+go run main.go
+```
+- Launches an MCP client with OAuth support
+- Opens a browser redirect for user authorization
+- Completes the authorization code exchange automatically
+
+## What it demonstrates
+
+1. **Client Authorization Flow**: How an MCP client performs OAuth 2.1 authorization using redirect URIs.
+2. **Server Protection**: How an MCP server integrates OAuth 2.1 for protecting tools and resources.
+3. **JWT Token Handling**: Issuing and verifying HMAC-signed JWT access and refresh tokens.
+4. **Metadata & Introspection**: Provides `.well-known` OAuth server metadata and introspection endpoints for compatibility.
+5. **Audit Logging**: Examples of secure server-side logging with sensitive data hashing and reduced verbosity.
\ No newline at end of file
diff --git a/examples/auth/client/main.go b/examples/auth/client/main.go
new file mode 100644
index 0000000..9a610a7
--- /dev/null
+++ b/examples/auth/client/main.go
@@ -0,0 +1,191 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "time"
+
+ mcp "trpc.group/trpc-go/trpc-mcp-go"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+const (
+ // Base origin of the MCP resource server
+ serverURL = "http://localhost:3000"
+
+ // Well-known OAuth protected resource metadata endpoint
+ resourceMetadataURL = "http://localhost:3000/.well-known/oauth-protected-resource"
+
+ // Local redirect URI that receives the authorization code
+ redirectURL = "http://localhost:5173/callback"
+
+ // Requested scopes for this demo
+ scope = "mcp.read mcp.write"
+
+ // HTTP listen address for the local callback server
+ callbackListenAddr = ":5173"
+
+ // MCP entry endpoint used by the SDK client
+ mcpEndpoint = "http://localhost:3000/mcp/"
+)
+
+func main() {
+ log.Println("🖥️ Starting OAuth Client Demo")
+ log.Println(" Target Server: http://localhost:3000")
+ log.Println(" Callback URL: http://localhost:5173/callback")
+ log.Println(" Required Scopes: mcp.read mcp.write")
+ log.Println()
+
+ // Configure the auth flow used by the MCP SDK
+ authFlow := mcp.AuthFlowConfig{
+ ServerURL: serverURL,
+ ClientMetadata: auth.OAuthClientMetadata{
+ ClientName: strPtr("demo-client"),
+ GrantTypes: []string{"authorization_code", "refresh_token"},
+ TokenEndpointAuthMethod: "client_secret_post",
+ RedirectURIs: []string{redirectURL},
+ Scope: strPtr(scope),
+ },
+ ResourceMetadataURL: strPtr(resourceMetadataURL),
+ RedirectURL: redirectURL,
+ Scope: strPtr(scope),
+ OnRedirect: func(u *url.URL) error {
+ log.Printf("🌐 Authorization Required\n")
+ log.Printf(" Please open this URL in your browser:\n")
+ log.Printf(" %s\n\n", u.String())
+ log.Printf(" Waiting for authorization...\n")
+ return nil
+ },
+ }
+
+ // Create the MCP client with auth flow enabled
+ client, err := mcp.NewClient(
+ mcpEndpoint,
+ mcp.Implementation{Name: "Auth-Example-Client", Version: "0.1.0"},
+ mcp.WithAuthFlow(authFlow),
+ )
+ if err != nil {
+ log.Printf("❌ Failed to create client: %v\n", err)
+ return
+ }
+
+ // Start the local HTTP callback server to capture the authorization code
+ authDone := make(chan struct{}, 1)
+ cbServer := startCallbackServer(client, authDone)
+ defer shutdownServer(cbServer)
+
+ // First initialize will typically request user authorization
+ log.Println("🔄 Step 1: Initializing client (triggering OAuth flow)...")
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ _, _ = client.Initialize(ctx, &mcp.InitializeRequest{})
+
+ // Wait for the browser redirect to complete the code exchange
+ select {
+ case <-authDone:
+ log.Println("✅ Step 2: Authorization flow completed successfully")
+ case <-time.After(3 * time.Minute):
+ log.Println("❌ Authorization timeout after 3 minutes")
+ return
+ }
+
+ // Small delay to ensure token persistence
+ time.Sleep(2 * time.Second)
+
+ // Second initialize should succeed using the stored tokens
+ log.Println("🔄 Step 3: Testing authenticated connection...")
+ ctx2, cancel2 := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel2()
+
+ initResp, err := client.Initialize(ctx2, &mcp.InitializeRequest{})
+ if err != nil {
+ log.Printf("❌ Authenticated connection failed: %v\n", err)
+ return
+ }
+
+ log.Println("📋 OAuth Flow Summary:")
+ log.Println(" 1. ✅ Client registration")
+ log.Println(" 2. ✅ User authorization")
+ log.Println(" 3. ✅ Token exchange")
+ log.Println(" 4. ✅ Authenticated API access")
+ log.Println()
+ log.Printf("🎉 Success! Connected to MCP Server\n")
+ log.Printf(" Server: %s v%s\n", initResp.ServerInfo.Name, initResp.ServerInfo.Version)
+ log.Printf(" Authentication: OAuth 2.0 with Bearer Token\n")
+}
+
+// startCallbackServer runs an HTTP server that handles /callback and completes the OAuth flow via the SDK
+func startCallbackServer(c *mcp.Client, done chan<- struct{}) *http.Server {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
+ code := r.URL.Query().Get("code")
+ state := r.URL.Query().Get("state")
+
+ log.Printf("🔄 Callback received\n")
+ log.Printf(" Authorization Code: %s\n", code[:20]+"...")
+ if state != "" {
+ log.Printf(" State: %s\n", state[:20]+"...")
+ }
+
+ if code == "" {
+ log.Println("❌ Missing authorization code")
+ http.Error(w, "missing code parameter", http.StatusBadRequest)
+ return
+ }
+
+ log.Printf("🎫 Exchanging authorization code for tokens...\n")
+
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+
+ if err := c.CompleteAuthFlow(ctx, code); err != nil {
+ log.Printf("❌ Token exchange failed: %v\n", err)
+ http.Error(w, fmt.Sprintf("Authorization failed: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ log.Println("✅ Token exchange successful")
+
+ // Send a nice response page
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`Authorization Complete!`))
+
+ // Notify the main goroutine
+ select {
+ case done <- struct{}{}:
+ default:
+ }
+ })
+
+ srv := &http.Server{
+ Addr: callbackListenAddr,
+ Handler: mux,
+ }
+ go func() {
+ log.Printf("🌐 Callback server listening on %s\n", callbackListenAddr)
+ srv.ListenAndServe()
+ }()
+ return srv
+}
+
+// shutdownServer gracefully stops the HTTP server within a short timeout
+func shutdownServer(srv *http.Server) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = srv.Shutdown(ctx)
+}
+
+// strPtr returns a pointer to s
+func strPtr(s string) *string {
+ return &s
+}
diff --git a/examples/auth/server/main.go b/examples/auth/server/main.go
new file mode 100644
index 0000000..d0c21aa
--- /dev/null
+++ b/examples/auth/server/main.go
@@ -0,0 +1,545 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/golang-jwt/jwt/v4"
+ mcp "trpc.group/trpc-go/trpc-mcp-go"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/providers"
+)
+
+const hmacSecret = "demo-shared-secret"
+
+// strPtr returns a pointer to the given string
+func strPtr(s string) *string {
+ return &s
+}
+
+// mustURL parses the given string as a URL and panics if invalid
+func mustURL(s string) *url.URL {
+ u, err := url.Parse(s)
+ if err != nil {
+ panic(err)
+ }
+ return u
+}
+
+func main() {
+ log.Println("🚀 Starting OAuth Authentication Server...")
+ log.Println(" Mock OAuth Server: http://localhost:3030")
+ log.Println(" MCP Server: http://localhost:3000/mcp")
+ log.Println()
+
+ // Start the mock OAuth server first
+ go startMockOAuthServer()
+ time.Sleep(2 * time.Second)
+
+ // Test the mock server
+ resp, err := http.Get("http://localhost:3030/authorize?test=1")
+ if err != nil {
+ log.Fatalf("Mock OAuth server not ready: %v", err)
+ }
+ resp.Body.Close()
+ log.Println("✅ OAuth infrastructure ready")
+
+ // Create OAuth Provider
+ provider := providers.NewProxyOAuthServerProvider(providers.ProxyOptions{
+ Endpoints: providers.ProxyEndpoints{
+ AuthorizationURL: "http://localhost:3030/authorize",
+ TokenURL: "http://localhost:3030/token",
+ RevocationURL: "http://localhost:3030/revoke",
+ RegistrationURL: "http://localhost:3030/register",
+ },
+
+ VerifyAccessToken: func(token string) (*server.AuthInfo, error) {
+ ai, err := mockVerifyJWT(token)
+ if err != nil {
+ fmt.Printf("❌ Token verification failed: %v\n", err)
+ return nil, err
+ }
+ return &ai, nil
+ },
+
+ GetClient: func(clientID string) (*auth.OAuthClientInformationFull, error) {
+ return &auth.OAuthClientInformationFull{
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"http://localhost:5173/callback"},
+ ResponseTypes: []string{"code"},
+ GrantTypes: []string{"authorization_code", "refresh_token"},
+ ClientName: strPtr("demo-client"),
+ Scope: strPtr("mcp.read mcp.write"),
+ },
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: clientID,
+ ClientSecret: "", // Public client, no secret
+ },
+ }, nil
+ },
+ })
+
+ // Create and start the MCP server
+ // Build a TokenVerifier (use introspection for the demo)
+ ctx := context.Background()
+ v, err := server.NewTokenVerifier(ctx, server.TokenVerifierConfig{
+ Introspection: &server.IntrospectionConfig{
+ Endpoint: "http://localhost:3030/introspect",
+ Timeout: 5 * time.Second,
+ CacheTTL: 30 * time.Second,
+ NegativeCacheTTL: 10 * time.Second,
+ UseOnJWTFail: true,
+ },
+ })
+ if err != nil {
+ log.Fatalf("failed to create TokenVerifier: %v", err)
+ }
+
+ mcpServer := mcp.NewServer(
+ "Auth-Example-Server",
+ "1.0.0",
+ mcp.WithServerAddress(":3000"),
+ mcp.WithServerPath("/mcp"),
+ mcp.WithOAuthRoutes(mcp.OAuthRoutesConfig{
+ Provider: provider,
+ IssuerURL: mustURL("http://localhost:3030"),
+ BaseURL: mustURL("http://localhost:3000"),
+ ScopesSupported: []string{"mcp.read", "mcp.write"},
+ }),
+ mcp.WithOAuthMetadata(mcp.OAuthMetadataConfig{
+ ResourceServerURL: mustURL("http://localhost:3000"),
+ ScopesSupported: []string{"mcp.read", "mcp.write"},
+ ResourceName: strPtr("MCP Server"),
+ }),
+ mcp.WithBearerAuth(&mcp.BearerAuthConfig{
+ Enabled: true,
+ RequiredScopes: []string{"mcp.read", "mcp.write"},
+ Verifier: v, // directly use TokenVerifier implementation
+ }),
+ mcp.WithAudit(&mcp.AuditConfig{
+ Enabled: true,
+ Level: "basic", // Reduced from "detailed"
+ HashSensitiveData: true,
+ IncludeRequestBody: false, // Disabled to reduce noise
+ IncludeResponseBody: false, // Disabled to reduce noise
+ EndpointPatterns: []string{"/mcp/", "/authorize", "/token"},
+ ExcludePatterns: []string{"/healthz"},
+ }),
+ )
+
+ // Set up a graceful shutdown.
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
+
+ // Start server (run in goroutine).
+ go func() {
+ log.Println("🔐 MCP Auth Server started successfully")
+ log.Println(" Waiting for authentication requests...")
+ fmt.Println()
+ if err := mcpServer.Start(); err != nil {
+ log.Fatalf("Server failed to start: %v", err)
+ }
+ }()
+ // Wait for termination signal.
+ <-stop
+ log.Println("🛑 Shutting down server...")
+}
+
+// startMockOAuthServer starts a simple mock OAuth server on port 3030
+func startMockOAuthServer() {
+ mux := http.NewServeMux()
+
+ // Store the authorization code
+ var authCode = "mock_auth_code_12345"
+
+ // Authorize endpoint
+ mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
+ // Handle test requests silently
+ if r.URL.Query().Get("test") != "" {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("OK"))
+ return
+ }
+
+ redirectURI := r.URL.Query().Get("redirect_uri")
+ state := r.URL.Query().Get("state")
+ clientID := r.URL.Query().Get("client_id")
+
+ log.Printf("🔐 OAuth Authorization Request\n")
+ log.Printf(" Client ID: %s\n", clientID)
+ log.Printf(" Scopes: %s\n", r.URL.Query().Get("scope"))
+
+ if redirectURI == "" {
+ http.Error(w, "Missing redirect_uri", http.StatusBadRequest)
+ return
+ }
+
+ // Construct redirect URLs
+ redirectURL := redirectURI + "?code=" + authCode
+ if state != "" {
+ redirectURL += "&state=" + state
+ }
+
+ log.Printf(" Redirecting to client callback\n\n")
+ http.Redirect(w, r, redirectURL, http.StatusFound)
+ })
+
+ // Token endpoint: supports authorization_code and refresh_token, and issues HS256 JWT
+ mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Unified parsing form
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Invalid form", http.StatusBadRequest)
+ return
+ }
+
+ grantType := r.FormValue("grant_type")
+
+ // Issuing HS256 JWT
+ signJWT := func(claims jwt.MapClaims) (string, error) {
+ tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ signed, err := tok.SignedString([]byte(hmacSecret))
+ if err != nil {
+ return "", err
+ }
+ return signed, nil
+ }
+
+ switch grantType {
+ case "authorization_code":
+ clientID := r.FormValue("client_id")
+ code := r.FormValue("code")
+
+ log.Printf("🎫 Token Exchange (Authorization Code)\n")
+ log.Printf(" Client ID: %s\n", clientID)
+ log.Printf(" Code: %s\n", code)
+
+ // Basic parameter verification
+ if clientID == "" || code == "" {
+ http.Error(w, "Missing required parameters", http.StatusBadRequest)
+ return
+ }
+
+ now := time.Now()
+ // Issue access_token
+ accessToken, err := signJWT(jwt.MapClaims{
+ "iss": "http://localhost:3030",
+ "aud": "http://localhost:3000",
+ "iat": now.Unix(),
+ "exp": now.Add(1 * time.Hour).Unix(),
+ "client_id": clientID,
+ "sub": clientID,
+ "scope": "mcp.read mcp.write",
+ })
+ if err != nil {
+ http.Error(w, "failed to sign access token", http.StatusInternalServerError)
+ return
+ }
+
+ // Issue refresh token
+ refreshToken, err := signJWT(jwt.MapClaims{
+ "iss": "http://localhost:3030",
+ "aud": "http://localhost:3000",
+ "iat": now.Unix(),
+ "exp": now.Add(24 * time.Hour).Unix(),
+ "client_id": clientID,
+ "sub": clientID,
+ "typ": "refresh",
+ })
+ if err != nil {
+ http.Error(w, "failed to sign refresh token", http.StatusInternalServerError)
+ return
+ }
+
+ log.Printf(" ✅ Tokens issued successfully\n\n")
+
+ resp := map[string]any{
+ "access_token": accessToken,
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "scope": "mcp.read mcp.write",
+ "refresh_token": refreshToken,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+
+ case "refresh_token":
+ rt := r.FormValue("refresh_token")
+ if rt == "" {
+ http.Error(w, "Missing refresh_token", http.StatusBadRequest)
+ return
+ }
+
+ log.Printf("🔄 Token Refresh Request\n")
+
+ // Parse and verify RT (HS256)
+ parsed, err := jwt.Parse(rt, func(t *jwt.Token) (interface{}, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return []byte(hmacSecret), nil
+ })
+ if err != nil || !parsed.Valid {
+ log.Printf(" ❌ Invalid refresh token\n\n")
+ http.Error(w, "invalid refresh_token", http.StatusUnauthorized)
+ return
+ }
+ claims, ok := parsed.Claims.(jwt.MapClaims)
+ if !ok {
+ http.Error(w, "invalid refresh_token claims", http.StatusUnauthorized)
+ return
+ }
+
+ // Extract client_id from RT claims
+ clientID, _ := claims["client_id"].(string)
+ if clientID == "" {
+ clientID = "public-client"
+ }
+
+ fmt.Printf(" Client ID: %s\n", clientID)
+
+ now := time.Now()
+ // New access_token
+ newAT, err := signJWT(jwt.MapClaims{
+ "iss": "http://localhost:3030",
+ "aud": "http://localhost:3000",
+ "iat": now.Unix(),
+ "exp": now.Add(1 * time.Hour).Unix(),
+ "client_id": clientID,
+ "sub": clientID,
+ "scope": "mcp.read mcp.write",
+ })
+ if err != nil {
+ http.Error(w, "failed to sign access token", http.StatusInternalServerError)
+ return
+ }
+
+ // New refresh_token
+ newRT, err := signJWT(jwt.MapClaims{
+ "iss": "http://localhost:3030",
+ "aud": "http://localhost:3000",
+ "iat": now.Unix(),
+ "exp": now.Add(24 * time.Hour).Unix(),
+ "client_id": clientID,
+ "sub": clientID,
+ "typ": "refresh",
+ })
+ if err != nil {
+ http.Error(w, "failed to sign refresh token", http.StatusInternalServerError)
+ return
+ }
+
+ fmt.Printf(" ✅ New tokens issued\n\n")
+
+ resp := map[string]any{
+ "access_token": newAT,
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "scope": "mcp.read mcp.write",
+ "refresh_token": newRT,
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+
+ default:
+ http.Error(w, "unsupported_grant_type", http.StatusBadRequest)
+ return
+ }
+ })
+
+ // Revocation endpoint (optional) - silent
+ mux.HandleFunc("/revoke", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+
+ // Registration endpoint (optional)
+ mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
+ log.Printf("📝 Client Registration Request\n")
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "client_id": "test-client-id",
+ "client_secret": "test-secret",
+ "client_name": "demo-client",
+ "scope": "mcp.read mcp.write",
+ "redirect_uris": []string{"http://localhost:5173/callback"},
+ "grant_types": []string{"authorization_code", "refresh_token"},
+ "response_types": []string{"code"},
+ })
+ log.Printf(" ✅ Client registered: test-client-id\n\n")
+ })
+
+ // Introspection endpoint (RFC7662 simplified for demo)
+ mux.HandleFunc("/introspect", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form", http.StatusBadRequest)
+ return
+ }
+ token := r.FormValue("token")
+ resp := map[string]any{"active": false}
+ if token != "" {
+ parsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return []byte(hmacSecret), nil
+ })
+ if err == nil && parsed != nil && parsed.Valid {
+ if claims, ok := parsed.Claims.(jwt.MapClaims); ok {
+ var exp int64
+ if v, ok := claims["exp"].(float64); ok {
+ exp = int64(v)
+ }
+ scope, _ := claims["scope"].(string)
+ clientID, _ := claims["client_id"].(string)
+ if clientID == "" {
+ if sub, _ := claims["sub"].(string); sub != "" {
+ clientID = sub
+ }
+ }
+ resp = map[string]any{
+ "active": true,
+ "exp": exp,
+ "scope": scope,
+ "client_id": clientID,
+ "aud": "http://localhost:3000",
+ "iss": "http://localhost:3030",
+ }
+ }
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+ })
+
+ // Authorization Server Metadata (RFC 8414) - silent
+ mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
+ meta := map[string]any{
+ "issuer": "http://localhost:3030",
+ "authorization_endpoint": "http://localhost:3030/authorize",
+ "token_endpoint": "http://localhost:3030/token",
+ "registration_endpoint": "http://localhost:3030/register",
+ "revocation_endpoint": "http://localhost:3030/revoke",
+ "response_types_supported": []string{"code"},
+ "grant_types_supported": []string{"authorization_code", "refresh_token"},
+ "code_challenge_methods_supported": []string{"S256"},
+ "token_endpoint_auth_methods_supported": []string{"client_secret_post"},
+ "scopes_supported": []string{"mcp.read", "mcp.write"},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(meta)
+ })
+
+ // Compatible with OIDC discovery - silent
+ mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
+ cfg := map[string]any{
+ "issuer": "http://localhost:3030",
+ "authorization_endpoint": "http://localhost:3030/authorize",
+ "token_endpoint": "http://localhost:3030/token",
+ "registration_endpoint": "http://localhost:3030/register",
+ "revocation_endpoint": "http://localhost:3030/revoke",
+ "response_types_supported": []string{"code"},
+ "grant_types_supported": []string{"authorization_code", "refresh_token"},
+ "code_challenge_methods_supported": []string{"S256"},
+ "token_endpoint_auth_methods_supported": []string{"client_secret_post"},
+ "scopes_supported": []string{"mcp.read", "mcp.write"},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(cfg)
+ })
+
+ server := &http.Server{
+ Addr: ":3030",
+ Handler: mux,
+ }
+
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Printf("Mock OAuth server error: %v", err)
+ }
+}
+
+// mockVerifyJWT verifies a JWT using HMAC and extracts AuthInfo
+func mockVerifyJWT(token string) (server.AuthInfo, error) {
+ parsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return []byte(hmacSecret), nil
+ })
+ if err != nil || !parsed.Valid {
+ return server.AuthInfo{}, fmt.Errorf("invalid token: %w", err)
+ }
+
+ claims, ok := parsed.Claims.(jwt.MapClaims)
+ if !ok {
+ return server.AuthInfo{}, fmt.Errorf("invalid claims")
+ }
+
+ // Parse client_id or sub
+ var clientID string
+ if cid, _ := claims["client_id"].(string); cid != "" {
+ clientID = cid
+ }
+ if sub, _ := claims["sub"].(string); sub != "" {
+ clientID = sub
+ }
+
+ // Parse scope
+ scopeStr, _ := claims["scope"].(string)
+ var scopes []string
+ if scopeStr != "" {
+ scopes = strings.Split(scopeStr, " ")
+ }
+
+ // Parse exp
+ var expPtr *int64
+ if v, ok := claims["exp"].(float64); ok {
+ vv := int64(v)
+ expPtr = &vv
+ }
+
+ // Make sure that Extra contains sub + client_id
+ if claims["client_id"] == nil && clientID != "" {
+ claims["client_id"] = clientID
+ }
+ if claims["sub"] == nil && clientID != "" {
+ claims["sub"] = clientID
+ }
+
+ return server.AuthInfo{
+ Token: token,
+ ClientID: clientID,
+ Scopes: scopes,
+ ExpiresAt: expPtr,
+ Extra: map[string]any{
+ "client_id": clientID,
+ "sub": clientID,
+ "scope": strings.Join(scopes, " "),
+ "exp": expPtr,
+ },
+ }, nil
+}
diff --git a/go.mod b/go.mod
index 911a3fe..f1cec73 100644
--- a/go.mod
+++ b/go.mod
@@ -4,21 +4,43 @@ go 1.20
require (
github.com/getkin/kin-openapi v0.124.0
+ github.com/go-playground/validator/v10 v10.22.0
+ github.com/golang-jwt/jwt/v4 v4.5.2
+ github.com/google/uuid v1.6.0
+ github.com/lestrrat-go/jwx/v2 v2.0.21
github.com/stretchr/testify v1.10.0
github.com/yosida95/uritemplate/v3 v3.0.2
go.uber.org/zap v1.27.0
+ golang.org/x/time v0.10.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/go-openapi/jsonpointer v0.20.2 // indirect
- github.com/go-openapi/swag v0.22.8 // indirect
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+ github.com/go-openapi/jsonpointer v0.21.1 // indirect
+ github.com/go-openapi/swag v0.23.1 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/goccy/go-json v0.10.3 // indirect
github.com/invopop/yaml v0.2.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
- github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/lestrrat-go/blackmagic v1.0.2 // indirect
+ github.com/lestrrat-go/httpcc v1.0.1 // indirect
+ github.com/lestrrat-go/httprc v1.0.6 // indirect
+ github.com/lestrrat-go/iter v1.0.2 // indirect
+ github.com/lestrrat-go/option v1.0.1 // indirect
+ github.com/mailru/easyjson v0.9.0 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/segmentio/asm v1.2.0 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.10.0 // indirect
+ golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/net v0.33.0 // indirect
+ golang.org/x/sys v0.28.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index c15603c..301073b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,20 +1,71 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M=
github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM=
-github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q=
-github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs=
-github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw=
-github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI=
+github.com/getkin/kin-openapi v0.131.0 h1:NO2UeHnFKRYhZ8wg6Nyh5Cq7dHk4suQQr72a4pMrDxE=
+github.com/getkin/kin-openapi v0.131.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
+github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
+github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
+github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
+github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
+github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
+github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
+github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
+github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY=
github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
+github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
+github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
+github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
+github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
+github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
+github.com/lestrrat-go/httprc v1.0.1/go.mod h1:5Ml+nB++j6IC0e6LzefJnrpMQDKgDwDCaIQQzhbqhJM=
+github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
+github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
+github.com/lestrrat-go/httprc/v2 v2.0.0 h1:dZia9gCSXkYYZN9YUe4U3KU4rvpKXzmGB4QTYDDrOU0=
+github.com/lestrrat-go/httprc/v2 v2.0.0/go.mod h1:smhwnjMK58yn+xnN/hxtdSRW2PCi9vNTZDzB85bxj24=
+github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
+github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
+github.com/lestrrat-go/jwx/v2 v2.0.2 h1:wkq9jwCkF3xrykISzn0Eksd7NEMOZ9yvCdnEpovIJX8=
+github.com/lestrrat-go/jwx/v2 v2.0.2/go.mod h1:xV8+xRcrKbmnScV8adOzUuuTrL8aAZJoY4q2JAqIYU8=
+github.com/lestrrat-go/jwx/v2 v2.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0=
+github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM=
+github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
+github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
+github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
+github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
+github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
+github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
+github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
@@ -22,18 +73,57 @@ github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0V
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
+github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
+golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
+golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
+golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/auth/client/flow.go b/internal/auth/client/flow.go
new file mode 100644
index 0000000..72318fb
--- /dev/null
+++ b/internal/auth/client/flow.go
@@ -0,0 +1,1256 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package client
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ stderrors "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "slices"
+ "strings"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/pkce"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// AuthResult describes the outcome of an OAuth flow
+type AuthResult string
+
+const (
+ AuthResultAuthorized AuthResult = "AUTHORIZED"
+ AuthResultRedirect AuthResult = "REDIRECT"
+)
+
+// ClientAuthMethod lists supported client authentication methods for token endpoint
+type ClientAuthMethod string
+
+const (
+ ClientAuthMethodBasic ClientAuthMethod = "client_secret_basic"
+ ClientAuthMethodPost ClientAuthMethod = "client_secret_post"
+ ClientAuthMethodNone ClientAuthMethod = "none"
+)
+
+// metadataDiscoveryOptions carries optional knobs for metadata discovery behavior
+type metadataDiscoveryOptions struct {
+ ProtocolVersion *string
+ MetadataUrl *string
+ MetadataServerUrl *string
+}
+
+// RegisterClientOptions configures dynamic client registration
+type RegisterClientOptions struct {
+ Metadata auth.AuthorizationServerMetadata
+ ClientMetadata auth.OAuthClientMetadata
+ FetchFn auth.FetchFunc
+}
+
+// discoveryUrlType distinguishes between OAuth and OIDC discovery endpoints
+type discoveryUrlType string
+
+const (
+ discoveryTypeOAuth discoveryUrlType = "oauth"
+ discoveryTypeOIDC discoveryUrlType = "oidc"
+)
+
+// discoveryUrl pairs a URL with its discovery type
+type discoveryUrl struct {
+ URL *url.URL
+ Type discoveryUrlType
+}
+
+// StartAuthorizationOptions configures OAuth authorization startup
+type StartAuthorizationOptions struct {
+ // Metadata contains authorization server configuration (optional)
+ Metadata auth.AuthorizationServerMetadata
+
+ // ClientInformation holds the OAuth client credentials
+ ClientInformation auth.OAuthClientInformation
+
+ // RedirectURL specifies where to redirect after authorization
+ RedirectURL string
+
+ // Scope defines the requested access permissions (optional)
+ Scope *string
+
+ // State provides CSRF protection (optional)
+ State *string
+
+ // Resource specifies the target resource URL (optional)
+ Resource *url.URL
+}
+
+// StartAuthorizationResult holds authorization startup results
+type StartAuthorizationResult struct {
+ // AuthorizationURL is where the user should be redirected for authorization
+ AuthorizationURL *url.URL
+
+ // CodeVerifier must be stored securely for the token exchange step
+ CodeVerifier string
+}
+
+// ExchangeAuthorizationOptions configures exchanging an authorization code for tokens
+type ExchangeAuthorizationOptions struct {
+ Metadata auth.AuthorizationServerMetadata // server config (optional)
+ ClientInformation *auth.OAuthClientInformation // client credentials
+ AuthorizationCode string // auth code from server
+ CodeVerifier string // PKCE verifier
+ RedirectURI string // must match auth request
+ Resource *url.URL // target resource (optional)
+ AddClientAuthentication func(http.Header, url.Values, string) error // custom auth (optional)
+ FetchFn auth.FetchFunc // custom HTTP client (optional)
+}
+
+// RefreshAuthorizationOptions configures exchanging a refresh token for new tokens
+type RefreshAuthorizationOptions struct {
+ Metadata auth.AuthorizationServerMetadata // server config (optional)
+ ClientInformation *auth.OAuthClientInformation // client credentials
+ RefreshToken string // refresh token
+ Resource *url.URL // target resource (optional)
+ AddClientAuthentication func(http.Header, url.Values, string) error // custom auth (optional)
+ FetchFn auth.FetchFunc // custom HTTP client (optional)
+}
+
+// UnauthorizedError represents an authorization failure that should be surfaced to callers
+type UnauthorizedError struct {
+ message string
+}
+
+// NewUnauthorizedError constructs an UnauthorizedError with a friendly message
+func NewUnauthorizedError(message string) *UnauthorizedError {
+ if message == "" {
+ message = "Unauthorized"
+ }
+ return &UnauthorizedError{message: message}
+}
+
+// Error returns the error message for UnauthorizedError
+func (e *UnauthorizedError) Error() string {
+ return e.message
+}
+
+// selectClientAuthMethod chooses a client auth method based on server support and client secrets
+func selectClientAuthMethod(
+ clientInformation auth.OAuthClientInformation,
+ supportedMethods []string,
+) ClientAuthMethod {
+ var hasClientSecret bool
+ hasClientSecret = clientInformation.ClientSecret != ""
+ if len(supportedMethods) == 0 {
+ if hasClientSecret {
+ return ClientAuthMethodPost
+ } else {
+ return ClientAuthMethodNone
+ }
+ }
+
+ if hasClientSecret && slices.Contains(supportedMethods, string(ClientAuthMethodBasic)) {
+ return ClientAuthMethodBasic
+ }
+ if hasClientSecret && slices.Contains(supportedMethods, string(ClientAuthMethodPost)) {
+ return ClientAuthMethodPost
+ }
+ if slices.Contains(supportedMethods, string(ClientAuthMethodNone)) {
+ return ClientAuthMethodNone
+ }
+ if hasClientSecret {
+ return ClientAuthMethodPost
+ } else {
+ return ClientAuthMethodNone
+ }
+}
+
+// applyClientAuthentication applies the chosen client auth to headers and or form parameters
+func applyClientAuthentication(
+ method ClientAuthMethod,
+ clientInformation auth.OAuthClientInformation,
+ headers http.Header,
+ params url.Values,
+) error {
+ clientID := clientInformation.ClientID
+ clientSecret := clientInformation.ClientSecret
+
+ switch method {
+ case ClientAuthMethodBasic:
+ return applyBasicAuth(clientID, clientSecret, headers)
+ case ClientAuthMethodPost:
+ applyPostAuth(clientID, clientSecret, params)
+ return nil
+ case ClientAuthMethodNone:
+ applyPublicAuth(clientID, params)
+ return nil
+ default:
+ return fmt.Errorf("unsupported client authentication method: %s", method)
+ }
+}
+
+// applyBasicAuth adds HTTP Basic Authorization using client id and secret
+func applyBasicAuth(clientID, clientSecret string, headers http.Header) error {
+ if clientSecret == "" {
+ return fmt.Errorf("client_secret_basic authentication requires a client_secret")
+ }
+
+ credentials := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
+ headers.Set("Authorization", "Basic "+credentials)
+ return nil
+}
+
+// applyPostAuth writes client credentials into the token form payload
+func applyPostAuth(clientID, clientSecret string, params url.Values) {
+ params.Set("client_id", clientID)
+ if clientSecret != "" {
+ params.Set("client_secret", clientSecret)
+ }
+}
+
+// applyPublicAuth writes public client id into the token form payload
+func applyPublicAuth(clientID string, params url.Values) {
+ params.Set("client_id", clientID)
+}
+
+// parseErrorResponse converts an OAuth style JSON error payload into an OAuthError
+func parseErrorResponse(input interface{}) (*errors.OAuthError, error) {
+ var responseBody []byte
+ var err error
+
+ // Handle different input types
+ switch v := input.(type) {
+ case []byte:
+ responseBody = v
+ case string:
+ responseBody = []byte(v)
+ case *http.Response:
+ defer v.Body.Close()
+ responseBody, err = io.ReadAll(v.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ default:
+ return nil, fmt.Errorf("unsupported input type: %T", input)
+ }
+
+ // Try to parse as OAuth error response
+ var oauthErrorResp errors.OAuthErrorResponse
+ if err := json.Unmarshal(responseBody, &oauthErrorResp); err != nil {
+ // Not a valid OAuth error response format
+ return nil, fmt.Errorf("failed to parse OAuth error response: %w", err)
+ }
+
+ // Validate required error field
+ if oauthErrorResp.Error == "" {
+ return nil, fmt.Errorf("invalid OAuth error response: missing error field")
+ }
+
+ // Map error code to OAuthErrorCode using the mapping table
+ errorCode, exists := errors.OAuthErrorMapping[oauthErrorResp.Error]
+ if !exists {
+ // Unknown error code, default to server error
+ errorCode = errors.ErrServerError
+ }
+
+ // Create OAuthError with parsed information
+ oauthError := errors.NewOAuthError(errorCode, oauthErrorResp.ErrorDescription, oauthErrorResp.ErrorURI)
+ return &oauthError, nil
+}
+
+// Auth performs high level authentication with retries and credential invalidation on specific errors
+func Auth(provider OAuthClientProvider, options auth.AuthOptions) (*AuthResult, error) {
+ result, err := authInternal(provider, options)
+ if err != nil {
+ if stderrors.Is(err, errors.ErrInvalidClient) || stderrors.Is(err, errors.ErrUnauthorizedClient) {
+ if invalidator, ok := provider.(OAuthCredentialInvalidator); ok {
+ if invalidateErr := invalidator.InvalidateCredentials("all"); invalidateErr != nil {
+ return nil, invalidateErr
+ }
+ }
+ return authInternal(provider, options)
+ } else if stderrors.Is(err, errors.ErrInvalidGrant) {
+ if invalidator, ok := provider.(OAuthCredentialInvalidator); ok {
+ if invalidateErr := invalidator.InvalidateCredentials("tokens"); invalidateErr != nil {
+ return nil, invalidateErr
+ }
+ }
+ return authInternal(provider, options)
+ }
+ return nil, err
+ }
+ return result, err
+}
+
+// authInternal runs the core auth logic including discovery registration refresh and redirect setup
+func authInternal(provider OAuthClientProvider, options auth.AuthOptions) (*AuthResult, error) {
+ var resourceMetadata *auth.OAuthProtectedResourceMetadata
+ var authorizationServerUrl string
+ metadata, err := DiscoverOAuthProtectedResourceMetadata(options.ServerUrl, &auth.DiscoveryOptions{
+ ResourceMetadataUrl: options.ResourceMetadataUrl,
+ }, options.FetchFn)
+ if err == nil {
+ resourceMetadata = metadata
+ if len(resourceMetadata.AuthorizationServers) > 0 {
+ authorizationServerUrl = resourceMetadata.AuthorizationServers[0]
+ }
+ }
+ if authorizationServerUrl == "" {
+ authorizationServerUrl = options.ServerUrl
+ }
+
+ resource, err := selectResourceURL(options.ServerUrl, provider, resourceMetadata)
+ if err != nil {
+ return nil, fmt.Errorf("failed to select resource URL: %w", err)
+ }
+
+ serverMetadata, err := DiscoverAuthorizationServerMetadata(context.Background(), authorizationServerUrl, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to discover authorization server metadata: %w", err)
+ }
+ clientInformation := provider.ClientInformation()
+
+ if clientInformation == nil {
+ if options.AuthorizationCode != nil {
+ return nil, stderrors.New("existing OAuth client information is required when exchanging an authorization code")
+ }
+
+ if _, ok := provider.(OAuthClientInfoProvider); !ok {
+ return nil, stderrors.New("OAuth client information must be saveable for dynamic registration")
+ }
+
+ fullInformation, err := RegisterClient(context.Background(), authorizationServerUrl, RegisterClientOptions{
+ Metadata: serverMetadata,
+ ClientMetadata: provider.ClientMetadata(),
+ FetchFn: options.FetchFn,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to register client: %w", err)
+ }
+
+ if clientInfoProvider, ok := provider.(OAuthClientInfoProvider); ok {
+ if err := clientInfoProvider.SaveClientInformation(*fullInformation); err != nil {
+ return nil, fmt.Errorf("failed to save client information: %w", err)
+ }
+ }
+ clientInformation = &auth.OAuthClientInformation{
+ ClientID: fullInformation.ClientID,
+ ClientSecret: fullInformation.ClientSecret,
+ }
+ }
+ tokens, err := provider.Tokens()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get tokens: %w", err)
+ }
+
+ if tokens != nil && tokens.RefreshToken != nil && *tokens.RefreshToken != "" {
+ var addClientAuth func(http.Header, url.Values, string) error
+ if authProvider, ok := provider.(OAuthClientAuthProvider); ok {
+ addClientAuth = authProvider.AddClientAuthentication
+ }
+
+ newTokens, err := RefreshAuthorization(authorizationServerUrl, RefreshAuthorizationOptions{
+ Metadata: serverMetadata,
+ ClientInformation: clientInformation,
+ RefreshToken: *tokens.RefreshToken,
+ Resource: resource,
+ AddClientAuthentication: addClientAuth,
+ FetchFn: options.FetchFn,
+ })
+ if err != nil {
+ var oauthErr *errors.OAuthError
+ if !stderrors.As(err, &oauthErr) {
+ // Network/non-OAuth errors, continue auth flow
+ } else {
+ return nil, err
+ }
+ } else {
+ if err := provider.SaveTokens(*newTokens); err != nil {
+ return nil, fmt.Errorf("failed to save refreshed tokens: %w", err)
+ }
+ result := AuthResultAuthorized
+ return &result, nil
+ }
+ }
+ var state *string
+ if stateProvider, ok := provider.(OAuthStateProvider); ok {
+ stateValue, err := stateProvider.State()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get state: %w", err)
+ }
+ state = &stateValue
+ }
+ scope := options.Scope
+ if scope == nil {
+ clientMetadata := provider.ClientMetadata()
+ if clientMetadata.Scope != nil {
+ scope = clientMetadata.Scope
+ }
+ }
+
+ authorizationResult, err := StartAuthorization(authorizationServerUrl, StartAuthorizationOptions{
+ Metadata: serverMetadata,
+ ClientInformation: *clientInformation,
+ State: state,
+ RedirectURL: provider.RedirectURL(),
+ Scope: scope,
+ Resource: resource,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to start authorization: %w", err)
+ }
+
+ if err := provider.SaveCodeVerifier(authorizationResult.CodeVerifier); err != nil {
+ return nil, fmt.Errorf("failed to save code verifier: %w", err)
+ }
+
+ if err := provider.RedirectToAuthorization(authorizationResult.AuthorizationURL); err != nil {
+ return nil, fmt.Errorf("failed to redirect to authorization: %w", err)
+ }
+
+ result := AuthResultRedirect
+ return &result, nil
+}
+
+// selectResourceURL determines the resource parameter to use validating against protected resource metadata
+func selectResourceURL(serverUrl string, provider OAuthClientProvider, resourceMetadata *auth.OAuthProtectedResourceMetadata) (*url.URL, error) {
+ defaultResource, err := auth.ResourceURLFromServerURL(serverUrl)
+ if err != nil {
+ return nil, err
+ }
+
+ // Use custom validator if available
+ if validator, ok := provider.(OAuthResourceValidator); ok {
+ return validator.ValidateResourceURL(defaultResource, resourceMetadata)
+ }
+
+ // Include resource param only when metadata exists
+ if resourceMetadata == nil {
+ return nil, nil // No resource param needed
+ }
+
+ // Check metadata resource compatibility
+ allowed, err := auth.CheckResourceAllowed(auth.CheckResourceAllowedParams{
+ RequestedResource: defaultResource,
+ ConfiguredResource: resourceMetadata.Resource,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to validate resource: %w", err)
+ }
+ if !allowed {
+ return nil, fmt.Errorf("protected resource %s does not match expected %s",
+ resourceMetadata.Resource, defaultResource.String())
+ }
+
+ // Use metadata resource - server expects this
+ return url.Parse(resourceMetadata.Resource)
+}
+
+// DiscoverOAuthProtectedResourceMetadata loads OAuth Protected Resource metadata with path aware fallback
+func DiscoverOAuthProtectedResourceMetadata(serverUrl string, opts *auth.DiscoveryOptions, fetchFn auth.FetchFunc) (*auth.OAuthProtectedResourceMetadata, error) {
+ if fetchFn == nil {
+ fetchFn = func(urlStr string, req *http.Request) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+ }
+ }
+
+ response, err := discoverMetadataWithFallback(
+ serverUrl,
+ "oauth-protected-resource",
+ fetchFn,
+ &metadataDiscoveryOptions{
+ ProtocolVersion: getProtocolVersion(opts),
+ MetadataUrl: getResourceMetadataUrl(opts),
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if response == nil || response.StatusCode == 404 {
+ return nil, fmt.Errorf("Resource server does not implement OAuth 2.0 Protected Resource Metadata.")
+ }
+
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return nil, fmt.Errorf("HTTP %d trying to load well-known OAuth protected resource metadata.", response.StatusCode)
+ }
+
+ defer response.Body.Close()
+ var metadata auth.OAuthProtectedResourceMetadata
+ if err := json.NewDecoder(response.Body).Decode(&metadata); err != nil {
+ return nil, fmt.Errorf("failed to parse metadata response: %w", err)
+ }
+
+ return &metadata, nil
+}
+
+// discoverMetadataWithFallback tries path aware discovery then falls back to root well known when applicable
+func discoverMetadataWithFallback(
+ serverUrl interface{},
+ wellKnownType string, // "oauth-authorization-server" or "oauth-protected-resource"
+ fetchFn auth.FetchFunc,
+ opts *metadataDiscoveryOptions,
+) (*http.Response, error) {
+ issuer, err := parseURL(serverUrl)
+ if err != nil {
+ return nil, fmt.Errorf("invalid server URL: %w", err)
+ }
+
+ protocolVersion := "2025-03-26" // LATEST_PROTOCOL_VERSION
+ if opts != nil && opts.ProtocolVersion != nil {
+ protocolVersion = *opts.ProtocolVersion
+ }
+
+ var targetUrl *url.URL
+ if opts != nil && opts.MetadataUrl != nil {
+ targetUrl, err = url.Parse(*opts.MetadataUrl)
+ if err != nil {
+ return nil, fmt.Errorf("invalid metadata URL: %w", err)
+ }
+ } else {
+ // Try path-aware discovery
+ wellKnownPath := buildWellKnownPath(wellKnownType, issuer.Path)
+ baseUrl := issuer
+ if opts != nil && opts.MetadataServerUrl != nil {
+ baseUrl, err = url.Parse(*opts.MetadataServerUrl)
+ if err != nil {
+ return nil, fmt.Errorf("invalid metadata server URL: %w", err)
+ }
+ }
+ targetUrl, _ = url.Parse(wellKnownPath)
+ targetUrl = baseUrl.ResolveReference(targetUrl)
+ targetUrl.RawQuery = issuer.RawQuery
+ }
+
+ response, err := tryMetadataDiscovery(targetUrl, protocolVersion, fetchFn)
+ if err != nil {
+ return nil, err
+ }
+
+ // If path-aware discovery fails with 404 and we're not at root, try fallback to root discovery
+ if (opts == nil || opts.MetadataUrl == nil) && shouldAttemptFallback(response, issuer.Path) {
+ rootUrl, _ := url.Parse(fmt.Sprintf("/.well-known/%s", wellKnownType))
+ rootUrl = issuer.ResolveReference(rootUrl)
+ response, err = tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return response, nil
+}
+
+// tryMetadataDiscovery issues a discovery request with protocol headers and returns the HTTP response
+func tryMetadataDiscovery(targetUrl *url.URL, protocolVersion string, fetchFn auth.FetchFunc) (*http.Response, error) {
+ req, err := http.NewRequest("GET", targetUrl.String(), nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("MCP-Protocol-Version", protocolVersion)
+
+ return fetchWithCorsRetry(targetUrl, req.Header, fetchFn)
+}
+
+// fetchWithCorsRetry helper function to handle CORS retry logic
+func fetchWithCorsRetry(targetUrl *url.URL, headers http.Header, fetchFn auth.FetchFunc) (*http.Response, error) {
+ req, err := http.NewRequest("GET", targetUrl.String(), nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Copy headers
+ for key, values := range headers {
+ for _, value := range values {
+ req.Header.Add(key, value)
+ }
+ }
+
+ response, err := fetchFn(targetUrl.String(), req)
+ if err != nil {
+ // If it's a network error (similar to TypeError in TypeScript), try retry without headers
+ if isNetworkError(err) && len(headers) > 0 {
+ return fetchWithCorsRetry(targetUrl, http.Header{}, fetchFn)
+ }
+ return nil, err
+ }
+
+ return response, nil
+}
+func shouldAttemptFallback(response *http.Response, pathname string) bool {
+ return response == nil || (response.StatusCode == 404 && pathname != "/")
+}
+
+// buildWellKnownPath builds well-known path for authentication-related metadata discovery
+func buildWellKnownPath(wellKnownPrefix, pathname string) string {
+ // Remove trailing slash from pathname to avoid double slashes
+ if strings.HasSuffix(pathname, "/") {
+ pathname = strings.TrimSuffix(pathname, "/")
+ }
+
+ return fmt.Sprintf("/.well-known/%s%s", wellKnownPrefix, pathname)
+}
+
+// parseURL accepts string or *url.URL and returns a parsed URL
+func parseURL(u interface{}) (*url.URL, error) {
+ switch v := u.(type) {
+ case string:
+ return url.Parse(v)
+ case *url.URL:
+ return v, nil
+ default:
+ return nil, fmt.Errorf("unsupported URL type")
+ }
+}
+
+// getProtocolVersion resolves the requested protocol version from options if provided
+func getProtocolVersion(opts *auth.DiscoveryOptions) *string {
+ if opts != nil && opts.ProtocolVersion != nil {
+ return opts.ProtocolVersion
+ }
+ return nil
+}
+
+// getResourceMetadataUrl resolves an explicit resource metadata URL from options if provided
+func getResourceMetadataUrl(opts *auth.DiscoveryOptions) *string {
+ if opts != nil && opts.ResourceMetadataUrl != nil {
+ return opts.ResourceMetadataUrl
+ }
+ return nil
+}
+
+// isNetworkError determines if it's a network error (simulating TypeError check in TypeScript)
+func isNetworkError(err error) bool {
+ // In Go, network errors usually contain these keywords
+ errorStr := strings.ToLower(err.Error())
+ return strings.Contains(errorStr, "network") ||
+ strings.Contains(errorStr, "connection") ||
+ strings.Contains(errorStr, "timeout") ||
+ strings.Contains(errorStr, "refused")
+}
+
+// buildDiscoveryUrls generates candidate OAuth and OIDC discovery URLs for a given authorization server URL
+func buildDiscoveryUrls(authorizationServerURL string) ([]discoveryUrl, error) {
+ parsedURL, err := url.Parse(authorizationServerURL)
+ if err != nil {
+ return nil, fmt.Errorf("invalid authorization server URL: %w", err)
+ }
+
+ hasPath := parsedURL.Path != "/" && parsedURL.Path != ""
+ var urlsToTry []discoveryUrl
+
+ if !hasPath {
+ // Root path: https://example.com/.well-known/oauth-authorization-server
+ oauthURL, _ := url.Parse(parsedURL.Scheme + "://" + parsedURL.Host + "/.well-known/oauth-authorization-server")
+ urlsToTry = append(urlsToTry, discoveryUrl{URL: oauthURL, Type: discoveryTypeOAuth})
+
+ // OIDC: https://example.com/.well-known/openid-configuration
+ oidcURL, _ := url.Parse(parsedURL.Scheme + "://" + parsedURL.Host + "/.well-known/openid-configuration")
+ urlsToTry = append(urlsToTry, discoveryUrl{URL: oidcURL, Type: discoveryTypeOIDC})
+
+ return urlsToTry, nil
+ }
+
+ // Strip trailing slash from pathname to avoid double slashes
+ pathname := parsedURL.Path
+ if strings.HasSuffix(pathname, "/") {
+ pathname = pathname[:len(pathname)-1]
+ }
+
+ // 1. OAuth metadata at the given URL
+ // Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1
+ oauthWithPath, _ := url.Parse(parsedURL.Scheme + "://" + parsedURL.Host + "/.well-known/oauth-authorization-server" + pathname)
+ urlsToTry = append(urlsToTry, discoveryUrl{URL: oauthWithPath, Type: discoveryTypeOAuth})
+
+ // Root path: https://example.com/.well-known/oauth-authorization-server
+ oauthRoot, _ := url.Parse(parsedURL.Scheme + "://" + parsedURL.Host + "/.well-known/oauth-authorization-server")
+ urlsToTry = append(urlsToTry, discoveryUrl{URL: oauthRoot, Type: discoveryTypeOAuth})
+
+ // 3. OIDC metadata endpoints
+ // RFC 8414 style: Insert /.well-known/openid-configuration before the path
+ oidcWithPath, _ := url.Parse(parsedURL.Scheme + "://" + parsedURL.Host + "/.well-known/openid-configuration" + pathname)
+ urlsToTry = append(urlsToTry, discoveryUrl{URL: oidcWithPath, Type: discoveryTypeOIDC})
+
+ // OIDC Discovery 1.0 style: Append /.well-known/openid-configuration after the path
+ oidcAfterPath, _ := url.Parse(parsedURL.Scheme + "://" + parsedURL.Host + pathname + "/.well-known/openid-configuration")
+ urlsToTry = append(urlsToTry, discoveryUrl{URL: oidcAfterPath, Type: discoveryTypeOIDC})
+
+ return urlsToTry, nil
+}
+
+// DiscoveryURL represents a metadata endpoint
+type DiscoveryURL struct {
+ URL *url.URL
+ Type string // "oauth" or "oidc"
+}
+
+// DiscoverAuthorizationServerMetadata discovers OAuth or OIDC metadata and verifies minimal capabilities
+func DiscoverAuthorizationServerMetadata(ctx context.Context, authServerUrl string, options *auth.DiscoveryOptions) (auth.AuthorizationServerMetadata, error) {
+ // Build discovery URLs
+ discoveryUrls, err := buildDiscoveryUrls(authServerUrl)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create default fetch function
+ fetchFunc := func(urlStr string, req *http.Request) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+ }
+
+ // Try each discovery URL
+ for _, discoveryUrl := range discoveryUrls {
+ // Create headers
+ headers := http.Header{
+ "Accept": []string{"application/json"},
+ }
+
+ // Try to fetch metadata
+ resp, err := fetchWithCorsRetry(discoveryUrl.URL, headers, fetchFunc)
+ if err != nil {
+ if isNetworkError(err) {
+ continue
+ }
+ return nil, fmt.Errorf("failed to fetch metadata from %s: %w", discoveryUrl.URL.String(), err)
+ }
+ defer resp.Body.Close()
+
+ // Check status code
+ if resp.StatusCode == 404 {
+ continue
+ }
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, discoveryUrl.URL.String())
+ }
+
+ // Read response body
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Parse metadata based on type
+ if discoveryUrl.Type == "oidc" {
+ // Try to parse as OpenID Connect metadata
+ var metadata auth.OpenIdProviderDiscoveryMetadata
+ if err := json.Unmarshal(body, &metadata); err != nil {
+ continue // Try next URL
+ }
+
+ // Validate required fields for OIDC
+ if metadata.Issuer == "" || metadata.AuthorizationEndpoint == "" || metadata.TokenEndpoint == "" {
+ continue // Try next URL
+ }
+
+ // Check if S256 PKCE is supported for OIDC
+ supportsS256 := false
+ for _, method := range metadata.CodeChallengeMethodsSupported {
+ if method == "S256" {
+ supportsS256 = true
+ break
+ }
+ }
+ if !supportsS256 {
+ return nil, fmt.Errorf("OIDC provider does not support S256 PKCE")
+ }
+
+ return &metadata, nil
+ } else {
+ // Try to parse as OAuth 2.0 metadata
+ var metadata auth.OAuthMetadata
+ if err := json.Unmarshal(body, &metadata); err != nil {
+ continue // Try next URL
+ }
+
+ // Validate required fields for OAuth 2.0
+ if metadata.Issuer == "" || metadata.AuthorizationEndpoint == "" || metadata.TokenEndpoint == "" {
+ continue // Try next URL
+ }
+
+ return &metadata, nil
+ }
+ }
+
+ return nil, fmt.Errorf("failed to discover authorization server metadata from %s", authServerUrl)
+}
+
+// RegisterClient performs dynamic client registration and returns full client information
+func RegisterClient(
+ ctx context.Context,
+ authorizationServerUrl string,
+ options RegisterClientOptions,
+) (*auth.OAuthClientInformationFull, error) {
+ var registrationUrl *url.URL
+ var err error
+
+ // Determine registration endpoint URL
+ if options.Metadata != nil {
+ // Check if dynamic client registration is supported
+ var registrationEndpoint string
+
+ // Get registration endpoint based on metadata type
+ switch metadata := options.Metadata.(type) {
+ case *auth.OAuthMetadata:
+ if metadata.RegistrationEndpoint == nil {
+ return nil, fmt.Errorf("incompatible auth server: does not support dynamic client registration")
+ }
+ registrationEndpoint = *metadata.RegistrationEndpoint
+ case *auth.OpenIdProviderMetadata:
+ if metadata.RegistrationEndpoint == nil {
+ return nil, fmt.Errorf("incompatible auth server: does not support dynamic client registration")
+ }
+ registrationEndpoint = *metadata.RegistrationEndpoint
+ case *auth.OpenIdProviderDiscoveryMetadata:
+ if metadata.RegistrationEndpoint == nil {
+ return nil, fmt.Errorf("incompatible auth server: does not support dynamic client registration")
+ }
+ registrationEndpoint = *metadata.RegistrationEndpoint
+ default:
+ return nil, fmt.Errorf("unsupported metadata type")
+ }
+
+ registrationUrl, err = url.Parse(registrationEndpoint)
+ if err != nil {
+ return nil, fmt.Errorf("invalid registration endpoint URL: %w", err)
+ }
+ } else {
+ // Use default registration path
+ baseUrl, err := url.Parse(authorizationServerUrl)
+ if err != nil {
+ return nil, fmt.Errorf("invalid authorization server URL: %w", err)
+ }
+ registrationUrl, err = baseUrl.Parse("/register")
+ if err != nil {
+ return nil, fmt.Errorf("failed to construct registration URL: %w", err)
+ }
+ }
+
+ // Serialize client metadata
+ requestBody, err := json.Marshal(options.ClientMetadata)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal client metadata: %w", err)
+ }
+
+ // Create HTTP request
+ req, err := http.NewRequestWithContext(ctx, "POST", registrationUrl.String(), strings.NewReader(string(requestBody)))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+
+ // Select fetch function
+ fetchFn := options.FetchFn
+ if fetchFn == nil {
+ fetchFn = func(url string, req *http.Request) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+ }
+ }
+
+ // Send request
+ resp, err := fetchFn(registrationUrl.String(), req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send registration request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ // Read response body
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Check response status
+ if !isSuccessStatusCode(resp.StatusCode) {
+ // Try to parse OAuth error response
+ var oauthError errors.OAuthError
+ if err := json.Unmarshal(responseBody, &oauthError); err == nil {
+ return nil, &oauthError
+ }
+ return nil, fmt.Errorf("registration failed with status %d: %s", resp.StatusCode, string(responseBody))
+ }
+
+ // Parse success response
+ var clientInfo auth.OAuthClientInformationFull
+ if err := json.Unmarshal(responseBody, &clientInfo); err != nil {
+ return nil, fmt.Errorf("failed to parse registration response: %w", err)
+ }
+
+ return &clientInfo, nil
+}
+
+// isSuccessStatusCode checks if HTTP status code indicates success
+func isSuccessStatusCode(statusCode int) bool {
+ return statusCode >= 200 && statusCode < 300
+}
+
+// StartAuthorization starts OAuth 2.0 authorization flow
+func StartAuthorization(
+ authorizationServerUrl string,
+ options StartAuthorizationOptions,
+) (*StartAuthorizationResult, error) {
+ const responseType = "code"
+ const codeChallengeMethod = "S256"
+
+ var authorizationURL *url.URL
+ var err error
+
+ // Determine authorization endpoint URL
+ if options.Metadata != nil {
+ authorizationURL, err = url.Parse(options.Metadata.GetAuthorizationEndpoint())
+ if err != nil {
+ return nil, fmt.Errorf("invalid authorization endpoint: %w", err)
+ }
+
+ // Verify server supports "code" response type
+ responseTypesSupported := options.Metadata.GetResponseTypesSupported()
+ supportsCode := false
+ for _, rt := range responseTypesSupported {
+ if rt == responseType {
+ supportsCode = true
+ break
+ }
+ }
+ if !supportsCode {
+ return nil, fmt.Errorf(
+ "incompatible auth server: does not support response type %s",
+ responseType,
+ )
+ }
+
+ // Verify server supports S256 PKCE method
+ var codeChallengeMethodsSupported []string
+
+ // Check different types of metadata
+ switch metadata := options.Metadata.(type) {
+ case *auth.OAuthMetadata:
+ codeChallengeMethodsSupported = metadata.CodeChallengeMethodsSupported
+ case *auth.OpenIdProviderDiscoveryMetadata:
+ codeChallengeMethodsSupported = metadata.CodeChallengeMethodsSupported
+ }
+
+ if len(codeChallengeMethodsSupported) > 0 {
+ supportsS256 := false
+ for _, method := range codeChallengeMethodsSupported {
+ if method == codeChallengeMethod {
+ supportsS256 = true
+ break
+ }
+ }
+ if !supportsS256 {
+ return nil, fmt.Errorf(
+ "incompatible auth server: does not support code challenge method %s",
+ codeChallengeMethod,
+ )
+ }
+ }
+ } else {
+ // If no metadata, use default /authorize endpoint
+ baseURL, err := url.Parse(authorizationServerUrl)
+ if err != nil {
+ return nil, fmt.Errorf("invalid authorization server URL: %w", err)
+ }
+ authorizationURL = baseURL.ResolveReference(&url.URL{Path: "/authorize"})
+ }
+
+ // Generate PKCE challenge
+ challenge, err := pkce.GeneratePKCEChallenge()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate PKCE challenge: %w", err)
+ }
+
+ // Build query parameters
+ params := url.Values{}
+ params.Set("response_type", responseType)
+ params.Set("client_id", options.ClientInformation.ClientID)
+ params.Set("redirect_uri", options.RedirectURL)
+ params.Set("code_challenge", challenge.CodeChallenge)
+ params.Set("code_challenge_method", codeChallengeMethod)
+
+ // Add optional parameters
+ if options.Scope != nil && *options.Scope != "" {
+ params.Set("scope", *options.Scope)
+
+ // OpenID Connect requirement: if scope contains 'offline_access', need to add consent prompt
+ if strings.Contains(*options.Scope, "offline_access") {
+ params.Set("prompt", "consent")
+ }
+ }
+
+ if options.State != nil && *options.State != "" {
+ params.Set("state", *options.State)
+ }
+
+ if options.Resource != nil {
+ params.Set("resource", options.Resource.String())
+ }
+
+ // Set query parameters
+ authorizationURL.RawQuery = params.Encode()
+
+ return &StartAuthorizationResult{
+ AuthorizationURL: authorizationURL,
+ CodeVerifier: challenge.CodeVerifier,
+ }, nil
+}
+
+// ExchangeAuthorization exchanges an authorization code for tokens applying appropriate client authentication
+func ExchangeAuthorization(
+ authorizationServerUrl string,
+ options ExchangeAuthorizationOptions,
+) (*auth.OAuthTokens, error) {
+ const grantType = "authorization_code"
+
+ // Determine token endpoint URL
+ var tokenURL *url.URL
+ var err error
+
+ if options.Metadata != nil {
+ tokenEndpoint := options.Metadata.GetTokenEndpoint()
+ if tokenEndpoint == "" {
+ return nil, fmt.Errorf("token endpoint not found in metadata")
+ }
+ tokenURL, err = url.Parse(tokenEndpoint)
+ if err != nil {
+ return nil, fmt.Errorf("invalid token endpoint: %w", err)
+ }
+
+ // Verify server supports authorization_code grant type
+ grantTypesSupported := options.Metadata.GetGrantTypesSupported()
+ if len(grantTypesSupported) > 0 {
+ supportsAuthCode := false
+ for _, gt := range grantTypesSupported {
+ if gt == grantType {
+ supportsAuthCode = true
+ break
+ }
+ }
+ if !supportsAuthCode {
+ return nil, fmt.Errorf(
+ "incompatible auth server: does not support grant type %s",
+ grantType,
+ )
+ }
+ }
+ } else {
+ // Use default /token endpoint
+ baseURL, err := url.Parse(authorizationServerUrl)
+ if err != nil {
+ return nil, fmt.Errorf("invalid authorization server URL: %w", err)
+ }
+ tokenURL = baseURL.ResolveReference(&url.URL{Path: "/token"})
+ }
+
+ // Prepare request headers and parameters
+ headers := http.Header{
+ "Content-Type": []string{"application/x-www-form-urlencoded"},
+ }
+ params := url.Values{
+ "grant_type": []string{grantType},
+ "code": []string{options.AuthorizationCode},
+ "redirect_uri": []string{options.RedirectURI},
+ "code_verifier": []string{options.CodeVerifier},
+ }
+
+ // Apply client authentication
+ if options.AddClientAuthentication != nil {
+ if err := options.AddClientAuthentication(headers, params, authorizationServerUrl); err != nil {
+ return nil, fmt.Errorf("failed to apply client authentication: %w", err)
+ }
+ } else {
+ // Determine and apply client authentication method
+ var supportedMethods []string
+ if options.Metadata != nil {
+ supportedMethods = options.Metadata.GetTokenEndpointAuthMethodsSupported()
+
+ }
+ authMethod := selectClientAuthMethod(*options.ClientInformation, supportedMethods)
+ if err := applyClientAuthentication(authMethod, *options.ClientInformation, headers, params); err != nil {
+ return nil, fmt.Errorf("failed to apply client authentication: %w", err)
+ }
+ }
+
+ // Add resource parameter (if provided)
+ if options.Resource != nil {
+ params.Set("resource", options.Resource.String())
+ }
+
+ // Create HTTP request
+ req, err := http.NewRequest("POST", tokenURL.String(), strings.NewReader(params.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header = headers
+
+ // Select fetch function
+ fetchFn := options.FetchFn
+ if fetchFn == nil {
+ fetchFn = func(url string, req *http.Request) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+ }
+ }
+
+ // Send request
+ resp, err := fetchFn(tokenURL.String(), req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send token request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ // Read response body
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Check response status
+ if !isSuccessStatusCode(resp.StatusCode) {
+ // Try to parse OAuth error response
+ var oauthError errors.OAuthError
+ if err := json.Unmarshal(responseBody, &oauthError); err == nil {
+ return nil, &oauthError
+ }
+ return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(responseBody))
+ }
+
+ // Parse success response
+ var tokens auth.OAuthTokens
+ if err := json.Unmarshal(responseBody, &tokens); err != nil {
+ return nil, fmt.Errorf("failed to parse token response: %w", err)
+ }
+
+ return &tokens, nil
+}
+
+// RefreshAuthorization exchanges a refresh token for a new access token and propagates refresh token when absent
+func RefreshAuthorization(
+ authorizationServerUrl string,
+ options RefreshAuthorizationOptions,
+) (*auth.OAuthTokens, error) {
+ const grantType = "refresh_token"
+
+ // Determine token endpoint URL
+ var tokenURL *url.URL
+ var err error
+
+ if options.Metadata != nil {
+ tokenEndpoint := options.Metadata.GetTokenEndpoint()
+ if tokenEndpoint == "" {
+ return nil, fmt.Errorf("token endpoint not found in metadata")
+ }
+ tokenURL, err = url.Parse(tokenEndpoint)
+ if err != nil {
+ return nil, fmt.Errorf("invalid token endpoint: %w", err)
+ }
+
+ // Verify server supports refresh_token grant type
+ grantTypesSupported := options.Metadata.GetGrantTypesSupported()
+ if len(grantTypesSupported) > 0 {
+ supportsRefreshToken := false
+ for _, gt := range grantTypesSupported {
+ if gt == grantType {
+ supportsRefreshToken = true
+ break
+ }
+ }
+ if !supportsRefreshToken {
+ return nil, fmt.Errorf(
+ "incompatible auth server: does not support grant type %s",
+ grantType,
+ )
+ }
+ }
+ } else {
+ // Use default /token endpoint
+ baseURL, err := url.Parse(authorizationServerUrl)
+ if err != nil {
+ return nil, fmt.Errorf("invalid authorization server URL: %w", err)
+ }
+ tokenURL = baseURL.ResolveReference(&url.URL{Path: "/token"})
+ }
+
+ // Prepare request headers and parameters
+ headers := http.Header{
+ "Content-Type": []string{"application/x-www-form-urlencoded"},
+ }
+ params := url.Values{
+ "grant_type": []string{grantType},
+ "refresh_token": []string{options.RefreshToken},
+ }
+
+ // Apply client authentication
+ if options.AddClientAuthentication != nil {
+ if err := options.AddClientAuthentication(headers, params, authorizationServerUrl); err != nil {
+ return nil, fmt.Errorf("failed to apply client authentication: %w", err)
+ }
+ } else {
+ // Determine and apply client authentication method
+ var supportedMethods []string
+ if options.Metadata != nil {
+ supportedMethods = options.Metadata.GetTokenEndpointAuthMethodsSupported()
+ }
+ authMethod := selectClientAuthMethod(*options.ClientInformation, supportedMethods)
+ if err := applyClientAuthentication(authMethod, *options.ClientInformation, headers, params); err != nil {
+ return nil, fmt.Errorf("failed to apply client authentication: %w", err)
+ }
+ }
+
+ // Add resource parameter (if provided)
+ if options.Resource != nil {
+ params.Set("resource", options.Resource.String())
+ }
+
+ // Create HTTP request
+ req, err := http.NewRequest("POST", tokenURL.String(), strings.NewReader(params.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header = headers
+
+ // Select fetch function
+ fetchFn := options.FetchFn
+ if fetchFn == nil {
+ fetchFn = func(url string, req *http.Request) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+ }
+ }
+
+ // Send request
+ resp, err := fetchFn(tokenURL.String(), req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send refresh request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ // Read response body
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Check response status
+ if !isSuccessStatusCode(resp.StatusCode) {
+ // Try to parse OAuth error response
+ var oauthError errors.OAuthError
+ if err := json.Unmarshal(responseBody, &oauthError); err == nil {
+ return nil, &oauthError
+ }
+ return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(responseBody))
+ }
+
+ // Parse success response
+ var tokens auth.OAuthTokens
+ if err := json.Unmarshal(responseBody, &tokens); err != nil {
+ return nil, fmt.Errorf("failed to parse token response: %w", err)
+ }
+
+ // If response doesn't contain new refresh token, keep the original one
+ if tokens.RefreshToken == nil || *tokens.RefreshToken == "" {
+ tokens.RefreshToken = &options.RefreshToken
+ }
+
+ return &tokens, nil
+}
diff --git a/internal/auth/client/flow_test.go b/internal/auth/client/flow_test.go
new file mode 100644
index 0000000..bdd3b3f
--- /dev/null
+++ b/internal/auth/client/flow_test.go
@@ -0,0 +1,603 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+// mockOAuthClientProvider provides a test double implementing the OAuth client provider interfaces
+type mockOAuthClientProvider struct {
+ clientInfo *auth.OAuthClientInformation
+ tokens *auth.OAuthTokens
+ codeVerifier string
+ redirectURL string
+ clientMeta auth.OAuthClientMetadata
+ saveTokensErr error
+ saveCodeErr error
+ redirectErr error
+ invalidateErr error
+ stateValue string
+ stateErr error
+}
+
+// ClientInformation returns the current OAuth client credentials used by the client
+func (m *mockOAuthClientProvider) ClientInformation() *auth.OAuthClientInformation {
+ return m.clientInfo
+}
+
+// Tokens returns the currently stored OAuth tokens
+func (m *mockOAuthClientProvider) Tokens() (*auth.OAuthTokens, error) {
+ return m.tokens, nil
+}
+
+// SaveTokens persists newly issued OAuth tokens
+func (m *mockOAuthClientProvider) SaveTokens(tokens auth.OAuthTokens) error {
+ m.tokens = &tokens
+ return m.saveTokensErr
+}
+
+// SaveCodeVerifier persists the PKCE code verifier for later token exchange
+func (m *mockOAuthClientProvider) SaveCodeVerifier(verifier string) error {
+ m.codeVerifier = verifier
+ return m.saveCodeErr
+}
+
+// CodeVerifier returns the stored PKCE code verifier
+func (m *mockOAuthClientProvider) CodeVerifier() (string, error) {
+ return m.codeVerifier, nil
+}
+
+// RedirectURL returns the client redirect URL registered with the authorization server
+func (m *mockOAuthClientProvider) RedirectURL() string {
+ return m.redirectURL
+}
+
+// ClientMetadata returns the OAuth client metadata used for dynamic registration
+func (m *mockOAuthClientProvider) ClientMetadata() auth.OAuthClientMetadata {
+ return m.clientMeta
+}
+
+// RedirectToAuthorization performs a redirect to the authorization URL in real implementations
+func (m *mockOAuthClientProvider) RedirectToAuthorization(authURL *url.URL) error {
+ return m.redirectErr
+}
+
+// InvalidateCredentials invalidates cached credentials according to the provided scope
+func (m *mockOAuthClientProvider) InvalidateCredentials(scope string) error {
+ return m.invalidateErr
+}
+
+// SaveClientInformation persists full client information returned by dynamic registration
+func (m *mockOAuthClientProvider) SaveClientInformation(info auth.OAuthClientInformationFull) error {
+ m.clientInfo = &auth.OAuthClientInformation{
+ ClientID: info.ClientID,
+ ClientSecret: info.ClientSecret,
+ }
+ return nil
+}
+
+// AddClientAuthentication attaches client authentication to token requests
+func (m *mockOAuthClientProvider) AddClientAuthentication(headers http.Header, params url.Values, serverUrl string) error {
+ return nil
+}
+
+// State returns a CSRF protection state value for authorization requests
+func (m *mockOAuthClientProvider) State() (string, error) {
+ return m.stateValue, m.stateErr
+}
+
+// ValidateResourceURL validates or adjusts the default resource URL using optional metadata
+func (m *mockOAuthClientProvider) ValidateResourceURL(defaultResource *url.URL, metadata *auth.OAuthProtectedResourceMetadata) (*url.URL, error) {
+ return defaultResource, nil
+}
+
+func TestSelectClientAuthMethod(t *testing.T) {
+ tests := []struct {
+ name string
+ clientInfo auth.OAuthClientInformation
+ supportedMethods []string
+ expected ClientAuthMethod
+ }{
+ {
+ name: "basic auth preferred with secret",
+ clientInfo: auth.OAuthClientInformation{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ },
+ supportedMethods: []string{"client_secret_basic", "client_secret_post"},
+ expected: ClientAuthMethodBasic,
+ },
+ {
+ name: "post auth when basic not supported",
+ clientInfo: auth.OAuthClientInformation{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ },
+ supportedMethods: []string{"client_secret_post"},
+ expected: ClientAuthMethodPost,
+ },
+ {
+ name: "none auth for public client",
+ clientInfo: auth.OAuthClientInformation{
+ ClientID: "test-client",
+ },
+ supportedMethods: []string{"none"},
+ expected: ClientAuthMethodNone,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := selectClientAuthMethod(tt.clientInfo, tt.supportedMethods)
+ if result != tt.expected {
+ t.Errorf("selectClientAuthMethod() = %v, want %v", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestApplyClientAuthentication(t *testing.T) {
+ clientInfo := auth.OAuthClientInformation{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ }
+
+ t.Run("basic auth", func(t *testing.T) {
+ headers := http.Header{}
+ params := url.Values{}
+
+ err := applyClientAuthentication(ClientAuthMethodBasic, clientInfo, headers, params)
+ if err != nil {
+ t.Fatalf("applyClientAuthentication() error = %v", err)
+ }
+
+ authHeader := headers.Get("Authorization")
+ if !strings.HasPrefix(authHeader, "Basic ") {
+ t.Errorf("Expected Basic authorization header, got %s", authHeader)
+ }
+ })
+
+ t.Run("post auth", func(t *testing.T) {
+ headers := http.Header{}
+ params := url.Values{}
+
+ err := applyClientAuthentication(ClientAuthMethodPost, clientInfo, headers, params)
+ if err != nil {
+ t.Fatalf("applyClientAuthentication() error = %v", err)
+ }
+
+ if params.Get("client_id") != "test-client" {
+ t.Errorf("Expected client_id parameter, got %s", params.Get("client_id"))
+ }
+ if params.Get("client_secret") != "test-secret" {
+ t.Errorf("Expected client_secret parameter, got %s", params.Get("client_secret"))
+ }
+ })
+}
+
+func TestParseErrorResponse(t *testing.T) {
+ tests := []struct {
+ name string
+ input interface{}
+ expectError bool
+ expectCode string
+ }{
+ {
+ name: "valid oauth error from bytes",
+ input: []byte(`{"error":"invalid_client","error_description":"Client authentication failed"}`),
+ expectError: false,
+ expectCode: "invalid_client",
+ },
+ {
+ name: "missing error field",
+ input: `{"error_description":"Missing error field"}`,
+ expectError: true,
+ },
+ {
+ name: "invalid json",
+ input: `invalid json`,
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ oauthErr, err := parseErrorResponse(tt.input)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("parseErrorResponse() expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("parseErrorResponse() unexpected error = %v", err)
+ }
+
+ if oauthErr.ErrorCode != tt.expectCode {
+ t.Errorf("parseErrorResponse() error code = %v, want %v", oauthErr.ErrorCode, tt.expectCode)
+ }
+ })
+ }
+}
+
+func TestBuildDiscoveryUrls(t *testing.T) {
+ tests := []struct {
+ name string
+ serverURL string
+ expectedCount int
+ expectError bool
+ }{
+ {
+ name: "root path server",
+ serverURL: "https://auth.example.com",
+ expectedCount: 2,
+ },
+ {
+ name: "server with path",
+ serverURL: "https://auth.example.com/tenant1",
+ expectedCount: 4,
+ },
+ {
+ name: "invalid URL",
+ serverURL: "://invalid-url",
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ urls, err := buildDiscoveryUrls(tt.serverURL)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("buildDiscoveryUrls() expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("buildDiscoveryUrls() unexpected error = %v", err)
+ }
+
+ if len(urls) != tt.expectedCount {
+ t.Errorf("buildDiscoveryUrls() returned %d URLs, want %d", len(urls), tt.expectedCount)
+ }
+ })
+ }
+}
+
+func TestStartAuthorization(t *testing.T) {
+ metadata := &auth.OAuthMetadata{
+ Issuer: "https://auth.example.com",
+ AuthorizationEndpoint: "https://auth.example.com/authorize",
+ TokenEndpoint: "https://auth.example.com/token",
+ ResponseTypesSupported: []string{"code"},
+ CodeChallengeMethodsSupported: []string{"S256"},
+ }
+
+ options := StartAuthorizationOptions{
+ Metadata: metadata,
+ ClientInformation: auth.OAuthClientInformation{
+ ClientID: "test-client",
+ },
+ RedirectURL: "https://client.example.com/callback",
+ }
+
+ result, err := StartAuthorization("https://auth.example.com", options)
+ if err != nil {
+ t.Fatalf("startAuthorization() error = %v", err)
+ }
+
+ if result.AuthorizationURL == nil {
+ t.Error("startAuthorization() AuthorizationURL is nil")
+ }
+
+ if result.CodeVerifier == "" {
+ t.Error("startAuthorization() CodeVerifier is empty")
+ }
+
+ // Check URL parameters
+ params := result.AuthorizationURL.Query()
+ if params.Get("response_type") != "code" {
+ t.Errorf("Expected response_type=code, got %s", params.Get("response_type"))
+ }
+ if params.Get("client_id") != "test-client" {
+ t.Errorf("Expected client_id=test-client, got %s", params.Get("client_id"))
+ }
+}
+
+func TestDiscoverAuthorizationServerMetadata(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ metadata := auth.OAuthMetadata{
+ Issuer: "https://auth.example.com",
+ AuthorizationEndpoint: "https://auth.example.com/authorize",
+ TokenEndpoint: "https://auth.example.com/token",
+ ResponseTypesSupported: []string{"code"},
+ GrantTypesSupported: []string{"authorization_code", "refresh_token"},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(metadata)
+ }))
+ defer server.Close()
+
+ result, err := DiscoverAuthorizationServerMetadata(context.Background(), server.URL, nil)
+ if err != nil {
+ t.Fatalf("DiscoverAuthorizationServerMetadata() error = %v", err)
+ }
+
+ if result.GetIssuer() != "https://auth.example.com" {
+ t.Errorf("Expected issuer https://auth.example.com, got %s", result.GetIssuer())
+ }
+}
+
+func TestAuthWithExistingTokens(t *testing.T) {
+ provider := &mockOAuthClientProvider{
+ clientInfo: &auth.OAuthClientInformation{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ },
+ tokens: &auth.OAuthTokens{
+ AccessToken: "valid-access-token",
+ RefreshToken: stringPtr("valid-refresh-token"),
+ },
+ redirectURL: "https://client.example.com/callback",
+ clientMeta: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://client.example.com/callback"},
+ },
+ }
+
+ var serverURL string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/.well-known/oauth-authorization-server"):
+ metadata := &auth.OAuthMetadata{
+ Issuer: serverURL, // Use server URL as issuer
+ AuthorizationEndpoint: serverURL + "/authorize",
+ TokenEndpoint: serverURL + "/token",
+ ResponseTypesSupported: []string{"code"},
+ GrantTypesSupported: []string{"authorization_code", "refresh_token"},
+ CodeChallengeMethodsSupported: []string{"S256"},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(metadata)
+ case r.URL.Path == "/token":
+ // Simulate successful token refresh
+ tokens := auth.OAuthTokens{
+ AccessToken: "new-access-token",
+ RefreshToken: stringPtr("new-refresh-token"),
+ TokenType: "Bearer",
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(tokens)
+ case strings.HasSuffix(r.URL.Path, "/.well-known/oauth-protected-resource"):
+ // Return 404 for resource metadata (optional)
+ w.WriteHeader(404)
+ default:
+ t.Logf("Unexpected request to: %s", r.URL.Path)
+ w.WriteHeader(404)
+ }
+ }))
+ defer server.Close()
+
+ serverURL = server.URL
+
+ options := auth.AuthOptions{
+ ServerUrl: server.URL,
+ FetchFn: func(url string, req *http.Request) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+ },
+ }
+
+ result, err := Auth(provider, options)
+ if err != nil {
+ t.Fatalf("Auth() error = %v", err)
+ }
+
+ if *result != AuthResultAuthorized {
+ t.Errorf("Expected AuthResultAuthorized, got %v", *result)
+ }
+
+ // Verify tokens were updated
+ if provider.tokens.AccessToken != "new-access-token" {
+ t.Errorf("Expected access token to be updated to 'new-access-token', got %s", provider.tokens.AccessToken)
+ }
+}
+
+func TestRegisterClient(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ t.Errorf("Expected POST request, got %s", r.Method)
+ }
+
+ clientInfo := auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: "generated-client-id",
+ ClientSecret: "generated-client-secret",
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(clientInfo)
+ }))
+ defer server.Close()
+
+ registrationEndpoint := server.URL + "/register"
+ metadata := &auth.OAuthMetadata{
+ RegistrationEndpoint: ®istrationEndpoint,
+ }
+
+ options := RegisterClientOptions{
+ Metadata: metadata,
+ ClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://client.example.com/callback"},
+ },
+ FetchFn: func(url string, req *http.Request) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+ },
+ }
+
+ result, err := RegisterClient(context.Background(), server.URL, options)
+ if err != nil {
+ t.Fatalf("RegisterClient() error = %v", err)
+ }
+
+ if result.ClientID != "generated-client-id" {
+ t.Errorf("Expected client_id 'generated-client-id', got %s", result.ClientID)
+ }
+}
+
+func TestAuthWithoutClientInfo(t *testing.T) {
+ provider := &mockOAuthClientProvider{
+ clientInfo: nil, // No existing client info
+ redirectURL: "https://client.example.com/callback",
+ clientMeta: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://client.example.com/callback"},
+ },
+ }
+
+ // Capture the server URL using a variable
+ var serverURL string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/.well-known/oauth-authorization-server"):
+ metadata := &auth.OAuthMetadata{
+ Issuer: serverURL,
+ AuthorizationEndpoint: serverURL + "/authorize",
+ TokenEndpoint: serverURL + "/token",
+ RegistrationEndpoint: stringPtr(serverURL + "/register"),
+ ResponseTypesSupported: []string{"code"},
+ GrantTypesSupported: []string{"authorization_code", "refresh_token"},
+ CodeChallengeMethodsSupported: []string{"S256"},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(metadata)
+ case r.URL.Path == "/register":
+ clientInfo := auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: "registered-client-id",
+ ClientSecret: "registered-client-secret",
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(clientInfo)
+ case strings.HasSuffix(r.URL.Path, "/.well-known/oauth-protected-resource"):
+ // Return 404 for resource metadata (optional)
+ w.WriteHeader(404)
+ default:
+ t.Logf("Unexpected request to: %s", r.URL.Path)
+ w.WriteHeader(404)
+ }
+ }))
+ defer server.Close()
+
+ serverURL = server.URL
+
+ options := auth.AuthOptions{
+ ServerUrl: server.URL,
+ FetchFn: func(url string, req *http.Request) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+ },
+ }
+
+ result, err := Auth(provider, options)
+ if err != nil {
+ t.Fatalf("Auth() error = %v", err)
+ }
+
+ if *result != AuthResultRedirect {
+ t.Errorf("Expected AuthResultRedirect, got %v", *result)
+ }
+
+ // Verify client information was saved after registration
+ if provider.clientInfo == nil {
+ t.Error("Expected client information to be saved after registration")
+ }
+ if provider.clientInfo.ClientID != "registered-client-id" {
+ t.Errorf("Expected registered client ID, got %s", provider.clientInfo.ClientID)
+ }
+}
+
+func TestAuthWithoutRefreshToken(t *testing.T) {
+ provider := &mockOAuthClientProvider{
+ clientInfo: &auth.OAuthClientInformation{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ },
+ tokens: &auth.OAuthTokens{
+ AccessToken: "valid-access-token",
+ // No refresh token
+ },
+ redirectURL: "https://client.example.com/callback",
+ clientMeta: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://client.example.com/callback"},
+ },
+ }
+
+ var serverURL string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/.well-known/oauth-authorization-server"):
+ metadata := &auth.OAuthMetadata{
+ Issuer: serverURL,
+ AuthorizationEndpoint: serverURL + "/authorize",
+ TokenEndpoint: serverURL + "/token",
+ ResponseTypesSupported: []string{"code"},
+ GrantTypesSupported: []string{"authorization_code", "refresh_token"},
+ CodeChallengeMethodsSupported: []string{"S256"},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(metadata)
+ case strings.HasSuffix(r.URL.Path, "/.well-known/oauth-protected-resource"):
+ w.WriteHeader(404)
+ default:
+ w.WriteHeader(404)
+ }
+ }))
+ defer server.Close()
+
+ serverURL = server.URL
+
+ options := auth.AuthOptions{
+ ServerUrl: server.URL,
+ FetchFn: func(url string, req *http.Request) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+ },
+ }
+
+ result, err := Auth(provider, options)
+ if err != nil {
+ t.Fatalf("Auth() error = %v", err)
+ }
+
+ if *result != AuthResultRedirect {
+ t.Errorf("Expected AuthResultRedirect, got %v", *result)
+ }
+}
+
+func BenchmarkSelectClientAuthMethod(b *testing.B) {
+ clientInfo := auth.OAuthClientInformation{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ }
+ supportedMethods := []string{"client_secret_basic", "client_secret_post"}
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ selectClientAuthMethod(clientInfo, supportedMethods)
+ }
+}
diff --git a/internal/auth/client/http.go b/internal/auth/client/http.go
new file mode 100644
index 0000000..41c74e0
--- /dev/null
+++ b/internal/auth/client/http.go
@@ -0,0 +1,97 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package client
+
+import (
+ "context"
+ "time"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+// ctxKey defines a private type for context keys to avoid collisions
+type ctxKey int
+
+const (
+ // ctxKeyClientAuthInfo is the context key used to store ClientAuthInfo
+ ctxKeyClientAuthInfo ctxKey = iota
+ // ctxKeyClientAuthErr is the context key used to store authentication errors
+ ctxKeyClientAuthErr
+)
+
+// ClientAuthInfo holds OAuth client authentication details
+type ClientAuthInfo struct {
+ AccessToken string
+ RefreshToken *string
+ ExpiresAt *time.Time
+ Scopes []string
+ Extra map[string]interface{}
+}
+
+// WithAuthInfo stores authentication information in the context
+func WithAuthInfo(ctx context.Context, info *ClientAuthInfo) context.Context {
+ if info == nil {
+ return ctx
+ }
+ return context.WithValue(ctx, ctxKeyClientAuthInfo, info)
+}
+
+// GetAuthInfo retrieves authentication information from the context
+func GetAuthInfo(ctx context.Context) (*ClientAuthInfo, bool) {
+ v := ctx.Value(ctxKeyClientAuthInfo)
+ if v == nil {
+ return nil, false
+ }
+ info, ok := v.(*ClientAuthInfo)
+ return info, ok && info != nil
+}
+
+// WithAuthErr stores an authentication error in the context
+func WithAuthErr(ctx context.Context, err error) context.Context {
+ if err == nil {
+ return ctx
+ }
+ return context.WithValue(ctx, ctxKeyClientAuthErr, err)
+}
+
+// ConvertTokensToAuthInfo converts OAuth tokens into ClientAuthInfo
+func ConvertTokensToAuthInfo(tokens *auth.OAuthTokens) *ClientAuthInfo {
+ if tokens == nil || tokens.AccessToken == "" {
+ return nil
+ }
+
+ authInfo := &ClientAuthInfo{
+ AccessToken: tokens.AccessToken,
+ RefreshToken: tokens.RefreshToken,
+ Scopes: parseTokenScopes(tokens),
+ Extra: make(map[string]interface{}),
+ }
+
+ if tokens.ExpiresIn != nil {
+ expiresAt := time.Now().Add(time.Duration(*tokens.ExpiresIn) * time.Second)
+ authInfo.ExpiresAt = &expiresAt
+ }
+
+ return authInfo
+}
+
+// IsTokenExpired checks if the token is expired or near expiry
+func IsTokenExpired(authInfo *ClientAuthInfo) bool {
+ if authInfo == nil {
+ return true // nil means expired
+ }
+ if authInfo.ExpiresAt == nil {
+ return false // no expiry means never expired
+ }
+ // expire if within 30 seconds of expiry
+ return !authInfo.ExpiresAt.After(time.Now().Add(30 * time.Second))
+}
+
+// parseTokenScopes extracts scopes from tokens (currently placeholder)
+func parseTokenScopes(tokens *auth.OAuthTokens) []string {
+ return []string{}
+}
diff --git a/internal/auth/client/http_test.go b/internal/auth/client/http_test.go
new file mode 100644
index 0000000..81cd9a7
--- /dev/null
+++ b/internal/auth/client/http_test.go
@@ -0,0 +1,594 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package client
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+func TestWithAuthInfo(t *testing.T) {
+ ctx := context.Background()
+
+ // Test with nil auth info
+ newCtx := WithAuthInfo(ctx, nil)
+ if newCtx != ctx {
+ t.Error("Expected same context when auth info is nil")
+ }
+
+ // Test with valid auth info
+ authInfo := &ClientAuthInfo{
+ AccessToken: "test-token",
+ Scopes: []string{"read", "write"},
+ }
+
+ newCtx = WithAuthInfo(ctx, authInfo)
+ if newCtx == ctx {
+ t.Error("Expected different context when auth info is provided")
+ }
+
+ // Verify the auth info was stored
+ stored, ok := GetAuthInfo(newCtx)
+ if !ok {
+ t.Error("Expected to retrieve auth info from context")
+ }
+ if stored != authInfo {
+ t.Error("Expected stored auth info to match original")
+ }
+}
+
+func TestGetAuthInfo(t *testing.T) {
+ ctx := context.Background()
+
+ // Test with empty context
+ authInfo, ok := GetAuthInfo(ctx)
+ if ok {
+ t.Error("Expected no auth info in empty context")
+ }
+ if authInfo != nil {
+ t.Error("Expected nil auth info from empty context")
+ }
+
+ // Test with auth info in context
+ originalAuthInfo := &ClientAuthInfo{
+ AccessToken: "test-access-token",
+ RefreshToken: stringPtr("test-refresh-token"),
+ Scopes: []string{"read", "write"},
+ Extra: map[string]interface{}{"custom": "value"},
+ }
+
+ ctx = WithAuthInfo(ctx, originalAuthInfo)
+ authInfo, ok = GetAuthInfo(ctx)
+
+ if !ok {
+ t.Error("Expected to find auth info in context")
+ }
+ if authInfo == nil {
+ t.Fatal("Expected non-nil auth info")
+ }
+ if authInfo.AccessToken != originalAuthInfo.AccessToken {
+ t.Errorf("Expected AccessToken %s, got %s", originalAuthInfo.AccessToken, authInfo.AccessToken)
+ }
+ if *authInfo.RefreshToken != *originalAuthInfo.RefreshToken {
+ t.Errorf("Expected RefreshToken %s, got %s", *originalAuthInfo.RefreshToken, *authInfo.RefreshToken)
+ }
+
+ // Test with nil auth info stored in context
+ ctx = context.WithValue(context.Background(), ctxKeyClientAuthInfo, (*ClientAuthInfo)(nil))
+ authInfo, ok = GetAuthInfo(ctx)
+ if ok {
+ t.Error("Expected no auth info when nil is stored")
+ }
+ if authInfo != nil {
+ t.Error("Expected nil auth info when nil is stored")
+ }
+
+ // Test with wrong type in context
+ ctx = context.WithValue(context.Background(), ctxKeyClientAuthInfo, "not-auth-info")
+ authInfo, ok = GetAuthInfo(ctx)
+ if ok {
+ t.Error("Expected no auth info when wrong type is stored")
+ }
+ if authInfo != nil {
+ t.Error("Expected nil auth info when wrong type is stored")
+ }
+}
+
+func TestWithAuthErr(t *testing.T) {
+ ctx := context.Background()
+
+ // Test with nil error
+ newCtx := WithAuthErr(ctx, nil)
+ if newCtx != ctx {
+ t.Error("Expected same context when error is nil")
+ }
+
+ // Test with valid error
+ testErr := errors.New("auth error")
+ newCtx = WithAuthErr(ctx, testErr)
+ if newCtx == ctx {
+ t.Error("Expected different context when error is provided")
+ }
+
+ // Verify the error was stored
+ stored := newCtx.Value(ctxKeyClientAuthErr)
+ if stored == nil {
+ t.Error("Expected error to be stored in context")
+ }
+ if stored != testErr {
+ t.Errorf("Expected stored error %v, got %v", testErr, stored)
+ }
+}
+
+func TestConvertTokensToAuthInfo(t *testing.T) {
+ // Test with nil tokens
+ authInfo := ConvertTokensToAuthInfo(nil)
+ if authInfo != nil {
+ t.Error("Expected nil auth info when tokens is nil")
+ }
+
+ // Test with empty access token
+ emptyTokens := &auth.OAuthTokens{
+ AccessToken: "",
+ }
+ authInfo = ConvertTokensToAuthInfo(emptyTokens)
+ if authInfo != nil {
+ t.Error("Expected nil auth info when access token is empty")
+ }
+
+ // Test with valid tokens (minimal)
+ validTokens := &auth.OAuthTokens{
+ AccessToken: "test-access-token",
+ TokenType: "Bearer",
+ }
+ authInfo = ConvertTokensToAuthInfo(validTokens)
+ if authInfo == nil {
+ t.Fatal("Expected non-nil auth info")
+ }
+ if authInfo.AccessToken != validTokens.AccessToken {
+ t.Errorf("Expected AccessToken %s, got %s", validTokens.AccessToken, authInfo.AccessToken)
+ }
+ if authInfo.RefreshToken != nil {
+ t.Error("Expected nil RefreshToken when not provided")
+ }
+ if authInfo.ExpiresAt != nil {
+ t.Error("Expected nil ExpiresAt when ExpiresIn not provided")
+ }
+ if authInfo.Extra == nil {
+ t.Error("Expected Extra map to be initialized")
+ }
+
+ // Test with full tokens
+ refreshToken := "test-refresh-token"
+ expiresIn := int64(3600)
+ scope := "read write admin"
+ fullTokens := &auth.OAuthTokens{
+ AccessToken: "test-access-token",
+ RefreshToken: &refreshToken,
+ TokenType: "Bearer",
+ ExpiresIn: &expiresIn,
+ Scope: &scope,
+ }
+
+ before := time.Now()
+ authInfo = ConvertTokensToAuthInfo(fullTokens)
+ after := time.Now()
+
+ if authInfo == nil {
+ t.Fatal("Expected non-nil auth info")
+ }
+ if authInfo.AccessToken != fullTokens.AccessToken {
+ t.Errorf("Expected AccessToken %s, got %s", fullTokens.AccessToken, authInfo.AccessToken)
+ }
+ if authInfo.RefreshToken == nil {
+ t.Fatal("Expected RefreshToken to be set")
+ }
+ if *authInfo.RefreshToken != *fullTokens.RefreshToken {
+ t.Errorf("Expected RefreshToken %s, got %s", *fullTokens.RefreshToken, *authInfo.RefreshToken)
+ }
+
+ // Test ExpiresAt calculation
+ if authInfo.ExpiresAt == nil {
+ t.Fatal("Expected ExpiresAt to be set")
+ }
+ expectedExpiry := before.Add(time.Duration(expiresIn) * time.Second)
+ actualExpiry := *authInfo.ExpiresAt
+ if actualExpiry.Before(expectedExpiry) || actualExpiry.After(after.Add(time.Duration(expiresIn)*time.Second)) {
+ t.Errorf("Expected ExpiresAt around %v, got %v", expectedExpiry, actualExpiry)
+ }
+
+ // Test scopes parsing (currently returns empty slice)
+ if len(authInfo.Scopes) != 0 {
+ t.Errorf("Expected empty scopes (parseTokenScopes returns empty), got %v", authInfo.Scopes)
+ }
+}
+
+func TestIsTokenExpired(t *testing.T) {
+ // Test with nil auth info
+ if !IsTokenExpired(nil) {
+ t.Error("Expected nil auth info to be considered expired")
+ }
+
+ // Test with no expiration time
+ authInfoNoExpiry := &ClientAuthInfo{
+ AccessToken: "test-token",
+ }
+ if IsTokenExpired(authInfoNoExpiry) {
+ t.Error("Expected auth info without expiry to not be expired")
+ }
+
+ // Test with future expiration (not expired)
+ futureExpiry := time.Now().Add(time.Hour)
+ authInfoFuture := &ClientAuthInfo{
+ AccessToken: "test-token",
+ ExpiresAt: &futureExpiry,
+ }
+ if IsTokenExpired(authInfoFuture) {
+ t.Error("Expected auth info with future expiry to not be expired")
+ }
+
+ // Test with past expiration (expired)
+ pastExpiry := time.Now().Add(-time.Hour)
+ authInfoPast := &ClientAuthInfo{
+ AccessToken: "test-token",
+ ExpiresAt: &pastExpiry,
+ }
+ if !IsTokenExpired(authInfoPast) {
+ t.Error("Expected auth info with past expiry to be expired")
+ }
+
+ // Test with expiration within 30 seconds (considered expired due to buffer)
+ soonExpiry := time.Now().Add(15 * time.Second)
+ authInfoSoon := &ClientAuthInfo{
+ AccessToken: "test-token",
+ ExpiresAt: &soonExpiry,
+ }
+ if !IsTokenExpired(authInfoSoon) {
+ t.Error("Expected auth info expiring within 30 seconds to be considered expired")
+ }
+
+ // Test with expiration just outside 30 second buffer
+ laterExpiry := time.Now().Add(35 * time.Second)
+ authInfoLater := &ClientAuthInfo{
+ AccessToken: "test-token",
+ ExpiresAt: &laterExpiry,
+ }
+ if IsTokenExpired(authInfoLater) {
+ t.Error("Expected auth info expiring outside 30 second buffer to not be expired")
+ }
+}
+
+func TestParseTokenScopes(t *testing.T) {
+ // Test with nil tokens
+ scopes := parseTokenScopes(nil)
+ if len(scopes) != 0 {
+ t.Errorf("Expected empty scopes for nil tokens, got %v", scopes)
+ }
+
+ // Test with tokens without scope
+ tokens := &auth.OAuthTokens{
+ AccessToken: "test-token",
+ }
+ scopes = parseTokenScopes(tokens)
+ if len(scopes) != 0 {
+ t.Errorf("Expected empty scopes when no scope in tokens, got %v", scopes)
+ }
+
+ // Test with tokens with scope
+ scope := "read write admin"
+ tokensWithScope := &auth.OAuthTokens{
+ AccessToken: "test-token",
+ Scope: &scope,
+ }
+ scopes = parseTokenScopes(tokensWithScope)
+ // Currently returns empty slice, but testing the current behavior
+ if len(scopes) != 0 {
+ t.Errorf("Expected empty scopes (current implementation), got %v", scopes)
+ }
+}
+
+func TestClientAuthInfoComplete(t *testing.T) {
+ // Test complete ClientAuthInfo structure
+ refreshToken := "refresh-123"
+ expiresAt := time.Now().Add(time.Hour)
+ authInfo := &ClientAuthInfo{
+ AccessToken: "access-123",
+ RefreshToken: &refreshToken,
+ ExpiresAt: &expiresAt,
+ Scopes: []string{"read", "write", "admin"},
+ Extra: map[string]interface{}{
+ "user_id": "12345",
+ "username": "testuser",
+ "custom": true,
+ },
+ }
+
+ // Test all fields are preserved
+ if authInfo.AccessToken != "access-123" {
+ t.Errorf("Expected AccessToken access-123, got %s", authInfo.AccessToken)
+ }
+ if authInfo.RefreshToken == nil || *authInfo.RefreshToken != refreshToken {
+ t.Errorf("Expected RefreshToken %s, got %v", refreshToken, authInfo.RefreshToken)
+ }
+ if authInfo.ExpiresAt == nil || !authInfo.ExpiresAt.Equal(expiresAt) {
+ t.Errorf("Expected ExpiresAt %v, got %v", expiresAt, authInfo.ExpiresAt)
+ }
+ if len(authInfo.Scopes) != 3 {
+ t.Errorf("Expected 3 scopes, got %d", len(authInfo.Scopes))
+ }
+ if authInfo.Extra["user_id"] != "12345" {
+ t.Errorf("Expected Extra user_id 12345, got %v", authInfo.Extra["user_id"])
+ }
+}
+
+func TestContextKeyUniqueness(t *testing.T) {
+ // Test that context keys are unique
+ if ctxKeyClientAuthInfo == ctxKeyClientAuthErr {
+ t.Error("Expected context keys to be unique")
+ }
+
+ // Test that different values can be stored with different keys
+ ctx := context.Background()
+ authInfo := &ClientAuthInfo{AccessToken: "test"}
+ authErr := errors.New("test error")
+
+ ctx = WithAuthInfo(ctx, authInfo)
+ ctx = WithAuthErr(ctx, authErr)
+
+ // Both should be retrievable
+ storedAuthInfo, ok := GetAuthInfo(ctx)
+ if !ok || storedAuthInfo != authInfo {
+ t.Error("Expected to retrieve stored auth info")
+ }
+
+ storedAuthErr := ctx.Value(ctxKeyClientAuthErr)
+ if storedAuthErr != authErr {
+ t.Error("Expected to retrieve stored auth error")
+ }
+}
+
+func TestAuthInfoEdgeCases(t *testing.T) {
+ // Test with zero values
+ authInfo := &ClientAuthInfo{}
+ if authInfo.AccessToken != "" {
+ t.Error("Expected empty AccessToken by default")
+ }
+ if authInfo.RefreshToken != nil {
+ t.Error("Expected nil RefreshToken by default")
+ }
+ if authInfo.ExpiresAt != nil {
+ t.Error("Expected nil ExpiresAt by default")
+ }
+ if authInfo.Scopes != nil {
+ t.Error("Expected nil Scopes by default")
+ }
+ if authInfo.Extra != nil {
+ t.Error("Expected nil Extra by default")
+ }
+
+ // Test IsTokenExpired with zero value auth info
+ if IsTokenExpired(authInfo) {
+ t.Error("Expected zero value auth info (no expiry) to not be expired")
+ }
+}
+
+func TestTokenExpirationBoundaryConditions(t *testing.T) {
+ // Test exactly at 30 second boundary
+ exactBoundary := time.Now().Add(30 * time.Second)
+ authInfoBoundary := &ClientAuthInfo{
+ AccessToken: "test-token",
+ ExpiresAt: &exactBoundary,
+ }
+
+ // Due to the -30 second buffer, this should be considered expired
+ if !IsTokenExpired(authInfoBoundary) {
+ t.Error("Expected token expiring at exactly 30 seconds to be considered expired")
+ }
+
+ // Test just before the boundary
+ justBefore := time.Now().Add(29 * time.Second)
+ authInfoJustBefore := &ClientAuthInfo{
+ AccessToken: "test-token",
+ ExpiresAt: &justBefore,
+ }
+ if !IsTokenExpired(authInfoJustBefore) {
+ t.Error("Expected token expiring before 30 second buffer to be expired")
+ }
+
+ // Test just after the boundary
+ justAfter := time.Now().Add(31 * time.Second)
+ authInfoJustAfter := &ClientAuthInfo{
+ AccessToken: "test-token",
+ ExpiresAt: &justAfter,
+ }
+ if IsTokenExpired(authInfoJustAfter) {
+ t.Error("Expected token expiring after 30 second buffer to not be expired")
+ }
+}
+
+func TestConvertTokensToAuthInfoEdgeCases(t *testing.T) {
+ // Test with tokens having only access token
+ minimalTokens := &auth.OAuthTokens{
+ AccessToken: "minimal-token",
+ TokenType: "Bearer",
+ }
+ authInfo := ConvertTokensToAuthInfo(minimalTokens)
+ if authInfo == nil {
+ t.Fatal("Expected non-nil auth info for minimal valid tokens")
+ }
+ if authInfo.AccessToken != "minimal-token" {
+ t.Errorf("Expected AccessToken minimal-token, got %s", authInfo.AccessToken)
+ }
+ if authInfo.RefreshToken != nil {
+ t.Error("Expected nil RefreshToken when not provided")
+ }
+ if authInfo.ExpiresAt != nil {
+ t.Error("Expected nil ExpiresAt when ExpiresIn not provided")
+ }
+
+ // Test with zero ExpiresIn
+ zeroExpiresIn := int64(0)
+ tokensZeroExpiry := &auth.OAuthTokens{
+ AccessToken: "test-token",
+ ExpiresIn: &zeroExpiresIn,
+ }
+ authInfo = ConvertTokensToAuthInfo(tokensZeroExpiry)
+ if authInfo == nil {
+ t.Fatal("Expected non-nil auth info")
+ }
+ if authInfo.ExpiresAt == nil {
+ t.Fatal("Expected ExpiresAt to be set even with zero ExpiresIn")
+ }
+ // Should be approximately now (since ExpiresIn is 0)
+ if time.Since(*authInfo.ExpiresAt) > time.Second {
+ t.Error("Expected ExpiresAt to be approximately now when ExpiresIn is 0")
+ }
+
+ // Test with negative ExpiresIn
+ negativeExpiresIn := int64(-3600)
+ tokensNegativeExpiry := &auth.OAuthTokens{
+ AccessToken: "test-token",
+ ExpiresIn: &negativeExpiresIn,
+ }
+ authInfo = ConvertTokensToAuthInfo(tokensNegativeExpiry)
+ if authInfo == nil {
+ t.Fatal("Expected non-nil auth info")
+ }
+ if authInfo.ExpiresAt == nil {
+ t.Fatal("Expected ExpiresAt to be set even with negative ExpiresIn")
+ }
+ // Should be in the past
+ if authInfo.ExpiresAt.After(time.Now()) {
+ t.Error("Expected ExpiresAt to be in the past when ExpiresIn is negative")
+ }
+}
+
+func TestConcurrentContextOperations(t *testing.T) {
+ // Test concurrent access to context operations
+ ctx := context.Background()
+
+ // Set up initial context with auth info
+ authInfo := &ClientAuthInfo{
+ AccessToken: "concurrent-test-token",
+ Scopes: []string{"read"},
+ }
+ ctx = WithAuthInfo(ctx, authInfo)
+
+ // Test concurrent reads
+ numGoroutines := 10
+ results := make(chan bool, numGoroutines)
+
+ for i := 0; i < numGoroutines; i++ {
+ go func() {
+ storedInfo, ok := GetAuthInfo(ctx)
+ results <- ok && storedInfo != nil && storedInfo.AccessToken == "concurrent-test-token"
+ }()
+ }
+
+ // Collect results
+ for i := 0; i < numGoroutines; i++ {
+ if !<-results {
+ t.Error("Expected successful concurrent read of auth info")
+ }
+ }
+}
+
+func TestAuthInfoDeepCopy(t *testing.T) {
+ // Test that modifications to returned auth info don't affect stored version
+ refreshToken := "original-refresh"
+ expiresAt := time.Now().Add(time.Hour)
+ originalAuthInfo := &ClientAuthInfo{
+ AccessToken: "original-access",
+ RefreshToken: &refreshToken,
+ ExpiresAt: &expiresAt,
+ Scopes: []string{"read", "write"},
+ Extra: map[string]interface{}{"key": "value"},
+ }
+
+ ctx := WithAuthInfo(context.Background(), originalAuthInfo)
+ retrievedAuthInfo, ok := GetAuthInfo(ctx)
+ if !ok {
+ t.Fatal("Expected to retrieve auth info")
+ }
+
+ // Modify the retrieved auth info
+ retrievedAuthInfo.AccessToken = "modified-access"
+ *retrievedAuthInfo.RefreshToken = "modified-refresh"
+ retrievedAuthInfo.Scopes[0] = "modified"
+ retrievedAuthInfo.Extra["key"] = "modified"
+
+ // Check that original is also modified (since we're returning the same pointer)
+ // This test documents the current behavior - no deep copy is performed
+ if originalAuthInfo.AccessToken != "modified-access" {
+ t.Error("Auth info appears to be deep copied (might be unexpected)")
+ }
+}
+
+// Helper function to create string pointers
+func stringPtr(s string) *string {
+ return &s
+}
+
+// Benchmark tests
+func BenchmarkWithAuthInfo(b *testing.B) {
+ ctx := context.Background()
+ authInfo := &ClientAuthInfo{
+ AccessToken: "benchmark-token",
+ Scopes: []string{"read", "write"},
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ WithAuthInfo(ctx, authInfo)
+ }
+}
+
+func BenchmarkGetAuthInfo(b *testing.B) {
+ authInfo := &ClientAuthInfo{
+ AccessToken: "benchmark-token",
+ Scopes: []string{"read", "write"},
+ }
+ ctx := WithAuthInfo(context.Background(), authInfo)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ GetAuthInfo(ctx)
+ }
+}
+
+func BenchmarkConvertTokensToAuthInfo(b *testing.B) {
+ refreshToken := "bench-refresh-token"
+ expiresIn := int64(3600)
+ tokens := &auth.OAuthTokens{
+ AccessToken: "bench-access-token",
+ RefreshToken: &refreshToken,
+ TokenType: "Bearer",
+ ExpiresIn: &expiresIn,
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ ConvertTokensToAuthInfo(tokens)
+ }
+}
+
+func BenchmarkIsTokenExpired(b *testing.B) {
+ expiresAt := time.Now().Add(time.Hour)
+ authInfo := &ClientAuthInfo{
+ AccessToken: "bench-token",
+ ExpiresAt: &expiresAt,
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ IsTokenExpired(authInfo)
+ }
+}
diff --git a/internal/auth/client/memory_provider.go b/internal/auth/client/memory_provider.go
new file mode 100644
index 0000000..8a7fd09
--- /dev/null
+++ b/internal/auth/client/memory_provider.go
@@ -0,0 +1,195 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package client
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+// InMemoryOAuthClientProvider provides an in-memory implementation of OAuthClientProvider
+// It stores client information, tokens, and PKCE code verifiers with thread safety
+//
+// NOTE: This is a demo implementation for testing and development purposes only.
+// It is NOT recommended for production use as it stores sensitive data in memory
+// without persistence or proper security measures.
+type InMemoryOAuthClientProvider struct {
+ redirectURL string
+ clientMetadata auth.OAuthClientMetadata
+ clientInfo *auth.OAuthClientInformation
+ tokens *auth.OAuthTokens
+ codeVerifier string
+ onRedirect func(*url.URL) error
+ mutex sync.RWMutex
+}
+
+// NewInMemoryOAuthClientProvider creates a new in-memory OAuth client provider
+func NewInMemoryOAuthClientProvider(
+ redirectURL string,
+ clientMetadata auth.OAuthClientMetadata,
+ onRedirect func(*url.URL) error) *InMemoryOAuthClientProvider {
+ if onRedirect == nil {
+ onRedirect = func(u *url.URL) error {
+ return nil
+ }
+ }
+ return &InMemoryOAuthClientProvider{
+ redirectURL: redirectURL,
+ clientMetadata: clientMetadata,
+ onRedirect: onRedirect,
+ }
+}
+
+// RedirectURL returns the registered redirect URL
+func (p *InMemoryOAuthClientProvider) RedirectURL() string {
+ return p.redirectURL
+}
+
+// ClientMetadata returns the client metadata
+func (p *InMemoryOAuthClientProvider) ClientMetadata() auth.OAuthClientMetadata {
+ return p.clientMetadata
+}
+
+// ClientInformation returns stored client credentials if available
+func (p *InMemoryOAuthClientProvider) ClientInformation() *auth.OAuthClientInformation {
+ p.mutex.RLock()
+ defer p.mutex.RUnlock()
+ return p.clientInfo
+}
+
+// SaveClientInformation saves client credentials into memory
+func (p *InMemoryOAuthClientProvider) SaveClientInformation(clientInformation auth.OAuthClientInformationFull) error {
+ p.mutex.Lock()
+ defer p.mutex.Unlock()
+ p.clientInfo = &auth.OAuthClientInformation{
+ ClientID: clientInformation.ClientID,
+ ClientSecret: clientInformation.ClientSecret,
+ ClientIDIssuedAt: clientInformation.ClientIDIssuedAt,
+ ClientSecretExpiresAt: clientInformation.ClientSecretExpiresAt,
+ }
+ return nil
+}
+
+// Tokens returns stored tokens if available
+func (p *InMemoryOAuthClientProvider) Tokens() (*auth.OAuthTokens, error) {
+ p.mutex.RLock()
+ defer p.mutex.RUnlock()
+ return p.tokens, nil
+}
+
+// SaveTokens saves OAuth tokens into memory
+func (p *InMemoryOAuthClientProvider) SaveTokens(tokens auth.OAuthTokens) error {
+ p.mutex.Lock()
+ defer p.mutex.Unlock()
+ p.tokens = &tokens
+ return nil
+}
+
+// RedirectToAuthorization executes the redirect callback with authorization URL
+func (p *InMemoryOAuthClientProvider) RedirectToAuthorization(authorizationUrl *url.URL) error {
+ return p.onRedirect(authorizationUrl)
+}
+
+// CodeVerifier retrieves the stored PKCE code verifier
+func (p *InMemoryOAuthClientProvider) CodeVerifier() (string, error) {
+ p.mutex.RLock()
+ defer p.mutex.RUnlock()
+ if p.codeVerifier == "" {
+ return "", fmt.Errorf("no code verifier saved")
+ }
+ return p.codeVerifier, nil
+}
+
+// SaveCodeVerifier saves the PKCE code verifier into memory
+func (p *InMemoryOAuthClientProvider) SaveCodeVerifier(codeVerifier string) error {
+ p.mutex.Lock()
+ defer p.mutex.Unlock()
+ p.codeVerifier = codeVerifier
+ return nil
+}
+
+// State generates a random state string for CSRF protection
+func (p *InMemoryOAuthClientProvider) State() (string, error) {
+ // Generate a random state parameter for CSRF protection
+ bytes := make([]byte, 32)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", fmt.Errorf("failed to generate random state: %w", err)
+ }
+ return base64.URLEncoding.EncodeToString(bytes), nil
+}
+
+// AddClientAuthentication adds client authentication parameters using client_secret_post
+func (p *InMemoryOAuthClientProvider) AddClientAuthentication(headers http.Header, params url.Values, tokenUrl string) error {
+ // Add client authentication using client_secret_post method
+ p.mutex.RLock()
+ clientInfo := p.clientInfo
+ p.mutex.RUnlock()
+
+ if clientInfo != nil && clientInfo.ClientID != "" {
+ params.Set("client_id", clientInfo.ClientID)
+ if clientInfo.ClientSecret != "" {
+ params.Set("client_secret", clientInfo.ClientSecret)
+ }
+ }
+ return nil
+}
+
+// ValidateResourceURL validates the resource URL from metadata against the server URL
+func (p *InMemoryOAuthClientProvider) ValidateResourceURL(serverUrl *url.URL, resourceMetadata *auth.OAuthProtectedResourceMetadata) (*url.URL, error) {
+ // If no resource metadata provided, return nil (no resource parameter needed)
+ if resourceMetadata == nil {
+ return nil, nil
+ }
+
+ // Parse the resource URL from metadata
+ resourceURL, err := url.Parse(resourceMetadata.Resource)
+ if err != nil {
+ return nil, fmt.Errorf("invalid resource URL in metadata: %w", err)
+ }
+
+ // Basic validation: ensure the resource URL has the same origin as server URL
+ if resourceURL.Scheme != serverUrl.Scheme || resourceURL.Host != serverUrl.Host {
+ // Allow if resource URL is a more specific path under the same origin
+ if !strings.HasPrefix(resourceURL.String(), serverUrl.Scheme+"://"+serverUrl.Host) {
+ return nil, fmt.Errorf("resource URL %s does not match server origin %s://%s",
+ resourceURL.String(), serverUrl.Scheme, serverUrl.Host)
+ }
+ }
+
+ return resourceURL, nil
+}
+
+// InvalidateCredentials clears stored credentials based on scope
+func (p *InMemoryOAuthClientProvider) InvalidateCredentials(scope string) error {
+ // Clear the corresponding credentials
+ // according to the scope and use a mutex to protect them
+ p.mutex.Lock()
+ defer p.mutex.Unlock()
+
+ switch scope {
+ case "all":
+ p.clientInfo = nil
+ p.tokens = nil
+ p.codeVerifier = ""
+ case "client":
+ p.clientInfo = nil
+ case "tokens":
+ p.tokens = nil
+ case "verifier":
+ p.codeVerifier = ""
+ default:
+ return fmt.Errorf("unknown invalidation scope: %s", scope)
+ }
+ return nil
+}
diff --git a/internal/auth/client/memory_provider_test.go b/internal/auth/client/memory_provider_test.go
new file mode 100644
index 0000000..9c173e5
--- /dev/null
+++ b/internal/auth/client/memory_provider_test.go
@@ -0,0 +1,704 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package client
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+func TestNewInMemoryOAuthClientProvider(t *testing.T) {
+ redirectURL := "http://localhost:8080/callback"
+ clientName := "Test Client"
+ scope := "read write"
+ clientMetadata := auth.OAuthClientMetadata{
+ ClientName: &clientName, // Use pointer to string
+ Scope: &scope, // Use pointer to string
+ }
+
+ // Test with onRedirect callback
+ var redirectCalled bool
+ onRedirect := func(u *url.URL) error {
+ redirectCalled = true
+ return nil
+ }
+
+ provider := NewInMemoryOAuthClientProvider(redirectURL, clientMetadata, onRedirect)
+
+ if provider.redirectURL != redirectURL {
+ t.Errorf("Expected redirectURL %s, got %s", redirectURL, provider.redirectURL)
+ }
+
+ if *provider.clientMetadata.ClientName != *clientMetadata.ClientName {
+ t.Errorf("Expected clientMetadata.ClientName %s, got %s",
+ *clientMetadata.ClientName, *provider.clientMetadata.ClientName)
+ }
+
+ if provider.onRedirect == nil {
+ t.Error("Expected onRedirect to be set")
+ }
+
+ // Test that onRedirect was properly set by calling it
+ testURL, _ := url.Parse("https://example.com")
+ provider.onRedirect(testURL)
+ if !redirectCalled {
+ t.Error("Expected onRedirect to be called")
+ }
+
+ // Test with nil onRedirect
+ provider2 := NewInMemoryOAuthClientProvider(redirectURL, clientMetadata, nil)
+ if provider2.onRedirect == nil {
+ t.Error("Expected default onRedirect to be set when nil is passed")
+ }
+
+ // Test default onRedirect doesn't panic
+ err := provider2.onRedirect(&url.URL{})
+ if err != nil {
+ t.Errorf("Expected default onRedirect to return nil, got %v", err)
+ }
+}
+
+func TestRedirectURL(t *testing.T) {
+ redirectURL := "http://localhost:8080/callback"
+ provider := NewInMemoryOAuthClientProvider(redirectURL, auth.OAuthClientMetadata{}, nil)
+
+ if provider.RedirectURL() != redirectURL {
+ t.Errorf("Expected %s, got %s", redirectURL, provider.RedirectURL())
+ }
+}
+
+func TestClientMetadata(t *testing.T) {
+ clientName := "Test Client"
+ scope := "read write"
+ clientMetadata := auth.OAuthClientMetadata{
+ ClientName: &clientName,
+ Scope: &scope,
+ }
+ provider := NewInMemoryOAuthClientProvider("", clientMetadata, nil)
+
+ result := provider.ClientMetadata()
+ if *result.ClientName != *clientMetadata.ClientName {
+ t.Errorf("Expected ClientName %s, got %s", *clientMetadata.ClientName, *result.ClientName)
+ }
+ if *result.Scope != *clientMetadata.Scope {
+ t.Errorf("Expected Scope %s, got %s", *clientMetadata.Scope, *result.Scope)
+ }
+}
+
+func TestClientInformation(t *testing.T) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ // Test when no client information is saved
+ clientInfo := provider.ClientInformation()
+ if clientInfo != nil {
+ t.Error("Expected nil client information when none is saved")
+ }
+
+ // Test after saving client information
+ fullClientInfo := auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: "test-client-id",
+ ClientSecret: "test-client-secret",
+ ClientIDIssuedAt: &[]int64{time.Now().Unix()}[0],
+ ClientSecretExpiresAt: &[]int64{time.Now().Add(time.Hour * 24).Unix()}[0],
+ },
+ }
+
+ err := provider.SaveClientInformation(fullClientInfo)
+ if err != nil {
+ t.Errorf("Expected no error saving client information, got %v", err)
+ }
+
+ clientInfo = provider.ClientInformation()
+ if clientInfo == nil {
+ t.Fatal("Expected client information to be saved")
+ }
+
+ if clientInfo.ClientID != fullClientInfo.ClientID {
+ t.Errorf("Expected ClientID %s, got %s", fullClientInfo.ClientID, clientInfo.ClientID)
+ }
+
+ if clientInfo.ClientSecret != fullClientInfo.ClientSecret {
+ t.Errorf("Expected ClientSecret %s, got %s", fullClientInfo.ClientSecret, clientInfo.ClientSecret)
+ }
+}
+
+func TestTokens(t *testing.T) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ // Test when no tokens are saved
+ tokens, err := provider.Tokens()
+ if err != nil {
+ t.Errorf("Expected no error getting tokens, got %v", err)
+ }
+ if tokens != nil {
+ t.Error("Expected nil tokens when none are saved")
+ }
+
+ // Test saving and retrieving tokens
+ testTokens := auth.OAuthTokens{
+ AccessToken: "test-access-token",
+ RefreshToken: &[]string{"test-refresh-token"}[0], // Use pointer
+ TokenType: "Bearer",
+ ExpiresIn: &[]int64{3600}[0], // Use pointer
+ }
+
+ err = provider.SaveTokens(testTokens)
+ if err != nil {
+ t.Errorf("Expected no error saving tokens, got %v", err)
+ }
+
+ tokens, err = provider.Tokens()
+ if err != nil {
+ t.Errorf("Expected no error getting tokens, got %v", err)
+ }
+
+ if tokens == nil {
+ t.Fatal("Expected tokens to be saved")
+ }
+
+ if tokens.AccessToken != testTokens.AccessToken {
+ t.Errorf("Expected AccessToken %s, got %s", testTokens.AccessToken, tokens.AccessToken)
+ }
+
+ if *tokens.RefreshToken != *testTokens.RefreshToken {
+ t.Errorf("Expected RefreshToken %s, got %s", *testTokens.RefreshToken, *tokens.RefreshToken)
+ }
+}
+
+func TestRedirectToAuthorization(t *testing.T) {
+ var capturedURL *url.URL
+ onRedirect := func(u *url.URL) error {
+ capturedURL = u
+ return nil
+ }
+
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, onRedirect)
+
+ testURL, _ := url.Parse("https://auth.example.com/authorize?response_type=code&client_id=123")
+ err := provider.RedirectToAuthorization(testURL)
+
+ if err != nil {
+ t.Errorf("Expected no error, got %v", err)
+ }
+
+ if capturedURL == nil {
+ t.Error("Expected onRedirect to be called")
+ }
+
+ if capturedURL.String() != testURL.String() {
+ t.Errorf("Expected URL %s, got %s", testURL.String(), capturedURL.String())
+ }
+}
+
+func TestCodeVerifier(t *testing.T) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ // Test when no code verifier is saved
+ verifier, err := provider.CodeVerifier()
+ if err == nil {
+ t.Error("Expected error when no code verifier is saved")
+ }
+ if verifier != "" {
+ t.Error("Expected empty verifier when none is saved")
+ }
+
+ // Test saving and retrieving code verifier
+ testVerifier := "test-code-verifier-12345"
+ err = provider.SaveCodeVerifier(testVerifier)
+ if err != nil {
+ t.Errorf("Expected no error saving code verifier, got %v", err)
+ }
+
+ verifier, err = provider.CodeVerifier()
+ if err != nil {
+ t.Errorf("Expected no error getting code verifier, got %v", err)
+ }
+
+ if verifier != testVerifier {
+ t.Errorf("Expected verifier %s, got %s", testVerifier, verifier)
+ }
+}
+
+func TestState(t *testing.T) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ state1, err := provider.State()
+ if err != nil {
+ t.Errorf("Expected no error generating state, got %v", err)
+ }
+
+ if state1 == "" {
+ t.Error("Expected non-empty state")
+ }
+
+ // Test that subsequent calls generate different states
+ state2, err := provider.State()
+ if err != nil {
+ t.Errorf("Expected no error generating state, got %v", err)
+ }
+
+ if state1 == state2 {
+ t.Error("Expected different states on subsequent calls")
+ }
+
+ /// Test that state is base64 URL encoded (no + or / characters, = padding is allowed)
+ if strings.Contains(state1, "+") || strings.Contains(state1, "/") {
+ t.Error("Expected URL-safe base64 encoding (no + or / characters)")
+ }
+}
+
+func TestAddClientAuthentication(t *testing.T) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ // Test with no client information
+ headers := make(http.Header)
+ params := make(url.Values)
+ err := provider.AddClientAuthentication(headers, params, "https://token.example.com")
+ if err != nil {
+ t.Errorf("Expected no error with no client info, got %v", err)
+ }
+
+ // Should not add any parameters when no client info
+ if params.Get("client_id") != "" {
+ t.Error("Expected no client_id when no client info is saved")
+ }
+
+ // Test with client information
+ clientInfo := auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: "test-client-id",
+ ClientSecret: "test-client-secret",
+ },
+ }
+ err = provider.SaveClientInformation(clientInfo)
+ if err != nil {
+ t.Errorf("Expected no error saving client info, got %v", err)
+ }
+
+ params = make(url.Values)
+ err = provider.AddClientAuthentication(headers, params, "https://token.example.com")
+ if err != nil {
+ t.Errorf("Expected no error with client info, got %v", err)
+ }
+
+ if params.Get("client_id") != clientInfo.ClientID {
+ t.Errorf("Expected client_id %s, got %s", clientInfo.ClientID, params.Get("client_id"))
+ }
+
+ if params.Get("client_secret") != clientInfo.ClientSecret {
+ t.Errorf("Expected client_secret %s, got %s", clientInfo.ClientSecret, params.Get("client_secret"))
+ }
+
+ // Test with only client ID (no secret)
+ clientInfoNoSecret := auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: "test-client-id-only",
+ },
+ }
+ err = provider.SaveClientInformation(clientInfoNoSecret)
+ if err != nil {
+ t.Errorf("Expected no error saving client info, got %v", err)
+ }
+
+ params = make(url.Values)
+ err = provider.AddClientAuthentication(headers, params, "https://token.example.com")
+ if err != nil {
+ t.Errorf("Expected no error with client info, got %v", err)
+ }
+
+ if params.Get("client_id") != clientInfoNoSecret.ClientID {
+ t.Errorf("Expected client_id %s, got %s", clientInfoNoSecret.ClientID, params.Get("client_id"))
+ }
+
+ if params.Get("client_secret") != "" {
+ t.Error("Expected no client_secret when not provided")
+ }
+}
+
+func TestValidateResourceURL(t *testing.T) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+ serverURL, _ := url.Parse("https://api.example.com")
+
+ // Test with nil resource metadata
+ result, err := provider.ValidateResourceURL(serverURL, nil)
+ if err != nil {
+ t.Errorf("Expected no error with nil metadata, got %v", err)
+ }
+ if result != nil {
+ t.Error("Expected nil result with nil metadata")
+ }
+
+ // Test with valid resource URL (same origin)
+ resourceMetadata := &auth.OAuthProtectedResourceMetadata{
+ Resource: "https://api.example.com/data",
+ }
+
+ result, err = provider.ValidateResourceURL(serverURL, resourceMetadata)
+ if err != nil {
+ t.Errorf("Expected no error with valid resource URL, got %v", err)
+ }
+ if result == nil {
+ t.Fatal("Expected non-nil result")
+ }
+ if result.String() != resourceMetadata.Resource {
+ t.Errorf("Expected resource URL %s, got %s", resourceMetadata.Resource, result.String())
+ }
+
+ // Test with valid resource URL (subpath)
+ resourceMetadata2 := &auth.OAuthProtectedResourceMetadata{
+ Resource: "https://api.example.com/v1/users",
+ }
+
+ result, err = provider.ValidateResourceURL(serverURL, resourceMetadata2)
+ if err != nil {
+ t.Errorf("Expected no error with valid subpath resource URL, got %v", err)
+ }
+ if result == nil {
+ t.Fatal("Expected non-nil result")
+ }
+
+ // Test with different scheme
+ resourceMetadata3 := &auth.OAuthProtectedResourceMetadata{
+ Resource: "http://api.example.com/data",
+ }
+
+ result, err = provider.ValidateResourceURL(serverURL, resourceMetadata3)
+ if err == nil {
+ t.Error("Expected error with different scheme")
+ }
+ if result != nil {
+ t.Error("Expected nil result with invalid URL")
+ }
+
+ // Test with different host
+ resourceMetadata4 := &auth.OAuthProtectedResourceMetadata{
+ Resource: "https://other.example.com/data",
+ }
+
+ result, err = provider.ValidateResourceURL(serverURL, resourceMetadata4)
+ if err == nil {
+ t.Error("Expected error with different host")
+ }
+ if result != nil {
+ t.Error("Expected nil result with invalid URL")
+ }
+
+ // Test with invalid URL
+ resourceMetadata5 := &auth.OAuthProtectedResourceMetadata{
+ Resource: "://invalid-url",
+ }
+
+ result, err = provider.ValidateResourceURL(serverURL, resourceMetadata5)
+ if err == nil {
+ t.Error("Expected error with invalid URL")
+ }
+ if result != nil {
+ t.Error("Expected nil result with invalid URL")
+ }
+}
+
+func TestInvalidateCredentials(t *testing.T) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ // Set up some data first
+ clientInfo := auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ },
+ }
+ provider.SaveClientInformation(clientInfo)
+
+ tokens := auth.OAuthTokens{
+ AccessToken: "test-access-token",
+ }
+ provider.SaveTokens(tokens)
+
+ provider.SaveCodeVerifier("test-verifier")
+
+ // Test invalidating all credentials
+ err := provider.InvalidateCredentials("all")
+ if err != nil {
+ t.Errorf("Expected no error invalidating all credentials, got %v", err)
+ }
+
+ if provider.ClientInformation() != nil {
+ t.Error("Expected client information to be cleared")
+ }
+
+ savedTokens, _ := provider.Tokens()
+ if savedTokens != nil {
+ t.Error("Expected tokens to be cleared")
+ }
+
+ _, err = provider.CodeVerifier()
+ if err == nil {
+ t.Error("Expected error getting code verifier after clearing")
+ }
+
+ // Set up data again for individual tests
+ provider.SaveClientInformation(clientInfo)
+ provider.SaveTokens(tokens)
+ provider.SaveCodeVerifier("test-verifier")
+
+ // Test invalidating only client info
+ err = provider.InvalidateCredentials("client")
+ if err != nil {
+ t.Errorf("Expected no error invalidating client credentials, got %v", err)
+ }
+
+ if provider.ClientInformation() != nil {
+ t.Error("Expected client information to be cleared")
+ }
+
+ savedTokens, _ = provider.Tokens()
+ if savedTokens == nil {
+ t.Error("Expected tokens to remain")
+ }
+
+ // Test invalidating only tokens
+ provider.SaveClientInformation(clientInfo) // Restore client info
+ err = provider.InvalidateCredentials("tokens")
+ if err != nil {
+ t.Errorf("Expected no error invalidating tokens, got %v", err)
+ }
+
+ savedTokens, _ = provider.Tokens()
+ if savedTokens != nil {
+ t.Error("Expected tokens to be cleared")
+ }
+
+ if provider.ClientInformation() == nil {
+ t.Error("Expected client information to remain")
+ }
+
+ // Test invalidating only code verifier
+ provider.SaveTokens(tokens) // Restore tokens
+ provider.SaveCodeVerifier("test-verifier") // Restore verifier
+
+ err = provider.InvalidateCredentials("verifier")
+ if err != nil {
+ t.Errorf("Expected no error invalidating verifier, got %v", err)
+ }
+
+ _, err = provider.CodeVerifier()
+ if err == nil {
+ t.Error("Expected error getting code verifier after clearing")
+ }
+
+ // Other data should remain
+ if provider.ClientInformation() == nil {
+ t.Error("Expected client information to remain")
+ }
+
+ savedTokens, _ = provider.Tokens()
+ if savedTokens == nil {
+ t.Error("Expected tokens to remain")
+ }
+
+ // Test invalid scope
+ err = provider.InvalidateCredentials("invalid-scope")
+ if err == nil {
+ t.Error("Expected error with invalid scope")
+ }
+
+ if !strings.Contains(err.Error(), "unknown invalidation scope") {
+ t.Errorf("Expected 'unknown invalidation scope' error, got %v", err)
+ }
+}
+
+func TestConcurrentAccess(t *testing.T) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ // Test concurrent access to ensure thread safety
+ var wg sync.WaitGroup
+ numGoroutines := 10
+
+ // Test concurrent writes
+ wg.Add(numGoroutines)
+ for i := 0; i < numGoroutines; i++ {
+ go func(id int) {
+ defer wg.Done()
+
+ clientInfo := auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: fmt.Sprintf("client-%d", id),
+ ClientSecret: fmt.Sprintf("secret-%d", id),
+ },
+ }
+ provider.SaveClientInformation(clientInfo)
+
+ tokens := auth.OAuthTokens{
+ AccessToken: fmt.Sprintf("token-%d", id),
+ }
+ provider.SaveTokens(tokens)
+
+ provider.SaveCodeVerifier(fmt.Sprintf("verifier-%d", id))
+ }(i)
+ }
+
+ wg.Wait()
+
+ // Test concurrent reads
+ wg.Add(numGoroutines)
+ for i := 0; i < numGoroutines; i++ {
+ go func() {
+ defer wg.Done()
+
+ provider.ClientInformation()
+ provider.Tokens()
+ provider.CodeVerifier()
+ }()
+ }
+
+ wg.Wait()
+
+ // Test concurrent invalidation
+ wg.Add(numGoroutines)
+ for i := 0; i < numGoroutines; i++ {
+ go func(id int) {
+ defer wg.Done()
+
+ scopes := []string{"client", "tokens", "verifier", "all"}
+ scope := scopes[id%len(scopes)]
+ provider.InvalidateCredentials(scope)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestStateRandomness(t *testing.T) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ // Generate multiple states and ensure they're different
+ states := make(map[string]bool)
+ for i := 0; i < 10; i++ {
+ state, err := provider.State()
+ if err != nil {
+ t.Errorf("Expected no error generating state, got %v", err)
+ }
+
+ if states[state] {
+ t.Errorf("Generated duplicate state: %s", state)
+ }
+ states[state] = true
+
+ // Verify length (32 bytes base64 encoded should be ~43 characters)
+ if len(state) < 40 {
+ t.Errorf("Expected state length >= 40, got %d", len(state))
+ }
+ }
+}
+
+func TestRedirectToAuthorizationError(t *testing.T) {
+ expectedErr := fmt.Errorf("redirect failed")
+ onRedirect := func(u *url.URL) error {
+ return expectedErr
+ }
+
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, onRedirect)
+
+ testURL, _ := url.Parse("https://auth.example.com/authorize")
+ err := provider.RedirectToAuthorization(testURL)
+
+ if err != expectedErr {
+ t.Errorf("Expected error %v, got %v", expectedErr, err)
+ }
+}
+
+func TestMutexProtection(t *testing.T) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ // Test that mutex properly protects against race conditions
+ // This test runs multiple operations concurrently and ensures no data races
+ var wg sync.WaitGroup
+ numOperations := 100
+
+ wg.Add(numOperations)
+ for i := 0; i < numOperations; i++ {
+ go func(id int) {
+ defer wg.Done()
+
+ // Mix of read and write operations
+ switch id % 4 {
+ case 0:
+ clientInfo := auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: fmt.Sprintf("client-%d", id),
+ },
+ }
+ provider.SaveClientInformation(clientInfo)
+ case 1:
+ provider.ClientInformation()
+ case 2:
+ tokens := auth.OAuthTokens{
+ AccessToken: fmt.Sprintf("token-%d", id),
+ }
+ provider.SaveTokens(tokens)
+ case 3:
+ provider.Tokens()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+
+ // If we reach here without data races, the mutex protection is working
+}
+
+// Benchmark tests
+func BenchmarkState(b *testing.B) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := provider.State()
+ if err != nil {
+ b.Fatalf("Error generating state: %v", err)
+ }
+ }
+}
+
+func BenchmarkConcurrentAccess(b *testing.B) {
+ provider := NewInMemoryOAuthClientProvider("", auth.OAuthClientMetadata{}, nil)
+
+ // Setup initial data
+ clientName := "bench-client"
+ clientInfo := auth.OAuthClientInformationFull{
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ ClientName: &clientName,
+ },
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: "bench-client-id",
+ ClientSecret: "bench-secret",
+ },
+ }
+ provider.SaveClientInformation(clientInfo)
+
+ tokens := auth.OAuthTokens{
+ AccessToken: "bench-token",
+ }
+ provider.SaveTokens(tokens)
+
+ b.ResetTimer()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ // Mix of read operations
+ provider.ClientInformation()
+ provider.Tokens()
+ }
+ })
+}
diff --git a/internal/auth/client/provider.go b/internal/auth/client/provider.go
new file mode 100644
index 0000000..842c41e
--- /dev/null
+++ b/internal/auth/client/provider.go
@@ -0,0 +1,67 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package client
+
+import (
+ "net/http"
+ "net/url"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+// OAuthClientProvider defines core OAuth 2.0 client operations
+// Provides client configuration, token management, and authorization flow handling
+type OAuthClientProvider interface {
+ // RedirectURL returns the client redirect URL for authorization
+ RedirectURL() string
+
+ // ClientMetadata returns static client metadata such as redirect URIs
+ ClientMetadata() auth.OAuthClientMetadata
+
+ // ClientInformation returns current client credentials if available
+ ClientInformation() *auth.OAuthClientInformation
+
+ // Tokens returns the current access and refresh tokens
+ Tokens() (*auth.OAuthTokens, error)
+
+ // SaveTokens persists the given OAuth tokens
+ SaveTokens(tokens auth.OAuthTokens) error
+
+ // RedirectToAuthorization handles redirection to the authorization endpoint
+ RedirectToAuthorization(authorizationUrl *url.URL) error
+
+ // SaveCodeVerifier persists the PKCE code verifier for later token exchange
+ SaveCodeVerifier(codeVerifier string) error
+
+ // CodeVerifier retrieves the stored PKCE code verifier
+ CodeVerifier() (string, error)
+}
+
+// OAuthStateProvider adds state parameter management for CSRF protection.
+type OAuthStateProvider interface {
+ State() (string, error)
+}
+
+// OAuthClientInfoProvider handles dynamic client credential storage.
+type OAuthClientInfoProvider interface {
+ SaveClientInformation(clientInformation auth.OAuthClientInformationFull) error
+}
+
+// OAuthClientAuthProvider enables custom client authentication methods.
+type OAuthClientAuthProvider interface {
+ AddClientAuthentication(headers http.Header, params url.Values, tokenUrl string) error
+}
+
+// OAuthResourceValidator validates resource URLs for specific server requirements.
+type OAuthResourceValidator interface {
+ ValidateResourceURL(serverUrl *url.URL, resourceMetadata *auth.OAuthProtectedResourceMetadata) (*url.URL, error)
+}
+
+// OAuthCredentialInvalidator handles logout and credential revocation.
+type OAuthCredentialInvalidator interface {
+ InvalidateCredentials(scope string) error
+}
diff --git a/internal/auth/pkce/utils.go b/internal/auth/pkce/utils.go
new file mode 100644
index 0000000..ebe5b70
--- /dev/null
+++ b/internal/auth/pkce/utils.go
@@ -0,0 +1,107 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package pkce
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "regexp"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+)
+
+// PKCEChallenge holds PKCE code verifier and challenge
+type PKCEChallenge struct {
+ // CodeVerifier is the high-entropy cryptographic random string
+ CodeVerifier string
+ // CodeChallenge is the derived challenge from the code verifier
+ CodeChallenge string
+}
+
+// ValidatePKCEParams validates the PKCE parameters provided in the authorization request.
+func ValidatePKCEParams(params server.AuthorizationParams) error {
+ if params.CodeChallenge == "" {
+ return fmt.Errorf("code_challenge is required")
+ }
+
+ // Verify code_challenge length (RFC 7636: 43-128 characters)
+ if len(params.CodeChallenge) < 43 || len(params.CodeChallenge) > 128 {
+ return fmt.Errorf("code_challenge length must be between 43 and 128 characters")
+ }
+
+ // Verify code_challenge format (BASE64URL)
+ if !isValidBase64URL(params.CodeChallenge) {
+ return fmt.Errorf("code_challenge must be valid BASE64URL")
+ }
+
+ return nil
+}
+
+// isValidBase64URL checks whether the given string is a valid Base64URL-encoded value
+// and decodes to exactly 32 bytes (the output size of SHA-256).
+func isValidBase64URL(s string) bool {
+ // Length check
+ if len(s) < 43 || len(s) > 128 {
+ return false
+ }
+
+ // Character set validation
+ base64URLPattern := `^[A-Za-z0-9_-]+$`
+ matched, err := regexp.MatchString(base64URLPattern, s)
+ if err != nil || !matched {
+ return false
+ }
+
+ // Try decoding verification
+ decoded, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil {
+ return false
+ }
+
+ // For code_challenge, it should be 32 bytes after decoding (SHA256 hash)
+ if len(decoded) != 32 {
+ return false
+ }
+
+ return true
+}
+
+// VerifyPKCEChallenge verifies the PKCE code_verifier against the code_challenge
+func VerifyPKCEChallenge(codeVerifier, codeChallenge string) bool {
+ if codeVerifier == "" || codeChallenge == "" {
+ return false
+ }
+
+ // Create SHA256 hash of the code_verifier
+ hash := sha256.Sum256([]byte(codeVerifier))
+
+ // Base64 URL encode the hash
+ computedChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
+
+ return computedChallenge == codeChallenge
+}
+
+// GeneratePKCEChallenge generates a new PKCE pair (code_verifier and code_challenge).
+func GeneratePKCEChallenge() (*PKCEChallenge, error) {
+ // Generate 43-128 character code_verifier (RFC 7636)
+ verifierBytes := make([]byte, 32) // 32 bytes = 43 chars in base64url
+ if _, err := rand.Read(verifierBytes); err != nil {
+ return nil, fmt.Errorf("failed to generate code verifier: %w", err)
+ }
+
+ codeVerifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
+
+ // Generate code_challenge using S256 method
+ hash := sha256.Sum256([]byte(codeVerifier))
+ codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
+
+ return &PKCEChallenge{
+ CodeVerifier: codeVerifier,
+ CodeChallenge: codeChallenge,
+ }, nil
+}
diff --git a/internal/auth/pkce/utils_test.go b/internal/auth/pkce/utils_test.go
new file mode 100644
index 0000000..17cd99d
--- /dev/null
+++ b/internal/auth/pkce/utils_test.go
@@ -0,0 +1,131 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package pkce
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "testing"
+)
+
+// genValidBase64URLDigest generates a valid base64url-encoded SHA256 digest
+func genValidBase64URLDigest(t *testing.T) string {
+ t.Helper()
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ t.Fatalf("failed to read random: %v", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(buf)
+}
+
+func TestIsValidBase64URL_Valid(t *testing.T) {
+ s := genValidBase64URLDigest(t)
+ if !isValidBase64URL(s) {
+ t.Fatalf("expected valid base64url digest, got invalid: %q", s)
+ }
+}
+
+func TestIsValidBase64URL_InvalidChars(t *testing.T) {
+ // '+' and '/' are invalid in base64url
+ s := "abcd+efg_hijklmnopqrstuvwxyz0123456789abcd"
+ if isValidBase64URL(s) {
+ t.Fatalf("expected invalid due to characters, got valid: %q", s)
+ }
+}
+
+func TestIsValidBase64URL_WrongDecodedLen(t *testing.T) {
+ // 31 bytes decoded -> invalid
+ decoded31 := make([]byte, 31)
+ s31 := base64.RawURLEncoding.EncodeToString(decoded31)
+ if isValidBase64URL(s31) {
+ t.Fatalf("expected invalid due to decoded len != 32, got valid")
+ }
+
+ // 33 bytes decoded -> invalid
+ decoded33 := make([]byte, 33)
+ s33 := base64.RawURLEncoding.EncodeToString(decoded33)
+ if isValidBase64URL(s33) {
+ t.Fatalf("expected invalid due to decoded len != 32, got valid")
+ }
+}
+
+func TestVerifyPKCEChallenge_Match(t *testing.T) {
+ verifierBytes := make([]byte, 32)
+ if _, err := rand.Read(verifierBytes); err != nil {
+ t.Fatalf("rand read: %v", err)
+ }
+ codeVerifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
+
+ sum := sha256.Sum256([]byte(codeVerifier))
+ expectedChallenge := base64.RawURLEncoding.EncodeToString(sum[:])
+
+ if !VerifyPKCEChallenge(codeVerifier, expectedChallenge) {
+ t.Fatalf("expected challenge to verify")
+ }
+}
+
+func TestVerifyPKCEChallenge_Mismatch(t *testing.T) {
+ verifierBytes := make([]byte, 32)
+ if _, err := rand.Read(verifierBytes); err != nil {
+ t.Fatalf("rand read: %v", err)
+ }
+ codeVerifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
+
+ sum := sha256.Sum256([]byte(codeVerifier))
+ expectedChallenge := base64.RawURLEncoding.EncodeToString(sum[:])
+
+ // tamper the verifier
+ codeVerifier += "A"
+
+ if VerifyPKCEChallenge(codeVerifier, expectedChallenge) {
+ t.Fatalf("expected verification to fail for mismatched verifier")
+ }
+}
+
+func TestGeneratePKCEChallenge(t *testing.T) {
+ pair, err := GeneratePKCEChallenge()
+ if err != nil {
+ t.Fatalf("GeneratePKCEChallenge returned error: %v", err)
+ }
+ if pair == nil {
+ t.Fatalf("expected non-nil pair")
+ }
+ if pair.CodeVerifier == "" || pair.CodeChallenge == "" {
+ t.Fatalf("expected non-empty verifier and challenge")
+ }
+
+ // RFC 7636 requires 43..128 characters for the verifier
+ if l := len(pair.CodeVerifier); l < 43 || l > 128 {
+ t.Fatalf("code_verifier length must be in [43,128], got %d", l)
+ }
+
+ // Challenge should be a base64url-encoded SHA256 digest (valid and 32 bytes decoded)
+ if !isValidBase64URL(pair.CodeChallenge) {
+ t.Fatalf("code_challenge should be valid base64url digest")
+ }
+
+ // Verify Challenge == S256(verifier)
+ sum := sha256.Sum256([]byte(pair.CodeVerifier))
+ expectedChallenge := base64.RawURLEncoding.EncodeToString(sum[:])
+ if pair.CodeChallenge != expectedChallenge {
+ t.Fatalf("code_challenge mismatch: got %q want %q", pair.CodeChallenge, expectedChallenge)
+ }
+
+ // And VerifyPKCEChallenge should pass
+ if !VerifyPKCEChallenge(pair.CodeVerifier, pair.CodeChallenge) {
+ t.Fatalf("VerifyPKCEChallenge should return true for generated pair")
+ }
+}
+
+func BenchmarkGeneratePKCEChallenge(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ if _, err := GeneratePKCEChallenge(); err != nil {
+ b.Fatalf("GeneratePKCEChallenge error: %v", err)
+ }
+ }
+}
diff --git a/internal/auth/server/clients.go b/internal/auth/server/clients.go
new file mode 100644
index 0000000..85ebc11
--- /dev/null
+++ b/internal/auth/server/clients.go
@@ -0,0 +1,81 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package server
+
+import (
+ "fmt"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+// OAuthClientsStoreInterface defines retrieval and optional dynamic registration for OAuth clients registered with this server
+type OAuthClientsStoreInterface interface {
+ // GetClient returns information about a registered client by its ID or nil if not found
+ GetClient(clientId string) (*auth.OAuthClientInformationFull, error)
+
+ // SupportDynamicClientRegistration adds optional dynamic client registration capability
+ // Implementations may return a modified client to reflect server enforced values
+ // Implementations should not delete expired client secrets in place
+ // Middleware validates client_secret_expires_at and rejects expired secrets
+ SupportDynamicClientRegistration
+}
+
+// SupportDynamicClientRegistration exposes the RegisterClient operation for dynamic client registration
+type SupportDynamicClientRegistration interface {
+ // RegisterClient registers a new OAuth client and returns the stored client record
+ RegisterClient(client auth.OAuthClientInformationFull) (*auth.OAuthClientInformationFull, error)
+}
+
+// OAuthClientsStore is a functional store adapter for OAuth client retrieval and optional registration
+type OAuthClientsStore struct {
+ getClient func(clientID string) (*auth.OAuthClientInformationFull, error) // lookup function injected by caller
+ registerClient func(client auth.OAuthClientInformationFull) (*auth.OAuthClientInformationFull, error) // optional registration function injected by caller
+}
+
+// GetClient returns the client record for the given clientID or an error from the underlying store
+func (s OAuthClientsStore) GetClient(clientID string) (*auth.OAuthClientInformationFull, error) {
+ // Delegate to injected lookup function
+ return s.getClient(clientID)
+}
+
+// RegisterClient registers a new client if dynamic registration is supported otherwise returns an error
+func (s OAuthClientsStore) RegisterClient(client auth.OAuthClientInformationFull) (*auth.OAuthClientInformationFull, error) {
+ // If no registration function is provided dynamic registration is not supported
+ if s.registerClient == nil {
+ return nil, fmt.Errorf("dynamic client registration is not supported")
+ }
+ // Delegate to injected registration function
+ return s.registerClient(client)
+}
+
+// NewOAuthClientStoreSupportDynamicRegistration constructs a store with both lookup and registration support
+func NewOAuthClientStoreSupportDynamicRegistration(
+ getClient func(clientID string) (*auth.OAuthClientInformationFull, error),
+ registerClient func(client auth.OAuthClientInformationFull) (*auth.OAuthClientInformationFull, error),
+) *OAuthClientsStore {
+ // Inject both handlers to enable dynamic registration support
+ return &OAuthClientsStore{
+ getClient: getClient,
+ registerClient: registerClient,
+ }
+}
+
+// NewOAuthClientStore constructs a store that supports only client lookup
+func NewOAuthClientStore(
+ getClient func(clientID string) (*auth.OAuthClientInformationFull, error),
+) *OAuthClientsStore {
+ // Inject lookup handler only leaving registration unsupported
+ return &OAuthClientsStore{
+ getClient: getClient,
+ }
+}
+
+// SupportsRegistration returns true if dynamic client registration is supported
+func (s OAuthClientsStore) SupportsRegistration() bool {
+ // Registration supported when a registration function is present
+ return s.registerClient != nil
+}
diff --git a/internal/auth/server/clients_test.go b/internal/auth/server/clients_test.go
new file mode 100644
index 0000000..6ab5bf5
--- /dev/null
+++ b/internal/auth/server/clients_test.go
@@ -0,0 +1,323 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package server
+
+import (
+ "errors"
+ "testing"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+// Test errors used by mocks
+var (
+ ErrClientNotFound = errors.New("client not found")
+ ErrGetClientFailed = errors.New("get client failed")
+ ErrRegisterClientFailed = errors.New("register client failed")
+)
+
+// mockGetClientSuccess returns a known client for id existing-client or ErrClientNotFound
+func mockGetClientSuccess(clientID string) (*auth.OAuthClientInformationFull, error) {
+ if clientID == "existing-client" {
+ return &auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: clientID,
+ ClientSecret: "secret-123",
+ },
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://example.com/callback"},
+ },
+ }, nil
+ }
+ return nil, ErrClientNotFound
+}
+
+// mockGetClientError always returns ErrGetClientFailed
+func mockGetClientError(clientID string) (*auth.OAuthClientInformationFull, error) {
+ return nil, ErrGetClientFailed
+}
+
+// mockRegisterClientSuccess simulates server generating client_id and client_secret
+func mockRegisterClientSuccess(client auth.OAuthClientInformationFull) (*auth.OAuthClientInformationFull, error) {
+ client.ClientID = "generated-client-id"
+ client.ClientSecret = "generated-secret"
+ return &client, nil
+}
+
+// mockRegisterClientError always returns ErrRegisterClientFailed
+func mockRegisterClientError(client auth.OAuthClientInformationFull) (*auth.OAuthClientInformationFull, error) {
+ return nil, ErrRegisterClientFailed
+}
+
+func TestNewOAuthClientStore(t *testing.T) {
+ store := NewOAuthClientStore(mockGetClientSuccess)
+
+ if store == nil {
+ t.Fatal("NewOAuthClientStore returned nil")
+ }
+
+ // Should not support registration
+ if store.SupportsRegistration() {
+ t.Error("basic store should not support registration")
+ }
+
+ // GetClient should work
+ client, err := store.GetClient("existing-client")
+ if err != nil {
+ t.Fatalf("GetClient failed: %v", err)
+ }
+ if client.ClientID != "existing-client" {
+ t.Errorf("expected client ID 'existing-client', got %s", client.ClientID)
+ }
+
+ // RegisterClient should return error
+ newClient := auth.OAuthClientInformationFull{
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://test.com/callback"},
+ },
+ }
+ _, err = store.RegisterClient(newClient)
+ if err == nil {
+ t.Error("RegisterClient should fail on basic store")
+ }
+ if err.Error() != "dynamic client registration is not supported" {
+ t.Errorf("unexpected error message: %s", err.Error())
+ }
+}
+
+func TestNewOAuthClientStoreSupportDynamicRegistration(t *testing.T) {
+ store := NewOAuthClientStoreSupportDynamicRegistration(mockGetClientSuccess, mockRegisterClientSuccess)
+
+ if store == nil {
+ t.Fatal("NewOAuthClientStoreSupportDynamicRegistration returned nil")
+ }
+
+ // Should support registration
+ if !store.SupportsRegistration() {
+ t.Error("dynamic registration store should support registration")
+ }
+
+ // GetClient should work
+ client, err := store.GetClient("existing-client")
+ if err != nil {
+ t.Fatalf("GetClient failed: %v", err)
+ }
+ if client.ClientID != "existing-client" {
+ t.Errorf("expected client ID 'existing-client', got %s", client.ClientID)
+ }
+
+ // RegisterClient should work
+ newClient := auth.OAuthClientInformationFull{
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://test.com/callback"},
+ },
+ }
+ registered, err := store.RegisterClient(newClient)
+ if err != nil {
+ t.Fatalf("RegisterClient failed: %v", err)
+ }
+ if registered.ClientID != "generated-client-id" {
+ t.Errorf("expected generated client ID, got %s", registered.ClientID)
+ }
+ if registered.ClientSecret != "generated-secret" {
+ t.Errorf("expected generated secret, got %s", registered.ClientSecret)
+ }
+}
+
+func TestOAuthClientsStore_GetClient_Success(t *testing.T) {
+ store := NewOAuthClientStore(mockGetClientSuccess)
+
+ // Test existing client
+ client, err := store.GetClient("existing-client")
+ if err != nil {
+ t.Fatalf("GetClient failed: %v", err)
+ }
+ if client.ClientID != "existing-client" {
+ t.Errorf("expected client ID 'existing-client', got %s", client.ClientID)
+ }
+ if client.ClientSecret != "secret-123" {
+ t.Errorf("expected secret 'secret-123', got %s", client.ClientSecret)
+ }
+ if len(client.RedirectURIs) != 1 || client.RedirectURIs[0] != "https://example.com/callback" {
+ t.Errorf("unexpected redirect URIs: %v", client.RedirectURIs)
+ }
+}
+
+func TestOAuthClientsStore_GetClient_NotFound(t *testing.T) {
+ store := NewOAuthClientStore(mockGetClientSuccess)
+
+ // Test non-existing client
+ client, err := store.GetClient("non-existing-client")
+ if err != ErrClientNotFound {
+ t.Errorf("expected ErrClientNotFound, got %v", err)
+ }
+ if client != nil {
+ t.Error("expected nil client for not found case")
+ }
+}
+
+func TestOAuthClientsStore_GetClient_Error(t *testing.T) {
+ store := NewOAuthClientStore(mockGetClientError)
+
+ // Test error case
+ client, err := store.GetClient("any-client")
+ if err != ErrGetClientFailed {
+ t.Errorf("expected ErrGetClientFailed, got %v", err)
+ }
+ if client != nil {
+ t.Error("expected nil client for error case")
+ }
+}
+
+func TestOAuthClientsStore_RegisterClient_Supported(t *testing.T) {
+ store := NewOAuthClientStoreSupportDynamicRegistration(mockGetClientSuccess, mockRegisterClientSuccess)
+
+ newClient := auth.OAuthClientInformationFull{
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://test.com/callback"},
+ ClientName: stringPtr("Test Client"),
+ },
+ }
+
+ registered, err := store.RegisterClient(newClient)
+ if err != nil {
+ t.Fatalf("RegisterClient failed: %v", err)
+ }
+
+ if registered.ClientID != "generated-client-id" {
+ t.Errorf("expected generated client ID, got %s", registered.ClientID)
+ }
+ if registered.ClientSecret != "generated-secret" {
+ t.Errorf("expected generated secret, got %s", registered.ClientSecret)
+ }
+ // Original metadata should be preserved
+ if registered.ClientName == nil || *registered.ClientName != "Test Client" {
+ t.Errorf("client name not preserved")
+ }
+}
+
+func TestOAuthClientsStore_RegisterClient_NotSupported(t *testing.T) {
+ store := NewOAuthClientStore(mockGetClientSuccess)
+
+ newClient := auth.OAuthClientInformationFull{
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://test.com/callback"},
+ },
+ }
+
+ registered, err := store.RegisterClient(newClient)
+ if err == nil {
+ t.Error("RegisterClient should fail when not supported")
+ }
+ if err.Error() != "dynamic client registration is not supported" {
+ t.Errorf("unexpected error message: %s", err.Error())
+ }
+ if registered != nil {
+ t.Error("expected nil result for unsupported registration")
+ }
+}
+
+func TestOAuthClientsStore_RegisterClient_Error(t *testing.T) {
+ store := NewOAuthClientStoreSupportDynamicRegistration(mockGetClientSuccess, mockRegisterClientError)
+
+ newClient := auth.OAuthClientInformationFull{
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://test.com/callback"},
+ },
+ }
+
+ registered, err := store.RegisterClient(newClient)
+ if err != ErrRegisterClientFailed {
+ t.Errorf("expected ErrRegisterClientFailed, got %v", err)
+ }
+ if registered != nil {
+ t.Error("expected nil result for failed registration")
+ }
+}
+
+func TestOAuthClientsStore_SupportsRegistration(t *testing.T) {
+ // Test store without registration support
+ basicStore := NewOAuthClientStore(mockGetClientSuccess)
+ if basicStore.SupportsRegistration() {
+ t.Error("basic store should not support registration")
+ }
+
+ // Test store with registration support
+ dynamicStore := NewOAuthClientStoreSupportDynamicRegistration(mockGetClientSuccess, mockRegisterClientSuccess)
+ if !dynamicStore.SupportsRegistration() {
+ t.Error("dynamic store should support registration")
+ }
+}
+
+func TestOAuthClientsStore_InterfaceCompliance(t *testing.T) {
+ // Test that OAuthClientsStore implements OAuthClientsStoreInterface
+ var _ OAuthClientsStoreInterface = &OAuthClientsStore{}
+
+ // Test that stores with registration support implement SupportDynamicClientRegistration
+ dynamicStore := NewOAuthClientStoreSupportDynamicRegistration(mockGetClientSuccess, mockRegisterClientSuccess)
+ var _ SupportDynamicClientRegistration = dynamicStore
+
+ // Verify interface methods work as expected
+ store := NewOAuthClientStoreSupportDynamicRegistration(mockGetClientSuccess, mockRegisterClientSuccess)
+
+ // Test as OAuthClientsStoreInterface
+ var iface OAuthClientsStoreInterface = store
+ client, err := iface.GetClient("existing-client")
+ if err != nil {
+ t.Fatalf("interface GetClient failed: %v", err)
+ }
+ if client.ClientID != "existing-client" {
+ t.Errorf("interface GetClient returned wrong client ID")
+ }
+
+ // Test as SupportDynamicClientRegistration
+ var regIface SupportDynamicClientRegistration = store
+ newClient := auth.OAuthClientInformationFull{
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://test.com/callback"},
+ },
+ }
+ registered, err := regIface.RegisterClient(newClient)
+ if err != nil {
+ t.Fatalf("interface RegisterClient failed: %v", err)
+ }
+ if registered.ClientID != "generated-client-id" {
+ t.Errorf("interface RegisterClient returned wrong client ID")
+ }
+}
+
+func TestOAuthClientsStore_EdgeCases(t *testing.T) {
+ t.Run("empty client ID", func(t *testing.T) {
+ store := NewOAuthClientStore(mockGetClientSuccess)
+ client, err := store.GetClient("")
+ if err != ErrClientNotFound {
+ t.Errorf("expected ErrClientNotFound for empty client ID, got %v", err)
+ }
+ if client != nil {
+ t.Error("expected nil client for empty client ID")
+ }
+ })
+
+ t.Run("register client with empty metadata", func(t *testing.T) {
+ store := NewOAuthClientStoreSupportDynamicRegistration(mockGetClientSuccess, mockRegisterClientSuccess)
+
+ // Test registering a client with minimal metadata
+ emptyClient := auth.OAuthClientInformationFull{}
+ registered, err := store.RegisterClient(emptyClient)
+ if err != nil {
+ t.Errorf("registering empty client failed: %v", err)
+ }
+ if registered.ClientID != "generated-client-id" {
+ t.Errorf("expected generated client ID even for empty client")
+ }
+ })
+}
+
+// stringPtr returns a pointer to s
+func stringPtr(s string) *string {
+ return &s
+}
diff --git a/internal/auth/server/handler/authorize.go b/internal/auth/server/handler/authorize.go
new file mode 100644
index 0000000..c062856
--- /dev/null
+++ b/internal/auth/server/handler/authorize.go
@@ -0,0 +1,350 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package handler
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/go-playground/validator/v10"
+ "golang.org/x/time/rate"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/pkce"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/middleware"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// AuthorizationHandlerOptions contains configuration for the /authorize endpoint handler.
+type AuthorizationHandlerOptions struct {
+ // Provider is the OAuth server implementation that issues authorization codes
+ // and validates client information.
+ Provider server.OAuthServerProvider `json:"provider"`
+
+ // RateLimit applies a standard rate limiter to the authorization endpoint.
+ // This helps protect against abuse or brute-force attempts.
+ RateLimit *rate.Limiter `json:"rateLimit,omitempty"`
+}
+
+// ClientAuthorizationParams defines the client parameters that must be validated
+// before redirecting to the authorization endpoint.
+type ClientAuthorizationParams struct {
+ // ClientID is the unique identifier for the client making the authorization request.
+ ClientID string `json:"client_id" validate:"required"`
+
+ // RedirectURI is the callback URI where the authorization code will be sent.
+ RedirectURI string `json:"redirect_uri,omitempty" validate:"omitempty,url"`
+}
+
+// RequestAuthorizationParams defines the parameters that must be validated
+// for a successful OAuth 2.1 authorization request.
+type RequestAuthorizationParams struct {
+ // ResponseType must be "code" for the authorization code flow.
+ ResponseType string `json:"response_type" validate:"required,eq=code"`
+
+ // CodeChallenge is the PKCE code challenge generated by the client.
+ CodeChallenge string `json:"code_challenge" validate:"required"`
+
+ // CodeChallengeMethod must be "S256" (SHA-256), the required method in OAuth 2.1.
+ CodeChallengeMethod string `json:"code_challenge_method" validate:"required,eq=S256"`
+
+ // Scope is an optional space-delimited list of requested permissions.
+ Scope string `json:"scope,omitempty"`
+
+ // State is an optional opaque value used by the client to maintain state
+ // between the request and callback (commonly for CSRF protection).
+ State string `json:"state,omitempty"`
+
+ // Resource is an optional absolute URL indicating the resource being accessed.
+ Resource string `json:"resource,omitempty" validate:"omitempty,url"`
+}
+
+// AuthorizationHandler creates an authorization handler
+// Returns http.HandlerFunc for consistency with other handlers
+func AuthorizationHandler(options AuthorizationHandlerOptions) http.HandlerFunc {
+ validate := validator.New()
+
+ // Core handler logic
+ coreHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+
+ if r.Method == http.MethodPost {
+ if err := r.ParseForm(); err != nil {
+ handleDirectError(w, errors.NewOAuthError(errors.ErrServerError, "Failed to parse form data", ""))
+ return
+ }
+ }
+
+ // Phase 1: Validate client_id and redirect_uri
+ _, redirectURI, client, err := validateClientAndRedirect(r, validate, options.Provider)
+ if err != nil {
+ handleDirectError(w, *err)
+ return
+ }
+
+ // Phase 2: Validate other parameters and authorize
+ if authErr := processAuthorization(r, validate, client, redirectURI, options.Provider, w); authErr != nil {
+ state := getStateFromRequest(r)
+ errorRedirect := createErrorRedirect(redirectURI, *authErr, state)
+ http.Redirect(w, r, errorRedirect, http.StatusFound)
+ return
+ }
+ })
+
+ // Apply middleware if needed
+ var handler http.Handler = coreHandler
+
+ // Apply rate limiting using standard middleware
+ if options.RateLimit != nil {
+ handler = middleware.RateLimitMiddleware(options.RateLimit)(handler)
+ }
+
+ // Apply method restrictions (GET and POST allowed)
+ handler = middleware.AllowedMethods([]string{"GET", "POST"})(handler)
+
+ // Convert back to http.HandlerFunc
+ return func(w http.ResponseWriter, r *http.Request) {
+ handler.ServeHTTP(w, r)
+ }
+}
+
+// validateClientAndRedirect validates client_id and redirect_uri
+func validateClientAndRedirect(r *http.Request, validate *validator.Validate, provider server.OAuthServerProvider) (string, string, *auth.OAuthClientInformationFull, *errors.OAuthError) {
+ clientParams := parseClientAuthorizationParams(r)
+ if err := validate.Struct(clientParams); err != nil {
+ oauthErr := errors.NewOAuthError(errors.ErrInvalidRequest, err.Error(), "")
+ return "", "", nil, &oauthErr
+ }
+
+ clientID := clientParams.ClientID
+ redirectURI := clientParams.RedirectURI
+
+ client, err := provider.ClientsStore().GetClient(clientID)
+ if err != nil {
+ oauthErr := errors.NewOAuthError(errors.ErrServerError, "Failed to get client", "")
+ return "", "", nil, &oauthErr
+ }
+ if client == nil {
+ oauthErr := errors.NewOAuthError(errors.ErrInvalidClient, "Invalid client_id", "")
+ return "", "", nil, &oauthErr
+ }
+
+ // Validate redirect_uri
+ if redirectURI != "" {
+ found := false
+ for _, uri := range client.RedirectURIs {
+ if uri == redirectURI {
+ found = true
+ break
+ }
+ }
+ if !found {
+ oauthErr := errors.NewOAuthError(errors.ErrInvalidRequest, "Unregistered redirect_uri", "")
+ return "", "", nil, &oauthErr
+ }
+ } else if len(client.RedirectURIs) == 1 {
+ redirectURI = client.RedirectURIs[0]
+ } else {
+ oauthErr := errors.NewOAuthError(errors.ErrInvalidRequest, "redirect_uri must be specified when client has multiple registered URIs", "")
+ return "", "", nil, &oauthErr
+ }
+
+ return clientID, redirectURI, client, nil
+}
+
+// processAuthorization processes the authorization request
+func processAuthorization(r *http.Request, validate *validator.Validate, client *auth.OAuthClientInformationFull, redirectURI string, provider server.OAuthServerProvider, w http.ResponseWriter) *errors.OAuthError {
+ reqParams := parseRequestAuthorizationParams(r)
+ if err := validate.Struct(reqParams); err != nil {
+ oauthErr := errors.NewOAuthError(errors.ErrInvalidRequest, err.Error(), "")
+ return &oauthErr
+ }
+
+ tempAuthParams := server.AuthorizationParams{
+ CodeChallenge: reqParams.CodeChallenge,
+ }
+
+ if err := pkce.ValidatePKCEParams(tempAuthParams); err != nil {
+ oauthErr := errors.NewOAuthError(errors.ErrInvalidRequest, err.Error(), "")
+ return &oauthErr
+ }
+
+ // Validate scopes
+ var requestedScopes []string
+ if reqParams.Scope != "" {
+ scopes := strings.Fields(reqParams.Scope)
+ for _, scope := range scopes {
+ if scope != "" {
+ requestedScopes = append(requestedScopes, scope)
+ }
+ }
+
+ if err := validateScopes(requestedScopes, client); err != nil {
+ return err
+ }
+ }
+
+ var resourceURL *url.URL
+ if reqParams.Resource != "" {
+ var err error
+ resourceURL, err = url.Parse(reqParams.Resource)
+ if err != nil {
+ oauthErr := errors.NewOAuthError(errors.ErrInvalidRequest, "Invalid resource URL", "")
+ return &oauthErr
+ }
+
+ // Verify that the resource URL is an absolute URL
+ if !resourceURL.IsAbs() {
+ oauthErr := errors.NewOAuthError(errors.ErrInvalidRequest, "Resource must be an absolute URL", "")
+ return &oauthErr
+ }
+ }
+
+ authParams := server.AuthorizationParams{
+ State: reqParams.State,
+ Scopes: requestedScopes,
+ RedirectURI: redirectURI,
+ CodeChallenge: reqParams.CodeChallenge,
+ Resource: resourceURL,
+ }
+
+ if err := provider.Authorize(*client, authParams, w, r); err != nil {
+ oauthErr := errors.NewOAuthError(errors.ErrServerError, "Authorization failed", "")
+ return &oauthErr
+ }
+
+ return nil
+}
+
+// validateScopes validates the requested scopes against client allowed scopes
+func validateScopes(requestedScopes []string, client *auth.OAuthClientInformationFull) *errors.OAuthError {
+ // If no scope is requested, return success directly
+ if len(requestedScopes) == 0 {
+ return nil
+ }
+
+ allowedScopes := make(map[string]bool)
+
+ // Handling client-scoped configuration
+ if client.Scope != nil && *client.Scope != "" {
+ scopes := strings.Fields(*client.Scope)
+ for _, scope := range scopes {
+ if scope != "" {
+ allowedScopes[scope] = true
+ }
+ }
+ }
+
+ // If the client does not have any scopes configured, reject all scope requests
+ if len(requestedScopes) == 0 {
+ oauthErr := errors.NewOAuthError(errors.ErrInvalidRequest, "Client has no registered scopes", "")
+ return &oauthErr
+ }
+
+ // Verify the scope of each request
+ for _, scope := range requestedScopes {
+ scope = strings.TrimSpace(scope)
+ if scope == "" {
+ continue
+ }
+ if !allowedScopes[scope] {
+ oauthErr := errors.NewOAuthError(errors.ErrInvalidScope, fmt.Sprintf("Client was not registered with scope %s", scope), "")
+ return &oauthErr
+ }
+ }
+
+ return nil
+}
+
+// handleDirectError handles direct error responses (before redirect)
+func handleDirectError(w http.ResponseWriter, oauthErr errors.OAuthError) {
+ status := http.StatusBadRequest
+
+ switch oauthErr.ErrorCode {
+ case errors.ErrServerError.Error():
+ status = http.StatusInternalServerError
+ default:
+ if oauthErr.ErrorCode == errors.ErrServerError.Error() {
+ status = http.StatusBadRequest
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(oauthErr.ToResponseStruct())
+}
+
+// getStateFromRequest extracts state parameter from request
+func getStateFromRequest(r *http.Request) string {
+ if r.Method == http.MethodPost {
+ return r.FormValue("state")
+ }
+ return r.URL.Query().Get("state")
+}
+
+// parseClientAuthorizationParams parses client authorization parameters
+func parseClientAuthorizationParams(r *http.Request) ClientAuthorizationParams {
+ var params ClientAuthorizationParams
+
+ if r.Method == http.MethodPost {
+ params.ClientID = strings.TrimSpace(r.FormValue("client_id"))
+ params.RedirectURI = strings.TrimSpace(r.FormValue("redirect_uri"))
+ } else {
+ query := r.URL.Query()
+ params.ClientID = strings.TrimSpace(query.Get("client_id"))
+ params.RedirectURI = strings.TrimSpace(query.Get("redirect_uri"))
+ }
+
+ return params
+}
+
+// parseRequestAuthorizationParams parses request authorization parameters
+func parseRequestAuthorizationParams(r *http.Request) RequestAuthorizationParams {
+ var params RequestAuthorizationParams
+
+ if r.Method == http.MethodPost {
+ params.ResponseType = strings.TrimSpace(r.FormValue("response_type"))
+ params.CodeChallenge = strings.TrimSpace(r.FormValue("code_challenge"))
+ params.CodeChallengeMethod = strings.TrimSpace(r.FormValue("code_challenge_method"))
+ params.Scope = strings.TrimSpace(r.FormValue("scope"))
+ params.State = r.FormValue("state")
+ params.Resource = strings.TrimSpace(r.FormValue("resource"))
+ } else {
+ query := r.URL.Query()
+ params.ResponseType = strings.TrimSpace(query.Get("response_type"))
+ params.CodeChallenge = strings.TrimSpace(query.Get("code_challenge"))
+ params.CodeChallengeMethod = strings.TrimSpace(query.Get("code_challenge_method"))
+ params.Scope = strings.TrimSpace(query.Get("scope"))
+ params.State = query.Get("state")
+ params.Resource = strings.TrimSpace(query.Get("resource"))
+ }
+
+ return params
+}
+
+// createErrorRedirect creates a redirect URL with error parameters
+func createErrorRedirect(redirectURI string, err errors.OAuthError, state string) string {
+ errorURL, _ := url.Parse(redirectURI)
+ query := errorURL.Query()
+
+ query.Set("error", err.ErrorCode)
+ query.Set("error_description", err.Message)
+
+ if err.ErrorURI != "" {
+ query.Set("error_uri", err.ErrorURI)
+ }
+
+ if state != "" {
+ query.Set("state", state)
+ }
+
+ errorURL.RawQuery = query.Encode()
+ return errorURL.String()
+}
diff --git a/internal/auth/server/handler/authorize_test.go b/internal/auth/server/handler/authorize_test.go
new file mode 100644
index 0000000..437539f
--- /dev/null
+++ b/internal/auth/server/handler/authorize_test.go
@@ -0,0 +1,373 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/time/rate"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ as "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+)
+
+// validChallenge is a known good S256 PKCE code challenge used in tests
+const validChallenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+
+// oauthErrResp matches the JSON shape returned for OAuth error responses in tests
+type oauthErrResp struct {
+ Error string `json:"error"`
+ ErrorDescription string `json:"error_description,omitempty"`
+ ErrorURI string `json:"error_uri,omitempty"`
+}
+
+// mockProvider is a test double that satisfies the OAuthServerProvider interface
+// it allows overriding Authorize via authorizeFunc for behavior-driven tests
+type mockProvider struct {
+ store *as.OAuthClientsStore
+ authorizeFunc func(client auth.OAuthClientInformationFull, params as.AuthorizationParams, w http.ResponseWriter, r *http.Request) error
+}
+
+// ClientsStore returns the in memory store used by the mock provider
+func (m *mockProvider) ClientsStore() *as.OAuthClientsStore { return m.store }
+
+// Authorize simulates the authorization endpoint behavior
+// if authorizeFunc is set it delegates to it
+// otherwise it redirects to redirect_uri with a fixed code and optional state
+func (m *mockProvider) Authorize(client auth.OAuthClientInformationFull, params as.AuthorizationParams, w http.ResponseWriter, r *http.Request) error {
+ // Delegate to custom behavior when provided
+ if m.authorizeFunc != nil {
+ return m.authorizeFunc(client, params, w, r)
+ }
+ // Compose redirect with code and optional state
+ u, _ := url.Parse(params.RedirectURI)
+ q := u.Query()
+ q.Set("code", "abc123")
+ if params.State != "" {
+ q.Set("state", params.State)
+ }
+ u.RawQuery = q.Encode()
+ // Issue HTTP 302 redirect
+ http.Redirect(w, r, u.String(), http.StatusFound)
+ return nil
+}
+
+// ChallengeForAuthorizationCode returns an empty string in this mock provider
+// real providers would return the stored code_challenge for the code
+func (m *mockProvider) ChallengeForAuthorizationCode(client auth.OAuthClientInformationFull, authorizationCode string) (string, error) {
+ return "", nil
+}
+
+// ExchangeAuthorizationCode is a stub that returns nil values for the mock
+func (m *mockProvider) ExchangeAuthorizationCode(client auth.OAuthClientInformationFull, authorizationCode string, codeVerifier *string, redirectUri *string, resource *url.URL) (*auth.OAuthTokens, error) {
+ return nil, nil
+}
+
+// ExchangeRefreshToken is a stub that returns nil values for the mock
+func (m *mockProvider) ExchangeRefreshToken(client auth.OAuthClientInformationFull, refreshToken string, scopes []string, resource *url.URL) (*auth.OAuthTokens, error) {
+ return nil, nil
+}
+
+// VerifyAccessToken is a stub that returns nil in this test double
+func (m *mockProvider) VerifyAccessToken(token string) (*as.AuthInfo, error) { return nil, nil }
+
+// RevokeToken satisfies the optional SupportTokenRevocation interface with a no op
+func (m *mockProvider) RevokeToken(client auth.OAuthClientInformationFull, request auth.OAuthTokenRevocationRequest) error {
+ return nil
+}
+
+// makeStoreWithClient creates a store that returns the provided client when looked up by id
+func makeStoreWithClient(c *auth.OAuthClientInformationFull) *as.OAuthClientsStore {
+ return as.NewOAuthClientStore(func(id string) (*auth.OAuthClientInformationFull, error) {
+ // Return client when ids match otherwise nil to simulate not found
+ if c != nil && c.ClientID == id {
+ return c, nil
+ }
+ return nil, nil
+ })
+}
+
+// makeClient builds a client record with id redirect uris and optional default scope
+func makeClient(id string, redirects []string, scope *string) *auth.OAuthClientInformationFull {
+ return &auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: id,
+ },
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: redirects,
+ Scope: scope,
+ },
+ }
+}
+
+// newGET constructs a GET request helper for tests
+func newGET(urlStr string) *http.Request {
+ return httptest.NewRequest(http.MethodGet, urlStr, nil)
+}
+
+// newPOST constructs a POST request with x www form urlencoded body for tests
+func newPOST(urlStr string, form url.Values) *http.Request {
+ req := httptest.NewRequest(http.MethodPost, urlStr, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return req
+}
+
+func TestAuthorization_SuccessGET(t *testing.T) {
+ // Prepare client with registered redirect and default scopes
+ scope := "read write"
+ client := makeClient("c1", []string{"https://app.example.com/cb"}, &scope)
+ mp := &mockProvider{store: makeStoreWithClient(client)}
+
+ // Build handler under test
+ h := AuthorizationHandler(AuthorizationHandlerOptions{Provider: mp})
+
+ // Compose query for a valid authorization request
+ qs := url.Values{
+ "client_id": {"c1"},
+ "redirect_uri": {"https://app.example.com/cb"},
+ "response_type": {"code"},
+ "code_challenge": {validChallenge},
+ "code_challenge_method": {"S256"},
+ "state": {"st-123"},
+ "scope": {"read"},
+ }
+ req := newGET("/authorize?" + qs.Encode())
+ rr := httptest.NewRecorder()
+
+ // Execute handler
+ h.ServeHTTP(rr, req)
+
+ // Assert redirect and parameters
+ assert.Equal(t, http.StatusFound, rr.Code)
+ loc := rr.Header().Get("Location")
+ u, err := url.Parse(loc)
+ require.NoError(t, err)
+ q := u.Query()
+ assert.Equal(t, "abc123", q.Get("code"))
+ assert.Equal(t, "st-123", q.Get("state"))
+}
+
+func TestAuthorization_MissingClientID_JSON400(t *testing.T) {
+ client := makeClient("c1", []string{"https://app.example.com/cb"}, nil)
+ mp := &mockProvider{store: makeStoreWithClient(client)}
+ h := AuthorizationHandler(AuthorizationHandlerOptions{Provider: mp})
+
+ // Build request without client_id
+ req := newGET("/authorize?redirect_uri=https://app.example.com/cb")
+ rr := httptest.NewRecorder()
+
+ // Execute handler
+ h.ServeHTTP(rr, req)
+
+ // Validate error payload
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var resp oauthErrResp
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+ assert.Equal(t, "invalid_request", resp.Error)
+ assert.NotEmpty(t, resp.ErrorDescription)
+}
+
+func TestAuthorization_UnregisteredRedirect_JSON400(t *testing.T) {
+ client := makeClient("c1", []string{"https://app.example.com/cb"}, nil)
+ mp := &mockProvider{store: makeStoreWithClient(client)}
+ h := AuthorizationHandler(AuthorizationHandlerOptions{Provider: mp})
+
+ // Use an unregistered redirect_uri
+ req := newGET("/authorize?client_id=c1&redirect_uri=https://evil.example.com/cb")
+ rr := httptest.NewRecorder()
+
+ // Execute and assert
+ h.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var resp oauthErrResp
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+ assert.Equal(t, "invalid_request", resp.Error)
+ assert.Contains(t, strings.ToLower(resp.ErrorDescription), "redirect")
+}
+
+func TestAuthorization_MultipleRedirects_RequireExplicit_JSON400(t *testing.T) {
+ client := makeClient("c1", []string{"https://a/cb", "https://b/cb"}, nil)
+ mp := &mockProvider{store: makeStoreWithClient(client)}
+ h := AuthorizationHandler(AuthorizationHandlerOptions{Provider: mp})
+
+ // Missing redirect_uri should fail when multiple are registered
+ req := newGET("/authorize?client_id=c1")
+ rr := httptest.NewRecorder()
+
+ // Execute and assert
+ h.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var resp oauthErrResp
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+ assert.Equal(t, "invalid_request", resp.Error)
+ assert.Contains(t, strings.ToLower(resp.ErrorDescription), "redirect")
+}
+
+func TestAuthorization_InvalidScope_302_WithState(t *testing.T) {
+ scope := "read write"
+ client := makeClient("c1", []string{"https://app.example.com/cb"}, &scope)
+ mp := &mockProvider{store: makeStoreWithClient(client)}
+ h := AuthorizationHandler(AuthorizationHandlerOptions{Provider: mp})
+
+ // Request includes a scope not in the client's allowed set
+ qs := url.Values{
+ "client_id": {"c1"},
+ "redirect_uri": {"https://app.example.com/cb"},
+ "response_type": {"code"},
+ "code_challenge": {validChallenge},
+ "code_challenge_method": {"S256"},
+ "scope": {"delete"},
+ "state": {"keep-me"},
+ }
+ req := newGET("/authorize?" + qs.Encode())
+ rr := httptest.NewRecorder()
+
+ // Execute and assert error redirect
+ h.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusFound, rr.Code)
+ u, _ := url.Parse(rr.Header().Get("Location"))
+ q := u.Query()
+ assert.Equal(t, "invalid_scope", q.Get("error"))
+ assert.Equal(t, "keep-me", q.Get("state"))
+ assert.NotEmpty(t, q.Get("error_description"))
+}
+
+func TestAuthorization_InvalidResourceURL_302_ErrorRedirect(t *testing.T) {
+ scope := "read"
+ client := makeClient("c1", []string{"https://app.example.com/cb"}, &scope)
+ mp := &mockProvider{store: makeStoreWithClient(client)}
+ h := AuthorizationHandler(AuthorizationHandlerOptions{Provider: mp})
+
+ // Provide a relative resource URL which is invalid
+ qs := url.Values{
+ "client_id": {"c1"},
+ "redirect_uri": {"https://app.example.com/cb"},
+ "response_type": {"code"},
+ "code_challenge": {validChallenge},
+ "code_challenge_method": {"S256"},
+ "resource": {"/relative"},
+ }
+ req := newGET("/authorize?" + qs.Encode())
+ rr := httptest.NewRecorder()
+
+ // Execute and assert error redirect
+ h.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusFound, rr.Code)
+ u, _ := url.Parse(rr.Header().Get("Location"))
+ q := u.Query()
+ assert.Equal(t, "invalid_request", q.Get("error"))
+ assert.NotEmpty(t, q.Get("error_description"))
+}
+
+func TestAuthorization_RateLimit_429_JSON(t *testing.T) {
+ client := makeClient("c1", []string{"https://app.example.com/cb"}, nil)
+ mp := &mockProvider{store: makeStoreWithClient(client)}
+ // Limiter with zero rate to always deny
+ limiter := rate.NewLimiter(0, 0)
+
+ h := AuthorizationHandler(AuthorizationHandlerOptions{
+ Provider: mp,
+ RateLimit: limiter,
+ })
+
+ // No params needed because limiter will block before validation
+ req := newGET("/authorize")
+ rr := httptest.NewRecorder()
+
+ // Execute and assert
+ h.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusTooManyRequests, rr.Code)
+ var resp oauthErrResp
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+ assert.Equal(t, "too_many_requests", resp.Error)
+}
+
+func TestAllowedMethods_GET_and_POST(t *testing.T) {
+ client := makeClient("c1", []string{"https://cb"}, nil)
+ mp := &mockProvider{store: makeStoreWithClient(client)}
+ h := AuthorizationHandler(AuthorizationHandlerOptions{Provider: mp})
+
+ // GET should be allowed
+ rr1 := httptest.NewRecorder()
+ h.ServeHTTP(rr1, newGET("/authorize"))
+ assert.NotEqual(t, http.StatusMethodNotAllowed, rr1.Code)
+
+ // PUT should be rejected with 405
+ rr2 := httptest.NewRecorder()
+ req2 := httptest.NewRequest(http.MethodPut, "/authorize", nil)
+ h.ServeHTTP(rr2, req2)
+ assert.Equal(t, http.StatusMethodNotAllowed, rr2.Code)
+}
+
+func TestHelpers_StateParsing_GET_and_POST(t *testing.T) {
+ // GET pathway
+ reqGet := newGET("/authorize?state=GETSTATE")
+ assert.Equal(t, "GETSTATE", getStateFromRequest(reqGet))
+
+ // POST pathway
+ form := url.Values{"state": {"POSTSTATE"}}
+ reqPost := newPOST("/authorize", form)
+ assert.Equal(t, "POSTSTATE", getStateFromRequest(reqPost))
+}
+
+func TestHelpers_ParseParams_Parity(t *testing.T) {
+ // ClientAuthorizationParams via GET
+ qs := url.Values{"client_id": {"c1"}, "redirect_uri": {"https://a/cb"}}
+ cp := parseClientAuthorizationParams(newGET("/authorize?" + qs.Encode()))
+ assert.Equal(t, "c1", cp.ClientID)
+ assert.Equal(t, "https://a/cb", cp.RedirectURI)
+
+ // RequestAuthorizationParams via POST
+ form := url.Values{
+ "response_type": {"code"},
+ "code_challenge": {"abc"},
+ "code_challenge_method": {"S256"},
+ "scope": {"read write"},
+ "resource": {"https://api.example.com"},
+ "state": {"s1"},
+ }
+ rp := parseRequestAuthorizationParams(newPOST("/authorize", form))
+ assert.Equal(t, "code", rp.ResponseType)
+ assert.Equal(t, "abc", rp.CodeChallenge)
+ assert.Equal(t, "S256", rp.CodeChallengeMethod)
+ assert.Equal(t, "read write", rp.Scope)
+ assert.Equal(t, "s1", rp.State)
+ assert.Equal(t, "https://api.example.com", rp.Resource)
+}
+
+func TestCreateErrorRedirect_ComposesQuery(t *testing.T) {
+ // Inline error type to mimic minimal shape used by createErrorRedirect
+ type inlineErr struct {
+ ErrorCode string
+ Message string
+ ErrorURI string
+ }
+ errObj := inlineErr{ErrorCode: "invalid request", Message: "oops"}
+
+ // Serialize and rehydrate to assert structure not affected by json tags
+ bs, _ := json.Marshal(errObj)
+ var rehydrated struct {
+ ErrorCode string
+ Message string
+ ErrorURI string
+ }
+ _ = json.Unmarshal(bs, &rehydrated)
+
+ // Build redirect URL and assert query parameters
+ loc := createErrorRedirect("https://app.example.com/cb", rehydrated, "st")
+ u, _ := url.Parse(loc)
+ q := u.Query()
+ assert.Equal(t, "invalid request", q.Get("error"))
+ assert.Equal(t, "oops", q.Get("error_description"))
+ assert.Equal(t, "st", q.Get("state"))
+}
diff --git a/internal/auth/server/handler/metadata.go b/internal/auth/server/handler/metadata.go
new file mode 100644
index 0000000..4f7c8d0
--- /dev/null
+++ b/internal/auth/server/handler/metadata.go
@@ -0,0 +1,33 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/middleware"
+)
+
+// MetadataHandler creates a handler for metadata endpoints
+// This matches the TypeScript implementation using middleware composition
+func MetadataHandler(metadata interface{}) http.HandlerFunc {
+ // Core handler that just serves JSON - no CORS or method validation
+ coreHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(metadata)
+ })
+
+ middlewareHandler := middleware.CorsMiddleware(
+ middleware.AllowedMethods([]string{"GET"})(coreHandler),
+ )
+
+ // Convert http.Handler to http.HandlerFunc
+ return func(w http.ResponseWriter, r *http.Request) {
+ middlewareHandler.ServeHTTP(w, r)
+ }
+}
diff --git a/internal/auth/server/handler/metadata_test.go b/internal/auth/server/handler/metadata_test.go
new file mode 100644
index 0000000..217e2c7
--- /dev/null
+++ b/internal/auth/server/handler/metadata_test.go
@@ -0,0 +1,345 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMetadataHandler(t *testing.T) {
+ // Test metadata
+ testMetadata := map[string]interface{}{
+ "name": "test-server",
+ "version": "1.0.0",
+ "description": "Test MCP server",
+ "capabilities": map[string]interface{}{
+ "auth": true,
+ "tools": []interface{}{"test-tool-1", "test-tool-2"}, // JSON decoding converts to []interface{}
+ },
+ }
+
+ // Create handler
+ handler := MetadataHandler(testMetadata)
+
+ // Verify handler is created successfully
+ assert.NotNil(t, handler)
+
+ // Test GET request
+ req := httptest.NewRequest(http.MethodGet, "/metadata", nil)
+ w := httptest.NewRecorder()
+
+ // Execute request
+ handler(w, req)
+
+ // Verify response
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
+
+ // Verify JSON response
+ var responseData map[string]interface{}
+ err := json.NewDecoder(w.Body).Decode(&responseData)
+ require.NoError(t, err)
+ assert.Equal(t, testMetadata, responseData)
+}
+
+func TestMetadataHandler_MethodValidation(t *testing.T) {
+ // Test metadata
+ testMetadata := map[string]interface{}{
+ "name": "test-server",
+ }
+
+ // Create handler
+ handler := MetadataHandler(testMetadata)
+
+ // Test cases for different HTTP methods
+ testCases := []struct {
+ name string
+ method string
+ expectedCode int
+ shouldHaveJSON bool
+ shouldHaveAllow bool
+ }{
+ {
+ name: "GET method allowed",
+ method: http.MethodGet,
+ expectedCode: http.StatusOK,
+ shouldHaveJSON: true,
+ shouldHaveAllow: false,
+ },
+ {
+ name: "POST method not allowed",
+ method: http.MethodPost,
+ expectedCode: http.StatusMethodNotAllowed,
+ shouldHaveJSON: true,
+ shouldHaveAllow: true,
+ },
+ {
+ name: "PUT method not allowed",
+ method: http.MethodPut,
+ expectedCode: http.StatusMethodNotAllowed,
+ shouldHaveJSON: true,
+ shouldHaveAllow: true,
+ },
+ {
+ name: "DELETE method not allowed",
+ method: http.MethodDelete,
+ expectedCode: http.StatusMethodNotAllowed,
+ shouldHaveJSON: true,
+ shouldHaveAllow: true,
+ },
+ {
+ name: "PATCH method not allowed",
+ method: http.MethodPatch,
+ expectedCode: http.StatusMethodNotAllowed,
+ shouldHaveJSON: true,
+ shouldHaveAllow: true,
+ },
+ }
+
+ // Execute tests
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(tc.method, "/metadata", nil)
+ w := httptest.NewRecorder()
+
+ // Execute request
+ handler(w, req)
+
+ // Verify status code
+ assert.Equal(t, tc.expectedCode, w.Code)
+
+ // Verify Content-Type header
+ if tc.shouldHaveJSON {
+ assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
+ }
+
+ // Verify Allow header for method not allowed responses
+ if tc.shouldHaveAllow {
+ allowHeader := w.Header().Get("Allow")
+ assert.Contains(t, allowHeader, "GET")
+ }
+
+ // For successful requests, verify metadata is returned
+ if tc.expectedCode == http.StatusOK {
+ var responseData map[string]interface{}
+ err := json.NewDecoder(w.Body).Decode(&responseData)
+ require.NoError(t, err)
+ assert.Equal(t, testMetadata, responseData)
+ }
+ })
+ }
+}
+
+func TestMetadataHandler_CORSHeaders(t *testing.T) {
+ // Test metadata
+ testMetadata := map[string]interface{}{
+ "name": "test-server",
+ }
+
+ // Create handler
+ handler := MetadataHandler(testMetadata)
+
+ // Test cases for CORS handling
+ testCases := []struct {
+ name string
+ method string
+ origin string
+ expectedCode int
+ shouldHaveCORSOrigin bool
+ shouldHaveCORSMethods bool
+ }{
+ {
+ name: "Non-CORS request",
+ method: http.MethodGet,
+ origin: "",
+ expectedCode: http.StatusOK,
+ shouldHaveCORSOrigin: false,
+ shouldHaveCORSMethods: false,
+ },
+ {
+ name: "CORS GET request",
+ method: http.MethodGet,
+ origin: "https://example.com",
+ expectedCode: http.StatusOK,
+ shouldHaveCORSOrigin: true,
+ shouldHaveCORSMethods: true,
+ },
+ {
+ name: "CORS OPTIONS preflight request",
+ method: http.MethodOptions,
+ origin: "https://example.com",
+ expectedCode: http.StatusNoContent,
+ shouldHaveCORSOrigin: true,
+ shouldHaveCORSMethods: true,
+ },
+ }
+
+ // Execute tests
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(tc.method, "/metadata", nil)
+ if tc.origin != "" {
+ req.Header.Set("Origin", tc.origin)
+ }
+ w := httptest.NewRecorder()
+
+ // Execute request
+ handler(w, req)
+
+ // Verify status code
+ assert.Equal(t, tc.expectedCode, w.Code)
+
+ // Verify CORS headers
+ if tc.shouldHaveCORSOrigin {
+ assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin"))
+ } else {
+ assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
+ }
+
+ if tc.shouldHaveCORSMethods {
+ allowMethods := w.Header().Get("Access-Control-Allow-Methods")
+ assert.Equal(t, "GET,HEAD,PUT,PATCH,POST,DELETE", allowMethods)
+ }
+
+ // For OPTIONS requests, verify Content-Length header
+ if tc.method == http.MethodOptions {
+ assert.Equal(t, "0", w.Header().Get("Content-Length"))
+ }
+
+ // For successful GET requests, verify metadata is returned
+ if tc.method == http.MethodGet && tc.expectedCode == http.StatusOK {
+ var responseData map[string]interface{}
+ err := json.NewDecoder(w.Body).Decode(&responseData)
+ require.NoError(t, err)
+ assert.Equal(t, testMetadata, responseData)
+ }
+ })
+ }
+}
+
+func TestMetadataHandler_DifferentMetadataTypes(t *testing.T) {
+ // Test cases with different metadata types
+ testCases := []struct {
+ name string
+ metadata interface{}
+ }{
+ {
+ name: "String metadata",
+ metadata: "simple string metadata",
+ },
+ {
+ name: "Number metadata",
+ metadata: float64(42),
+ },
+ {
+ name: "Boolean metadata",
+ metadata: true,
+ },
+ {
+ name: "Array metadata",
+ metadata: []interface{}{"item1", "item2", "item3"},
+ },
+ {
+ name: "Complex object metadata",
+ metadata: map[string]interface{}{
+ "server": map[string]interface{}{
+ "name": "mcp-server",
+ "version": "2.0.0",
+ "config": map[string]interface{}{
+ "debug": true,
+ "port": float64(8080),
+ },
+ },
+ "capabilities": []interface{}{"auth", "tools", "resources"},
+ "stats": map[string]interface{}{
+ "uptime": float64(3600),
+ "requests": float64(1500),
+ "errors": float64(5),
+ },
+ },
+ },
+ {
+ name: "Empty metadata",
+ metadata: map[string]interface{}{},
+ },
+ {
+ name: "Nil metadata",
+ metadata: nil,
+ },
+ }
+
+ // Execute tests
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Create handler with test metadata
+ handler := MetadataHandler(tc.metadata)
+
+ // Create request
+ req := httptest.NewRequest(http.MethodGet, "/metadata", nil)
+ w := httptest.NewRecorder()
+
+ // Execute request
+ handler(w, req)
+
+ // Verify response
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
+
+ // Verify JSON response matches expected metadata
+ var responseData interface{}
+ err := json.NewDecoder(w.Body).Decode(&responseData)
+ require.NoError(t, err)
+ assert.Equal(t, tc.metadata, responseData)
+ })
+ }
+}
+
+func TestMetadataHandler_ConcurrentRequests(t *testing.T) {
+ // Test metadata
+ testMetadata := map[string]interface{}{
+ "name": "concurrent-test-server",
+ "version": "1.0.0",
+ }
+
+ // Create handler
+ handler := MetadataHandler(testMetadata)
+
+ // Number of concurrent requests
+ numRequests := 10
+ responses := make(chan *httptest.ResponseRecorder, numRequests)
+
+ // Launch concurrent requests
+ for i := 0; i < numRequests; i++ {
+ go func() {
+ req := httptest.NewRequest(http.MethodGet, "/metadata", nil)
+ w := httptest.NewRecorder()
+ handler(w, req)
+ responses <- w
+ }()
+ }
+
+ // Collect and verify all responses
+ for i := 0; i < numRequests; i++ {
+ w := <-responses
+
+ // Verify response
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
+
+ // Verify JSON response
+ var responseData map[string]interface{}
+ err := json.NewDecoder(w.Body).Decode(&responseData)
+ require.NoError(t, err)
+ assert.Equal(t, testMetadata, responseData)
+ }
+}
diff --git a/internal/auth/server/handler/register.go b/internal/auth/server/handler/register.go
new file mode 100644
index 0000000..01fe48b
--- /dev/null
+++ b/internal/auth/server/handler/register.go
@@ -0,0 +1,241 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package handler
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/middleware"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+
+ "github.com/google/uuid"
+ "golang.org/x/time/rate"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+)
+
+const (
+ DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60 // 30 days
+ DEFAULT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour
+ DEFAULT_RATE_LIMIT_MAX = 20 // 20 requests per hour
+)
+
+// ClientRegistrationHandlerOptions configuration for client registration handler
+type ClientRegistrationHandlerOptions struct {
+ // A store used to save information about dynamically registered OAuth clients.
+ ClientsStore server.SupportDynamicClientRegistration
+
+ // The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended).
+ // If not set, defaults to 30 days.
+ ClientSecretExpirySeconds *int
+
+ // Rate limiting configuration for the client registration endpoint.
+ // Set to nil to disable rate limiting for this endpoint.
+ // Registration endpoints are particularly sensitive to abuse and should be rate limited.
+ RateLimit *RegisterRateLimitConfig
+
+ // Whether to generate a client ID before calling the client registration endpoint.
+ // If not set, defaults to true.
+ ClientIdGeneration *bool
+}
+
+type RegisterRateLimitConfig struct {
+ WindowMs int // Window duration in milliseconds
+ Max int // Maximum requests per window
+ Message string // Customize over-limit prompt information
+}
+
+// ClientRegistrationHandler creates a handler for OAuth client registration
+func ClientRegistrationHandler(options ClientRegistrationHandlerOptions) http.Handler {
+ rateLimitConfig := options.RateLimit
+ if rateLimitConfig == nil {
+ rateLimitConfig = &RegisterRateLimitConfig{
+ WindowMs: DEFAULT_RATE_LIMIT_WINDOW_MS,
+ Max: DEFAULT_RATE_LIMIT_MAX,
+ }
+ }
+
+ if options.ClientsStore == nil {
+ // Return a handler that always returns an error
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotImplemented)
+
+ notImplError := errors.NewOAuthError(
+ errors.ErrUnsupportedGrantType,
+ "Dynamic client registration is not supported by this server",
+ "https://datatracker.ietf.org/doc/html/rfc7591",
+ )
+ json.NewEncoder(w).Encode(notImplError.ToResponseStruct())
+ })
+ }
+
+ clientSecretExpirySeconds := DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS
+ if options.ClientSecretExpirySeconds != nil {
+ clientSecretExpirySeconds = *options.ClientSecretExpirySeconds
+ }
+
+ clientIdGeneration := true
+ if options.ClientIdGeneration != nil {
+ clientIdGeneration = *options.ClientIdGeneration
+ }
+
+ // Core handler logic
+ coreHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+
+ // Parse JSON request body
+ var clientMetadata auth.OAuthClientMetadata
+ if err := json.NewDecoder(r.Body).Decode(&clientMetadata); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ oauthErr := errors.NewOAuthError(
+ errors.ErrInvalidClientMetadata,
+ fmt.Sprintf("Invalid JSON in request body: %v", err),
+ "",
+ )
+ json.NewEncoder(w).Encode(oauthErr.ToResponseStruct())
+ return
+ }
+
+ // Validate client metadata
+ if err := validateClientMetadata(&clientMetadata); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ oauthErr := errors.NewOAuthError(
+ errors.ErrInvalidClientMetadata,
+ err.Error(),
+ "",
+ )
+ json.NewEncoder(w).Encode(oauthErr.ToResponseStruct())
+ return
+ }
+
+ isPublicClient := clientMetadata.TokenEndpointAuthMethod == "none"
+
+ // Generate client credentials
+ var clientSecret string
+ if !isPublicClient {
+ secret, err := generateClientSecret()
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+
+ oauthErr := errors.NewOAuthError(
+ errors.ErrServerError,
+ "Failed to generate client secret",
+ "",
+ )
+ json.NewEncoder(w).Encode(oauthErr.ToResponseStruct())
+ return
+ }
+ clientSecret = secret
+ }
+
+ clientIdIssuedAt := time.Now().Unix()
+
+ // Calculate client secret expiry time
+ clientsDoExpire := clientSecretExpirySeconds > 0
+ var clientSecretExpiresAt *int64
+ if !isPublicClient {
+ if clientsDoExpire {
+ expiryTime := clientIdIssuedAt + int64(clientSecretExpirySeconds)
+ clientSecretExpiresAt = &expiryTime
+ } else {
+ zero := int64(0)
+ clientSecretExpiresAt = &zero
+ }
+ }
+
+ // Create client information
+ clientInfo := auth.OAuthClientInformationFull{
+ OAuthClientMetadata: clientMetadata,
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientSecret: clientSecret,
+ ClientSecretExpiresAt: clientSecretExpiresAt,
+ },
+ }
+
+ if clientIdGeneration {
+ clientId := uuid.New().String()
+ clientInfo.OAuthClientInformation.ClientID = clientId
+ clientInfo.OAuthClientInformation.ClientIDIssuedAt = &clientIdIssuedAt
+ }
+
+ registeredClient, err := options.ClientsStore.RegisterClient(clientInfo)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+
+ oauthErr := errors.NewOAuthError(
+ errors.ErrServerError,
+ "Failed to register client",
+ "",
+ )
+ json.NewEncoder(w).Encode(oauthErr.ToResponseStruct())
+ return
+ }
+
+ // Success response
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(registeredClient)
+ })
+
+ var handler http.Handler = coreHandler
+
+ if options.RateLimit != nil {
+ windowDuration := time.Duration(rateLimitConfig.WindowMs) * time.Millisecond
+ limit := rate.Every(windowDuration / time.Duration(rateLimitConfig.Max))
+ limiter := rate.NewLimiter(limit, rateLimitConfig.Max)
+
+ handler = middleware.RateLimitMiddleware(limiter)(handler)
+ }
+
+ handler = middleware.JSONValidationMiddleware()(handler)
+
+ handler = middleware.AllowedMethods([]string{"POST"})(handler)
+
+ handler = middleware.CorsMiddleware(handler)
+
+ return handler
+}
+
+// generateClientSecret generates a random 32-byte hex string
+func generateClientSecret() (string, error) {
+ bytes := make([]byte, 32)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
+
+// validateClientMetadata performs basic validation on client metadata
+func validateClientMetadata(metadata *auth.OAuthClientMetadata) error {
+ // Add validation logic as needed
+ if metadata.TokenEndpointAuthMethod == "" {
+ return fmt.Errorf("token_endpoint_auth_method is required")
+ }
+
+ switch metadata.TokenEndpointAuthMethod {
+ case "client_secret_basic", "client_secret_post", "none":
+ default:
+ return fmt.Errorf("invalid token_endpoint_auth_method: %s", metadata.TokenEndpointAuthMethod)
+ }
+
+ if len(metadata.RedirectURIs) == 0 {
+ return fmt.Errorf("redirect_uris is required")
+ }
+
+ return nil
+}
diff --git a/internal/auth/server/handler/register_test.go b/internal/auth/server/handler/register_test.go
new file mode 100644
index 0000000..d4af024
--- /dev/null
+++ b/internal/auth/server/handler/register_test.go
@@ -0,0 +1,294 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package handler
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/time/rate"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+// mockDynClientStore is a test double implementation of a dynamic client store
+// It captures inputs, simulates errors, and returns controlled responses for testing
+type mockDynClientStore struct {
+ wantErr error // optional error to return on RegisterClient
+ lastRegistered *auth.OAuthClientInformationFull // last registered client captured during call
+ returnedClient *auth.OAuthClientInformationFull // client to return instead of echoing input
+ callCount int // number of times RegisterClient was invoked
+}
+
+// RegisterClient mocks the dynamic client registration behavior
+func (m *mockDynClientStore) RegisterClient(in auth.OAuthClientInformationFull) (*auth.OAuthClientInformationFull, error) {
+ m.callCount++
+ // capture input
+ tmp := in
+ m.lastRegistered = &tmp
+
+ // if configured, return a forced error
+ if m.wantErr != nil {
+ return nil, m.wantErr
+ }
+
+ // if configured, return a pre-set client
+ if m.returnedClient != nil {
+ return m.returnedClient, nil
+ }
+
+ // by default echo back
+ return &in, nil
+}
+
+// postJSONBody is a helper that sends an HTTP POST request with a JSON body
+func postJSONBody(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
+ t.Helper()
+
+ var buf bytes.Buffer
+
+ // encode request body as JSON if provided
+ if body != nil {
+ require.NoError(t, json.NewEncoder(&buf).Encode(body))
+ }
+
+ // build POST request
+ req := httptest.NewRequest(http.MethodPost, path, &buf)
+ req.Header.Set("Content-Type", "application/json")
+
+ // record the response
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ return rr
+}
+
+func TestClientRegistration_NotImplemented_WhenNoStore(t *testing.T) {
+ // ClientsStore==nil -> 501 Not Implemented (Explicitly returns that dynamic registration is not supported)
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{})
+
+ rr := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "client_secret_post",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusNotImplemented, rr.Code)
+
+ var resp map[string]any
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+ assert.Equal(t, "unsupported_grant_type", resp["error"])
+}
+
+func TestClientRegistration_MethodNotAllowed_Get405(t *testing.T) {
+ // Only POST is allowed, other methods will return 405
+ store := &mockDynClientStore{}
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{
+ ClientsStore: store,
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/register", nil)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
+}
+
+func TestClientRegistration_InvalidJSON_400(t *testing.T) {
+ // JSON parsing failed -> 400 + invalid_client_metadata (determined within core logic)
+ store := &mockDynClientStore{}
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{ClientsStore: store})
+
+ req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBufferString("{bad json"))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var resp map[string]any
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+ assert.Equal(t, "invalid_client_metadata", resp["error"])
+}
+
+func TestClientRegistration_MetadataValidation_400(t *testing.T) {
+ // Missing required fields -> validateClientMetadata returns an error -> 400 (token_endpoint_auth_method and redirect_uris required)
+ store := &mockDynClientStore{}
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{ClientsStore: store})
+
+ rr := postJSONBody(t, h, "/register", map[string]any{
+ "redirect_uris": []string{}, // invalid
+ })
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+
+ var resp map[string]any
+ _ = json.Unmarshal(rr.Body.Bytes(), &resp)
+ assert.Equal(t, "invalid_client_metadata", resp["error"])
+}
+
+func TestClientRegistration_PublicClient_NoSecret(t *testing.T) {
+ // token_endpoint_auth_method == "none" -> Public client: does not generate client_secret and does not set the expiration field
+ store := &mockDynClientStore{}
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{ClientsStore: store})
+
+ rr := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "none",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusCreated, rr.Code)
+
+ var resp auth.OAuthClientInformationFull
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+
+ // By default, the server generates client_id / client_id_issued_at (ClientIdGeneration is true by default)
+ assert.NotEmpty(t, resp.OAuthClientInformation.ClientID)
+ assert.NotNil(t, resp.OAuthClientInformation.ClientIDIssuedAt)
+
+ // Public clients have no secrets and no expiration
+ assert.Equal(t, "", resp.OAuthClientInformation.ClientSecret)
+ assert.Nil(t, resp.OAuthClientInformation.ClientSecretExpiresAt)
+}
+
+func TestClientRegistration_ConfidentialClient_GenerateSecret_And_Expiry(t *testing.T) {
+ // Non-public clients generate a 32-byte hex secret (length 64) and set an expiration time (default 30 days)
+ store := &mockDynClientStore{}
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{ClientsStore: store})
+
+ start := time.Now().Unix()
+ rr := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "client_secret_post",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusCreated, rr.Code)
+
+ var resp auth.OAuthClientInformationFull
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+
+ secret := resp.OAuthClientInformation.ClientSecret
+ if assert.NotEmpty(t, secret) {
+ assert.Len(t, secret, 64) // 32 bytes hex -> 64 chars
+ }
+ if assert.NotNil(t, resp.OAuthClientInformation.ClientSecretExpiresAt) {
+ exp := *resp.OAuthClientInformation.ClientSecretExpiresAt
+ assert.Greater(t, exp, start)
+ }
+}
+
+func TestClientRegistration_ConfidentialClient_NoExpiryWhenZeroConfig(t *testing.T) {
+ // ClientSecretExpirySeconds==0 -> Does not expire (value is 0)
+ store := &mockDynClientStore{}
+ zero := 0
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{
+ ClientsStore: store,
+ ClientSecretExpirySeconds: &zero,
+ })
+
+ rr := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "client_secret_basic",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusCreated, rr.Code)
+
+ var resp auth.OAuthClientInformationFull
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+ if assert.NotNil(t, resp.OAuthClientInformation.ClientSecretExpiresAt) {
+ assert.Equal(t, int64(0), *resp.OAuthClientInformation.ClientSecretExpiresAt)
+ }
+}
+
+func TestClientRegistration_RegisterError_500(t *testing.T) {
+ // RegisterClient error -> 500 server_error
+ store := &mockDynClientStore{wantErr: assert.AnError}
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{ClientsStore: store})
+
+ rr := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "client_secret_post",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusInternalServerError, rr.Code)
+
+ var resp map[string]any
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+ assert.Equal(t, "server_error", resp["error"])
+}
+
+func TestClientRegistration_RateLimit_429_WhenEnabled(t *testing.T) {
+ // Explicitly enable current limiting configuration: Max=1, the second request in the same window will result in a 429 error
+ store := &mockDynClientStore{}
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{
+ ClientsStore: store,
+ RateLimit: &RegisterRateLimitConfig{
+ WindowMs: 60_000,
+ Max: 1,
+ Message: "too many",
+ },
+ })
+
+ // First request OK
+ rr1 := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "client_secret_post",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusCreated, rr1.Code)
+
+ // Second immediate request -> 429
+ rr2 := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "client_secret_post",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusTooManyRequests, rr2.Code)
+}
+
+// Extra: Verified that rate limiting is not applied when RateLimit=nil to avoid interfering with other tests.
+func TestClientRegistration_NoRateLimitByDefault(t *testing.T) {
+ store := &mockDynClientStore{}
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{
+ ClientsStore: store,
+ // RateLimit omitted -> unlimited flow (create limiter and wrap middleware only when it is not nil)
+ })
+
+ for i := 0; i < 3; i++ {
+ rr := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "client_secret_post",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusCreated, rr.Code)
+ }
+}
+
+// When a custom current limiting configuration is in effect,
+// the window parameter conversion logic using rate.Limiter will not panic
+func TestClientRegistration_CustomLimiter_DoesNotPanic(t *testing.T) {
+ store := &mockDynClientStore{}
+ cfg := &RegisterRateLimitConfig{WindowMs: 1000, Max: 2}
+ h := ClientRegistrationHandler(ClientRegistrationHandlerOptions{
+ ClientsStore: store,
+ RateLimit: cfg,
+ })
+
+ // Manually construct 2 requests, the third one should be 429
+ rr1 := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "client_secret_post",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusCreated, rr1.Code)
+
+ rr2 := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "client_secret_post",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusCreated, rr2.Code)
+
+ rr3 := postJSONBody(t, h, "/register", map[string]any{
+ "token_endpoint_auth_method": "client_secret_post",
+ "redirect_uris": []string{"https://cb"},
+ })
+ assert.Equal(t, http.StatusTooManyRequests, rr3.Code)
+
+ // Extra sanity check
+ _ = rate.NewLimiter(rate.Every(time.Second/time.Duration(cfg.Max)), cfg.Max)
+}
diff --git a/internal/auth/server/handler/revoke.go b/internal/auth/server/handler/revoke.go
new file mode 100644
index 0000000..36edc78
--- /dev/null
+++ b/internal/auth/server/handler/revoke.go
@@ -0,0 +1,232 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package handler
+
+import (
+ "encoding/json"
+ "golang.org/x/time/rate"
+ "net/http"
+ "strings"
+ "time"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/middleware"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// RevocationHandlerOptions configuration for the token revocation endpoint
+type RevocationHandlerOptions struct {
+ Provider server.OAuthServerProvider
+ RateLimit *RevocationRateLimitConfig // Set to nil to disable rate limiting for this endpoint
+ RequireHTTPS bool // Enforce HTTPS in production (recommended for OAuth 2.1)
+ AllowJSONFallback bool // Allow JSON format for backward compatibility (non-compliant with RFC 7009)
+ EnableMCPHeaders bool // Add MCP-specific headers for MCP 2025-03-26 compliance
+}
+
+// RevocationRateLimitConfig rate limiting configuration
+type RevocationRateLimitConfig struct {
+ WindowMs int // Window duration in milliseconds
+ Max int // Maximum requests per window
+}
+
+// RevocationHandler creates a handler for OAuth token revocation with client authentication middleware
+func RevocationHandler(opts RevocationHandlerOptions) http.Handler {
+ // Check if provider supports token revocation
+ revoker, ok := opts.Provider.(server.SupportTokenRevocation)
+ if !ok {
+ panic("Auth provider does not support revoking tokens")
+ }
+
+ // Create the core handler
+ coreHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Set OAuth 2.1 required security headers
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Pragma", "no-cache")
+
+ // Add MCP-specific headers if enabled
+ if opts.EnableMCPHeaders {
+ w.Header().Set("X-MCP-Version", "2025-03-26")
+ w.Header().Set("X-MCP-Transport", "http")
+ }
+
+ // Enforce HTTPS if required (OAuth 2.1 best practice)
+ if opts.RequireHTTPS && r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ invalidReqError := errors.NewOAuthError(
+ errors.ErrInvalidRequest,
+ "HTTPS is required for OAuth 2.1 token revocation",
+ "https://datatracker.ietf.org/doc/html/rfc6749#section-3",
+ )
+ json.NewEncoder(w).Encode(invalidReqError.ToResponseStruct())
+ return
+ }
+
+ // RFC 7009 Section 2.1: Strict Content-Type validation
+ contentType := r.Header.Get("Content-Type")
+ isURLEncoded := strings.HasPrefix(contentType, "application/x-www-form-urlencoded")
+ isJSON := strings.HasPrefix(contentType, "application/json")
+
+ if !isURLEncoded && (!opts.AllowJSONFallback || !isJSON) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errorMsg := "Content-Type must be application/x-www-form-urlencoded per RFC 7009"
+ if opts.AllowJSONFallback {
+ errorMsg = "Content-Type must be application/x-www-form-urlencoded (preferred) or application/json"
+ }
+
+ invalidReqError := errors.NewOAuthError(
+ errors.ErrInvalidRequest,
+ errorMsg,
+ "https://datatracker.ietf.org/doc/html/rfc7009#section-2.1",
+ )
+ json.NewEncoder(w).Encode(invalidReqError.ToResponseStruct())
+ return
+ }
+
+ // Parse request body based on content type
+ var reqBody auth.OAuthTokenRevocationRequest
+
+ if isURLEncoded {
+ // RFC 7009 compliant: Parse URL-encoded form data
+ if err := r.ParseForm(); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ invalidReqError := errors.NewOAuthError(errors.ErrInvalidRequest,
+ "Failed to parse application/x-www-form-urlencoded data: "+err.Error(), "")
+ json.NewEncoder(w).Encode(invalidReqError.ToResponseStruct())
+ return
+ }
+
+ // Extract form values
+ reqBody.Token = r.FormValue("token")
+ reqBody.TokenTypeHint = r.FormValue("token_type_hint")
+ } else if opts.AllowJSONFallback && isJSON {
+ // Non-standard JSON fallback for backward compatibility
+ if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ invalidReqError := errors.NewOAuthError(errors.ErrInvalidRequest,
+ "Failed to parse JSON data: "+err.Error(), "")
+ json.NewEncoder(w).Encode(invalidReqError.ToResponseStruct())
+ return
+ }
+ }
+
+ // Validate request - token is required (RFC 7009 Section 2.1)
+ if err := validateRevocationRequest(reqBody); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(err.(errors.OAuthError).ToResponseStruct())
+ return
+ }
+
+ // Get authenticated client from context (set by clientAuth middleware)
+ client, ok := middleware.GetAuthenticatedClient(r)
+ if !ok {
+ // This should never happen if middleware is properly configured
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+
+ serverError := errors.NewOAuthError(errors.ErrServerError, "Internal Server Error", "")
+ json.NewEncoder(w).Encode(serverError.ToResponseStruct())
+ return
+ }
+
+ // Revoke the token
+ err := revoker.RevokeToken(*client, reqBody)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+
+ if oauthErr, ok := err.(errors.OAuthError); ok {
+ status := http.StatusBadRequest
+ if oauthErr.ErrorCode == errors.ErrServerError.Error() {
+ status = http.StatusInternalServerError
+ }
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(oauthErr.ToResponseStruct())
+ return
+ }
+
+ w.WriteHeader(http.StatusInternalServerError)
+ serverError := errors.NewOAuthError(errors.ErrServerError, "Internal Server Error", "")
+ json.NewEncoder(w).Encode(serverError.ToResponseStruct())
+ return
+ }
+
+ // RFC 7009 Section 2: Success response - HTTP 200 with empty JSON
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("{}"))
+ })
+
+ // Apply middlewares in order (wrapping from inside out to match TS middleware order)
+ var handler http.Handler = coreHandler
+
+ // Apply client authentication middleware (innermost, like TS)
+ handler = middleware.AuthenticateClient(middleware.ClientAuthenticationMiddlewareOptions{
+ ClientsStore: opts.Provider.ClientsStore(),
+ })(handler)
+
+ // Apply rate limiting middleware only if explicitly configured
+ if opts.RateLimit != nil {
+ windowDuration := time.Duration(opts.RateLimit.WindowMs) * time.Millisecond
+ if opts.RateLimit.Max <= 0 {
+ panic("RateLimit Max must be greater than 0")
+ }
+
+ // Calculate rate to match the window-based approach of express-rate-limit
+ limit := rate.Every(windowDuration / time.Duration(opts.RateLimit.Max))
+ limiter := rate.NewLimiter(limit, opts.RateLimit.Max)
+
+ handler = middleware.RateLimitMiddleware(limiter)(handler)
+ }
+ // Note: No default rate limiting applied when opts.RateLimit is nil
+ // This matches the TypeScript behavior where rateLimit: false disables it
+
+ // Apply URL-encoded parsing middleware (RFC 7009 compliance)
+ handler = middleware.URLEncodedValidationMiddleware(opts.AllowJSONFallback)(handler)
+
+ // Apply method restriction middleware (only POST allowed per RFC 7009)
+ handler = middleware.AllowedMethods([]string{"POST"})(handler)
+
+ // Apply CORS middleware (outermost, like TS)
+ handler = middleware.CorsMiddleware(handler)
+
+ return handler
+}
+
+// validateRevocationRequest validates the OAuth token revocation request per RFC 7009
+func validateRevocationRequest(reqBody auth.OAuthTokenRevocationRequest) error {
+ // RFC 7009 Section 2.1: token parameter is required
+ if reqBody.Token == "" {
+ return errors.NewOAuthError(errors.ErrInvalidRequest,
+ "token parameter is required per RFC 7009",
+ "https://datatracker.ietf.org/doc/html/rfc7009#section-2.1")
+ }
+
+ // RFC 7009 Section 2.1: token_type_hint is optional but must be valid if provided
+ if reqBody.TokenTypeHint != "" {
+ validTypes := map[string]bool{
+ "access_token": true,
+ "refresh_token": true,
+ }
+ if !validTypes[reqBody.TokenTypeHint] {
+ return errors.NewOAuthError(
+ errors.ErrInvalidRequest,
+ "invalid token_type_hint, must be 'access_token' or 'refresh_token' per RFC 7009",
+ "https://datatracker.ietf.org/doc/html/rfc7009#section-2.1",
+ )
+ }
+ }
+
+ return nil
+}
diff --git a/internal/auth/server/handler/revoke_test.go b/internal/auth/server/handler/revoke_test.go
new file mode 100644
index 0000000..6f860ee
--- /dev/null
+++ b/internal/auth/server/handler/revoke_test.go
@@ -0,0 +1,224 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package handler
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ as "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+)
+
+const (
+ testClientID = "c1"
+ testClientSecret = "s3cr3t"
+)
+
+// mockRevokeProvider is a fake implementation of OAuthServerProvider used for revocation tests
+// It tracks calls to RevokeToken and allows simulating errors
+type mockRevokeProvider struct {
+ store *as.OAuthClientsStore // backing client store
+ lastReq *auth.OAuthTokenRevocationRequest // last revocation request captured
+ revokeErr error // error to return from RevokeToken
+ calledRevoke int // number of times RevokeToken was invoked
+}
+
+func (m *mockRevokeProvider) ClientsStore() *as.OAuthClientsStore {
+ return m.store
+}
+
+func (m *mockRevokeProvider) RevokeToken(client auth.OAuthClientInformationFull, request auth.OAuthTokenRevocationRequest) error {
+ m.calledRevoke++
+ // capture the request for assertions
+ tmp := request
+ m.lastReq = &tmp
+ return m.revokeErr
+}
+
+// The remaining interface methods are no-ops since they are not used in revocation tests
+func (m *mockRevokeProvider) Authorize(client auth.OAuthClientInformationFull, params as.AuthorizationParams, w http.ResponseWriter, r *http.Request) error {
+ return nil
+}
+func (m *mockRevokeProvider) ChallengeForAuthorizationCode(client auth.OAuthClientInformationFull, authorizationCode string) (string, error) {
+ return "", nil
+}
+func (m *mockRevokeProvider) ExchangeAuthorizationCode(client auth.OAuthClientInformationFull, authorizationCode string, codeVerifier *string, redirectUri *string, resource *url.URL) (*auth.OAuthTokens, error) {
+ return nil, nil
+}
+func (m *mockRevokeProvider) ExchangeRefreshToken(client auth.OAuthClientInformationFull, refreshToken string, scopes []string, resource *url.URL) (*auth.OAuthTokens, error) {
+ return nil, nil
+}
+func (m *mockRevokeProvider) VerifyAccessToken(token string) (*as.AuthInfo, error) { return nil, nil }
+
+// makeClientBasic constructs a client using client_secret_basic authentication
+func makeClientBasic(id string) *auth.OAuthClientInformationFull {
+ return &auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: id,
+ ClientSecret: testClientSecret,
+ },
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://cb"},
+ TokenEndpointAuthMethod: "client_secret_basic",
+ },
+ }
+}
+
+// makeClientPost constructs a client using client_secret_post authentication
+func makeClientPost(id string) *auth.OAuthClientInformationFull {
+ return &auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: id,
+ ClientSecret: testClientSecret,
+ },
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://cb"},
+ TokenEndpointAuthMethod: "client_secret_post",
+ },
+ }
+}
+
+// postFormBasicAuth helper submits a POST form request using HTTP Basic authentication
+func postFormBasicAuth(t *testing.T, h http.Handler, path, clientID, clientSecret string, form url.Values) *httptest.ResponseRecorder {
+ t.Helper()
+ if form == nil {
+ form = url.Values{}
+ }
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.SetBasicAuth(clientID, clientSecret)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ return rr
+}
+
+// postFormClientSecretPost helper submits a POST form request using client_secret_post authentication
+func postFormClientSecretPost(t *testing.T, h http.Handler, path, clientID, clientSecret string, form url.Values) *httptest.ResponseRecorder {
+ t.Helper()
+ if form == nil {
+ form = url.Values{}
+ }
+ form.Set("client_id", clientID)
+ form.Set("client_secret", clientSecret)
+
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ return rr
+}
+
+func TestRevocation_Success_200(t *testing.T) {
+ mpBasic := &mockRevokeProvider{store: makeStoreWithClient(makeClientBasic(testClientID))}
+ hBasic := RevocationHandler(RevocationHandlerOptions{Provider: mpBasic})
+
+ form := url.Values{"token": {"at-123"}}
+ rr := postFormBasicAuth(t, hBasic, "/revoke", testClientID, testClientSecret, form)
+
+ if rr.Code != http.StatusOK {
+ mpPost := &mockRevokeProvider{store: makeStoreWithClient(makeClientPost(testClientID))}
+ hPost := RevocationHandler(RevocationHandlerOptions{Provider: mpPost})
+ rr2 := postFormClientSecretPost(t, hPost, "/revoke", testClientID, testClientSecret, form)
+
+ if rr2.Code != http.StatusOK {
+ t.Skipf("Skip: Authentication failed (Basic=%d/%s, Post=%d/%s)",
+ rr.Code, strings.TrimSpace(rr.Body.String()),
+ rr2.Code, strings.TrimSpace(rr2.Body.String()),
+ )
+ return
+ }
+ assert.Equal(t, 1, mpPost.calledRevoke)
+ require.NotNil(t, mpPost.lastReq)
+ assert.Equal(t, "at-123", mpPost.lastReq.Token)
+ return
+ }
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+ assert.Equal(t, 1, mpBasic.calledRevoke)
+ require.NotNil(t, mpBasic.lastReq)
+ assert.Equal(t, "at-123", mpBasic.lastReq.Token)
+}
+
+func TestRevocation_MissingToken_400(t *testing.T) {
+ mp := &mockRevokeProvider{store: makeStoreWithClient(makeClientBasic(testClientID))}
+ h := RevocationHandler(RevocationHandlerOptions{Provider: mp})
+
+ rr := postFormBasicAuth(t, h, "/revoke", testClientID, testClientSecret, url.Values{})
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ assert.Contains(t, strings.ToLower(rr.Body.String()), "invalid_request")
+}
+
+func TestRevocation_UnsupportedTokenHint_Still200(t *testing.T) {
+ mpBasic := &mockRevokeProvider{store: makeStoreWithClient(makeClientBasic(testClientID))}
+ hBasic := RevocationHandler(RevocationHandlerOptions{Provider: mpBasic})
+
+ form := url.Values{
+ "token": {"rt-xyz"},
+ "token_type_hint": {"access_token"},
+ }
+ rr := postFormBasicAuth(t, hBasic, "/revoke", testClientID, testClientSecret, form)
+
+ if rr.Code != http.StatusOK {
+ mpPost := &mockRevokeProvider{store: makeStoreWithClient(makeClientPost(testClientID))}
+ hPost := RevocationHandler(RevocationHandlerOptions{Provider: mpPost})
+ rr2 := postFormClientSecretPost(t, hPost, "/revoke", testClientID, testClientSecret, form)
+
+ if rr2.Code != http.StatusOK {
+ t.Skipf("Skip: Authentication failed (Basic=%d/%s, Post=%d/%s)",
+ rr.Code, strings.TrimSpace(rr.Body.String()),
+ rr2.Code, strings.TrimSpace(rr2.Body.String()),
+ )
+ return
+ }
+ require.Equal(t, http.StatusOK, rr2.Code)
+ return
+ }
+
+ require.Equal(t, http.StatusOK, rr.Code)
+}
+
+func TestRevocation_MethodNotAllowed_405(t *testing.T) {
+ mp := &mockRevokeProvider{store: makeStoreWithClient(makeClientBasic(testClientID))}
+ h := RevocationHandler(RevocationHandlerOptions{Provider: mp})
+
+ req := httptest.NewRequest(http.MethodGet, "/revoke", nil)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
+}
+
+func TestRevocation_RateLimit_429(t *testing.T) {
+ mp := &mockRevokeProvider{store: makeStoreWithClient(makeClientBasic(testClientID))}
+ h := RevocationHandler(RevocationHandlerOptions{
+ Provider: mp,
+ RateLimit: &RevocationRateLimitConfig{
+ WindowMs: 60_000,
+ Max: 1,
+ },
+ })
+
+ _ = postFormBasicAuth(t, h, "/revoke", testClientID, testClientSecret, url.Values{"token": {"at-123"}})
+
+ rr2 := postFormBasicAuth(t, h, "/revoke", testClientID, testClientSecret, url.Values{"token": {"at-456"}})
+ require.Equal(t, http.StatusTooManyRequests, rr2.Code)
+}
+
+func TestRevocation_OPTIONS_405(t *testing.T) {
+ mp := &mockRevokeProvider{store: makeStoreWithClient(makeClientBasic(testClientID))}
+ h := RevocationHandler(RevocationHandlerOptions{Provider: mp})
+
+ req := httptest.NewRequest(http.MethodOptions, "/revoke", nil)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
+}
diff --git a/internal/auth/server/handler/token.go b/internal/auth/server/handler/token.go
new file mode 100644
index 0000000..9485d04
--- /dev/null
+++ b/internal/auth/server/handler/token.go
@@ -0,0 +1,382 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/go-playground/validator/v10"
+ "golang.org/x/time/rate"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/pkce"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/middleware"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// TokenHandlerOptions defines configuration options for the token endpoint
+type TokenHandlerOptions struct {
+ Provider server.OAuthServerProvider `json:"provider"`
+ RateLimit *rate.Limiter `json:"rateLimit,omitempty"`
+}
+
+// TokenRequest defines the base structure of a token request.
+// Every token request must specify a grant_type to indicate which flow is being used.
+type TokenRequest struct {
+ // GrantType is the type of OAuth grant being requested.
+ // Common values include "authorization_code" and "refresh_token".
+ GrantType string `form:"grant_type" json:"grant_type" validate:"required"`
+}
+
+// AuthorizationCodeGrant represents a token request using the Authorization Code flow.
+type AuthorizationCodeGrant struct {
+ // Code is the authorization code previously issued to the client.
+ Code string `form:"code" json:"code" validate:"required"`
+
+ // CodeVerifier is the PKCE verifier string that matches the original code_challenge.
+ CodeVerifier string `form:"code_verifier" json:"code_verifier" validate:"required"`
+
+ // RedirectURI must match the redirect_uri used in the authorization request,
+ // if one was included there.
+ RedirectURI *string `form:"redirect_uri" json:"redirect_uri,omitempty"`
+
+ // Resource is an optional absolute URL indicating the target resource server.
+ Resource *string `form:"resource" json:"resource,omitempty" validate:"omitempty,url"`
+}
+
+// RefreshTokenGrant represents a token request using the Refresh Token flow.
+type RefreshTokenGrant struct {
+ // RefreshToken is the refresh token previously issued to the client.
+ RefreshToken string `form:"refresh_token" json:"refresh_token" validate:"required"`
+
+ // Scope is an optional space-delimited list of scopes being requested.
+ // If omitted, the scope is assumed to be identical to the scope originally granted.
+ Scope *string `form:"scope" json:"scope,omitempty"`
+
+ // Resource is an optional absolute URL indicating the target resource server.
+ Resource *string `form:"resource" json:"resource,omitempty" validate:"omitempty,url"`
+}
+
+// TokenHandler creates a token endpoint handler with full middleware stack
+func TokenHandler(options TokenHandlerOptions) http.HandlerFunc {
+ // Create the core handler logic
+ coreHandler := createTokenCoreHandler(options)
+
+ // Apply middlewares in order
+ var handler http.Handler = coreHandler
+
+ // Apply client authentication middleware
+ handler = middleware.AuthenticateClient(middleware.ClientAuthenticationMiddlewareOptions{
+ ClientsStore: options.Provider.ClientsStore(),
+ })(handler)
+
+ // Apply rate limiting middleware
+ limiter := options.RateLimit
+ if limiter == nil {
+ // Default rate limiting: 50 requests per 15 minutes
+ limiter = rate.NewLimiter(rate.Every(15*time.Minute/50), 50)
+ }
+ handler = middleware.RateLimitMiddleware(limiter)(handler)
+
+ // Apply method restriction middleware (only POST allowed)
+ handler = middleware.AllowedMethods([]string{"POST"})(handler)
+
+ // Apply CORS middleware
+ handler = middleware.CorsMiddleware(handler)
+
+ // Convert http.Handler to http.HandlerFunc
+ return func(w http.ResponseWriter, r *http.Request) {
+ handler.ServeHTTP(w, r)
+ }
+}
+
+// createTokenCoreHandler creates the core token handler logic shared between both versions
+func createTokenCoreHandler(options TokenHandlerOptions) http.HandlerFunc {
+ // Create a validator
+ validate := validator.New()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ // Set cache-control headers
+ w.Header().Set("Cache-Control", "no-store")
+
+ // Parsing form data
+ if err := r.ParseForm(); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, "Failed to parse form data", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+
+ // Get grant_type from form
+ grantType := r.FormValue("grant_type")
+ if grantType == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, "invalid client credentials", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+
+ // Verify basic token request
+ tokenReq := TokenRequest{
+ GrantType: grantType,
+ }
+
+ if err := validate.Struct(tokenReq); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, err.Error(), "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+
+ // Check client authentication result
+ client, ok := middleware.GetAuthenticatedClient(r)
+ if !ok {
+ // NOW this code will actually execute because middleware didn't terminate
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized) // Proper OAuth error status
+ errResp := errors.NewOAuthError(errors.ErrInvalidClient, "invalid client credentials", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+
+ switch grantType {
+ case "authorization_code":
+ handleAuthorizationCodeGrant(w, r, validate, options.Provider, *client)
+ case "refresh_token":
+ handleRefreshTokenGrant(w, r, validate, options.Provider, *client)
+ default:
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errResp := errors.NewOAuthError(errors.ErrUnsupportedGrantType, "The grant type is not supported by this authorization server.", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ }
+ }
+}
+
+// handleAuthorizationCodeGrant processes authorization code grant
+func handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request, validate *validator.Validate, provider server.OAuthServerProvider, client auth.OAuthClientInformationFull) {
+ // Parsing the authorization code grant request
+ var redirectURI *string
+ if uri := r.FormValue("redirect_uri"); uri != "" {
+ redirectURI = &uri
+ }
+
+ var resource *string
+ if res := r.FormValue("resource"); res != "" {
+ resource = &res
+ }
+
+ grant := AuthorizationCodeGrant{
+ Code: r.FormValue("code"),
+ CodeVerifier: r.FormValue("code_verifier"),
+ RedirectURI: redirectURI,
+ Resource: resource,
+ }
+
+ if err := validate.Struct(grant); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ switch verrs := err.(type) {
+ case validator.ValidationErrors:
+ for _, fe := range verrs {
+ if fe.Field() == "Resource" && fe.Tag() == "url" {
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, "resource must be a valid URL", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+ }
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, err.Error(), "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ default:
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, err.Error(), "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+ }
+
+ // Check if the provider supports skipLocalPKceValidation
+ type skipLocalPKceValidation interface {
+ GetSkipLocalPkceValidation() bool
+ }
+
+ skipLocalValidation := false
+ if p, ok := provider.(skipLocalPKceValidation); ok {
+ skipLocalValidation = p.GetSkipLocalPkceValidation()
+ }
+
+ // Perform local PKCE validation unless explicitly skipped
+ if !skipLocalValidation {
+ codeChallenge, err := provider.ChallengeForAuthorizationCode(client, grant.Code)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errResp := errors.NewOAuthError(errors.ErrInvalidGrant, "Failed to retrieve code challenge", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+
+ if !pkce.VerifyPKCEChallenge(grant.CodeVerifier, codeChallenge) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errResp := errors.NewOAuthError(errors.ErrInvalidGrant, "code_verifier does not match the challenge", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+ }
+
+ var resourceURL *url.URL
+ if grant.Resource != nil {
+ var err error
+ resourceURL, err = url.Parse(*grant.Resource)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, "Invalid resource URL", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+ }
+
+ // Parse the code_verifier to the provider if PKCE validation did not occur locally
+ var codeVerifier *string
+ if skipLocalValidation {
+ codeVerifier = &grant.CodeVerifier
+ }
+
+ // Exchange the authorization code for a token
+ tokens, err := provider.ExchangeAuthorizationCode(
+ client,
+ grant.Code,
+ codeVerifier,
+ grant.RedirectURI,
+ resourceURL,
+ )
+
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+
+ // Return an appropriate OAuth error response based on the error type
+ switch {
+ case err == errors.ErrInvalidParams || err == errors.ErrMissingParams:
+ w.WriteHeader(http.StatusBadRequest)
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, err.Error(), "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ case err == errors.ErrInvalidJSONRPCParams:
+ w.WriteHeader(http.StatusBadRequest)
+ errResp := errors.NewOAuthError(errors.ErrInvalidGrant, err.Error(), "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ default:
+ w.WriteHeader(http.StatusInternalServerError)
+ errResp := errors.NewOAuthError(errors.ErrServerError, "Internal Server Error", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ }
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(tokens)
+}
+
+// handleRefreshTokenGrant handles refresh token grant
+func handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request, validate *validator.Validate, provider server.OAuthServerProvider, client auth.OAuthClientInformationFull) {
+ var scope *string
+ if s := r.FormValue("scope"); s != "" {
+ scope = &s
+ }
+
+ var resource *string
+ if res := r.FormValue("resource"); res != "" {
+ resource = &res
+ }
+
+ grant := RefreshTokenGrant{
+ RefreshToken: r.FormValue("refresh_token"),
+ Scope: scope,
+ Resource: resource,
+ }
+
+ if err := validate.Struct(grant); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, err.Error(), "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+
+ // Handle scopes
+ var scopes []string
+ if grant.Scope != nil {
+ scopes = strings.Split(*grant.Scope, " ")
+ }
+
+ // Handle resource URL
+ var resourceURL *url.URL
+ if grant.Resource != nil {
+ var err error
+ resourceURL, err = url.Parse(*grant.Resource)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, "Invalid resource URL", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+ }
+
+ // Swap refresh token
+ tokens, err := provider.ExchangeRefreshToken(client, grant.RefreshToken, scopes, resourceURL)
+
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+
+ if strings.Contains(strings.ToLower(err.Error()), "invalid") {
+ w.WriteHeader(http.StatusInternalServerError)
+ errResp := errors.NewOAuthError(errors.ErrInvalidGrant, err.Error(), "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ return
+ }
+
+ switch {
+ case err == errors.ErrInvalidParams || err == errors.ErrMissingParams:
+ w.WriteHeader(http.StatusBadRequest)
+ errResp := errors.NewOAuthError(errors.ErrInvalidRequest, err.Error(), "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ case err == errors.ErrInvalidJSONRPCParams:
+ w.WriteHeader(http.StatusBadRequest)
+ errResp := errors.NewOAuthError(errors.ErrInvalidGrant, err.Error(), "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ default:
+ w.WriteHeader(http.StatusInternalServerError)
+ errResp := errors.NewOAuthError(errors.ErrServerError, "Internal Server Error", "")
+ json.NewEncoder(w).Encode(errResp.ToResponseStruct())
+ }
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(tokens)
+}
diff --git a/internal/auth/server/handler/token_test.go b/internal/auth/server/handler/token_test.go
new file mode 100644
index 0000000..369f256
--- /dev/null
+++ b/internal/auth/server/handler/token_test.go
@@ -0,0 +1,697 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package handler
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/time/rate"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// postFormWithBasicAuth sends a POST request with x-www-form-urlencoded body and HTTP Basic auth
+func postFormWithBasicAuth(t *testing.T, h http.Handler, path string, form url.Values, clientID, clientSecret string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.SetBasicAuth(clientID, clientSecret)
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ return rr
+}
+
+// postForm sends a POST request with x-www-form-urlencoded body (no client auth)
+func postForm(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ return rr
+}
+
+// postFormWithOrigin sends a POST request with x-www-form-urlencoded body, Basic auth, and Origin header for CORS tests
+func postFormWithOrigin(t *testing.T, h http.Handler, path string, form url.Values, clientID, clientSecret, origin string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", origin)
+ req.SetBasicAuth(clientID, clientSecret)
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ return rr
+}
+
+// enhancedMockOAuthClientsStore is a simple in-memory clients store used in tests
+type enhancedMockOAuthClientsStore struct {
+ clients map[string]*auth.OAuthClientInformationFull // client_id -> client record
+}
+
+// GetClient returns the client by id or an error if not found.
+func (m *enhancedMockOAuthClientsStore) GetClient(clientID string) (*auth.OAuthClientInformationFull, error) {
+ client, exists := m.clients[clientID]
+ if !exists {
+ return nil, fmt.Errorf("client not found")
+ }
+ return client, nil
+}
+
+// enhancedMockOAuthServerProvider simulates an OAuth server provider with toggles for different paths
+type enhancedMockOAuthServerProvider struct {
+ clientStore *enhancedMockOAuthClientsStore // backing store for clients
+ skipLocalPkceValidation bool // when true, PKCE is not validated locally
+ shouldReturnIdToken bool // when true, adds id_token to token response
+ shouldFailCodeChallenge bool // when true, ChallengeForAuthorizationCode fails
+ shouldFailCodeExchange bool // when true, ExchangeAuthorizationCode fails with invalid_grant
+ shouldFailRefreshExchange bool // when true, ExchangeRefreshToken fails with invalid_grant
+ supportedScopes []string // list of supported scopes (for tests that depend on scope echo)
+}
+
+// GetSkipLocalPkceValidation exposes whether local PKCE verification should be skipped
+func (m *enhancedMockOAuthServerProvider) GetSkipLocalPkceValidation() bool {
+ return m.skipLocalPkceValidation
+}
+
+// ClientsStore returns a thin adapter around the in-memory store to satisfy the interface
+func (m *enhancedMockOAuthServerProvider) ClientsStore() *server.OAuthClientsStore {
+ return server.NewOAuthClientStore(m.clientStore.GetClient)
+}
+
+// Authorize simulates authorization success by redirecting with code and echoing state
+func (m *enhancedMockOAuthServerProvider) Authorize(client auth.OAuthClientInformationFull, params server.AuthorizationParams, res http.ResponseWriter, req *http.Request) error {
+ // Compose a 302 redirect with code + state
+ res.Header().Set("Location", "https://redirect-uri.com?code=valid-code&state="+params.State)
+ res.WriteHeader(http.StatusFound)
+ return nil
+}
+
+// ChallengeForAuthorizationCode returns a fixed S256 challenge for "valid-code" and errors otherwise
+func (m *enhancedMockOAuthServerProvider) ChallengeForAuthorizationCode(
+ client auth.OAuthClientInformationFull,
+ authorizationCode string,
+) (string, error) {
+ if m.shouldFailCodeChallenge {
+ return "", errors.ErrInvalidGrant
+ }
+ switch authorizationCode {
+ case "valid-code":
+ // Matches code_verifier = "valid-verifier"
+ return "A_DCKa0ei4rJGhNfKEbwNpiuHzQP7skGQPZ4CBTkJdQ", nil
+ case "expired-code":
+ return "", fmt.Errorf("authorization code has expired")
+ case "invalid-code":
+ return "", fmt.Errorf("authorization code is invalid")
+ default:
+ return "", fmt.Errorf("unknown authorization code")
+ }
+}
+
+// ExchangeAuthorizationCode returns mock tokens for "valid-code" and errors for others
+func (m *enhancedMockOAuthServerProvider) ExchangeAuthorizationCode(
+ client auth.OAuthClientInformationFull,
+ authorizationCode string,
+ codeVerifier *string,
+ redirectUri *string,
+ resource *url.URL,
+) (*auth.OAuthTokens, error) {
+ // Simulate upstream failure toggle
+ if m.shouldFailCodeExchange {
+ return nil, errors.ErrInvalidGrant
+ }
+
+ switch authorizationCode {
+ case "valid-code":
+ expiresIn := int64(3600)
+ refreshToken := "mock-refresh-token"
+
+ tokens := &auth.OAuthTokens{
+ AccessToken: "mock-access-token",
+ TokenType: "bearer",
+ ExpiresIn: &expiresIn,
+ RefreshToken: &refreshToken,
+ }
+ // Optionally attach an ID token for OIDC scenarios
+ if m.shouldReturnIdToken {
+ idToken := "mock-id-token"
+ tokens.IDToken = &idToken
+ }
+ return tokens, nil
+ case "expired-code":
+ return nil, fmt.Errorf("authorization code has expired")
+ case "invalid-code":
+ return nil, fmt.Errorf("authorization code is invalid")
+ default:
+ return nil, errors.ErrInvalidGrant
+ }
+}
+
+// ExchangeRefreshToken returns a new access/refresh token pair for a valid refresh token
+func (m *enhancedMockOAuthServerProvider) ExchangeRefreshToken(
+ client auth.OAuthClientInformationFull,
+ refreshToken string,
+ scopes []string,
+ resource *url.URL,
+) (*auth.OAuthTokens, error) {
+ if m.shouldFailRefreshExchange {
+ return nil, errors.ErrInvalidGrant
+ }
+
+ switch refreshToken {
+ case "valid-refresh-token":
+ expiresIn := int64(3600)
+ newRefreshToken := "new-mock-refresh-token"
+
+ tokens := &auth.OAuthTokens{
+ AccessToken: "new-mock-access-token",
+ TokenType: "bearer",
+ ExpiresIn: &expiresIn,
+ RefreshToken: &newRefreshToken,
+ }
+ if len(scopes) > 0 {
+ scope := strings.Join(scopes, " ")
+ tokens.Scope = &scope
+ }
+ return tokens, nil
+ case "invalid-refresh-token":
+ return nil, fmt.Errorf("refresh token is invalid")
+ default:
+ return nil, errors.ErrInvalidGrant
+ }
+}
+
+// VerifyAccessToken returns a stubbed AuthInfo when token == "valid-token"
+func (m *enhancedMockOAuthServerProvider) VerifyAccessToken(token string) (*server.AuthInfo, error) {
+ if token == "valid-token" {
+ return &server.AuthInfo{
+ ClientID: "valid-client-id",
+ Scopes: []string{"read", "write"},
+ }, nil
+ }
+ return nil, fmt.Errorf("invalid token")
+}
+
+// RevokeToken is a no-op in this mock; revocation success is implied
+func (m *enhancedMockOAuthServerProvider) RevokeToken(client auth.OAuthClientInformationFull, request auth.OAuthTokenRevocationRequest) error {
+ return nil
+}
+
+// createMockClient builds a confidential client (client_secret_basic) with a fixed secret
+func createMockClient(id string) *auth.OAuthClientInformationFull {
+ return &auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: id,
+ ClientSecret: "valid-secret",
+ },
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://example.com/callback"},
+ TokenEndpointAuthMethod: "client_secret_basic",
+ },
+ }
+}
+
+// createEnhancedMockProvider wires the in-memory clients store and returns a preconfigured provider
+func createEnhancedMockProvider() *enhancedMockOAuthServerProvider {
+ clients := make(map[string]*auth.OAuthClientInformationFull)
+ clients["valid-client"] = createMockClient("valid-client")
+ store := &enhancedMockOAuthClientsStore{clients: clients}
+
+ return &enhancedMockOAuthServerProvider{
+ clientStore: store,
+ supportedScopes: []string{"read", "write", "profile", "email"},
+ }
+}
+
+func TestToken_RequiresPostMethod(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/token", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
+ assert.Equal(t, "POST", rr.Header().Get("Allow"))
+}
+
+func TestToken_RequiresGrantType(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{}
+ // Missing grant_type
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+ assert.Equal(t, "invalid_request", errResp["error"])
+}
+
+func TestToken_RejectsUnsupportedGrantTypes(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"password"}, // Unsupported grant type
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+ assert.Equal(t, "unsupported_grant_type", errResp["error"])
+ assert.Equal(t, "The grant type is not supported by this authorization server.", errResp["error_description"])
+}
+
+func TestToken_RequiresValidClientCredentials_CurrentBehavior(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "invalid-client", "wrong-secret")
+
+ // HTTP status should be 401
+ assert.Equal(t, http.StatusUnauthorized, rr.Code)
+
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+
+ assert.Equal(t, "invalid_client", errResp["error"])
+ assert.Contains(t, errResp["error_description"].(string), "client")
+}
+
+func TestToken_AcceptsValidClientCredentials(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"valid-code"},
+ "code_verifier": {"valid-verifier"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+}
+
+func TestToken_AuthorizationCode_RequiresCodeParameter(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ // Missing code
+ "code_verifier": {"valid-verifier"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+ assert.Equal(t, "invalid_request", errResp["error"])
+}
+
+func TestToken_AuthorizationCode_RequiresCodeVerifierParameter(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"valid-code"},
+ // Missing code_verifier
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+ assert.Equal(t, "invalid_request", errResp["error"])
+}
+
+func TestToken_AuthorizationCode_VerifiesPKCEChallenge(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ provider.shouldFailCodeChallenge = false // Ensure challenge retrieval works
+
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"valid-code"},
+ "code_verifier": {"invalid-verifier"}, // This won't match the challenge
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+ assert.Equal(t, "invalid_grant", errResp["error"])
+ assert.Contains(t, errResp["error_description"], "code_verifier")
+}
+
+func TestToken_AuthorizationCode_RejectsExpiredCode(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"expired-code"},
+ "code_verifier": {"valid-verifier"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+ assert.Equal(t, "invalid_grant", errResp["error"])
+}
+
+func TestToken_AuthorizationCode_RejectsInvalidCode(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"invalid-code"},
+ "code_verifier": {"valid-verifier"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+ assert.Equal(t, "invalid_grant", errResp["error"])
+}
+
+func TestToken_AuthorizationCode_ReturnsTokensForValidExchange(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"valid-code"},
+ "code_verifier": {"valid-verifier"},
+ "resource": {"https://api.example.com/resource"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var tokens map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
+ assert.Equal(t, "mock-access-token", tokens["access_token"])
+ assert.Equal(t, "bearer", tokens["token_type"])
+ assert.Equal(t, float64(3600), tokens["expires_in"])
+ assert.Equal(t, "mock-refresh-token", tokens["refresh_token"])
+}
+
+func TestToken_AuthorizationCode_ReturnsIdTokenWhenProvided(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ provider.shouldReturnIdToken = true
+
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"valid-code"},
+ "code_verifier": {"valid-verifier"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var tokens map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
+ assert.Equal(t, "mock-id-token", tokens["id_token"])
+}
+
+func TestToken_RefreshToken_RequiresRefreshTokenParameter(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"refresh_token"},
+ // Missing refresh_token
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+ assert.Equal(t, "invalid_request", errResp["error"])
+}
+
+func TestToken_RefreshToken_RejectsInvalidRefreshToken(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {"invalid-refresh-token"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusInternalServerError, rr.Code)
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+ assert.Equal(t, "invalid_grant", errResp["error"])
+}
+
+func TestToken_RefreshToken_ReturnsNewTokensForValidRefresh(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {"valid-refresh-token"},
+ "resource": {"https://api.example.com/resource"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var tokens map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
+ assert.Equal(t, "new-mock-access-token", tokens["access_token"])
+ assert.Equal(t, "bearer", tokens["token_type"])
+ assert.Equal(t, float64(3600), tokens["expires_in"])
+ assert.Equal(t, "new-mock-refresh-token", tokens["refresh_token"])
+}
+
+func TestToken_RefreshToken_RespectsRequestedScopes(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {"valid-refresh-token"},
+ "scope": {"profile email"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ var tokens map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
+ assert.Equal(t, "profile email", tokens["scope"])
+}
+
+func TestToken_IncludesCORSHeaders(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"valid-code"},
+ "code_verifier": {"valid-verifier"},
+ }
+
+ rr := postFormWithOrigin(t, handler, "/token", form, "valid-client", "valid-secret", "https://example.com")
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ // Check CORS headers
+ assert.Contains(t, rr.Header().Get("Access-Control-Allow-Origin"), "*")
+}
+
+func TestToken_RateLimiting(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 1), // Only 1 request allowed
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"valid-code"},
+ "code_verifier": {"valid-verifier"},
+ }
+
+ // First request should succeed
+ rr1 := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+ assert.Equal(t, http.StatusOK, rr1.Code)
+
+ // Second request should be rate-limited
+ rr2 := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+ assert.Equal(t, http.StatusTooManyRequests, rr2.Code)
+}
+
+func TestToken_SetsCacheControlHeaders(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"valid-code"},
+ "code_verifier": {"valid-verifier"},
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, "no-store", rr.Header().Get("Cache-Control"))
+ assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
+}
+
+func TestToken_RejectsOPTIONSMethod(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ req := httptest.NewRequest(http.MethodOptions, "/token", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
+}
+
+func TestToken_ValidatesResourceParameter(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"valid-code"},
+ "code_verifier": {"valid-verifier"},
+ "resource": {"invalid-url"}, // Invalid URL
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusBadRequest, rr.Code)
+ var errResp map[string]interface{}
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &errResp))
+ assert.Equal(t, "invalid_request", errResp["error"])
+ assert.Contains(t, errResp["error_description"], "resource")
+}
+
+func TestToken_SkipLocalPKCEValidation(t *testing.T) {
+ provider := createEnhancedMockProvider()
+ provider.skipLocalPkceValidation = true
+
+ handler := TokenHandler(TokenHandlerOptions{
+ Provider: provider,
+ RateLimit: rate.NewLimiter(rate.Every(15*time.Minute/50), 50),
+ })
+
+ form := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {"valid-code"},
+ "code_verifier": {"any-verifier"}, // Should be passed through without local validation
+ }
+
+ rr := postFormWithBasicAuth(t, handler, "/token", form, "valid-client", "valid-secret")
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+}
diff --git a/internal/auth/server/http.go b/internal/auth/server/http.go
new file mode 100644
index 0000000..18d59f0
--- /dev/null
+++ b/internal/auth/server/http.go
@@ -0,0 +1,134 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package server
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+
+ oauthErrors "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+type ctxKey int
+type ctxKeyScope int
+
+const (
+ ctxKeyAuthInfo ctxKey = iota
+ ctxKeyAuthErr
+ ctxKeyRequiredScope ctxKeyScope = 1
+)
+
+// WithAuthInfo writes authentication information into the context
+func WithAuthInfo(ctx context.Context, info *AuthInfo) context.Context {
+ // If no info provided, just return the original context
+ if info == nil {
+ return ctx
+ }
+ // Store authentication info in the context with a private key
+ return context.WithValue(ctx, ctxKeyAuthInfo, info)
+}
+
+// GetAuthInfo retrieves authentication information from the context
+func GetAuthInfo(ctx context.Context) (*AuthInfo, bool) {
+ // Extract value from context
+ v := ctx.Value(ctxKeyAuthInfo)
+ // Return false if not set
+ if v == nil {
+ return nil, false
+ }
+ // Type assert to *AuthInfo
+ info, ok := v.(*AuthInfo)
+ return info, ok && info != nil
+}
+
+// WithAuthErr stores an authentication error into the context
+func WithAuthErr(ctx context.Context, err error) context.Context {
+ // No error means no need to wrap context
+ if err == nil {
+ return ctx
+ }
+ // Store error in context for later retrieval
+ return context.WithValue(ctx, ctxKeyAuthErr, err)
+}
+
+// GetAuthErr retrieves an authentication error from the context
+func GetAuthErr(ctx context.Context) error {
+ // Extract error from context
+ v := ctx.Value(ctxKeyAuthErr)
+ // If not set, return nil (no error)
+ if v == nil {
+ return nil
+ }
+ // Type assert to error
+ if err, ok := v.(error); ok {
+ return err
+ }
+ // Fallback: return generic error if type is invalid
+ return errors.New("auth err")
+}
+
+// WriteAuthChallenge writes a Bearer authentication challenge response header
+// and sends the specified HTTP status code
+func WriteAuthChallenge(w http.ResponseWriter, status int, code, desc, scope string) {
+ // Build WWW-Authenticate header per RFC 6750
+ val := fmt.Sprintf(`Bearer realm="mcp", error="%s", error_description="%s"`, code, desc)
+ // Append required scope if present
+ if scope != "" {
+ val += fmt.Sprintf(`, scope="%s"`, scope)
+ }
+ // Add headers to prevent caching
+ w.Header().Set("WWW-Authenticate", val)
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Pragma", "no-cache")
+ // Return error with HTTP status
+ http.Error(w, http.StatusText(status), status)
+}
+
+// DetermineAuthError maps OAuth error types to HTTP status codes
+// and standardized error code/description strings
+func DetermineAuthError(err error) (int, string, string) {
+ // Match specific OAuth error cases
+ switch {
+ case errors.Is(err, oauthErrors.ErrInsufficientScope):
+ // Missing required scope: 403 Forbidden
+ return http.StatusForbidden, "insufficient_scope", "Token lacks required scope"
+ case errors.Is(err, oauthErrors.ErrInvalidRequest):
+ // Invalid/malformed request: 400 Bad Request
+ return http.StatusBadRequest, "invalid_request", "Missing or malformed authorization"
+ case errors.Is(err, oauthErrors.ErrInvalidClient):
+ // Invalid client authentication: 401 Unauthorized
+ return http.StatusUnauthorized, "invalid_client", "Client authentication failed"
+ case errors.Is(err, oauthErrors.ErrInvalidToken):
+ // Invalid/expired token: 401 Unauthorized
+ return http.StatusUnauthorized, "invalid_token", "The access token is invalid or expired"
+ default:
+ // Default: classify as invalid_token for security
+ return http.StatusUnauthorized, "invalid_token", "Token verification failed"
+ }
+}
+
+// WithRequiredScope stores the minimum required scope into the context
+func WithRequiredScope(ctx context.Context, scope string) context.Context {
+ // No scope provided, no modification
+ if scope == "" {
+ return ctx
+ }
+ // Store required scope for later validation
+ return context.WithValue(ctx, ctxKeyRequiredScope, scope)
+}
+
+// GetRequiredScope retrieves the required scope from the context
+func GetRequiredScope(ctx context.Context) (string, bool) {
+ // Extract scope value
+ v := ctx.Value(ctxKeyRequiredScope)
+ // Assert type to string
+ s, ok := v.(string)
+ // Return non-empty scope if valid
+ return s, ok && s != ""
+}
diff --git a/internal/auth/server/http_test.go b/internal/auth/server/http_test.go
new file mode 100644
index 0000000..e6ac2d2
--- /dev/null
+++ b/internal/auth/server/http_test.go
@@ -0,0 +1,90 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package server
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAuthInfoFields(t *testing.T) {
+ // Setting authInfo data
+ token := "mock-token"
+ clientID := "client-123"
+ scopes := []string{"read", "write"}
+ expiresAt := int64(1609459200) // 2021-01-01 00:00:00
+ resource, err := url.Parse("https://example.com/resource")
+ require.NoError(t, err)
+ extra := map[string]interface{}{"key1": "value1"}
+
+ authInfo := &AuthInfo{
+ Token: token,
+ ClientID: clientID,
+ Scopes: scopes,
+ ExpiresAt: &expiresAt,
+ Resource: resource,
+ Extra: extra,
+ }
+
+ // Verify that the stored data is correct
+ assert.Equal(t, token, authInfo.Token)
+ assert.Equal(t, clientID, authInfo.ClientID)
+ assert.ElementsMatch(t, scopes, authInfo.Scopes)
+ assert.Equal(t, expiresAt, *authInfo.ExpiresAt)
+ assert.Equal(t, resource, authInfo.Resource)
+ assert.Equal(t, extra, authInfo.Extra)
+}
+
+func TestAuthInfoWithNilExpiresAt(t *testing.T) {
+ authInfo := &AuthInfo{
+ Token: "mock-token",
+ ClientID: "client-123",
+ Scopes: []string{"read", "write"},
+ ExpiresAt: nil, // nil expiresAt
+ }
+
+ // Verify that ExpiresAt is nil
+ assert.Nil(t, authInfo.ExpiresAt)
+}
+
+func TestAuthInfoResourceValidation(t *testing.T) {
+ validURL, err := url.Parse("https://example.com/resource")
+ require.NoError(t, err)
+ invalidURL, err := url.Parse("https://example.com/invalid-resource")
+ require.NoError(t, err)
+
+ authInfo := &AuthInfo{
+ Token: "mock-token",
+ ClientID: "client-123",
+ Scopes: []string{"read"},
+ Resource: validURL,
+ }
+
+ // Verify that resource match
+ assert.Equal(t, validURL.String(), authInfo.Resource.String())
+
+ // Setting Resource to an invalid URL
+ authInfo.Resource = invalidURL
+ assert.Equal(t, invalidURL.String(), authInfo.Resource.String())
+}
+
+func TestAuthInfoExtraData(t *testing.T) {
+ extraData := map[string]interface{}{"key1": "value1", "key2": 1234}
+
+ authInfo := &AuthInfo{
+ Token: "mock-token",
+ ClientID: "client-123",
+ Scopes: []string{"read"},
+ Extra: extraData,
+ }
+
+ // Verify that Extra data is stored correctly
+ assert.Equal(t, extraData, authInfo.Extra)
+}
diff --git a/internal/auth/server/middleware/allowedMethods.go b/internal/auth/server/middleware/allowedMethods.go
new file mode 100644
index 0000000..ee1d1d2
--- /dev/null
+++ b/internal/auth/server/middleware/allowedMethods.go
@@ -0,0 +1,48 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// AllowedMethods returns a middleware that permits only the provided HTTP methods
+// If the request method is not allowed it responds with 405 Method Not Allowed
+// The response includes an Allow header and a JSON OAuth error body
+func AllowedMethods(methods []string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Allow request to proceed when method matches one of the allowed methods
+ for _, method := range methods {
+ if r.Method == method {
+ next.ServeHTTP(w, r)
+ return
+ }
+ }
+
+ // Build 405 response with Allow header listing permitted methods
+ w.Header().Set("Allow", strings.Join(methods, ", "))
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusMethodNotAllowed)
+
+ // Create an OAuth-style error payload for method not allowed
+ oauthErr := errors.NewOAuthError(
+ errors.ErrMethodNotAllowed,
+ fmt.Sprintf("The method %s is not allowed for this endpoint", r.Method),
+ "", // Optional error URI
+ )
+
+ // Encode the error as JSON response body
+ _ = json.NewEncoder(w).Encode(oauthErr.ToResponseStruct())
+ })
+ }
+}
diff --git a/internal/auth/server/middleware/allowedMethods_test.go b/internal/auth/server/middleware/allowedMethods_test.go
new file mode 100644
index 0000000..44a5c49
--- /dev/null
+++ b/internal/auth/server/middleware/allowedMethods_test.go
@@ -0,0 +1,130 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestAllowedMethods(t *testing.T) {
+ // Create a test handler
+ createTestHandler := func() http.Handler {
+ mux := http.NewServeMux()
+ // Define /test route that only supports GET
+ mux.Handle("/test", AllowedMethods([]string{"GET"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("GET success"))
+ })))
+ return mux
+ }
+
+ // Case 1 allows specified HTTP method
+ t.Run("allows specified HTTP method", func(t *testing.T) {
+ handler := createTestHandler()
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code)
+ }
+ if body := rr.Body.String(); body != "GET success" {
+ t.Errorf("expected body %q, got %q", "GET success", body)
+ }
+ })
+
+ // Case 2 returns 405 for unsupported methods
+ t.Run("returns 405 for unspecified HTTP methods", func(t *testing.T) {
+ methods := []string{"POST", "PUT", "DELETE", "PATCH"}
+
+ for _, method := range methods {
+ t.Run(method, func(t *testing.T) {
+ handler := createTestHandler()
+ req := httptest.NewRequest(method, "/test", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusMethodNotAllowed {
+ t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
+ }
+
+ var response map[string]string
+ if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ expected := map[string]string{
+ "error": "method not allowed",
+ "error_description": "The method " + method + " is not allowed for this endpoint",
+ }
+ if response["error"] == expected["error"] && response["error_description"] == expected["error_description"] {
+ t.Errorf("expected response %v, got %v", expected, response)
+ }
+ })
+ }
+ })
+
+ // Case 3 checks Allow header
+ t.Run("includes Allow header with specified methods", func(t *testing.T) {
+ handler := createTestHandler()
+ req := httptest.NewRequest(http.MethodPost, "/test", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if allow := rr.Header().Get("Allow"); allow != "GET" {
+ t.Errorf("expected Allow header %q, got %q", "GET", allow)
+ }
+ })
+
+ // Case 4 supports multiple allowed methods
+ t.Run("works with multiple allowed methods", func(t *testing.T) {
+ // Define /multi route supporting GET and POST
+ mux := http.NewServeMux()
+ mux.Handle("/multi", AllowedMethods([]string{"GET", "POST"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ _, _ = w.Write([]byte("GET"))
+ } else if r.Method == http.MethodPost {
+ _, _ = w.Write([]byte("POST"))
+ }
+ })))
+
+ // Allowed methods
+ for _, method := range []string{http.MethodGet, http.MethodPost} {
+ t.Run(method, func(t *testing.T) {
+ req := httptest.NewRequest(method, "/multi", nil)
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code)
+ }
+ expectedBody := strings.ToUpper(method)
+ if body := rr.Body.String(); body != expectedBody {
+ t.Errorf("expected body %q, got %q", expectedBody, body)
+ }
+ })
+ }
+
+ // Unsupported method PUT
+ t.Run("PUT", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPut, "/multi", nil)
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusMethodNotAllowed {
+ t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
+ }
+ if allow := rr.Header().Get("Allow"); allow != "GET, POST" {
+ t.Errorf("expected Allow header %q, got %q", "GET, POST", allow)
+ }
+ })
+ })
+}
diff --git a/internal/auth/server/middleware/audit.go b/internal/auth/server/middleware/audit.go
new file mode 100644
index 0000000..9abd5d3
--- /dev/null
+++ b/internal/auth/server/middleware/audit.go
@@ -0,0 +1,768 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+)
+
+// AuditLevel defines audit log verbosity levels
+type AuditLevel int
+
+const (
+ AuditLevelNone AuditLevel = iota
+ AuditLevelBasic
+ AuditLevelDetailed
+ AuditLevelFull
+)
+
+// AuditEvent represents an OAuth 2.1 operation audit record
+type AuditEvent struct {
+ EventID string `json:"event_id"`
+ Timestamp time.Time `json:"timestamp"`
+ EventType string `json:"event_type"`
+ AuditLevel AuditLevel `json:"audit_level"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ QueryParams map[string]string `json:"query_params,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+ RemoteAddr string `json:"remote_addr"`
+ UserAgent string `json:"user_agent"`
+ RequestID string `json:"request_id,omitempty"`
+ ClientID string `json:"client_id,omitempty"`
+ Subject string `json:"subject,omitempty"`
+ Scopes []string `json:"scopes,omitempty"`
+ GrantType string `json:"grant_type,omitempty"`
+ ResponseType string `json:"response_type,omitempty"`
+ RedirectURI string `json:"redirect_uri,omitempty"`
+ Resource string `json:"resource,omitempty"`
+ StatusCode int `json:"status_code"`
+ ResponseTime time.Duration `json:"response_time"`
+ ErrorCode string `json:"error_code,omitempty"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ TokenHash string `json:"token_hash,omitempty"`
+ CodeHash string `json:"code_hash,omitempty"`
+ IPHash string `json:"ip_hash,omitempty"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
+ RiskLevel string `json:"risk_level,omitempty"`
+ RiskFactors []string `json:"risk_factors,omitempty"`
+ RequestBody string `json:"request_body,omitempty"`
+ ResponseBody string `json:"response_body,omitempty"`
+}
+
+// AuditLogger defines an interface for emitting audit logs
+type AuditLogger interface {
+ LogEvent(event AuditEvent) error
+ LogError(event AuditEvent, err error) error
+}
+
+// DefaultAuditLogger provides a zap based implementation of AuditLogger
+type DefaultAuditLogger struct {
+ logger *zap.Logger
+}
+
+// NewAuditLogger creates a DefaultAuditLogger using the provided zap logger or sensible defaults
+func NewAuditLogger(logger *zap.Logger) *DefaultAuditLogger {
+ // Build a production logger by default and fall back to development if needed
+ if logger == nil {
+ var err error
+ logger, err = zap.NewProduction()
+ if err != nil {
+ logger, _ = zap.NewDevelopment()
+ }
+ }
+ return &DefaultAuditLogger{logger: logger}
+}
+
+// GetZapLogger exposes the underlying zap logger for advanced usage
+func (l *DefaultAuditLogger) GetZapLogger() *zap.Logger {
+ return l.logger
+}
+
+// LogEvent writes a structured audit event at info level
+func (l *DefaultAuditLogger) LogEvent(event AuditEvent) error {
+ // Guard against uninitialized logger
+ if l.logger == nil {
+ return fmt.Errorf("zap logger not initialized")
+ }
+
+ // Marshal full event payload for a single structured field
+ data, err := json.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("failed to marshal audit event: %w", err)
+ }
+
+ // Emit event with a compact summary sub-structure for quick filtering
+ l.logger.Info("[AUDIT]",
+ zap.ByteString("event", data),
+ zap.Any("audit", struct {
+ Method string
+ Path string
+ StatusCode int
+ ResponseTime time.Duration
+ ClientID string
+ Subject string
+ Scopes []string
+ RiskLevel string
+ }{
+ Method: event.Method,
+ Path: event.Path,
+ StatusCode: event.StatusCode,
+ ResponseTime: event.ResponseTime,
+ ClientID: event.ClientID,
+ Subject: event.Subject,
+ Scopes: event.Scopes,
+ RiskLevel: event.RiskLevel,
+ }),
+ )
+ return nil
+}
+
+// LogError writes an audit event including the provided error message
+func (l *DefaultAuditLogger) LogError(event AuditEvent, err error) error {
+ // Attach error message then delegate to LogEvent
+ event.ErrorMessage = err.Error()
+ return l.LogEvent(event)
+}
+
+// AuditMiddlewareOptions configures what and how the middleware audits
+type AuditMiddlewareOptions struct {
+ Logger AuditLogger
+ Level AuditLevel
+ HashSensitiveData bool
+ IncludeRequestBody bool
+ IncludeResponseBody bool
+ RiskAssessor func(AuditEvent) (string, []string)
+ MetadataExtractor func(*http.Request) map[string]interface{}
+ EndpointPatterns []string
+ ExcludePatterns []string
+ SensitiveKeys []string
+}
+
+// DefaultAuditMiddlewareOptions returns a sane default configuration for OAuth endpoints
+func DefaultAuditMiddlewareOptions() *AuditMiddlewareOptions {
+ // Default to detailed level with hashing and common OAuth endpoint patterns
+ return &AuditMiddlewareOptions{
+ Logger: NewAuditLogger(nil),
+ Level: AuditLevelDetailed,
+ HashSensitiveData: true,
+ EndpointPatterns: []string{
+ "/oauth2/authorize",
+ "/oauth2/token",
+ "/oauth2/revoke",
+ "/oauth2/register",
+ "/oauth2/metadata",
+ },
+ SensitiveKeys: []string{"client_secret", "code_verifier", "password", "authorization", "cookie", "x-api-key"},
+ }
+}
+
+// AuditOptionsBuilder helps compose AuditMiddlewareOptions with a fluent API
+type AuditOptionsBuilder struct {
+ options *AuditMiddlewareOptions
+}
+
+// NewAuditOptionsBuilder creates a builder initialized with default options
+func NewAuditOptionsBuilder() *AuditOptionsBuilder {
+ return &AuditOptionsBuilder{options: DefaultAuditMiddlewareOptions()}
+}
+
+// WithLogger sets a custom zap logger for audit output
+func (b *AuditOptionsBuilder) WithLogger(logger *zap.Logger) *AuditOptionsBuilder {
+ b.options.Logger = NewAuditLogger(logger)
+ return b
+}
+
+// WithLevel sets the audit verbosity level
+func (b *AuditOptionsBuilder) WithLevel(level AuditLevel) *AuditOptionsBuilder {
+ b.options.Level = level
+ return b
+}
+
+// WithHashSensitiveData toggles hashing of sensitive fields before logging
+func (b *AuditOptionsBuilder) WithHashSensitiveData(hash bool) *AuditOptionsBuilder {
+ b.options.HashSensitiveData = hash
+ return b
+}
+
+// WithRequestBody toggles inclusion of request body in audit events
+func (b *AuditOptionsBuilder) WithRequestBody(include bool) *AuditOptionsBuilder {
+ b.options.IncludeRequestBody = include
+ return b
+}
+
+// WithResponseBody toggles inclusion of response body in audit events
+func (b *AuditOptionsBuilder) WithResponseBody(include bool) *AuditOptionsBuilder {
+ b.options.IncludeResponseBody = include
+ return b
+}
+
+// WithRiskAssessor sets a custom risk assessment function
+func (b *AuditOptionsBuilder) WithRiskAssessor(assessor func(AuditEvent) (string, []string)) *AuditOptionsBuilder {
+ b.options.RiskAssessor = assessor
+ return b
+}
+
+// WithMetadataExtractor sets a function to extract extra metadata from requests
+func (b *AuditOptionsBuilder) WithMetadataExtractor(extractor func(*http.Request) map[string]interface{}) *AuditOptionsBuilder {
+ b.options.MetadataExtractor = extractor
+ return b
+}
+
+// WithEndpointPatterns sets regex patterns for endpoints to include in auditing
+func (b *AuditOptionsBuilder) WithEndpointPatterns(patterns []string) *AuditOptionsBuilder {
+ b.options.EndpointPatterns = patterns
+ return b
+}
+
+// WithExcludePatterns sets regex patterns for endpoints to exclude from auditing
+func (b *AuditOptionsBuilder) WithExcludePatterns(patterns []string) *AuditOptionsBuilder {
+ b.options.ExcludePatterns = patterns
+ return b
+}
+
+// WithSensitiveKeys sets keys that should be redacted in headers and queries
+func (b *AuditOptionsBuilder) WithSensitiveKeys(keys []string) *AuditOptionsBuilder {
+ b.options.SensitiveKeys = keys
+ return b
+}
+
+// Build finalizes and returns the configured options
+func (b *AuditOptionsBuilder) Build() *AuditMiddlewareOptions {
+ return b.options
+}
+
+// AuditMiddleware returns an HTTP middleware that emits audit events based on the provided options
+func AuditMiddleware(options *AuditMiddlewareOptions) func(http.Handler) http.Handler {
+ // Initialize default options and logger as needed
+ if options == nil {
+ options = DefaultAuditMiddlewareOptions()
+ }
+ if options.Logger == nil {
+ options.Logger = NewAuditLogger(nil)
+ }
+ // Validate configuration early and fail fast for programmer errors
+ if err := validateOptions(options); err != nil {
+ panic(fmt.Sprintf("invalid audit middleware options: %v", err))
+ }
+
+ // Wrap the next handler with auditing behavior
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Skip auditing if path does not match include/exclude rules
+ if !shouldAuditPath(r.URL.Path, options.EndpointPatterns, options.ExcludePatterns) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Detect Server-Sent Events and avoid capturing streaming bodies
+ acceptHeader := r.Header.Get("Accept")
+ isSSE := strings.Contains(acceptHeader, "text/event-stream")
+
+ // Initialize event and wrap writer for status and body capture
+ event, wrappedWriter := initializeAuditEvent(w, r, options)
+
+ // Disable response capture for SSE to prevent interference with streaming
+ if isSSE {
+ wrappedWriter.captured = false
+ }
+
+ // Ensure event is logged even if downstream panics or early returns
+ defer logAuditEvent(event, wrappedWriter, options)
+
+ // Continue to next handler with wrapped writer
+ next.ServeHTTP(wrappedWriter, r)
+ })
+ }
+}
+
+// validateOptions ensures the options contain at least one include or exclude pattern
+func validateOptions(options *AuditMiddlewareOptions) error {
+ // Must define what to include or exclude to avoid auditing everything by accident
+ if len(options.EndpointPatterns) == 0 && len(options.ExcludePatterns) == 0 {
+ return fmt.Errorf("at least one endpoint pattern or exclude pattern must be specified")
+ }
+ return nil
+}
+
+// auditResponseWriter wraps ResponseWriter to capture status and response body
+type auditResponseWriter struct {
+ http.ResponseWriter
+ statusCode int
+ body []byte
+ captured bool
+}
+
+// WriteHeader intercepts status codes for auditing
+func (w *auditResponseWriter) WriteHeader(code int) {
+ w.statusCode = code
+ w.ResponseWriter.WriteHeader(code)
+}
+
+// Write intercepts response body bytes when capture is enabled
+func (w *auditResponseWriter) Write(b []byte) (int, error) {
+ // Default status to 200 OK if not set
+ if w.statusCode == 0 {
+ w.statusCode = http.StatusOK
+ }
+ // Append to buffer only when capture is enabled
+ if w.captured || w.body != nil {
+ w.body = append(w.body, b...)
+ }
+ return w.ResponseWriter.Write(b)
+}
+
+// Flush forwards flush calls for streaming responses
+func (w *auditResponseWriter) Flush() {
+ if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
+ flusher.Flush()
+ }
+}
+
+// Unwrap returns the underlying ResponseWriter
+func (w *auditResponseWriter) Unwrap() http.ResponseWriter {
+ return w.ResponseWriter
+}
+
+// OAuthInfo carries OAuth 2.1 specific request attributes extracted for auditing
+type OAuthInfo struct {
+ ClientID string
+ Subject string
+ Scopes []string
+ GrantType string
+ ResponseType string
+ RedirectURI string
+ Resource string
+ Token string
+ Code string
+}
+
+// extractOAuthInfo pulls OAuth related fields from URL query, form body, headers, and context
+func extractOAuthInfo(r *http.Request) OAuthInfo {
+ info := OAuthInfo{}
+
+ // Extract from query parameters
+ if r.URL != nil {
+ query := r.URL.Query()
+ info.ClientID = query.Get("client_id")
+ info.ResponseType = query.Get("response_type")
+ info.RedirectURI = query.Get("redirect_uri")
+ info.Resource = query.Get("resource")
+ if scope := query.Get("scope"); scope != "" {
+ info.Scopes = strings.Split(scope, " ")
+ }
+ }
+
+ // Extract from form body when present
+ if err := r.ParseForm(); err == nil {
+ if info.GrantType == "" {
+ info.GrantType = r.FormValue("grant_type")
+ }
+ info.Code = r.FormValue("code")
+ if scope := r.FormValue("scope"); scope != "" && len(info.Scopes) == 0 {
+ info.Scopes = strings.Split(scope, " ")
+ }
+ }
+
+ // Extract bearer token from Authorization header
+ if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
+ info.Token = strings.TrimPrefix(auth, "Bearer ")
+ }
+
+ if authInfo, ok := server.GetAuthInfo(r.Context()); ok {
+ // Extract subject from the Extra claims
+ if authInfo.Extra != nil {
+ if sub, ok := authInfo.Extra["sub"].(string); ok {
+ info.Subject = sub
+ }
+ }
+
+ // Use scopes from authInfo if not already populated
+ if len(info.Scopes) == 0 && len(authInfo.Scopes) > 0 {
+ info.Scopes = authInfo.Scopes
+ }
+
+ // Extract client_id from Extra claims first
+ if cid, ok := authInfo.Extra["client_id"].(string); ok && info.ClientID == "" {
+ info.ClientID = cid
+ }
+
+ // Fallback to AuthInfo.ClientID field if Extra doesn't contain client_id
+ if info.ClientID == "" && authInfo.ClientID != "" {
+ info.ClientID = authInfo.ClientID
+ }
+ }
+ return info
+}
+
+// shouldAuditPath checks include and exclude regex patterns to decide auditing
+func shouldAuditPath(path string, includePatterns, excludePatterns []string) bool {
+ // Exclude takes precedence when matched
+ for _, pattern := range excludePatterns {
+ if matched, _ := regexp.MatchString(pattern, path); matched {
+ return false
+ }
+ }
+ // If no include patterns set then audit all non excluded paths
+ if len(includePatterns) == 0 {
+ return true
+ }
+ // Audit when any include pattern matches
+ for _, pattern := range includePatterns {
+ if matched, _ := regexp.MatchString(pattern, path); matched {
+ return true
+ }
+ }
+ return false
+}
+
+// determineEventType maps path and method to a coarse event category
+func determineEventType(path, method string) string {
+ switch {
+ case strings.Contains(path, "/authorize"):
+ return "oauth_authorization"
+ case strings.Contains(path, "/token"):
+ return "oauth_token"
+ case strings.Contains(path, "/revoke"):
+ return "oauth_revocation"
+ case strings.Contains(path, "/register"):
+ return "oauth_registration"
+ case strings.Contains(path, "/metadata"):
+ return "oauth_metadata"
+ default:
+ return "oauth_request"
+ }
+}
+
+// generateEventID builds a unique event identifier based on time and random suffix
+func generateEventID() string {
+ return fmt.Sprintf("audit_%d_%s", time.Now().UnixNano(), randomString(8))
+}
+
+// randomString generates a pseudo random lowercase alphanumeric string
+func randomString(length int) string {
+ const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
+ b := make([]byte, length)
+
+ // Try cryptographic randomness first and fall back to time based selection
+ if _, err := rand.Read(b); err != nil {
+ for i := range b {
+ b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
+ }
+ } else {
+ for i := range b {
+ b[i] = charset[int(b[i])%len(charset)]
+ }
+ }
+ return string(b)
+}
+
+// sanitizeMap redacts configured sensitive keys and normalizes values to a single string
+func sanitizeMap[T string | []string](data map[string]T, sensitiveKeys []string) map[string]string {
+ sanitized := make(map[string]string)
+
+ // Iterate keys and redact any that match configured sensitive keys
+ for key, value := range data {
+ isSensitive := false
+ for _, sensitiveKey := range sensitiveKeys {
+ if strings.EqualFold(key, sensitiveKey) {
+ isSensitive = true
+ break
+ }
+ }
+ if isSensitive {
+ sanitized[key] = "[REDACTED]"
+ } else {
+ // Normalize to first value for slice and direct string otherwise
+ switch v := any(value).(type) {
+ case string:
+ sanitized[key] = v
+ case []string:
+ if len(v) > 0 {
+ sanitized[key] = v[0]
+ }
+ }
+ }
+ }
+ return sanitized
+}
+
+// sanitizeQueryParams applies redaction and normalization to URL query parameters
+func sanitizeQueryParams(query map[string][]string, sensitiveKeys []string) map[string]string {
+ return sanitizeMap(query, sensitiveKeys)
+}
+
+// sanitizeHeaders applies redaction and normalization to HTTP headers
+func sanitizeHeaders(headers map[string][]string, sensitiveKeys []string) map[string]string {
+ return sanitizeMap(headers, sensitiveKeys)
+}
+
+// hashSensitiveData returns a hex encoded SHA256 hash for a sensitive string
+func hashSensitiveData(data string) string {
+ if data == "" {
+ return ""
+ }
+ hash := sha256.Sum256([]byte(data))
+ return hex.EncodeToString(hash[:])
+}
+
+// defaultRiskAssessment computes a simple risk score and contributing factors
+func defaultRiskAssessment(event AuditEvent) (string, []string) {
+ var riskFactors []string
+ riskLevel := "low"
+
+ // Client side error increases risk
+ if event.StatusCode >= 400 {
+ riskFactors = append(riskFactors, "client_error")
+ }
+
+ // Server side error increases risk more
+ if event.StatusCode >= 500 {
+ riskFactors = append(riskFactors, "server_error")
+ riskLevel = "medium"
+ }
+
+ // Slow responses may indicate issues
+ if event.ResponseTime > 5*time.Second {
+ riskFactors = append(riskFactors, "slow_response")
+ riskLevel = "medium"
+ }
+
+ // Missing client identifier is suspicious
+ if event.ClientID == "" {
+ riskFactors = append(riskFactors, "missing_client_id")
+ riskLevel = "high"
+ }
+
+ // Specific endpoint categories slightly elevate risk
+ if strings.Contains(event.Path, "/revoke") {
+ riskFactors = append(riskFactors, "token_revocation")
+ riskLevel = "medium"
+ }
+ if strings.Contains(event.Path, "/register") {
+ riskFactors = append(riskFactors, "client_registration")
+ riskLevel = "medium"
+ }
+ return riskLevel, riskFactors
+}
+
+// determineErrorCode maps HTTP status codes to OAuth style error codes
+func determineErrorCode(statusCode int) string {
+ switch {
+ case statusCode == 400:
+ return "invalid_request"
+ case statusCode == 401:
+ return "invalid_token"
+ case statusCode == 403:
+ return "insufficient_scope"
+ case statusCode == 404:
+ return "not_found"
+ case statusCode == 429:
+ return "too_many_requests"
+ case statusCode >= 500:
+ return "server_error"
+ default:
+ return "unknown_error"
+ }
+}
+
+// determineErrorMessage extracts error text from a JSON error response or falls back to status text
+func determineErrorMessage(statusCode int, body []byte) string {
+ if len(body) == 0 {
+ return ""
+ }
+ var errorResponse struct {
+ Error string `json:"error"`
+ ErrorDescription string `json:"error_description"`
+ }
+ // Try decode standard OAuth error response
+ if err := json.Unmarshal(body, &errorResponse); err == nil {
+ if errorResponse.ErrorDescription != "" {
+ return errorResponse.ErrorDescription
+ }
+ if errorResponse.Error != "" {
+ return errorResponse.Error
+ }
+ }
+ // Fallback to generic status text when payload is not structured
+ return http.StatusText(statusCode)
+}
+
+// extractSubject reads the subject claim from AuthInfo.Extra
+func extractSubject(authInfo server.AuthInfo) string {
+ if authInfo.Extra != nil {
+ if sub, ok := authInfo.Extra["sub"].(string); ok {
+ return sub
+ }
+ }
+ return ""
+}
+
+// GetAuthInfo extracts AuthInfo from the request context
+func GetAuthInfo(ctx context.Context) (server.AuthInfo, bool) {
+ // The context key is expected to be provided by upstream auth middleware
+ if authInfo, ok := ctx.Value(AuthInfoKey).(server.AuthInfo); ok {
+ return authInfo, true
+ }
+ return server.AuthInfo{}, false
+}
+
+// initializeAuditEvent constructs an AuditEvent and wraps the ResponseWriter for capture
+func initializeAuditEvent(w http.ResponseWriter, r *http.Request, options *AuditMiddlewareOptions) (AuditEvent, *auditResponseWriter) {
+ start := time.Now()
+
+ // Optionally read and restore the request body for logging
+ var reqBody []byte
+ if (options.Level >= AuditLevelFull || options.IncludeRequestBody) && r.Body != nil {
+ reqBody, _ = io.ReadAll(r.Body)
+ _ = r.Body.Close()
+ r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
+ }
+
+ // Configure wrapped writer to capture response body when enabled
+ wrappedWriter := &auditResponseWriter{
+ ResponseWriter: w,
+ captured: (options.Level >= AuditLevelFull || options.IncludeResponseBody),
+ }
+
+ // Gather OAuth specific attributes for context
+ oauthInfo := extractOAuthInfo(r)
+
+ // Seed the event with request metadata and extracted OAuth fields
+ event := AuditEvent{
+ EventID: generateEventID(),
+ Timestamp: start,
+ EventType: determineEventType(r.URL.Path, r.Method),
+ AuditLevel: options.Level,
+ Method: r.Method,
+ Path: r.URL.Path,
+ RemoteAddr: r.RemoteAddr,
+ UserAgent: r.UserAgent(),
+ RequestID: r.Header.Get("X-Request-ID"),
+ ClientID: oauthInfo.ClientID,
+ Subject: oauthInfo.Subject,
+ Scopes: oauthInfo.Scopes,
+ GrantType: oauthInfo.GrantType,
+ ResponseType: oauthInfo.ResponseType,
+ RedirectURI: oauthInfo.RedirectURI,
+ Resource: oauthInfo.Resource,
+ Metadata: make(map[string]interface{}),
+ }
+
+ // At detailed level and above include sanitized query params and headers
+ if options.Level >= AuditLevelDetailed {
+ event.QueryParams = sanitizeQueryParams(r.URL.Query(), options.SensitiveKeys)
+ event.Headers = sanitizeHeaders(r.Header, options.SensitiveKeys)
+ }
+
+ // Hash tokens and IP address when configured to avoid leaking PII
+ if options.HashSensitiveData {
+ event.TokenHash = hashSensitiveData(oauthInfo.Token)
+ event.CodeHash = hashSensitiveData(oauthInfo.Code)
+ event.IPHash = hashSensitiveData(r.RemoteAddr)
+ }
+
+ // Include request body when configured
+ if (options.Level >= AuditLevelFull || options.IncludeRequestBody) && len(reqBody) > 0 {
+ event.RequestBody = string(reqBody)
+ }
+
+ // Extract custom metadata when a provider is supplied
+ if options.MetadataExtractor != nil {
+ event.Metadata = options.MetadataExtractor(r)
+ }
+
+ // Assess risk using custom function or defaults
+ if options.RiskAssessor != nil {
+ event.RiskLevel, event.RiskFactors = options.RiskAssessor(event)
+ } else {
+ event.RiskLevel, event.RiskFactors = defaultRiskAssessment(event)
+ }
+
+ return event, wrappedWriter
+}
+
+// logAuditEvent finalizes timing and status then emits the audit event via the configured logger
+func logAuditEvent(event AuditEvent, w *auditResponseWriter, options *AuditMiddlewareOptions) {
+ // Compute latency and attach final status code
+ event.ResponseTime = time.Since(event.Timestamp)
+ event.StatusCode = w.statusCode
+
+ // Optionally include captured response body
+ if w.captured && len(w.body) > 0 && (options.Level >= AuditLevelFull || options.IncludeResponseBody) {
+ event.ResponseBody = string(w.body)
+ }
+
+ // Derive error details for non successful responses
+ if event.StatusCode >= 400 {
+ event.ErrorCode = determineErrorCode(event.StatusCode)
+ event.ErrorMessage = determineErrorMessage(event.StatusCode, w.body)
+ }
+
+ // Emit the event and log any failure to stdout as a last resort
+ if err := options.Logger.LogEvent(event); err != nil {
+ fmt.Printf("[AUDIT ERROR] Failed to log audit event: %v\n", err)
+ }
+}
+
+// WithOAuthAudit returns an OAuth specific audit middleware using provided options
+func WithOAuthAudit(options *AuditMiddlewareOptions) func(http.Handler) http.Handler {
+ return AuditMiddleware(options)
+}
+
+// WithBasicAudit returns a middleware configured for basic auditing
+func WithBasicAudit() func(http.Handler) http.Handler {
+ return AuditMiddleware(NewAuditOptionsBuilder().
+ WithLevel(AuditLevelBasic).
+ Build())
+}
+
+// WithDetailedAudit returns a middleware configured for detailed auditing
+func WithDetailedAudit() func(http.Handler) http.Handler {
+ return AuditMiddleware(NewAuditOptionsBuilder().
+ WithLevel(AuditLevelDetailed).
+ Build())
+}
+
+// WithFullAudit returns a middleware configured for full auditing including bodies
+func WithFullAudit() func(http.Handler) http.Handler {
+ return AuditMiddleware(NewAuditOptionsBuilder().
+ WithLevel(AuditLevelFull).
+ WithRequestBody(true).
+ WithResponseBody(true).
+ Build())
+}
+
+// WithZapLogger returns options pre configured with a custom zap logger
+func WithZapLogger(logger *zap.Logger) *AuditMiddlewareOptions {
+ return NewAuditOptionsBuilder().
+ WithLogger(logger).
+ Build()
+}
+
+// WithCustomZapLogger returns options configured with a custom zap logger and core toggles
+func WithCustomZapLogger(logger *zap.Logger, level AuditLevel, hashSensitive bool) *AuditMiddlewareOptions {
+ return NewAuditOptionsBuilder().
+ WithLogger(logger).
+ WithLevel(level).
+ WithHashSensitiveData(hashSensitive).
+ Build()
+}
diff --git a/internal/auth/server/middleware/audit_test.go b/internal/auth/server/middleware/audit_test.go
new file mode 100644
index 0000000..d36ec83
--- /dev/null
+++ b/internal/auth/server/middleware/audit_test.go
@@ -0,0 +1,979 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "go.uber.org/zap/zaptest"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+)
+
+// contains checks whether a slice of strings contains a specific item
+func contains(slice []string, item string) bool {
+ for _, s := range slice {
+ if s == item {
+ return true
+ }
+ }
+ return false
+}
+
+// captureLogger is a mock implementation of AuditLogger
+// It captures the last logged AuditEvent for inspection in tests
+type captureLogger struct {
+ last AuditEvent
+}
+
+// LogEvent stores the provided event in captureLogger
+func (c *captureLogger) LogEvent(e AuditEvent) error {
+ c.last = e
+ return nil
+}
+
+// LogError stores the event along with the error message in captureLogger
+func (c *captureLogger) LogError(e AuditEvent, err error) error {
+ e.ErrorMessage = err.Error()
+ c.last = e
+ return nil
+}
+
+func TestAuditLevelConstants(t *testing.T) {
+ // Test audit level constant value
+ if AuditLevelNone != 0 {
+ t.Errorf("Expected AuditLevelNone to be 0, got %d", AuditLevelNone)
+ }
+ if AuditLevelBasic != 1 {
+ t.Errorf("Expected AuditLevelBasic to be 1, got %d", AuditLevelBasic)
+ }
+ if AuditLevelDetailed != 2 {
+ t.Errorf("Expected AuditLevelDetailed to be 2, got %d", AuditLevelDetailed)
+ }
+ if AuditLevelFull != 3 {
+ t.Errorf("Expected AuditLevelFull to be 3, got %d", AuditLevelFull)
+ }
+}
+
+func TestNewAuditLogger(t *testing.T) {
+ // Test create default logger
+ logger := NewAuditLogger(nil)
+ if logger == nil {
+ t.Fatal("Expected logger to be created")
+ }
+
+ // Test to get the underlying zap logger
+ zapLogger := logger.GetZapLogger()
+ if zapLogger == nil {
+ t.Fatal("Expected underlying zap logger to exist")
+ }
+
+ // Testing using a custom zap logger
+ testLogger := zaptest.NewLogger(t)
+ customLogger := NewAuditLogger(testLogger)
+ if customLogger == nil {
+ t.Fatal("Expected custom logger to be created")
+ }
+
+ if customLogger.GetZapLogger() != testLogger {
+ t.Fatal("Expected custom zap logger to be used")
+ }
+}
+
+func TestDefaultAuditLoggerLogEvent(t *testing.T) {
+ // Creating a test logger
+ testLogger := zaptest.NewLogger(t)
+ auditLogger := NewAuditLogger(testLogger)
+
+ // Creating a test event
+ event := AuditEvent{
+ EventID: "test_123",
+ Timestamp: time.Now(),
+ EventType: "test_event",
+ AuditLevel: AuditLevelBasic,
+ Method: "GET",
+ Path: "/test",
+ StatusCode: 200,
+ ResponseTime: 100 * time.Millisecond,
+ ClientID: "test_client",
+ Subject: "test_user",
+ Scopes: []string{"read", "write"},
+ RiskLevel: "low",
+ RiskFactors: []string{"normal"},
+ }
+
+ // Test logging
+ err := auditLogger.LogEvent(event)
+ if err != nil {
+ t.Errorf("Expected no error, got %v", err)
+ }
+}
+
+func TestDefaultAuditLoggerLogError(t *testing.T) {
+ testLogger := zaptest.NewLogger(t)
+ auditLogger := NewAuditLogger(testLogger)
+
+ event := AuditEvent{
+ EventID: "test_123",
+ Timestamp: time.Now(),
+ Method: "GET",
+ Path: "/test",
+ }
+
+ testErr := &http.MaxBytesError{}
+ err := auditLogger.LogError(event, testErr)
+ if err != nil {
+ t.Errorf("Expected no error, got %v", err)
+ }
+
+ // Note: Since the event is passed by value, the ErrorMessage is not modified.
+ // This test mainly verifies that the LogError method does not return an error.
+}
+
+func TestDefaultAuditMiddlewareOptions(t *testing.T) {
+ options := DefaultAuditMiddlewareOptions()
+
+ // Verify default values
+ if options.Logger == nil {
+ t.Error("Expected logger to be set")
+ }
+ if options.Level != AuditLevelDetailed {
+ t.Errorf("Expected level to be Detailed, got %v", options.Level)
+ }
+ if !options.HashSensitiveData {
+ t.Error("Expected HashSensitiveData to be true")
+ }
+ if len(options.EndpointPatterns) == 0 {
+ t.Error("Expected endpoint patterns to be set")
+ }
+ if len(options.SensitiveKeys) == 0 {
+ t.Error("Expected sensitive keys to be set")
+ }
+}
+
+func TestAuditOptionsBuilder(t *testing.T) {
+ builder := NewAuditOptionsBuilder()
+
+ // Testing chain calls
+ options := builder.
+ WithLevel(AuditLevelFull).
+ WithHashSensitiveData(false).
+ WithRequestBody(true).
+ WithResponseBody(true).
+ Build()
+
+ if options.Level != AuditLevelFull {
+ t.Errorf("Expected level to be Full, got %v", options.Level)
+ }
+ if options.HashSensitiveData {
+ t.Error("Expected HashSensitiveData to be false")
+ }
+ if !options.IncludeRequestBody {
+ t.Error("Expected IncludeRequestBody to be true")
+ }
+ if !options.IncludeResponseBody {
+ t.Error("Expected IncludeResponseBody to be true")
+ }
+}
+
+func TestAuditOptionsBuilderWithCustomFunctions(t *testing.T) {
+ builder := NewAuditOptionsBuilder()
+
+ // Custom Risk Assessor
+ customRiskAssessor := func(event AuditEvent) (string, []string) {
+ return "high", []string{"custom_risk"}
+ }
+
+ // Custom metadata extractors
+ customMetadataExtractor := func(r *http.Request) map[string]interface{} {
+ return map[string]interface{}{
+ "custom_field": "custom_value",
+ }
+ }
+
+ options := builder.
+ WithRiskAssessor(customRiskAssessor).
+ WithMetadataExtractor(customMetadataExtractor).
+ Build()
+
+ if options.RiskAssessor == nil {
+ t.Error("Expected RiskAssessor to be set")
+ }
+ if options.MetadataExtractor == nil {
+ t.Error("Expected MetadataExtractor to be set")
+ }
+
+ // Testing custom functions
+ event := AuditEvent{}
+ riskLevel, riskFactors := options.RiskAssessor(event)
+ if riskLevel != "high" {
+ t.Errorf("Expected risk level 'high', got %s", riskLevel)
+ }
+ if len(riskFactors) != 1 || riskFactors[0] != "custom_risk" {
+ t.Errorf("Expected risk factors ['custom_risk'], got %v", riskFactors)
+ }
+}
+
+func TestValidateOptions(t *testing.T) {
+ // Testing a valid configuration
+ validOptions := &AuditMiddlewareOptions{
+ EndpointPatterns: []string{"/test"},
+ }
+ if err := validateOptions(validOptions); err != nil {
+ t.Errorf("Expected no error for valid options, got %v", err)
+ }
+
+ // Testing for invalid configurations
+ invalidOptions := &AuditMiddlewareOptions{
+ EndpointPatterns: []string{},
+ ExcludePatterns: []string{},
+ }
+ if err := validateOptions(invalidOptions); err == nil {
+ t.Error("Expected error for invalid options")
+ }
+}
+
+func TestShouldAuditPath(t *testing.T) {
+ tests := []struct {
+ path string
+ includePatterns []string
+ excludePatterns []string
+ expectedResult bool
+ description string
+ }{
+ {
+ path: "/oauth2/authorize",
+ includePatterns: []string{"/oauth2/.*"},
+ excludePatterns: []string{},
+ expectedResult: true,
+ description: "Path matches include pattern",
+ },
+ {
+ path: "/health",
+ includePatterns: []string{"/oauth2/.*"},
+ excludePatterns: []string{},
+ expectedResult: false,
+ description: "Path doesn't match include pattern",
+ },
+ {
+ path: "/oauth2/token",
+ includePatterns: []string{"/oauth2/.*"},
+ excludePatterns: []string{"/oauth2/token"},
+ expectedResult: false,
+ description: "Path matches exclude pattern",
+ },
+ {
+ path: "/any/path",
+ includePatterns: []string{},
+ excludePatterns: []string{},
+ expectedResult: true,
+ description: "No patterns specified, audit all",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ result := shouldAuditPath(tt.path, tt.includePatterns, tt.excludePatterns)
+ if result != tt.expectedResult {
+ t.Errorf("shouldAuditPath(%q, %v, %v) = %v, want %v",
+ tt.path, tt.includePatterns, tt.excludePatterns, result, tt.expectedResult)
+ }
+ })
+ }
+}
+
+func TestDetermineEventType(t *testing.T) {
+ tests := []struct {
+ path string
+ method string
+ expected string
+ }{
+ {"/oauth2/authorize", "GET", "oauth_authorization"},
+ {"/oauth2/token", "POST", "oauth_token"},
+ {"/oauth2/revoke", "POST", "oauth_revocation"},
+ {"/oauth2/register", "POST", "oauth_registration"},
+ {"/oauth2/metadata", "GET", "oauth_metadata"},
+ {"/unknown/path", "GET", "oauth_request"},
+ }
+
+ for _, tt := range tests {
+ result := determineEventType(tt.path, tt.method)
+ if result != tt.expected {
+ t.Errorf("determineEventType(%q, %q) = %q, want %q",
+ tt.path, tt.method, result, tt.expected)
+ }
+ }
+}
+
+func TestGenerateEventID(t *testing.T) {
+ id1 := generateEventID()
+ id2 := generateEventID()
+
+ if id1 == id2 {
+ t.Error("Expected different event IDs")
+ }
+
+ if !strings.HasPrefix(id1, "audit_") {
+ t.Errorf("Expected event ID to start with 'audit_', got %s", id1)
+ }
+}
+
+func TestRandomString(t *testing.T) {
+ str1 := randomString(10)
+ str2 := randomString(10)
+
+ if len(str1) != 10 {
+ t.Errorf("Expected string length 10, got %d", len(str1))
+ }
+
+ if str1 == str2 {
+ t.Error("Expected different random strings")
+ }
+}
+
+func TestSanitizeMap(t *testing.T) {
+ // Test query parameter sanitization
+ queryParams := map[string][]string{
+ "client_id": {"test_client"},
+ "client_secret": {"secret_value"},
+ "scope": {"read write"},
+ }
+
+ sensitiveKeys := []string{"client_secret", "password"}
+
+ sanitized := sanitizeQueryParams(queryParams, sensitiveKeys)
+
+ if sanitized["client_id"] != "test_client" {
+ t.Errorf("Expected client_id to be preserved, got %s", sanitized["client_id"])
+ }
+
+ if sanitized["client_secret"] != "[REDACTED]" {
+ t.Errorf("Expected client_secret to be redacted, got %s", sanitized["client_secret"])
+ }
+
+ if sanitized["scope"] != "read write" {
+ t.Errorf("Expected scope to be preserved, got %s", sanitized["scope"])
+ }
+}
+
+func TestSanitizeHeaders(t *testing.T) {
+ headers := map[string][]string{
+ "content-type": {"application/json"},
+ "authorization": {"Bearer token123"},
+ "user-agent": {"test-agent"},
+ }
+
+ sensitiveKeys := []string{"authorization", "cookie"}
+
+ sanitized := sanitizeHeaders(headers, sensitiveKeys)
+
+ if sanitized["content-type"] != "application/json" {
+ t.Errorf("Expected content-type to be preserved, got %s", sanitized["content-type"])
+ }
+
+ if sanitized["authorization"] != "[REDACTED]" {
+ t.Errorf("Expected authorization to be redacted, got %s", sanitized["authorization"])
+ }
+
+ if sanitized["user-agent"] != "test-agent" {
+ t.Errorf("Expected user-agent to be preserved, got %s", sanitized["user-agent"])
+ }
+}
+
+func TestHashSensitiveData(t *testing.T) {
+ data := "sensitive_data"
+ hash1 := hashSensitiveData(data)
+ hash2 := hashSensitiveData(data)
+
+ if hash1 == "" {
+ t.Error("Expected non-empty hash")
+ }
+
+ if hash1 != hash2 {
+ t.Error("Expected same hash for same data")
+ }
+
+ if hashSensitiveData("") != "" {
+ t.Error("Expected empty string for empty data")
+ }
+}
+
+func TestDefaultRiskAssessment(t *testing.T) {
+ tests := []struct {
+ name string
+ event AuditEvent
+ expectedRisk string
+ checkFactors func([]string) bool
+ }{
+ {
+ name: "Normal request",
+ event: AuditEvent{
+ StatusCode: 200,
+ ResponseTime: 100 * time.Millisecond,
+ ClientID: "test_client",
+ },
+ expectedRisk: "low",
+ checkFactors: func(factors []string) bool {
+ return len(factors) == 0
+ },
+ },
+ {
+ name: "Client error",
+ event: AuditEvent{
+ StatusCode: 400,
+ ResponseTime: 100 * time.Millisecond,
+ ClientID: "test_client",
+ },
+ expectedRisk: "low",
+ checkFactors: func(factors []string) bool {
+ return contains(factors, "client_error")
+ },
+ },
+ {
+ name: "Server error",
+ event: AuditEvent{
+ StatusCode: 500,
+ ResponseTime: 100 * time.Millisecond,
+ ClientID: "test_client",
+ },
+ expectedRisk: "medium",
+ checkFactors: func(factors []string) bool {
+ return contains(factors, "server_error")
+ },
+ },
+ {
+ name: "Slow response",
+ event: AuditEvent{
+ StatusCode: 200,
+ ResponseTime: 6 * time.Second,
+ ClientID: "test_client",
+ },
+ expectedRisk: "medium",
+ checkFactors: func(factors []string) bool {
+ return contains(factors, "slow_response")
+ },
+ },
+ {
+ name: "Missing client ID",
+ event: AuditEvent{
+ StatusCode: 200,
+ ResponseTime: 100 * time.Millisecond,
+ ClientID: "",
+ },
+ expectedRisk: "high",
+ checkFactors: func(factors []string) bool {
+ return contains(factors, "missing_client_id")
+ },
+ },
+ {
+ name: "Token revocation",
+ event: AuditEvent{
+ StatusCode: 200,
+ ResponseTime: 100 * time.Millisecond,
+ ClientID: "test_client",
+ Path: "/oauth2/revoke",
+ },
+ expectedRisk: "medium",
+ checkFactors: func(factors []string) bool {
+ return contains(factors, "token_revocation")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ riskLevel, riskFactors := defaultRiskAssessment(tt.event)
+ if riskLevel != tt.expectedRisk {
+ t.Errorf("Expected risk level %s, got %s", tt.expectedRisk, riskLevel)
+ }
+ if !tt.checkFactors(riskFactors) {
+ t.Errorf("Risk factors check failed for factors: %v", riskFactors)
+ }
+ })
+ }
+}
+
+func TestDetermineErrorCode(t *testing.T) {
+ tests := []struct {
+ statusCode int
+ expected string
+ }{
+ {400, "invalid_request"},
+ {401, "invalid_token"},
+ {403, "insufficient_scope"},
+ {404, "not_found"},
+ {429, "too_many_requests"},
+ {500, "server_error"},
+ {999, "server_error"}, // 999 is still considered a server error
+ }
+
+ for _, tt := range tests {
+ result := determineErrorCode(tt.statusCode)
+ if result != tt.expected {
+ t.Errorf("determineErrorCode(%d) = %s, want %s", tt.statusCode, result, tt.expected)
+ }
+ }
+}
+
+func TestDetermineErrorMessage(t *testing.T) {
+ // Testing JSON error responses
+ jsonError := `{"error": "invalid_grant", "error_description": "Invalid authorization code"}`
+ message := determineErrorMessage(400, []byte(jsonError))
+ if message != "Invalid authorization code" {
+ t.Errorf("Expected 'Invalid authorization code', got %s", message)
+ }
+
+ // Testing responses with only an error field
+ jsonErrorOnly := `{"error": "invalid_request"}`
+ message = determineErrorMessage(400, []byte(jsonErrorOnly))
+ if message != "invalid_request" {
+ t.Errorf("Expected 'invalid_request', got %s", message)
+ }
+
+ // Testing for an empty response body
+ message = determineErrorMessage(404, []byte{})
+ if message != "" {
+ t.Errorf("Expected empty message for empty body, got %s", message)
+ }
+
+ // Testing for invalid JSON
+ invalidJSON := `{invalid json}`
+ message = determineErrorMessage(500, []byte(invalidJSON))
+ if message != "Internal Server Error" {
+ t.Errorf("Expected 'Internal Server Error', got %s", message)
+ }
+}
+
+func TestExtractOAuthInfo(t *testing.T) {
+ // Creating a test request
+ req := httptest.NewRequest("POST", "/oauth2/token?client_id=test_client&scope=read+write", strings.NewReader("grant_type=authorization_code&code=test_code"))
+ req.Header.Set("Authorization", "Bearer test_token")
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ // Set the authentication information in the context
+ ctx := server.WithAuthInfo(req.Context(), &server.AuthInfo{
+ Scopes: []string{"read", "write"},
+ Extra: map[string]interface{}{
+ "sub": "test_user",
+ "client_id": "ctx_client_id",
+ },
+ })
+ req = req.WithContext(ctx)
+
+ info := extractOAuthInfo(req)
+
+ // Verify the extracted information
+ if info.ClientID != "test_client" {
+ t.Errorf("Expected client_id 'test_client', got %s", info.ClientID)
+ }
+
+ // Note: Since ParseForm may not work in some test environments, we mainly test URL parameters and headers
+ if info.Token != "test_token" {
+ t.Errorf("Expected token 'test_token', got %s", info.Token)
+ }
+
+ if len(info.Scopes) != 2 {
+ t.Errorf("Expected 2 scopes, got %d", len(info.Scopes))
+ }
+
+ if info.Subject != "test_user" {
+ t.Errorf("Expected subject 'test_user', got %s", info.Subject)
+ }
+}
+
+func TestExtractSubject(t *testing.T) {
+ authInfo := server.AuthInfo{
+ Extra: map[string]interface{}{
+ "sub": "test_user",
+ "other": "value",
+ },
+ }
+
+ subject := extractSubject(authInfo)
+ if subject != "test_user" {
+ t.Errorf("Expected subject 'test_user', got %s", subject)
+ }
+
+ // Testing without a subject
+ authInfoNoSub := server.AuthInfo{
+ Extra: map[string]interface{}{
+ "other": "value",
+ },
+ }
+
+ subject = extractSubject(authInfoNoSub)
+ if subject != "" {
+ t.Errorf("Expected empty subject, got %s", subject)
+ }
+}
+
+func TestAuditMiddlewareBasic(t *testing.T) {
+ // Create basic audit middleware
+ middleware := WithBasicAudit()
+
+ // Create a Test Handler
+ testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("test response"))
+ })
+
+ // Package handler
+ wrappedHandler := middleware(testHandler)
+
+ // Create a test request
+ req := httptest.NewRequest("GET", "/oauth2/authorize?client_id=test_client", nil)
+ w := httptest.NewRecorder()
+
+ // Execute Request
+ wrappedHandler.ServeHTTP(w, req)
+
+ // Validate response
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+}
+
+func TestAuditMiddlewareDetailed(t *testing.T) {
+ // Create detailed audit middleware
+ middleware := WithDetailedAudit()
+
+ // Create a Test Handler
+ testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("test response"))
+ })
+
+ // Package handler
+ wrappedHandler := middleware(testHandler)
+
+ // Create a test request
+ req := httptest.NewRequest("POST", "/oauth2/token", strings.NewReader("grant_type=client_credentials"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("User-Agent", "test-agent")
+
+ w := httptest.NewRecorder()
+
+ // Execute Request
+ wrappedHandler.ServeHTTP(w, req)
+
+ // Validate response
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+}
+
+func TestAuditMiddlewareFull(t *testing.T) {
+ // Create full audit middleware
+ middleware := WithFullAudit()
+
+ // Create a Test Handler
+ testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("test response"))
+ })
+
+ // Package handler
+ wrappedHandler := middleware(testHandler)
+
+ // Create a test request
+ req := httptest.NewRequest("DELETE", "/oauth2/revoke", strings.NewReader("token=test_token"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ w := httptest.NewRecorder()
+
+ // Execute request
+ wrappedHandler.ServeHTTP(w, req)
+
+ // Validate response
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+}
+
+func TestAuditMiddlewareWithCustomZapLogger(t *testing.T) {
+ // Create a custom zap logger
+ testLogger := zaptest.NewLogger(t)
+
+ // Create audit middleware with a custom logger
+ options := WithCustomZapLogger(testLogger, AuditLevelDetailed, true)
+ middleware := AuditMiddleware(options)
+
+ // Create a Test Handler
+ testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("test response"))
+ })
+
+ // Package handler
+ wrappedHandler := middleware(testHandler)
+
+ // Create a test request
+ req := httptest.NewRequest("GET", "/oauth2/metadata", nil)
+ w := httptest.NewRecorder()
+
+ // Execute request
+ wrappedHandler.ServeHTTP(w, req)
+
+ // Validate response
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+}
+
+func TestAuditMiddlewareExcludePatterns(t *testing.T) {
+ // Create custom configured audit middleware
+ options := NewAuditOptionsBuilder().
+ WithEndpointPatterns([]string{"/oauth2/.*"}).
+ WithExcludePatterns([]string{"/oauth2/health"}).
+ Build()
+
+ middleware := AuditMiddleware(options)
+
+ // Create a test handler
+ testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("test response"))
+ })
+
+ // Package handler
+ wrappedHandler := middleware(testHandler)
+
+ // Test paths that should be audited
+ req1 := httptest.NewRequest("GET", "/oauth2/authorize", nil)
+ w1 := httptest.NewRecorder()
+ wrappedHandler.ServeHTTP(w1, req1)
+
+ if w1.Code != http.StatusOK {
+ t.Errorf("Expected status 200 for audited path, got %d", w1.Code)
+ }
+
+ // Testing paths that should be excluded
+ req2 := httptest.NewRequest("GET", "/oauth2/health", nil)
+ w2 := httptest.NewRecorder()
+ wrappedHandler.ServeHTTP(w2, req2)
+
+ if w2.Code != http.StatusOK {
+ t.Errorf("Expected status 200 for excluded path, got %d", w2.Code)
+ }
+}
+
+func TestAuditMiddlewareErrorHandling(t *testing.T) {
+ // Create a test logger
+ testLogger := zaptest.NewLogger(t)
+
+ // Create test audit middleware
+ options := WithZapLogger(testLogger)
+ middleware := AuditMiddleware(options)
+
+ // Creating a test handler that returns an error
+ testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ errorResponse := map[string]string{
+ "error": "invalid_request",
+ "error_description": "Missing required parameter",
+ }
+ json.NewEncoder(w).Encode(errorResponse)
+ })
+
+ // Package handler
+ wrappedHandler := middleware(testHandler)
+
+ // Create a test request
+ req := httptest.NewRequest("POST", "/oauth2/token", strings.NewReader(""))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ w := httptest.NewRecorder()
+
+ // Execute request
+ wrappedHandler.ServeHTTP(w, req)
+
+ // Validate request
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("Expected status 400, got %d", w.Code)
+ }
+}
+
+func TestAuditMiddlewarePerformance(t *testing.T) {
+ // Creating audit middleware for performance testing
+ options := NewAuditOptionsBuilder().
+ WithLevel(AuditLevelBasic).
+ WithHashSensitiveData(false).
+ Build()
+
+ middleware := AuditMiddleware(options)
+
+ // Create test handler
+ testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Simulation processing time
+ time.Sleep(50 * time.Millisecond)
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("test response"))
+ })
+
+ // Package handler
+ wrappedHandler := middleware(testHandler)
+
+ // Creating a test request
+ req := httptest.NewRequest("GET", "/oauth2/authorize", nil)
+ w := httptest.NewRecorder()
+
+ // Execute request
+ start := time.Now()
+ wrappedHandler.ServeHTTP(w, req)
+ duration := time.Since(start)
+
+ // Validate response
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ // Verify performance
+ if duration > 200*time.Millisecond {
+ t.Errorf("Expected reasonable performance, took %v", duration)
+ }
+}
+
+func TestAuditResponseWriter(t *testing.T) {
+ // Creating a Test Response Recorder
+ recorder := httptest.NewRecorder()
+
+ // Creating an Audit Response Writer
+ auditWriter := &auditResponseWriter{
+ ResponseWriter: recorder,
+ body: make([]byte, 0),
+ }
+
+ // Test write header
+ auditWriter.WriteHeader(http.StatusCreated)
+ if auditWriter.statusCode != http.StatusCreated {
+ t.Errorf("Expected status code %d, got %d", http.StatusCreated, auditWriter.statusCode)
+ }
+
+ // Test writing data
+ testData := []byte("test response")
+ written, err := auditWriter.Write(testData)
+ if err != nil {
+ t.Errorf("Expected no error, got %v", err)
+ }
+ if written != len(testData) {
+ t.Errorf("Expected written bytes %d, got %d", len(testData), written)
+ }
+
+ // Verification status code is set
+ if auditWriter.statusCode == 0 {
+ auditWriter.statusCode = http.StatusOK
+ }
+
+ // Verify that the response body is captured
+ if len(auditWriter.body) == 0 {
+ t.Error("Expected response body to be captured")
+ }
+}
+
+func BenchmarkAuditMiddleware(b *testing.B) {
+ // Creating basic audit middleware
+ middleware := WithBasicAudit()
+
+ // Creating a Test Handler
+ testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("test response"))
+ })
+
+ // Packaging Processor
+ wrappedHandler := middleware(testHandler)
+
+ // Creating a test request
+ req := httptest.NewRequest("GET", "/oauth2/authorize", nil)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ w := httptest.NewRecorder()
+ wrappedHandler.ServeHTTP(w, req)
+ }
+}
+
+func BenchmarkHashSensitiveData(b *testing.B) {
+ testData := "sensitive_test_data_that_needs_hashing"
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = hashSensitiveData(testData)
+ }
+}
+
+func BenchmarkSanitizeMap(b *testing.B) {
+ queryParams := map[string][]string{
+ "client_id": {"test_client"},
+ "client_secret": {"secret_value"},
+ "scope": {"read write"},
+ }
+ sensitiveKeys := []string{"client_secret", "password"}
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = sanitizeQueryParams(queryParams, sensitiveKeys)
+ }
+}
+
+func TestAuditWriter_DefaultStatusOnWrite(t *testing.T) {
+ rec := httptest.NewRecorder()
+ aw := &auditResponseWriter{ResponseWriter: rec, body: make([]byte, 0)}
+ _, _ = aw.Write([]byte("hi"))
+ if aw.statusCode != http.StatusOK {
+ t.Fatalf("status should default to 200 on Write, got %d", aw.statusCode)
+ }
+}
+
+func TestSSE_DisablesCapture(t *testing.T) {
+ cl := &captureLogger{}
+ opts := NewAuditOptionsBuilder().Build()
+ opts.Logger = cl
+ opts.IncludeResponseBody = true // 即便配置为 true,SSE 也应禁用
+ mw := AuditMiddleware(opts)
+
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // 模拟 SSE 输出
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(http.StatusOK)
+ w.(http.Flusher).Flush()
+ w.Write([]byte("data: ping\n\n"))
+ })
+
+ req := httptest.NewRequest("GET", "/oauth2/authorize", nil)
+ req.Header.Set("Accept", "text/event-stream")
+ w := httptest.NewRecorder()
+ mw(h).ServeHTTP(w, req)
+
+ if cl.last.ResponseBody != "" {
+ t.Fatalf("SSE responses should not be captured")
+ }
+}
+
+func TestRequestBody_CapturedWhenEnabled(t *testing.T) {
+ cl := &captureLogger{}
+ opts := NewAuditOptionsBuilder().Build()
+ opts.Logger = cl
+ opts.IncludeRequestBody = true
+
+ mw := AuditMiddleware(opts)
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
+ req := httptest.NewRequest("POST", "/oauth2/token", strings.NewReader("grant_type=client_credentials"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ w := httptest.NewRecorder()
+ mw(h).ServeHTTP(w, req)
+
+ if cl.last.RequestBody == "" || !strings.Contains(cl.last.RequestBody, "grant_type=client_credentials") {
+ t.Fatalf("request body should be captured when IncludeRequestBody=true")
+ }
+}
diff --git a/internal/auth/server/middleware/bearer_auth.go b/internal/auth/server/middleware/bearer_auth.go
new file mode 100644
index 0000000..72be91c
--- /dev/null
+++ b/internal/auth/server/middleware/bearer_auth.go
@@ -0,0 +1,166 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// audienceMatchLocal matches resource against allowed audience values (trim trailing '#')
+func audienceMatchLocal(resource string, allowed []string) bool {
+ resource = strings.TrimSuffix(strings.TrimSpace(resource), "#")
+ for _, a := range allowed {
+ if resource == strings.TrimSuffix(strings.TrimSpace(a), "#") {
+ return true
+ }
+ }
+ return false
+}
+
+// BearerAuthMiddlewareOptions defines configuration for the Bearer auth middleware
+type BearerAuthMiddlewareOptions struct {
+ // Verifier is used to validate the access token
+ Verifier server.TokenVerifierInterface
+
+ // RequiredScopes lists scopes that must all be present in the token
+ RequiredScopes []string
+
+ // ResourceMetadataURL is optionally included in the WWW-Authenticate header
+ ResourceMetadataURL *string
+
+ // Issuer restricts accepted tokens to this issuer (optional)
+ Issuer string
+
+ // Audience restricts accepted tokens to this audience/resource (optional)
+ Audience []string
+}
+
+// RequireBearerAuth returns an HTTP middleware that validates Bearer tokens on incoming requests
+func RequireBearerAuth(options BearerAuthMiddlewareOptions) func(handler http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ // setErrorResponse writes a JSON OAuth error and appropriate status and headers
+ setErrorResponse := func(w http.ResponseWriter, err errors.OAuthError, statusCode int) {
+ // Set WWW-Authenticate only for 401 or 403 to align with TS implementation
+ if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
+ wwwAuthValue := fmt.Sprintf(`Bearer error="%s", error_description="%s"`, err.ErrorCode, err.Message)
+ if options.ResourceMetadataURL != nil {
+ wwwAuthValue += fmt.Sprintf(`, resource_metadata="%s"`, *options.ResourceMetadataURL)
+ }
+ // Append scope for insufficient_scope
+ if err.ErrorCode == errors.ErrInsufficientScope.Error() && len(options.RequiredScopes) > 0 {
+ wwwAuthValue += fmt.Sprintf(`, scope="%s"`, strings.Join(options.RequiredScopes, " "))
+ }
+ w.Header().Set("WWW-Authenticate", wwwAuthValue)
+ }
+ // Write JSON body with error details
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ _ = json.NewEncoder(w).Encode(err.ToResponseStruct())
+ }
+
+ // Read Authorization header and ensure presence
+ authHeader := req.Header.Get("Authorization")
+ if authHeader == "" {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidToken, "Missing Authorization header", ""), http.StatusUnauthorized)
+ return
+ }
+
+ // Expect "Bearer " format and extract the token
+ parts := strings.Split(authHeader, " ")
+ if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" || parts[1] == "" {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidToken, "Invalid Authorization header format, expected 'Bearer TOKEN'", ""), http.StatusUnauthorized)
+ return
+ }
+ token := parts[1]
+
+ // Verify token using provided verifier
+ authInfo, err := options.Verifier.VerifyAccessToken(req.Context(), token)
+ if err != nil {
+ // Map verifier error to HTTP status via OAuth error code
+ if oauthErr, ok := err.(errors.OAuthError); ok {
+ switch oauthErr.ErrorCode {
+ case errors.ErrInvalidToken.Error():
+ setErrorResponse(w, oauthErr, http.StatusUnauthorized)
+ case errors.ErrInsufficientScope.Error():
+ setErrorResponse(w, oauthErr, http.StatusForbidden)
+ case errors.ErrServerError.Error():
+ setErrorResponse(w, oauthErr, http.StatusInternalServerError)
+ default:
+ setErrorResponse(w, oauthErr, http.StatusBadRequest)
+ }
+ } else {
+ // Default unknown errors to invalid_token (401) to avoid leaking internals
+ invalid := errors.NewOAuthError(errors.ErrInvalidToken, "Invalid access token", "")
+ setErrorResponse(w, invalid, http.StatusUnauthorized)
+ }
+ return
+ }
+
+ // Optional issuer guarantee
+ if options.Issuer != "" {
+ if authInfo.Extra != nil {
+ if iss, _ := authInfo.Extra["iss"].(string); iss != "" && iss != options.Issuer {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidToken, "Invalid token issuer", ""), http.StatusUnauthorized)
+ return
+ }
+ }
+ }
+
+ // Optional audience/resource check (RFC 8707 simplified)
+ if len(options.Audience) > 0 && authInfo.Resource != nil {
+ if !audienceMatchLocal(authInfo.Resource.String(), options.Audience) {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidToken, "Invalid token audience", ""), http.StatusUnauthorized)
+ return
+ }
+ }
+
+ // Enforce required scopes if configured
+ if len(options.RequiredScopes) > 0 {
+ for _, scope := range options.RequiredScopes {
+ found := false
+ for _, tokenScope := range authInfo.Scopes {
+ if tokenScope == scope {
+ found = true
+ break
+ }
+ }
+ if !found {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInsufficientScope, "Insufficient scope", ""), http.StatusForbidden)
+ return
+ }
+ }
+ }
+
+ // Ensure token has an expiration time and is not expired
+ if authInfo.ExpiresAt == nil || *authInfo.ExpiresAt == 0 {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidToken, "Token has no expiration time", ""), http.StatusUnauthorized)
+ return
+ }
+ if *authInfo.ExpiresAt <= time.Now().Unix() {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidToken, "Token has expired", ""), http.StatusUnauthorized)
+ return
+ }
+
+ // Attach validated auth info to the request context under AuthInfoKey (avoid token propagation)
+ authInfo.Token = ""
+ ctx := context.WithValue(req.Context(), AuthInfoKey, authInfo)
+ req = req.WithContext(ctx)
+
+ // Delegate to next handler
+ next.ServeHTTP(w, req)
+ })
+ }
+}
diff --git a/internal/auth/server/middleware/bearer_auth_test.go b/internal/auth/server/middleware/bearer_auth_test.go
new file mode 100644
index 0000000..6117dfc
--- /dev/null
+++ b/internal/auth/server/middleware/bearer_auth_test.go
@@ -0,0 +1,766 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ srv "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ oauth "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// mockVerifier is a test double for TokenVerifierInterface that records the
+// last token and delegates verification to a provided function
+type mockVerifier struct {
+ verify func(ctx context.Context, token string) (srv.AuthInfo, error)
+ last string
+}
+
+// VerifyAccessToken records the token and forwards verification to the mock function
+func (m *mockVerifier) VerifyAccessToken(ctx context.Context, token string) (srv.AuthInfo, error) {
+ m.last = token
+ return m.verify(ctx, token)
+}
+
+// runWithMiddleware builds a request, executes the BearerAuth middleware with the
+// provided options and Authorization header, and returns the recorder and whether
+// the next handler was called
+func runWithMiddleware(t *testing.T, options BearerAuthMiddlewareOptions, authHeader string) (rec *httptest.ResponseRecorder, nextCalled bool) {
+ t.Helper()
+ nextCalled = false
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ nextCalled = true
+ // return 200 to assert the middleware allowed the reques
+ w.WriteHeader(http.StatusOK)
+ })
+
+ handler := RequireBearerAuth(options)(next)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ if authHeader != "" {
+ req.Header.Set("Authorization", authHeader)
+ }
+ rec = httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+ return rec, nextCalled
+}
+
+// decodeOAuthResp parses the OAuth error response body into OAuthErrorResponse
+func decodeOAuthResp(t *testing.T, rec *httptest.ResponseRecorder) *oauth.OAuthErrorResponse {
+ t.Helper()
+ var body oauth.OAuthErrorResponse
+ _ = json.NewDecoder(rec.Body).Decode(&body)
+ return &body
+}
+
+func TestRequireBearerAuth_ValidToken(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ valid := srv.AuthInfo{Token: "valid-token", ClientID: "client-123", Scopes: []string{"read", "write"}, ExpiresAt: &exp}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return valid, nil }}
+
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer valid-token")
+
+ if mv.last != "valid-token" {
+ t.Fatalf("expected token 'valid-token', got %q", mv.last)
+ }
+ if !nextCalled {
+ t.Fatalf("expected next to be called")
+ }
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", rec.Code)
+ }
+}
+
+func TestRequireBearerAuth_ExpiredToken(t *testing.T) {
+ expiredSeconds := []int{100, 0}
+
+ for _, seconds := range expiredSeconds {
+ t.Run(fmt.Sprintf("expired_%d_seconds_ago", seconds), func(t *testing.T) {
+ // Set the expiration time to the current time minus the specified number of seconds
+ exp := time.Now().Add(time.Duration(-seconds) * time.Second).Unix()
+ expired := srv.AuthInfo{
+ Token: "expired-token",
+ ClientID: "client-123",
+ Scopes: []string{"read", "write"},
+ ExpiresAt: &exp,
+ }
+ mv := &mockVerifier{
+ verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return expired, nil
+ },
+ }
+
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer expired-token")
+
+ if mv.last != "expired-token" {
+ t.Fatalf("expected token 'expired-token', got %q", mv.last)
+ }
+ if nextCalled {
+ t.Fatalf("expected next not to be called")
+ }
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", rec.Code)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if !strings.Contains(hdr, `error="invalid_token"`) || !strings.Contains(hdr, "Token has expired") {
+ t.Fatalf("unexpected WWW-Authenticate: %q", hdr)
+ }
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "invalid_token" || body.ErrorDescription != "Token has expired" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+ })
+ }
+}
+
+func TestRequireBearerAuth_NoExpiration(t *testing.T) {
+ // case1: nil
+ t.Run("ExpiresAt=nil", func(t *testing.T) {
+ noexp := srv.AuthInfo{Token: "t1", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: nil}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return noexp, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer t1")
+ if nextCalled || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ if hdr := rec.Header().Get("WWW-Authenticate"); !strings.Contains(hdr, `error="invalid_token"`) || !strings.Contains(hdr, "Token has no expiration time") {
+ t.Fatalf("unexpected WWW-Authenticate: %q", hdr)
+ }
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "invalid_token" || body.ErrorDescription != "Token has no expiration time" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+ })
+
+ // case2: 0
+ t.Run("ExpiresAt=0", func(t *testing.T) {
+ zero := int64(0)
+ noexp2 := srv.AuthInfo{Token: "t2", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &zero}
+ mv2 := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return noexp2, nil }}
+ rec2, nextCalled2 := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv2}, "Bearer t2")
+ if nextCalled2 || rec2.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 without next, got %d next=%v", rec2.Code, nextCalled2)
+ }
+ if hdr := rec2.Header().Get("WWW-Authenticate"); !strings.Contains(hdr, `error="invalid_token"`) || !strings.Contains(hdr, "Token has no expiration time") {
+ t.Fatalf("unexpected WWW-Authenticate: %q", hdr)
+ }
+ body := decodeOAuthResp(t, rec2)
+ if body.Error != "invalid_token" || body.ErrorDescription != "Token has no expiration time" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+ })
+}
+func TestRequireBearerAuth_NonExpiredAccepted(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "valid", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer valid")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+}
+
+func TestRequireBearerAuth_RequiredScopes(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "valid", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, RequiredScopes: []string{"read", "write"}}, "Bearer valid")
+
+ if mv.last != "valid" {
+ t.Fatalf("expected verifier to be called with token 'valid', got %q", mv.last)
+ }
+ if nextCalled || rec.Code != http.StatusForbidden {
+ t.Fatalf("expected 403 and next not called, got %d next=%v", rec.Code, nextCalled)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if !strings.Contains(hdr, `error="insufficient_scope"`) {
+ t.Fatalf("unexpected WWW-Authenticate: %q", hdr)
+ }
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "insufficient_scope" || body.ErrorDescription != "Insufficient scope" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+}
+
+func TestRequireBearerAuth_AcceptsAllRequiredScopes(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "valid", ClientID: "c", Scopes: []string{"read", "write", "admin"}, ExpiresAt: &exp}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, RequiredScopes: []string{"read", "write"}}, "Bearer valid")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+}
+
+func TestRequireBearerAuth_MissingAuthorization(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return srv.AuthInfo{}, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "")
+ if mv.last != "" {
+ t.Fatalf("verifier should not be called")
+ }
+ if nextCalled || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ // 检查完整的 WWW-Authenticate 头
+ expectedHeader := `Bearer error="invalid_token", error_description="Missing Authorization header"`
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if hdr != expectedHeader {
+ t.Fatalf("expected WWW-Authenticate: %q, got %q", expectedHeader, hdr)
+ }
+ // 检查响应体
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "invalid_token" || body.ErrorDescription != "Missing Authorization header" {
+ t.Fatalf("expected body error=\"invalid_token\", error_description=\"Missing Authorization header\", got %+v", body)
+ }
+}
+
+func TestRequireBearerAuth_InvalidAuthorizationFormat(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return srv.AuthInfo{}, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "InvalidFormat")
+ if mv.last != "" {
+ t.Fatalf("verifier should not be called")
+ }
+ if nextCalled || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ // 验证 WWW-Authenticate 头的完整字符串
+ expectedHeader := `Bearer error="invalid_token", error_description="Invalid Authorization header format, expected 'Bearer TOKEN'"`
+ if hdr := rec.Header().Get("WWW-Authenticate"); hdr != expectedHeader {
+ t.Fatalf("expected WWW-Authenticate: %q, got %q", expectedHeader, hdr)
+ }
+ // 验证响应体
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "invalid_token" || body.ErrorDescription != "Invalid Authorization header format, expected 'Bearer TOKEN'" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+}
+
+func TestRequireBearerAuth_VerifierErrors(t *testing.T) {
+ t.Run("invalid_token -> 401", func(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrInvalidToken, "Token expired", "")
+ }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer invalid-token")
+ if mv.last != "invalid-token" {
+ t.Fatalf("expected token 'invalid-token', got %q", mv.last)
+ }
+ if nextCalled || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if !strings.Contains(hdr, `error="invalid_token"`) || !strings.Contains(hdr, "Token expired") {
+ t.Fatalf("unexpected WWW-Authenticate: %q", hdr)
+ }
+ })
+
+ t.Run("insufficient_scope -> 403", func(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrInsufficientScope, "Required scopes: read, write", "")
+ }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer valid-token")
+ if mv.last != "valid-token" {
+ t.Fatalf("expected token 'valid-token', got %q", mv.last)
+ }
+ if nextCalled || rec.Code != http.StatusForbidden {
+ t.Fatalf("expected 403 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if !strings.Contains(hdr, `error="insufficient_scope"`) {
+ t.Fatalf("unexpected WWW-Authenticate: %q", hdr)
+ }
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "insufficient_scope" || body.ErrorDescription != "Required scopes: read, write" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+ })
+
+ t.Run("server_error -> 500", func(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrServerError, "Internal server issue", "")
+ }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer valid-token")
+ if mv.last != "valid-token" {
+ t.Fatalf("expected token 'valid-token', got %q", mv.last)
+ }
+ if nextCalled || rec.Code != http.StatusInternalServerError {
+ t.Fatalf("expected 500 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ if hdr := rec.Header().Get("WWW-Authenticate"); hdr != "" {
+ t.Fatalf("expected no WWW-Authenticate header, got %q", hdr)
+ }
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "server_error" || body.ErrorDescription != "Internal server issue" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+ })
+
+ t.Run("generic oauth error -> 400", func(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrInvalidRequest, "Some OAuth error", "")
+ }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer valid-token")
+ if nextCalled || rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ if mv.last != "valid-token" {
+ t.Fatalf("expected token 'valid-token', got %q", mv.last)
+ }
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "invalid_request" || body.ErrorDescription != "Some OAuth error" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+ })
+
+ t.Run("unexpected error -> 401", func(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, fmt.Errorf("unexpected error")
+ }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer valid-token")
+ if mv.last != "valid-token" {
+ t.Fatalf("expected token 'valid-token', got %q", mv.last)
+ }
+ if nextCalled {
+ t.Fatalf("expected next not to be called")
+ }
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", rec.Code)
+ }
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "invalid_token" || body.ErrorDescription != "Invalid access token" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if !strings.Contains(hdr, `error="invalid_token"`) || !strings.Contains(hdr, "Invalid access token") {
+ t.Fatalf("unexpected WWW-Authenticate: %q", hdr)
+ }
+ })
+}
+
+func TestRequireBearerAuth_WithResourceMetadata(t *testing.T) {
+ url := "https://api.example.com/.well-known/oauth-protected-resource"
+
+ t.Run("401 includes resource_metadata when missing header", func(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return srv.AuthInfo{}, nil }}
+ rec, _ := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, ResourceMetadataURL: &url}, "")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", rec.Code)
+ }
+ if mv.last != "" {
+ t.Fatalf("verifier should not be called, got token %q", mv.last)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ expectedHdr := `Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="` + url + `"`
+ if hdr != expectedHdr {
+ t.Fatalf("expected WWW-Authenticate: %q, got %q", expectedHdr, hdr)
+ }
+ })
+
+ t.Run("401 includes resource_metadata when verifier returns invalid_token", func(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrInvalidToken, "Token expired", "")
+ }}
+ rec, _ := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, ResourceMetadataURL: &url}, "Bearer bad")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", rec.Code)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if !strings.Contains(hdr, `resource_metadata="`+url+`"`) {
+ t.Fatalf("resource_metadata missing in header: %q", hdr)
+ }
+ })
+
+ t.Run("403 includes resource_metadata for insufficient scope", func(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrInsufficientScope, "Required scopes: admin", "")
+ }}
+ rec, _ := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, ResourceMetadataURL: &url}, "Bearer t")
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("expected 403, got %d", rec.Code)
+ }
+ if hdr := rec.Header().Get("WWW-Authenticate"); !strings.Contains(hdr, `resource_metadata="`+url+`"`) {
+ t.Fatalf("resource_metadata missing in header: %q", hdr)
+ }
+ })
+
+ t.Run("expired token includes resource_metadata", func(t *testing.T) {
+ exp := time.Now().Add(-100 * time.Second).Unix()
+ ai := srv.AuthInfo{Token: "expired", ClientID: "c", Scopes: []string{"read", "write"}, ExpiresAt: &exp}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, _ := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, ResourceMetadataURL: &url}, "Bearer expired")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", rec.Code)
+ }
+ if hdr := rec.Header().Get("WWW-Authenticate"); !strings.Contains(hdr, `resource_metadata="`+url+`"`) {
+ t.Fatalf("resource_metadata missing in header: %q", hdr)
+ }
+ })
+
+ t.Run("scope check fail includes resource_metadata", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "ok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, _ := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, RequiredScopes: []string{"read", "write"}, ResourceMetadataURL: &url}, "Bearer ok")
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("expected 403, got %d", rec.Code)
+ }
+ if hdr := rec.Header().Get("WWW-Authenticate"); !strings.Contains(hdr, `resource_metadata="`+url+`"`) || !strings.Contains(hdr, `scope="read write"`) {
+ t.Fatalf("resource_metadata or scope missing in header: %q", hdr)
+ }
+ })
+
+ t.Run("server error does not include WWW-Authenticate header", func(t *testing.T) {
+ url := "https://api.example.com/.well-known/oauth-protected-resource"
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrServerError, "Internal server issue", "")
+ }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, ResourceMetadataURL: &url}, "Bearer valid-token")
+ if mv.last != "valid-token" {
+ t.Fatalf("expected token 'valid-token', got %q", mv.last)
+ }
+ if nextCalled {
+ t.Fatalf("expected next not to be called")
+ }
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("expected 500, got %d", rec.Code)
+ }
+ if hdr := rec.Header().Get("WWW-Authenticate"); hdr != "" {
+ t.Fatalf("expected no WWW-Authenticate header, got %q", hdr)
+ }
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "server_error" || body.ErrorDescription != "Internal server issue" {
+ t.Fatalf("expected body {error: \"server_error\", error_description: \"Internal server issue\"}, got %+v", body)
+ }
+ })
+}
+
+func TestRequireBearerAuth_IssuerChecks(t *testing.T) {
+ t.Run("issuer accepted when matches", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Extra: map[string]interface{}{"iss": "https://issuer.example"}}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Issuer: "https://issuer.example"}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+
+ t.Run("issuer rejected when mismatches", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Extra: map[string]interface{}{"iss": "https://issuer.example"}}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Issuer: "https://another-issuer"}, "Bearer tok")
+ if nextCalled || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if !strings.Contains(hdr, `error="invalid_token"`) || !strings.Contains(hdr, "Invalid token issuer") {
+ t.Fatalf("unexpected WWW-Authenticate: %q", hdr)
+ }
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "invalid_token" || body.ErrorDescription != "Invalid token issuer" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+ })
+
+ t.Run("issuer check skipped when Extra is nil", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Extra: nil}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Issuer: "https://issuer.example"}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+
+ t.Run("issuer check skipped when iss claim is non-string", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Extra: map[string]interface{}{"iss": 12345}}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Issuer: "https://issuer.example"}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+
+ t.Run("issuer check skipped when iss is empty string", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Extra: map[string]interface{}{"iss": ""}}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Issuer: "https://issuer.example"}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+}
+
+func TestRequireBearerAuth_AudienceChecks(t *testing.T) {
+ t.Run("audience accepted when matches exactly", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ // token resource: https://api.example.com/mcp# -> middleware trims trailing '#'
+ u := mustParseURL(t, "https://api.example.com/mcp#")
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Resource: u}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Audience: []string{"https://api.example.com/mcp"}}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+
+ t.Run("audience rejected when mismatched", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ u := mustParseURL(t, "https://api.example.com/mcp")
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Resource: u}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Audience: []string{"https://other.example.com/mcp"}}, "Bearer tok")
+ if nextCalled || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if !strings.Contains(hdr, `error="invalid_token"`) || !strings.Contains(hdr, "Invalid token audience") {
+ t.Fatalf("unexpected WWW-Authenticate: %q", hdr)
+ }
+ body := decodeOAuthResp(t, rec)
+ if body.Error != "invalid_token" || body.ErrorDescription != "Invalid token audience" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+ })
+
+ t.Run("audience accepted when in multi-value list", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ u := mustParseURL(t, "https://api.example.com/mcp#")
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Resource: u}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Audience: []string{"https://other.example.com", "https://api.example.com/mcp"}}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+
+ t.Run("audience accepted when allowed value has trailing hash", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ u := mustParseURL(t, "https://api.example.com/mcp")
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Resource: u}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Audience: []string{"https://api.example.com/mcp#"}}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+
+ t.Run("skip audience when Resource is nil", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Resource: nil}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Audience: []string{"https://api.example.com/mcp"}}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+
+ t.Run("skip audience when options.Audience is empty", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ u := mustParseURL(t, "https://api.example.com/mcp")
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Resource: u}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Audience: []string{}}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+}
+
+func TestRequireBearerAuth_ContextInjectionAndTokenCleared(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "secret-token", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+
+ nextCalled := false
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ nextCalled = true
+ v := r.Context().Value(AuthInfoKey)
+ info, ok := v.(srv.AuthInfo)
+ if !ok {
+ t.Fatalf("auth info not injected in context")
+ }
+ if info.Token != "" {
+ t.Fatalf("expected token to be cleared, got %q", info.Token)
+ }
+ if info.ClientID != "c" || len(info.Scopes) != 1 || info.Scopes[0] != "read" {
+ t.Fatalf("unexpected auth info: %+v", info)
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+
+ handler := RequireBearerAuth(BearerAuthMiddlewareOptions{Verifier: mv})(next)
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("Authorization", "Bearer secret-token")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+}
+
+func TestRequireBearerAuth_WWWAuthenticateScopeParamOnInsufficientScope(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+
+ opts := BearerAuthMiddlewareOptions{Verifier: mv, RequiredScopes: []string{"read", "write"}}
+ rec, _ := runWithMiddleware(t, opts, "Bearer tok")
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("expected 403, got %d", rec.Code)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if !strings.Contains(hdr, `error="insufficient_scope"`) {
+ t.Fatalf("expected insufficient_scope in header, got %q", hdr)
+ }
+ if !strings.Contains(hdr, `scope="read write"`) {
+ t.Fatalf("expected scope=\"read write\" in header, got %q", hdr)
+ }
+}
+
+func TestRequireBearerAuth_WWWAuthenticateHeaderCombos(t *testing.T) {
+ t.Run("invalid_token 401 header has no scope param", func(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrInvalidToken, "Bad token", "")
+ }}
+ rec, _ := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, RequiredScopes: []string{"read", "write"}}, "Bearer bad")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", rec.Code)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if strings.Contains(hdr, "scope=") {
+ t.Fatalf("unexpected scope param in header: %q", hdr)
+ }
+ })
+
+ t.Run("400 invalid_request should not set WWW-Authenticate header", func(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrInvalidRequest, "Bad req", "")
+ }}
+ rec, _ := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer t")
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+ // For 400, middleware should NOT set WWW-Authenticate header
+ if hdr := rec.Header().Get("WWW-Authenticate"); hdr != "" {
+ t.Fatalf("expected no WWW-Authenticate header, got %q", hdr)
+ }
+ })
+}
+
+func TestRequireBearerAuth_BearerPrefixCaseInsensitive(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+
+ // Use mixed-case prefix "BeArEr"
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "BeArEr tok")
+ if mv.last != "tok" {
+ t.Fatalf("expected verifier to receive token 'tok', got %q", mv.last)
+ }
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+}
+
+func TestRequireBearerAuth_IssuerEdgeCases(t *testing.T) {
+ t.Run("issuer check skipped when Extra=nil", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Extra: nil}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Issuer: "https://issuer.example"}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+
+ t.Run("issuer check skipped when iss is not a string", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Extra: map[string]interface{}{"iss": 123}}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Issuer: "https://issuer.example"}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+
+ t.Run("issuer check skipped when iss is empty string", func(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp, Extra: map[string]interface{}{"iss": ""}}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) { return ai, nil }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, Issuer: "https://issuer.example"}, "Bearer tok")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+ })
+}
+
+func TestRequireBearerAuth_NoWWWAuthenticateOn400(t *testing.T) {
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrInvalidRequest, "invalid input", "")
+ }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv}, "Bearer any")
+ if nextCalled || rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ if hdr := rec.Header().Get("WWW-Authenticate"); hdr != "" {
+ t.Fatalf("expected no WWW-Authenticate header, got %q", hdr)
+ }
+}
+
+func TestRequireBearerAuth_InsufficientScopeFromVerifierIncludesScopeParam(t *testing.T) {
+ exp := time.Now().Add(1 * time.Hour).Unix()
+ ai := srv.AuthInfo{Token: "tok", ClientID: "c", Scopes: []string{"read"}, ExpiresAt: &exp}
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return ai, oauth.NewOAuthError(oauth.ErrInsufficientScope, "need read write", "")
+ }}
+ rec, _ := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, RequiredScopes: []string{"read", "write"}}, "Bearer tok")
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("expected 403, got %d", rec.Code)
+ }
+ hdr := rec.Header().Get("WWW-Authenticate")
+ if !strings.Contains(hdr, `error="insufficient_scope"`) {
+ t.Fatalf("expected insufficient_scope in header, got %q", hdr)
+ }
+ if !strings.Contains(hdr, `scope="read write"`) {
+ t.Fatalf("expected scope=\"read write\" in header, got %q", hdr)
+ }
+}
+
+func TestRequireBearerAuth_NoWWWAuthenticateOn400WithMetadata(t *testing.T) {
+ urlStr := "https://api.example.com/.well-known/oauth-protected-resource"
+ mv := &mockVerifier{verify: func(ctx context.Context, token string) (srv.AuthInfo, error) {
+ return srv.AuthInfo{}, oauth.NewOAuthError(oauth.ErrInvalidRequest, "invalid input", "")
+ }}
+ rec, nextCalled := runWithMiddleware(t, BearerAuthMiddlewareOptions{Verifier: mv, ResourceMetadataURL: &urlStr}, "Bearer any")
+ if nextCalled || rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ if hdr := rec.Header().Get("WWW-Authenticate"); hdr != "" {
+ t.Fatalf("expected no WWW-Authenticate header, got %q", hdr)
+ }
+}
+
+// mustParseURL is a small helper for building *url.URL in tests
+func mustParseURL(t *testing.T, raw string) *url.URL {
+ t.Helper()
+ u, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("parse url: %v", err)
+ }
+ return u
+}
diff --git a/internal/auth/server/middleware/client_auth.go b/internal/auth/server/middleware/client_auth.go
new file mode 100644
index 0000000..1e559f4
--- /dev/null
+++ b/internal/auth/server/middleware/client_auth.go
@@ -0,0 +1,199 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// ClientAuthenticationMiddlewareOptions contains options for client authentication middleware
+type ClientAuthenticationMiddlewareOptions struct {
+ // ClientsStore is a store used to read information about registered OAuth clients
+ ClientsStore server.OAuthClientsStoreInterface
+ // Optional: When grant_type=refresh_token and client_id is not provided, try to parse/reverse-check client_id from refresh_token
+ ResolveClientIDFromRefreshToken func(refreshToken string) (clientID string, ok bool)
+}
+
+// ClientAuthenticatedRequest represents the request schema for client authentication
+type ClientAuthenticatedRequest struct {
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"client_secret,omitempty"`
+}
+
+// clientInfoKeyType used to identify the context key storing OAuthClientInformationFull
+type clientInfoKeyType struct{}
+
+// validateClientRequest validates the client authentication request
+func validateClientRequest(req *ClientAuthenticatedRequest) error {
+ if req.ClientID == "" {
+ return errors.NewOAuthError(errors.ErrInvalidRequest, "client_id is required", "")
+ }
+ return nil
+}
+
+// AuthenticateClient returns an HTTP middleware function for client authentication
+func AuthenticateClient(options ClientAuthenticationMiddlewareOptions) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ setErrorResponse := func(w http.ResponseWriter, err errors.OAuthError, clientID string) {
+ var statusCode int
+ switch err.ErrorCode {
+ case errors.ErrInvalidClient.Error():
+ statusCode = http.StatusUnauthorized
+ case errors.ErrInvalidRequest.Error():
+ statusCode = http.StatusBadRequest
+ case errors.ErrServerError.Error():
+ statusCode = http.StatusInternalServerError
+ default:
+ statusCode = http.StatusBadRequest
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ _ = json.NewEncoder(w).Encode(err.ToResponseStruct())
+ }
+
+ var reqData ClientAuthenticatedRequest
+ var clientID string
+ var bodyBytes []byte
+
+ // Priority: Basic Auth first
+ if authz := r.Header.Get("Authorization"); strings.HasPrefix(strings.ToLower(authz), "basic ") {
+ enc := strings.TrimSpace(authz[len("Basic "):])
+ raw, decErr := base64.StdEncoding.DecodeString(enc)
+ if decErr != nil {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidClient, "malformed basic credentials", ""), "")
+ return
+ }
+ parts := strings.SplitN(string(raw), ":", 2)
+ if len(parts) != 2 {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidClient, "malformed basic credentials", ""), "")
+ return
+ }
+ reqData.ClientID, reqData.ClientSecret = parts[0], parts[1]
+ clientID = reqData.ClientID
+ } else {
+ // Non-Basic: buffer and restore Body, support form or JSON
+ bodyBytes, _ = io.ReadAll(r.Body)
+ _ = r.Body.Close()
+ r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
+
+ ct := strings.ToLower(r.Header.Get("Content-Type"))
+ switch {
+ case strings.HasPrefix(ct, "application/x-www-form-urlencoded"):
+ formVals, _ := url.ParseQuery(string(bodyBytes))
+ reqData.ClientID = formVals.Get("client_id")
+ reqData.ClientSecret = formVals.Get("client_secret")
+ clientID = reqData.ClientID
+ case strings.HasPrefix(ct, "application/json"):
+ if err := json.Unmarshal(bodyBytes, &reqData); err != nil {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidRequest, "Invalid request body", ""), "")
+ return
+ }
+ clientID = reqData.ClientID
+ default:
+ // Unknown type: maintain compatibility behavior, treat as JSON decode error
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidRequest, "Invalid request body", ""), "")
+ return
+ }
+ }
+
+ // Only try to fall back when client_id is not obtained, and it is a form or JSON, and grant_type=refresh_token
+ if reqData.ClientID == "" {
+ ct := strings.ToLower(r.Header.Get("Content-Type"))
+ var grantType, refreshToken string
+
+ switch {
+ case strings.HasPrefix(ct, "application/x-www-form-urlencoded"):
+ formVals, _ := url.ParseQuery(string(bodyBytes))
+ grantType = formVals.Get("grant_type")
+ refreshToken = formVals.Get("refresh_token")
+
+ case strings.HasPrefix(ct, "application/json"):
+ type raw struct {
+ GrantType string `json:"grant_type"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ var v raw
+ _ = json.Unmarshal(bodyBytes, &v)
+ grantType = v.GrantType
+ refreshToken = v.RefreshToken
+ }
+
+ if strings.EqualFold(grantType, "refresh_token") && refreshToken != "" && options.ResolveClientIDFromRefreshToken != nil {
+ if cid, ok := options.ResolveClientIDFromRefreshToken(refreshToken); ok && cid != "" {
+ reqData.ClientID = cid
+ clientID = cid
+ }
+ }
+ }
+
+ // Validate client_id
+ if err := validateClientRequest(&reqData); err != nil {
+ if oauthErr, ok := err.(errors.OAuthError); ok {
+ setErrorResponse(w, oauthErr, clientID)
+ } else {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidRequest, "Invalid client_id", ""), clientID)
+ }
+ return
+ }
+
+ // Read client and validate secret/expiration
+ client, err := options.ClientsStore.GetClient(reqData.ClientID)
+ if err != nil {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidClient, "invalid client credentials", ""), clientID)
+ return
+ }
+ if client == nil {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidClient, "invalid client credentials", ""), clientID)
+ return
+ }
+ if client.ClientSecret != "" {
+ if reqData.ClientSecret == "" {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidClient, "Client secret is required", ""), clientID)
+ return
+ }
+ if client.ClientSecret != reqData.ClientSecret {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidClient, "Invalid client_secret", ""), clientID)
+ return
+ }
+ if client.ClientSecretExpiresAt != nil {
+ now := time.Now().Unix()
+ if *client.ClientSecretExpiresAt != 0 && *client.ClientSecretExpiresAt < now {
+ setErrorResponse(w, errors.NewOAuthError(errors.ErrInvalidClient, "Client secret has expired", ""), clientID)
+ return
+ }
+ }
+ }
+
+ ctx := context.WithValue(r.Context(), clientInfoKeyType{}, client)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// GetAuthenticatedClient retrieves the authenticated client from HTTP request context
+func GetAuthenticatedClient(r *http.Request) (*auth.OAuthClientInformationFull, bool) {
+ client := r.Context().Value(clientInfoKeyType{})
+ if client == nil {
+ return nil, false
+ }
+
+ authenticatedClient, ok := client.(*auth.OAuthClientInformationFull)
+ return authenticatedClient, ok
+}
diff --git a/internal/auth/server/middleware/client_auth_test.go b/internal/auth/server/middleware/client_auth_test.go
new file mode 100644
index 0000000..b1201ec
--- /dev/null
+++ b/internal/auth/server/middleware/client_auth_test.go
@@ -0,0 +1,208 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ srv "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ oauth "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// mockClientsStore is a test double for OAuthClientsStoreInterface that lets tests
+// control lookups via a provided function
+type mockClientsStore struct {
+ get func(clientID string) (*auth.OAuthClientInformationFull, error)
+}
+
+// GetClient returns the mocked client info for the given client ID
+func (m *mockClientsStore) GetClient(clientID string) (*auth.OAuthClientInformationFull, error) {
+ return m.get(clientID)
+}
+
+// RegisterClient indicates dynamic client registration is not supported in this mock
+func (m *mockClientsStore) RegisterClient(client auth.OAuthClientInformationFull) (*auth.OAuthClientInformationFull, error) {
+ return nil, fmt.Errorf("dynamic client registration is not supported")
+}
+
+// runClientAuth executes a request through the client-authentication middleware and
+// returns the recorder and whether the next handler was called
+func runClientAuth(t *testing.T, store srv.OAuthClientsStoreInterface, body interface{}, contentType string) (rec *httptest.ResponseRecorder, nextCalled bool) {
+ t.Helper()
+
+ handlerCalled := false
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handlerCalled = true
+ cli, ok := GetAuthenticatedClient(r)
+ if !ok {
+ t.Fatalf("authenticated client not found in context")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(struct {
+ Success bool `json:"success"`
+ Client *auth.OAuthClientInformationFull `json:"client"`
+ }{Success: true, Client: cli})
+ })
+
+ middleware := AuthenticateClient(ClientAuthenticationMiddlewareOptions{ClientsStore: store})
+ h := middleware(next)
+
+ var b []byte
+ switch v := body.(type) {
+ case []byte:
+ b = v
+ default:
+ var err error
+ b, err = json.Marshal(v)
+ if err != nil {
+ t.Fatalf("failed to marshal body: %v", err)
+ }
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/protected", bytes.NewReader(b))
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ } else {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec, handlerCalled
+}
+
+// decodeOAuthError parses an OAuthErrorResponse from the test recorder body
+func decodeOAuthError(t *testing.T, rec *httptest.ResponseRecorder) *oauth.OAuthErrorResponse {
+ t.Helper()
+ var v oauth.OAuthErrorResponse
+ _ = json.NewDecoder(rec.Body).Decode(&v)
+ return &v
+}
+
+func TestAuthenticateClient_ValidCredentials(t *testing.T) {
+ store := &mockClientsStore{get: func(clientID string) (*auth.OAuthClientInformationFull, error) {
+ if clientID == "valid-client" {
+ return &auth.OAuthClientInformationFull{OAuthClientMetadata: auth.OAuthClientMetadata{RedirectURIs: []string{"https://example.com/callback"}}, OAuthClientInformation: auth.OAuthClientInformation{ClientID: "valid-client", ClientSecret: "valid-secret"}}, nil
+ }
+ return nil, nil
+ }}
+
+ rec, nextCalled := runClientAuth(t, store, map[string]interface{}{"client_id": "valid-client", "client_secret": "valid-secret"}, "application/json")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+
+ var body struct {
+ Success bool `json:"success"`
+ Client struct {
+ ClientID string `json:"client_id"`
+ } `json:"client"`
+ }
+ _ = json.NewDecoder(rec.Body).Decode(&body)
+ if !body.Success || body.Client.ClientID != "valid-client" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+}
+
+func TestAuthenticateClient_InvalidClientID(t *testing.T) {
+ store := &mockClientsStore{get: func(clientID string) (*auth.OAuthClientInformationFull, error) { return nil, nil }}
+ rec, nextCalled := runClientAuth(t, store, map[string]interface{}{"client_id": "non-existent-client", "client_secret": "some-secret"}, "application/json")
+ if nextCalled || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ body := decodeOAuthError(t, rec)
+ if body.Error != "invalid_client" || body.ErrorDescription != "invalid client credentials" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+}
+
+func TestAuthenticateClient_InvalidClientSecret(t *testing.T) {
+ store := &mockClientsStore{get: func(clientID string) (*auth.OAuthClientInformationFull, error) {
+ if clientID == "valid-client" {
+ return &auth.OAuthClientInformationFull{OAuthClientInformation: auth.OAuthClientInformation{ClientID: "valid-client", ClientSecret: "valid-secret"}}, nil
+ }
+ return nil, nil
+ }}
+ rec, nextCalled := runClientAuth(t, store, map[string]interface{}{"client_id": "valid-client", "client_secret": "wrong-secret"}, "application/json")
+ if nextCalled || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ body := decodeOAuthError(t, rec)
+ if body.Error != "invalid_client" || body.ErrorDescription != "Invalid client_secret" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+}
+
+func TestAuthenticateClient_MissingClientID(t *testing.T) {
+ store := &mockClientsStore{get: func(clientID string) (*auth.OAuthClientInformationFull, error) { return nil, nil }}
+ rec, nextCalled := runClientAuth(t, store, map[string]interface{}{"client_secret": "valid-secret"}, "application/json")
+ if nextCalled || rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ body := decodeOAuthError(t, rec)
+ if body.Error != "invalid_request" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+}
+
+func TestAuthenticateClient_AllowsMissingSecretIfClientHasNone(t *testing.T) {
+ store := &mockClientsStore{get: func(clientID string) (*auth.OAuthClientInformationFull, error) {
+ if clientID == "expired-client" {
+ return &auth.OAuthClientInformationFull{OAuthClientInformation: auth.OAuthClientInformation{ClientID: "expired-client"}}, nil
+ }
+ return nil, nil
+ }}
+ rec, nextCalled := runClientAuth(t, store, map[string]interface{}{"client_id": "expired-client"}, "application/json")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+}
+
+func TestAuthenticateClient_RejectsExpiredSecret(t *testing.T) {
+ past := time.Now().Add(-1 * time.Hour).Unix()
+ store := &mockClientsStore{get: func(clientID string) (*auth.OAuthClientInformationFull, error) {
+ if clientID == "client-with-expired-secret" {
+ return &auth.OAuthClientInformationFull{OAuthClientInformation: auth.OAuthClientInformation{ClientID: "client-with-expired-secret", ClientSecret: "expired-secret", ClientSecretExpiresAt: &past}}, nil
+ }
+ return nil, nil
+ }}
+ rec, nextCalled := runClientAuth(t, store, map[string]interface{}{"client_id": "client-with-expired-secret", "client_secret": "expired-secret"}, "application/json")
+ if nextCalled || rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+ body := decodeOAuthError(t, rec)
+ if body.Error != "invalid_client" || body.ErrorDescription != "Client secret has expired" {
+ t.Fatalf("unexpected body: %+v", body)
+ }
+}
+
+func TestAuthenticateClient_MalformedRequestBody(t *testing.T) {
+ store := &mockClientsStore{get: func(clientID string) (*auth.OAuthClientInformationFull, error) { return nil, nil }}
+ rec, nextCalled := runClientAuth(t, store, []byte("not-json-format"), "application/json")
+ if nextCalled || rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 without next, got %d next=%v", rec.Code, nextCalled)
+ }
+}
+
+func TestAuthenticateClient_IgnoresExtraFields(t *testing.T) {
+ store := &mockClientsStore{get: func(clientID string) (*auth.OAuthClientInformationFull, error) {
+ if clientID == "valid-client" {
+ return &auth.OAuthClientInformationFull{OAuthClientInformation: auth.OAuthClientInformation{ClientID: "valid-client", ClientSecret: "valid-secret"}}, nil
+ }
+ return nil, nil
+ }}
+ rec, nextCalled := runClientAuth(t, store, map[string]interface{}{"client_id": "valid-client", "client_secret": "valid-secret", "extra_field": "ignored"}, "application/json")
+ if rec.Code != http.StatusOK || !nextCalled {
+ t.Fatalf("expected 200 and next called, got %d next=%v", rec.Code, nextCalled)
+ }
+}
diff --git a/internal/auth/server/middleware/context.go b/internal/auth/server/middleware/context.go
new file mode 100644
index 0000000..6eb3d2f
--- /dev/null
+++ b/internal/auth/server/middleware/context.go
@@ -0,0 +1,14 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+// authInfoKeyType is an unexported empty struct used as a context key to prevent collisions with other packages
+type authInfoKeyType struct{}
+
+// AuthInfoKey is the context key for storing and retrieving authentication information on requests
+// Use context.WithValue(ctx, AuthInfoKey, authInfo) to attach and ctx.Value(AuthInfoKey) to read
+var AuthInfoKey = authInfoKeyType{}
diff --git a/internal/auth/server/middleware/security.go b/internal/auth/server/middleware/security.go
new file mode 100644
index 0000000..bc4015e
--- /dev/null
+++ b/internal/auth/server/middleware/security.go
@@ -0,0 +1,249 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "golang.org/x/time/rate"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// SecurityMiddlewareOption holds pluggable security dependencies for middleware
+type SecurityMiddlewareOption struct {
+ verifier server.TokenVerifier // Token verifier used by middleware that need direct verification
+}
+
+// Authorizer defines a unified authorization decision interface
+// Implementations should return nil when access is allowed and an OAuthError when denied
+type Authorizer interface {
+ Authorize(authInfo server.AuthInfo, resource string, action string) error
+}
+
+// ScopePermissionMapper maps OAuth scopes to internal permissions
+// The returned slice represents permissions granted by the provided scopes
+type ScopePermissionMapper interface {
+ MapScopes(scopes []string) []string
+}
+
+// DefaultScopeMapper is a simple mapper using a static scope→permissions table
+type DefaultScopeMapper struct {
+ Mapping map[string][]string // Per-scope permission list
+}
+
+// MapScopes expands scopes into a flattened permission list using Mapping
+func (m *DefaultScopeMapper) MapScopes(scopes []string) []string {
+ // Accumulate permissions granted by each scope
+ var perms []string
+ for _, scope := range scopes {
+ if mapped, ok := m.Mapping[scope]; ok {
+ perms = append(perms, mapped...)
+ }
+ }
+ return perms
+}
+
+// PolicyAuthorizer authorizes by checking if the required permission exists after scope mapping
+type PolicyAuthorizer struct {
+ ScopeMapper ScopePermissionMapper // Pluggable scope→permission mapper
+}
+
+// Authorize checks whether authInfo scopes grant the required {resource}:{action} permission
+func (a *PolicyAuthorizer) Authorize(authInfo server.AuthInfo, resource string, action string) error {
+ // Convert scopes to internal permissions
+ perms := a.ScopeMapper.MapScopes(authInfo.Scopes)
+
+ // Build required permission string like urn:mcp:workspace:xyz:read
+ required := fmt.Sprintf("%s:%s", resource, action)
+
+ // Return success when permission is present
+ for _, p := range perms {
+ if p == required {
+ return nil
+ }
+ }
+
+ // Otherwise return standardized insufficient_scope error
+ return errors.NewOAuthError(errors.ErrInsufficientScope,
+ fmt.Sprintf("Missing permission %s", required), "")
+}
+
+// responseWriterWithStatus wraps http.ResponseWriter to capture the final status code
+type responseWriterWithStatus struct {
+ http.ResponseWriter
+ statusCode int
+}
+
+// WriteHeader intercepts WriteHeader calls to store the status code
+func (rw *responseWriterWithStatus) WriteHeader(code int) {
+ rw.statusCode = code
+ rw.ResponseWriter.WriteHeader(code)
+}
+
+// CorsMiddleware applies permissive CORS headers similar to express default behavior
+// It returns 204 for OPTIONS preflight while forwarding non-preflight requests downstream
+func CorsMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Read Origin header to detect cross origin requests
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ // Not a CORS request so proceed without CORS headers
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Set basic CORS headers
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE")
+
+ // Handle preflight with 204 and zero content length
+ if r.Method == http.MethodOptions {
+ w.Header().Set("Content-Length", "0")
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ // Forward actual request
+ next.ServeHTTP(w, r)
+ })
+}
+
+// RateLimitMiddleware applies a token bucket limiter to incoming requests
+// When the limiter denies a request a 429 JSON OAuth error is returned
+func RateLimitMiddleware(limiter *rate.Limiter) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Short circuit when the limiter does not allow the request
+ if !limiter.Allow() {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTooManyRequests)
+
+ // Build standardized OAuth error payload
+ tooManyRequestsError := errors.NewOAuthError(
+ errors.ErrTooManyRequests,
+ "You have exceeded the rate limit for token revocation requests",
+ "",
+ )
+ _ = json.NewEncoder(w).Encode(tooManyRequestsError.ToResponseStruct())
+ return
+ }
+
+ // Continue to next handler
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// ContentTypeValidationMiddleware validates the Content-Type header against an allowlist
+// When allowJSONFallback is true application/json is accepted in addition to allowedTypes[0]
+func ContentTypeValidationMiddleware(allowedTypes []string, allowJSONFallback bool) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ contentType := r.Header.Get("Content-Type")
+
+ // Content-Type header is required for these endpoints
+ if contentType == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ invalidReqError := errors.NewOAuthError(
+ errors.ErrInvalidRequest,
+ "Content-Type header is required",
+ "",
+ )
+ _ = json.NewEncoder(w).Encode(invalidReqError.ToResponseStruct())
+ return
+ }
+
+ // Check prefix match to allow charset parameters
+ var isValid bool
+ for _, allowedType := range allowedTypes {
+ if strings.HasPrefix(contentType, allowedType) {
+ isValid = true
+ break
+ }
+ }
+
+ // Optionally accept JSON when configured
+ if !isValid && allowJSONFallback && strings.HasPrefix(contentType, "application/json") {
+ isValid = true
+ }
+
+ // Reject unsupported content types with a helpful message
+ if !isValid {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+
+ errorMsg := fmt.Sprintf("Content-Type must be one of: %s", strings.Join(allowedTypes, ", "))
+ if allowJSONFallback && len(allowedTypes) > 0 {
+ errorMsg = fmt.Sprintf("Content-Type must be %s (preferred) or application/json", allowedTypes[0])
+ }
+
+ invalidReqError := errors.NewOAuthError(
+ errors.ErrInvalidRequest,
+ errorMsg,
+ "",
+ )
+ _ = json.NewEncoder(w).Encode(invalidReqError.ToResponseStruct())
+ return
+ }
+
+ // Forward to the next handler
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// URLEncodedValidationMiddleware enforces application/x-www-form-urlencoded for RFC 7009 style endpoints
+func URLEncodedValidationMiddleware(allowJSONFallback bool) func(http.Handler) http.Handler {
+ return ContentTypeValidationMiddleware([]string{"application/x-www-form-urlencoded"}, allowJSONFallback)
+}
+
+// JSONValidationMiddleware enforces application/json for endpoints that only accept JSON
+func JSONValidationMiddleware() func(http.Handler) http.Handler {
+ return ContentTypeValidationMiddleware([]string{"application/json"}, false)
+}
+
+// AuthorizationMiddleware performs authorization using the provided Authorizer for a {resource, action} pair
+// It requires a validated AuthInfo in context and returns 401 or 403 with OAuth style error when denied
+func AuthorizationMiddleware(authorizer Authorizer, resource string, action string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Read validated auth info placed in context by upstream auth middleware
+ authInfo, ok := GetAuthInfo(r.Context())
+ if !ok {
+ w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", error_description="No authentication info found"`)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // Evaluate authorization decision for the requested resource and action
+ err := authorizer.Authorize(authInfo, resource, action)
+ if err != nil {
+ // Return standardized insufficient_scope response
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("WWW-Authenticate", `Bearer error="insufficient_scope"`)
+ w.WriteHeader(http.StatusForbidden)
+ _ = json.NewEncoder(w).Encode(err.(errors.OAuthError).ToResponseStruct())
+
+ // Optionally extract subject for audit or side effects
+ _ = extractSubject(authInfo)
+ return
+ }
+
+ // Optionally extract subject for audit or side effects
+ _ = extractSubject(authInfo)
+
+ // Authorized so continue to next handler
+ next.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/internal/auth/server/middleware/security_test.go b/internal/auth/server/middleware/security_test.go
new file mode 100644
index 0000000..261e8cd
--- /dev/null
+++ b/internal/auth/server/middleware/security_test.go
@@ -0,0 +1,233 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package middleware
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "golang.org/x/time/rate"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+)
+
+// okHandler returns a simple HTTP handler that always responds with status 200 OK and the body "ok"
+func okHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`ok`))
+ })
+}
+
+// do executes the given handler with a constructed HTTP request
+func do(handler http.Handler, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, body)
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ return rr
+}
+
+func TestCorsMiddleware_NoOrigin_PassThrough(t *testing.T) {
+ h := CorsMiddleware(okHandler())
+ rr := do(h, http.MethodGet, "/", nil, nil)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+ if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" {
+ t.Fatalf("expected no CORS header, got %q", v)
+ }
+}
+
+func TestCorsMiddleware_WithOrigin_SetsHeaders(t *testing.T) {
+ h := CorsMiddleware(okHandler())
+ rr := do(h, http.MethodGet, "/", nil, map[string]string{
+ "Origin": "http://example.com",
+ })
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+ if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "*" {
+ t.Fatalf("expected '*', got %q", v)
+ }
+ if v := rr.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(v, "GET") {
+ t.Fatalf("expected allow methods set, got %q", v)
+ }
+}
+
+func TestCorsMiddleware_Options_Preflight(t *testing.T) {
+ h := CorsMiddleware(okHandler())
+ rr := do(h, http.MethodOptions, "/", nil, map[string]string{
+ "Origin": "http://example.com",
+ })
+
+ if rr.Code != http.StatusNoContent {
+ t.Fatalf("expected 204, got %d", rr.Code)
+ }
+ if v := rr.Header().Get("Content-Length"); v != "0" {
+ t.Fatalf("expected Content-Length 0, got %q", v)
+ }
+}
+
+func TestRateLimitMiddleware_Allow(t *testing.T) {
+ lim := rate.NewLimiter(rate.Inf, 0) // always allow
+ h := RateLimitMiddleware(lim)(okHandler())
+
+ rr := do(h, http.MethodPost, "/token", nil, nil)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+}
+
+func TestRateLimitMiddleware_Reject(t *testing.T) {
+ lim := rate.NewLimiter(0, 0) // always reject
+ h := RateLimitMiddleware(lim)(okHandler())
+
+ rr := do(h, http.MethodPost, "/token", nil, nil)
+ if rr.Code != http.StatusTooManyRequests {
+ t.Fatalf("expected 429, got %d", rr.Code)
+ }
+ if !strings.Contains(strings.ToLower(rr.Body.String()), "too_many_requests") {
+ t.Fatalf("expected body to contain too_many_requests, got %q", rr.Body.String())
+ }
+}
+
+func TestContentTypeValidation_MissingHeader(t *testing.T) {
+ h := ContentTypeValidationMiddleware([]string{"application/x-www-form-urlencoded"}, false)(okHandler())
+
+ rr := do(h, http.MethodPost, "/token", bytes.NewBufferString("a=b"), nil)
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+ if !strings.Contains(strings.ToLower(rr.Body.String()), "invalid_request") {
+ t.Fatalf("expected invalid_request, got %q", rr.Body.String())
+ }
+}
+
+func TestContentTypeValidation_Allowed(t *testing.T) {
+ h := ContentTypeValidationMiddleware([]string{"application/x-www-form-urlencoded"}, false)(okHandler())
+
+ rr := do(h, http.MethodPost, "/token", bytes.NewBufferString("a=b"), map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
+ })
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+}
+
+func TestContentTypeValidation_NotAllowed_NoFallback(t *testing.T) {
+ h := ContentTypeValidationMiddleware([]string{"application/x-www-form-urlencoded"}, false)(okHandler())
+
+ rr := do(h, http.MethodPost, "/token", bytes.NewBufferString(`{"a":"b"}`), map[string]string{
+ "Content-Type": "application/json",
+ })
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+}
+
+func TestContentTypeValidation_JSONFallback(t *testing.T) {
+ h := ContentTypeValidationMiddleware([]string{"application/x-www-form-urlencoded"}, true)(okHandler())
+
+ rr := do(h, http.MethodPost, "/token", bytes.NewBufferString(`{"a":"b"}`), map[string]string{
+ "Content-Type": "application/json; charset=utf-8",
+ })
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200 with JSON fallback, got %d", rr.Code)
+ }
+}
+
+func TestURLEncodedValidationMiddleware(t *testing.T) {
+ h := URLEncodedValidationMiddleware(false)(okHandler())
+
+ rr := do(h, http.MethodPost, "/revoke", bytes.NewBufferString("a=b"), map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+}
+
+func TestJSONValidationMiddleware(t *testing.T) {
+ h := JSONValidationMiddleware()(okHandler())
+
+ rr := do(h, http.MethodPost, "/register", bytes.NewBufferString(`{"a":"b"}`), map[string]string{
+ "Content-Type": "application/json",
+ })
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+}
+
+type denyAllAuthorizer struct{}
+
+func (denyAllAuthorizer) Authorize(authInfo server.AuthInfo, resource, action string) error {
+ // not used in this test since no auth info will be present
+ return nil
+}
+
+func TestAuthorizationMiddleware_NoAuthInfo_Returns401(t *testing.T) {
+ h := AuthorizationMiddleware(denyAllAuthorizer{}, "urn:mcp:resource", "read")(okHandler())
+
+ rr := do(h, http.MethodGet, "/protected", nil, nil)
+ if rr.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", rr.Code)
+ }
+ if v := rr.Header().Get("WWW-Authenticate"); !strings.Contains(v, "invalid_token") {
+ t.Fatalf("expected WWW-Authenticate with invalid_token, got %q", v)
+ }
+}
+
+func TestPolicyAuthorizer_Authorize_Allowed(t *testing.T) {
+ mapper := &DefaultScopeMapper{
+ Mapping: map[string][]string{
+ "workspace.read": {"urn:mcp:workspace:xyz:read"},
+ },
+ }
+ a := &PolicyAuthorizer{ScopeMapper: mapper}
+
+ authInfo := server.AuthInfo{Scopes: []string{"workspace.read"}}
+ if err := a.Authorize(authInfo, "urn:mcp:workspace:xyz", "read"); err != nil {
+ t.Fatalf("expected authorize success, got %v", err)
+ }
+}
+
+func TestPolicyAuthorizer_Authorize_Denied(t *testing.T) {
+ mapper := &DefaultScopeMapper{
+ Mapping: map[string][]string{
+ "workspace.read": {"urn:mcp:workspace:xyz:read"},
+ },
+ }
+ a := &PolicyAuthorizer{ScopeMapper: mapper}
+
+ authInfo := server.AuthInfo{Scopes: []string{"workspace.read"}}
+ if err := a.Authorize(authInfo, "urn:mcp:workspace:xyz", "write"); err == nil {
+ t.Fatalf("expected authorize deny, got nil error")
+ }
+}
+
+func TestRateLimitMiddleware_Reject_ReturnsJSON(t *testing.T) {
+ lim := rate.NewLimiter(0, 0)
+ h := RateLimitMiddleware(lim)(okHandler())
+
+ rr := do(h, http.MethodPost, "/token", nil, nil)
+ if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
+ t.Fatalf("expected application/json, got %q", ct)
+ }
+ var parsed map[string]any
+ if err := json.Unmarshal(rr.Body.Bytes(), &parsed); err != nil {
+ t.Fatalf("expected JSON body, got err: %v; body=%q", err, rr.Body.String())
+ }
+}
diff --git a/internal/auth/server/provider.go b/internal/auth/server/provider.go
new file mode 100644
index 0000000..78daf2d
--- /dev/null
+++ b/internal/auth/server/provider.go
@@ -0,0 +1,71 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package server
+
+import (
+ "net/http"
+ "net/url"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+)
+
+// AuthorizationParams carries parameters for starting an OAuth authorization request
+type AuthorizationParams struct {
+ CodeChallenge string `json:"code_challenge"` // PKCE code challenge from client
+ RedirectURI string `json:"redirect_uri"` // Redirect URI registered by the client
+ State string `json:"state"` // Optional opaque value to maintain client state between request and callback
+ Scopes []string `json:"scopes"` // Optional empty slice means not provided
+ Resource *url.URL `json:"resource"` // Optional nil means not provided
+}
+
+// OAuthServerProvider defines a complete OAuth 2.1 server interface including client management authorization token exchange verification and revocation
+type OAuthServerProvider interface {
+
+ // ClientsStore returns the store used to read registered OAuth client information
+ ClientsStore() *OAuthClientsStore
+
+ // Authorize starts the authorization flow implemented by this server or by redirecting to another authorization server
+ // The server must ultimately redirect to the given redirect URI with either a success or an error response per OAuth 2.1
+ // On success include query params code and state if provided
+ // On error include query param error and may include error_description
+ Authorize(client auth.OAuthClientInformationFull, params AuthorizationParams, res http.ResponseWriter, req *http.Request) error
+
+ // ChallengeForAuthorizationCode returns the codeChallenge that was used when the indicated authorization began
+ ChallengeForAuthorizationCode(client auth.OAuthClientInformationFull, authorizationCode string) (string, error)
+
+ // ExchangeAuthorizationCode exchanges an authorization code for access tokens
+ // Validate code and optional PKCE codeVerifier and optional redirectUri and resource
+ ExchangeAuthorizationCode(
+ client auth.OAuthClientInformationFull,
+ authorizationCode string, codeVerifier *string,
+ redirectUri *string,
+ resource *url.URL,
+ ) (*auth.OAuthTokens, error)
+
+ // ExchangeRefreshToken exchanges a refresh token for new access tokens
+ // Accept optional scopes and optional resource
+ ExchangeRefreshToken(
+ client auth.OAuthClientInformationFull,
+ refreshToken string,
+ scopes []string, // Optional empty slice if not provided
+ resource *url.URL, // Optional nil if not provided
+ ) (*auth.OAuthTokens, error)
+
+ // VerifyAccessToken verifies an access token and returns its associated information
+ VerifyAccessToken(token string) (*AuthInfo, error)
+
+ // SupportTokenRevocation indicates optional support for token revocation
+ SupportTokenRevocation
+}
+
+// SupportTokenRevocation defines optional token revocation capability
+type SupportTokenRevocation interface {
+ // RevokeToken revokes an access or refresh token
+ // If the token is invalid or already revoked this should be a no op
+ // Optional method
+ RevokeToken(client auth.OAuthClientInformationFull, request auth.OAuthTokenRevocationRequest) error
+}
diff --git a/internal/auth/server/providers/proxy.go b/internal/auth/server/providers/proxy.go
new file mode 100644
index 0000000..51916dd
--- /dev/null
+++ b/internal/auth/server/providers/proxy.go
@@ -0,0 +1,418 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package providers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/go-playground/validator/v10"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// ProxyEndpoints defines the OAuth 2.0/2.1 server endpoints used by the proxy.
+// It contains the URLs for various OAuth operations.
+type ProxyEndpoints struct {
+ // AuthorizationURL is the URL of the OAuth 2.0/2.1 authorization endpoint.
+ // This is where users are redirected to authorize the client.
+ // "https://auth.example.com/authorize"
+ AuthorizationURL string `json:"authorizationUrl"`
+
+ // TokenURL is the URL of the OAuth 2.0/2.1 token endpoint.
+ // This is where the client exchanges an authorization code for an access token.
+ // "https://auth.example.com/token"
+ TokenURL string `json:"tokenUrl"`
+
+ // RevocationURL is the optional URL of the OAuth 2.0 token revocation endpoint.
+ // If provided, it's used to revoke access tokens or refresh tokens.
+ // "https://auth.example.com/revoke"
+ RevocationURL string `json:"revocationUrl,omitempty"`
+
+ // RegistrationURL is the optional URL of the OAuth 2.0 dynamic client registration endpoint.
+ // If provided, it allows clients to register with the authorization server dynamically. 。
+ // "https://auth.example.com/register"
+ RegistrationURL string `json:"registrationUrl,omitempty"`
+}
+
+// ProxyOptions defines configuration options for the proxy OAuth server
+type ProxyOptions struct {
+ // Endpoints presents Endpoint configuration for proxy OAuth operations
+ Endpoints ProxyEndpoints
+
+ // VerifyAccessToken verifies access tokens and return auth info
+ VerifyAccessToken func(token string) (*server.AuthInfo, error)
+
+ // GetClient fetches client information from the upstream server
+ GetClient func(clientID string) (*auth.OAuthClientInformationFull, error)
+
+ // Fetch customs fetch implementation used for all network requests, optional
+ Fetch auth.FetchFunc
+}
+
+// ProxyOAuthServerProvider defines proxy OAuth server provider
+type ProxyOAuthServerProvider struct {
+ // endpoints defines proxy endpoint configuration
+ endpoints ProxyEndpoints
+
+ // verifyAccessToken verifies access tokens
+ verifyAccessToken func(token string) (*server.AuthInfo, error)
+
+ // getClient get client's information
+ getClient func(clientID string) (*auth.OAuthClientInformationFull, error)
+
+ // SkipLocalPkceValidation determines whether to skip local PKCE validation.
+ // If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server.
+ // NOTE: This should only be true if the upstream server is performing the actual PKCE validation.
+ // 可选字段,默认false / Optional field, defaults to false
+ SkipLocalPkceValidation bool `json:"skipLocalPkceValidation,omitempty"`
+
+ // Custom fetch implementation, optional
+ fetch auth.FetchFunc
+}
+
+// Authorize handles an OAuth authorization request by constructing the query
+// parameters and redirecting the user agent to the configured authorization endpoint.
+func (p *ProxyOAuthServerProvider) Authorize(client auth.OAuthClientInformationFull, params server.AuthorizationParams, res http.ResponseWriter, req *http.Request) error {
+ // Validate the configured authorization endpoint URL
+ targetURL, err := url.Parse(p.endpoints.AuthorizationURL)
+ if err != nil {
+ return fmt.Errorf("invalid authorization URL: %v", err)
+ }
+
+ // Build required OAuth query parameters
+ query := url.Values{
+ "client_id": {client.ClientID},
+ "response_type": {"code"},
+ "redirect_uri": {params.RedirectURI},
+ "code_challenge": {params.CodeChallenge},
+ "code_challenge_method": {"S256"},
+ }
+
+ // Add optional parameters when present
+ if params.State != "" {
+ query.Set("state", params.State)
+ }
+ if len(params.Scopes) > 0 {
+ query.Set("scope", strings.Join(params.Scopes, " "))
+ }
+ if params.Resource != nil {
+ query.Set("resource", params.Resource.String())
+ }
+
+ // Attach encoded query to the target URL
+ targetURL.RawQuery = query.Encode()
+
+ // Perform a 302 redirect to the upstream authorization endpoint
+ http.Redirect(res, req, targetURL.String(), http.StatusFound)
+ return nil
+}
+
+// VerifyAccessToken proxies token verification to the configured verifier function.
+func (p *ProxyOAuthServerProvider) VerifyAccessToken(token string) (*server.AuthInfo, error) {
+ // Delegate to injected verifier to allow custom verification strategies
+ return p.verifyAccessToken(token)
+}
+
+// NewProxyOAuthServerProvider creates a new ProxyOAuthServerProvider using the provided options.
+// By default SkipLocalPkceValidation is set to true to defer PKCE verification to the upstream server.
+func NewProxyOAuthServerProvider(options ProxyOptions) *ProxyOAuthServerProvider {
+ // Populate provider with endpoints, dependency functions, and optional fetch
+ provider := &ProxyOAuthServerProvider{
+ endpoints: options.Endpoints,
+ verifyAccessToken: options.VerifyAccessToken,
+ getClient: options.GetClient,
+ fetch: options.Fetch,
+ SkipLocalPkceValidation: true,
+ }
+ // Return the ready to use provider
+ return provider
+}
+
+// doFetch executes an HTTP request using the custom fetch function if provided,
+// otherwise falls back to the default HTTP client.
+func (p *ProxyOAuthServerProvider) doFetch(req *http.Request) (*http.Response, error) {
+ // Prefer custom fetch to allow callers to add auth, retries, or instrumentation
+ if p.fetch != nil {
+ return p.fetch(req.URL.String(), req)
+ }
+ // Fallback to a vanilla http.Client
+ client := &http.Client{}
+ return client.Do(req)
+}
+
+// RevokeToken sends a token revocation request to the configured revocation endpoint.
+// If the revocation endpoint is not configured, an error is returned.
+func (p *ProxyOAuthServerProvider) RevokeToken(client auth.OAuthClientInformationFull, request auth.OAuthTokenRevocationRequest) error {
+ // Ensure the revocation endpoint exists
+ if p.endpoints.RevocationURL == "" {
+ return fmt.Errorf("no revocation endpoint configured")
+ }
+
+ // Build form-encoded body with required parameters
+ params := url.Values{
+ "token": {request.Token},
+ "client_id": {client.ClientID},
+ }
+
+ // Include client_secret when available for confidential clients
+ if client.ClientSecret != "" {
+ params.Set("client_secret", client.ClientSecret)
+ }
+
+ // Optionally include token_type_hint to help the AS
+ if request.TokenTypeHint != "" {
+ params.Set("token_type_hint", request.TokenTypeHint)
+ }
+
+ // Create POST request with application/x-www-form-urlencoded payload
+ req, err := http.NewRequest("POST", p.endpoints.RevocationURL, strings.NewReader(params.Encode()))
+ if err != nil {
+ return fmt.Errorf("create request failed: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ // Execute request via custom fetch or default client
+ resp, err := p.doFetch(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ // Expect 200 OK per RFC 7009
+ if resp.StatusCode != http.StatusOK {
+ return errors.NewOAuthError(errors.ErrServerError, fmt.Sprintf("Token revocation failed: %v", resp.StatusCode), "")
+ }
+
+ // No body parsing required for successful revocation
+ return nil
+}
+
+// ClientsStore returns an OAuthClientsStore wired for lookup and optional dynamic client registration
+// depending on whether a registration endpoint is configured.
+func (p *ProxyOAuthServerProvider) ClientsStore() *server.OAuthClientsStore {
+ var store *server.OAuthClientsStore
+
+ // If registration URL is configured, enable dynamic client registration proxy
+ if p.endpoints.RegistrationURL != "" {
+ // Define registration function that forwards the registration request upstream
+ registerClient := func(client auth.OAuthClientInformationFull) (*auth.OAuthClientInformationFull, error) {
+ // Serialize client metadata to JSON request body
+ body, err := json.Marshal(client)
+ if err != nil {
+ // TODO: add logging for serialization error
+ return nil, fmt.Errorf("failed to marshal client: %v", err)
+ }
+
+ // Create HTTP POST to upstream registration endpoint
+ req, err := http.NewRequest("POST", p.endpoints.RegistrationURL, bytes.NewReader(body))
+ if err != nil {
+ // TODO: add logging for request creation error
+ return nil, fmt.Errorf("failed to create request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ // Execute registration request
+ resp, err := p.doFetch(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ // Expect 200 OK with client registration response
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.NewOAuthError(errors.ErrServerError, fmt.Errorf("client registration failed: %v", resp.StatusCode).Error(), "")
+ }
+
+ // Decode response JSON into full client record
+ var data auth.OAuthClientInformationFull
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ // TODO: add logging for decode error
+ return nil, fmt.Errorf("failed to decode response: %v", err)
+ }
+
+ // Return registered client info to caller
+ return &data, nil
+ }
+
+ // Build a store that supports both lookup and registration
+ store = server.NewOAuthClientStoreSupportDynamicRegistration(p.getClient, registerClient)
+ } else {
+ // Build a lookup-only store when registration is not supported
+ store = server.NewOAuthClientStore(p.getClient)
+ }
+
+ return store
+}
+
+// ChallengeForAuthorizationCode returns the PKCE code_challenge for a previously initiated authorization.
+// In a proxy setup this is not stored locally and we defer validation to the upstream server.
+func (p *ProxyOAuthServerProvider) ChallengeForAuthorizationCode(client auth.OAuthClientInformationFull, authorizationCode string) (string, error) {
+ // No local storage of code_challenge in proxy mode
+ // Upstream AS validates code_verifier against its stored challenge
+ return "", nil
+}
+
+// ExchangeAuthorizationCode exchanges an authorization code for tokens by forwarding
+// the request to the upstream token endpoint and returning the parsed response.
+func (p *ProxyOAuthServerProvider) ExchangeAuthorizationCode(client auth.OAuthClientInformationFull, authorizationCode string, codeVerifier *string, redirectUri *string, resource *url.URL) (*auth.OAuthTokens, error) {
+ // Ensure a token endpoint is configured
+ if p.endpoints.TokenURL == "" {
+ return nil, fmt.Errorf("no token endpoint configured")
+ }
+
+ // Build form parameters required by the authorization_code grant
+ params := url.Values{
+ "grant_type": {"authorization_code"},
+ "client_id": {client.ClientID},
+ "code": {authorizationCode},
+ }
+
+ // Include client_secret for confidential clients
+ if client.ClientSecret != "" {
+ params.Set("client_secret", client.ClientSecret)
+ }
+
+ // Forward PKCE code_verifier when provided
+ if codeVerifier != nil {
+ params.Set("code_verifier", *codeVerifier)
+ }
+
+ // Include redirect_uri when provided to satisfy AS validation
+ if redirectUri != nil {
+ params.Set("redirect_uri", *redirectUri)
+ }
+
+ // Forward resource indicator when present
+ if resource != nil {
+ params.Set("resource", resource.String())
+ }
+
+ // Create POST request to token endpoint with form-encoded body
+ req, err := http.NewRequest("POST", p.endpoints.TokenURL, bytes.NewReader([]byte(params.Encode())))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ // Send request via fetch helper
+ resp, err := p.doFetch(req)
+ if err != nil {
+ return nil, errors.NewOAuthError(errors.ErrServerError, fmt.Sprintf("token exchange failed: %v", err), "")
+ }
+ defer resp.Body.Close()
+
+ // Expect 200 OK for successful token response
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.NewOAuthError(errors.ErrServerError, fmt.Sprintf("token exchange failed: %v", resp.StatusCode), "")
+ }
+
+ // Decode token response JSON into OAuthTokens
+ var data auth.OAuthTokens
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %v", err)
+ }
+
+ // Return parsed tokens to caller
+ return &data, nil
+}
+
+// ExchangeRefreshToken exchanges a refresh token for a new access token by calling
+// the upstream token endpoint and validating the response payload.
+func (p *ProxyOAuthServerProvider) ExchangeRefreshToken(
+ client auth.OAuthClientInformationFull,
+ refreshToken string,
+ scopes []string, // Optional empty slice if not provided
+ resource *url.URL, // Optional nil if not provided
+) (*auth.OAuthTokens, error) {
+ // Assemble form parameters for the refresh_token grant
+ params := url.Values{
+ "grant_type": {"refresh_token"},
+ "client_id": {client.ClientID},
+ "refresh_token": {refreshToken},
+ }
+
+ // Include client_secret for confidential clients
+ if client.ClientSecret != "" {
+ params.Set("client_secret", client.ClientSecret)
+ }
+
+ // Optionally narrow or expand scopes as requested
+ if len(scopes) > 0 {
+ params.Set("scope", strings.Join(scopes, " "))
+ }
+
+ // Forward resource indicator when present
+ if resource != nil {
+ params.Set("resource", resource.String())
+ }
+
+ // Create a context bound POST request to the token endpoint
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, p.endpoints.TokenURL, bytes.NewBufferString(params.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ // Choose custom fetch when provided, otherwise use default client
+ fetch := p.fetch
+ if fetch == nil {
+ fetch = func(url string, req *http.Request) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+ }
+ }
+
+ // Execute the HTTP request
+ resp, err := fetch(p.endpoints.TokenURL, req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Expect a successful status code from the token endpoint
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.NewOAuthError(errors.ErrServerError, fmt.Sprintf("token refresh failed: %v", resp.StatusCode), "")
+ }
+
+ // Decode the token response body
+ var data auth.OAuthTokens
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %v", err)
+ }
+
+ // Validate the decoded structure using validator/v10
+ if err := validateOAuthTokens(&data); err != nil {
+ return nil, fmt.Errorf("validation failed: %v", err)
+ }
+
+ // Return validated tokens
+ return &data, nil
+}
+
+// validateOAuthTokens validates the OAuthTokens struct using github.com/go-playground/validator.
+// This can be extended with custom field validators if needed.
+func validateOAuthTokens(tokens *auth.OAuthTokens) error {
+ // Initialize a new validator instance and run struct validation
+ validate := validator.New()
+ if err := validate.Struct(tokens); err != nil {
+ return fmt.Errorf("validation errors: %v", err)
+ }
+ return nil
+}
+
+// GetSkipLocalPkceValidation exposes whether local PKCE verification should be skipped.
+// Token handlers can use this to decide if code_verifier must be validated locally or forwarded.
+func (p *ProxyOAuthServerProvider) GetSkipLocalPkceValidation() bool {
+ // Return the current setting as provided during construction
+ return p.SkipLocalPkceValidation
+}
diff --git a/internal/auth/server/providers/proxy_test.go b/internal/auth/server/providers/proxy_test.go
new file mode 100644
index 0000000..2d0781e
--- /dev/null
+++ b/internal/auth/server/providers/proxy_test.go
@@ -0,0 +1,572 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package providers
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ oauthErrors "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+// shared test fixtures for proxy provider tests
+var (
+ // validClient models a typical confidential client usable across tests
+ validClient = auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ },
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://example.com/callback"},
+ },
+ }
+
+ // baseOptions is the baseline ProxyOptions used by tests, with hooks injected in TestMain
+ baseOptions = ProxyOptions{
+ Endpoints: ProxyEndpoints{
+ AuthorizationURL: "https://auth.example.com/authorize",
+ TokenURL: "https://auth.example.com/token",
+ RevocationURL: "https://auth.example.com/revoke",
+ RegistrationURL: "https://auth.example.com/register",
+ },
+ VerifyAccessToken: nil,
+ GetClient: nil,
+ Fetch: nil,
+ }
+
+ // mock token payload values reused across assertions
+ RefreshToken = "new-refresh-token"
+ ExpiresIn = int64(3600)
+ mockTokenResponse = auth.OAuthTokens{
+ AccessToken: "new-access-token",
+ TokenType: "Bearer",
+ ExpiresIn: &ExpiresIn,
+ RefreshToken: &RefreshToken,
+ }
+
+ // mockFetch is an overridable HTTP transport used to intercept outbound requests in tests
+ mockFetch func(url string, req *http.Request) (*http.Response, error)
+)
+
+// TestMain wires per-suite hooks for VerifyAccessToken, GetClient and fetch before running tests
+func TestMain(m *testing.M) {
+ // set up VerifyAccessToken behavior
+ baseOptions.VerifyAccessToken = func(token string) (*server.AuthInfo, error) {
+ if token == "valid-token" {
+ ExpiresAt := time.Now().Unix() + 3600
+ return &server.AuthInfo{
+ Token: token,
+ ClientID: "test-client",
+ Scopes: []string{"read", "write"},
+ ExpiresAt: &ExpiresAt,
+ }, nil
+ }
+ if token == "token-with-insufficient-scope" {
+ return nil, oauthErrors.NewOAuthError(oauthErrors.ErrInsufficientScope, "Required scopes: read, write", "")
+ }
+ if token == "valid-token-unexpected" {
+ return nil, errors.New("unexpected error")
+ }
+ return nil, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "Invalid token", "")
+ }
+
+ // set up client lookup behavior
+ baseOptions.GetClient = func(clientID string) (*auth.OAuthClientInformationFull, error) {
+ if clientID == "test-client" {
+ return &validClient, nil
+ }
+ return nil, nil
+ }
+
+ // run tests
+ code := m.Run()
+
+ // cleanup
+ mockFetch = nil
+ os.Exit(code)
+}
+
+func TestProxyOAuthServerProvider(t *testing.T) {
+ provider := NewProxyOAuthServerProvider(baseOptions)
+
+ // Mock codeVerifier and redirectURI
+ codeVerifier := "test-verifier"
+ redirectURI := "https://example.com/callback"
+
+ t.Run("Authorization", func(t *testing.T) {
+ t.Run("Redirects to authorization endpoint with correct parameters", func(t *testing.T) {
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/", nil)
+ resource, _ := url.Parse("https://api.example.com/resource")
+ err := provider.Authorize(validClient, server.AuthorizationParams{
+ RedirectURI: "https://example.com/callback",
+ CodeChallenge: "test-challenge",
+ State: "test-state",
+ Scopes: []string{"read", "write"},
+ Resource: resource,
+ }, rr, req)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Verify the status code and Location header
+ if rr.Code != http.StatusFound {
+ t.Errorf("expected status code %d, got %d", http.StatusFound, rr.Code)
+ }
+
+ gotURL := rr.Header().Get("Location")
+ // Debug output
+ t.Logf("got redirect URL: %s", gotURL)
+ expectedURL, _ := url.Parse("https://auth.example.com/authorize")
+ q := expectedURL.Query()
+ q.Set("client_id", "test-client")
+ q.Set("response_type", "code")
+ q.Set("redirect_uri", "https://example.com/callback")
+ q.Set("code_challenge", "test-challenge")
+ q.Set("code_challenge_method", "S256")
+ q.Set("state", "test-state")
+ q.Set("scope", "read write")
+ q.Set("resource", "https://api.example.com/resource")
+ expectedURL.RawQuery = q.Encode()
+
+ if gotURL != expectedURL.String() {
+ t.Errorf("expected redirect URL %s, got %s", expectedURL.String(), gotURL)
+ }
+ })
+ })
+
+ t.Run("Token Exchange", func(t *testing.T) {
+ t.Run("Exchanges authorization code for tokens", func(t *testing.T) {
+ mockFetch = func(url string, req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ t.Logf("request body: %s", string(body)) // 调试请求体
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"access_token":"new-access-token","token_type":"Bearer","expires_in":3600,"refresh_token":"new-refresh-token"}`)),
+ }, nil
+ }
+ provider.fetch = mockFetch
+
+ tokens, err := provider.ExchangeAuthorizationCode(validClient, "test-code", &codeVerifier, nil, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // Debug output
+ t.Logf("tokens: %+v", tokens)
+ if tokens.AccessToken != mockTokenResponse.AccessToken {
+ t.Errorf("expected access_token %s, got %s", mockTokenResponse.AccessToken, tokens.AccessToken)
+ }
+ if tokens.TokenType != mockTokenResponse.TokenType {
+ t.Errorf("expected token_type %s, got %s", mockTokenResponse.TokenType, tokens.TokenType)
+ }
+ if tokens.ExpiresIn == nil || *tokens.ExpiresIn != *mockTokenResponse.ExpiresIn {
+ t.Errorf("expected expires_in %d, got %v", *mockTokenResponse.ExpiresIn, tokens.ExpiresIn)
+ }
+ if tokens.RefreshToken == nil || *tokens.RefreshToken != *mockTokenResponse.RefreshToken {
+ t.Errorf("expected refresh_token %s, got %v", *mockTokenResponse.RefreshToken, tokens.RefreshToken)
+ }
+ })
+
+ t.Run("Includes redirect_uri in token request when provided", func(t *testing.T) {
+ var calledBody string
+ mockFetch = func(url string, req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ calledBody = string(body)
+ // Debug output
+ t.Logf("request body: %s", calledBody)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"access_token":"new-access-token","token_type":"Bearer","expires_in":3600,"refresh_token":"new-refresh-token"}`)),
+ }, nil
+ }
+ provider.fetch = mockFetch
+
+ _, err := provider.ExchangeAuthorizationCode(validClient, "test-code", &codeVerifier, &redirectURI, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(calledBody, "redirect_uri=https%3A%2F%2Fexample.com%2Fcallback") {
+ t.Errorf("expected redirect_uri in body, got %s", calledBody)
+ }
+ })
+
+ t.Run("Handles token exchange failure", func(t *testing.T) {
+ mockFetch = func(url string, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusBadRequest,
+ Body: io.NopCloser(strings.NewReader("")),
+ }, nil
+ }
+ provider.fetch = mockFetch
+
+ _, err := provider.ExchangeAuthorizationCode(validClient, "test-code", &codeVerifier, nil, nil)
+ // Debug output
+ t.Logf("error: %v", err)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ var oauthErr oauthErrors.OAuthError
+ if !errors.As(err, &oauthErr) {
+ t.Errorf("expected error to be of type oauthErrors.OAuthError, got %T", err)
+ }
+ if oauthErr.ErrorCode != oauthErrors.ErrServerError.Error() {
+ t.Errorf("expected OAuthError with code %s, got %s", oauthErrors.ErrServerError.Error(), oauthErr.ErrorCode)
+ }
+ })
+
+ t.Run("Includes resource parameter in authorization code exchange", func(t *testing.T) {
+ var calledBody string
+ mockFetch = func(url string, req *http.Request) (*http.Response, error) {
+ if url != "https://auth.example.com/token" {
+ t.Errorf("expected URL %s, got %s", "https://auth.example.com/token", url)
+ }
+ if req.Method != http.MethodPost {
+ t.Errorf("expected method POST, got %s", req.Method)
+ }
+ if contentType := req.Header.Get("Content-Type"); contentType != "application/x-www-form-urlencoded" {
+ t.Errorf("expected Content-Type application/x-www-form-urlencoded, got %s", contentType)
+ }
+ body, _ := io.ReadAll(req.Body)
+ calledBody = string(body)
+ t.Logf("request body: %s", calledBody)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"access_token":"new-access-token","token_type":"Bearer","expires_in":3600,"refresh_token":"new-refresh-token"}`)),
+ }, nil
+ }
+ provider.fetch = mockFetch
+
+ resource, _ := url.Parse("https://api.example.com/resource")
+ codeVerifier := "test-verifier"
+ redirectURI := "https://example.com/callback"
+ tokens, err := provider.ExchangeAuthorizationCode(validClient, "test-code", &codeVerifier, &redirectURI, resource)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(calledBody, "resource=https%3A%2F%2Fapi.example.com%2Fresource") {
+ t.Errorf("expected resource parameter in body, got %s", calledBody)
+ }
+ if tokens.AccessToken != mockTokenResponse.AccessToken {
+ t.Errorf("expected access_token %s, got %s", mockTokenResponse.AccessToken, tokens.AccessToken)
+ }
+ if tokens.TokenType != mockTokenResponse.TokenType {
+ t.Errorf("expected token_type %s, got %s", mockTokenResponse.TokenType, tokens.TokenType)
+ }
+ if tokens.ExpiresIn == nil || *tokens.ExpiresIn != *mockTokenResponse.ExpiresIn {
+ t.Errorf("expected expires_in %d, got %v", *mockTokenResponse.ExpiresIn, tokens.ExpiresIn)
+ }
+ if tokens.RefreshToken == nil || *tokens.RefreshToken != *mockTokenResponse.RefreshToken {
+ t.Errorf("expected refresh_token %s, got %v", *mockTokenResponse.RefreshToken, tokens.RefreshToken)
+ }
+ })
+
+ t.Run("Handles authorization code exchange without resource parameter", func(t *testing.T) {
+ var calledBody string
+ mockFetch = func(url string, req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ calledBody = string(body)
+ t.Logf("request body: %s", calledBody) // 调试输出
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"access_token":"new-access-token","token_type":"Bearer","expires_in":3600,"refresh_token":"new-refresh-token"}`)),
+ }, nil
+ }
+ provider.fetch = mockFetch
+
+ tokens, err := provider.ExchangeAuthorizationCode(validClient, "test-code", &codeVerifier, nil, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if strings.Contains(calledBody, "resource=") {
+ t.Errorf("expected no resource parameter in body, got %s", calledBody)
+ }
+ if tokens.AccessToken != mockTokenResponse.AccessToken {
+ t.Errorf("expected access_token %s, got %s", mockTokenResponse.AccessToken, tokens.AccessToken)
+ }
+ if tokens.TokenType != mockTokenResponse.TokenType {
+ t.Errorf("expected token_type %s, got %s", mockTokenResponse.TokenType, tokens.TokenType)
+ }
+ if tokens.ExpiresIn == nil || *tokens.ExpiresIn != *mockTokenResponse.ExpiresIn {
+ t.Errorf("expected expires_in %d, got %v", *mockTokenResponse.ExpiresIn, tokens.ExpiresIn)
+ }
+ if tokens.RefreshToken == nil || *tokens.RefreshToken != *mockTokenResponse.RefreshToken {
+ t.Errorf("expected refresh_token %s, got %v", *mockTokenResponse.RefreshToken, tokens.RefreshToken)
+ }
+ })
+
+ t.Run("Includes resource parameter in refresh token exchange", func(t *testing.T) {
+ var calledBody string
+ mockFetch = func(url string, req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ calledBody = string(body)
+ // Debug output
+ t.Logf("request body: %s", calledBody)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"access_token":"new-access-token","token_type":"Bearer","expires_in":3600,"refresh_token":"new-refresh-token"}`)),
+ }, nil
+ }
+ provider.fetch = mockFetch
+
+ resource, _ := url.Parse("https://api.example.com/resource")
+ tokens, err := provider.ExchangeRefreshToken(validClient, "test-refresh-token", []string{"read", "write"}, resource)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(calledBody, "resource=https%3A%2F%2Fapi.example.com%2Fresource") {
+ t.Errorf("expected resource in body, got %s", calledBody)
+ }
+ if tokens.AccessToken != mockTokenResponse.AccessToken {
+ t.Errorf("expected access_token %s, got %s", mockTokenResponse.AccessToken, tokens.AccessToken)
+ }
+ if tokens.TokenType != mockTokenResponse.TokenType {
+ t.Errorf("expected token_type %s, got %s", mockTokenResponse.TokenType, tokens.TokenType)
+ }
+ if tokens.ExpiresIn == nil || *tokens.ExpiresIn != *mockTokenResponse.ExpiresIn {
+ t.Errorf("expected expires_in %d, got %v", *mockTokenResponse.ExpiresIn, tokens.ExpiresIn)
+ }
+ if tokens.RefreshToken == nil || *tokens.RefreshToken != *mockTokenResponse.RefreshToken {
+ t.Errorf("expected refresh_token %s, got %v", *mockTokenResponse.RefreshToken, tokens.RefreshToken)
+ }
+ })
+ })
+
+ t.Run("Client Registration", func(t *testing.T) {
+ t.Run("Registers new client", func(t *testing.T) {
+ mockFetch = func(url string, req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ // Debug output
+ t.Logf("register request body: %s", string(body))
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"client_id":"new-client","client_secret":"new-secret","redirect_uris":["https://new-client.com/callback"]}`)),
+ }, nil
+ }
+ provider.fetch = mockFetch
+
+ newClient := auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: "new-client",
+ },
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://new-client.com/callback"},
+ },
+ }
+ result, err := provider.ClientsStore().RegisterClient(newClient)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.ClientID != newClient.ClientID {
+ t.Errorf("expected client_id %s, got %s", newClient.ClientID, result.ClientID)
+ }
+ })
+
+ t.Run("Handles registration failure", func(t *testing.T) {
+ mockFetch = func(url string, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusBadRequest,
+ Body: io.NopCloser(strings.NewReader("")),
+ }, nil
+ }
+ provider.fetch = mockFetch
+
+ newClient := auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: "new-client",
+ },
+ OAuthClientMetadata: auth.OAuthClientMetadata{
+ RedirectURIs: []string{"https://new-client.com/callback"},
+ },
+ }
+ _, err := provider.ClientsStore().RegisterClient(newClient)
+ t.Logf("error: %v", err) // Debug output
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ var serverErr oauthErrors.OAuthError
+ if !errors.As(err, &serverErr) {
+ t.Errorf("expected error to be of type oauthErrors.OAuthError, got %T", err)
+ }
+ if serverErr.ErrorCode != oauthErrors.ErrServerError.Error() {
+ t.Errorf("expected OAuthError with code %s, got %s", oauthErrors.ErrServerError.Error(), serverErr.ErrorCode)
+ }
+ })
+ })
+
+ t.Run("Token Revocation", func(t *testing.T) {
+ t.Run("Revokes token", func(t *testing.T) {
+ mockFetch = func(url string, req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ t.Logf("revoke request body: %s", string(body)) // 调试输出
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader("")),
+ }, nil
+ }
+ provider.fetch = mockFetch
+
+ err := provider.RevokeToken(validClient, auth.OAuthTokenRevocationRequest{
+ Token: "token-to-revoke",
+ TokenTypeHint: "access_token",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("Handles revocation failure", func(t *testing.T) {
+ mockFetch = func(url string, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusBadRequest,
+ Body: io.NopCloser(strings.NewReader("")),
+ }, nil
+ }
+ provider.fetch = mockFetch
+
+ err := provider.RevokeToken(validClient, auth.OAuthTokenRevocationRequest{
+ Token: "invalid-token",
+ })
+ t.Logf("error: %v", err) // 调试输出
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ var serverErr oauthErrors.OAuthError
+ if !errors.As(err, &serverErr) {
+ t.Errorf("expected error to be of type oauthErrors.OAuthError, got %T", err)
+ }
+ if serverErr.ErrorCode != oauthErrors.ErrServerError.Error() {
+ t.Errorf("expected OAuthError with code %s, got %s", oauthErrors.ErrServerError.Error(), serverErr.ErrorCode)
+ }
+ })
+ })
+
+ t.Run("Token Verification", func(t *testing.T) {
+ t.Run("Verifies valid token", func(t *testing.T) {
+ var calledToken string
+ options := baseOptions
+ options.VerifyAccessToken = func(token string) (*server.AuthInfo, error) {
+ calledToken = token
+ return baseOptions.VerifyAccessToken(token)
+ }
+ provider := NewProxyOAuthServerProvider(options)
+
+ authInfo, err := provider.VerifyAccessToken("valid-token")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if calledToken != "valid-token" {
+ t.Errorf("expected VerifyAccessToken called with 'valid-token', got %q", calledToken)
+ }
+ t.Logf("authInfo: %+v", authInfo) // 调试输出
+ if authInfo.ClientID != "test-client" {
+ t.Errorf("expected clientId test-client, got %s", authInfo.ClientID)
+ }
+ })
+
+ t.Run("Passes through InvalidTokenError", func(t *testing.T) {
+ var calledToken string
+ options := baseOptions
+ options.VerifyAccessToken = func(token string) (*server.AuthInfo, error) {
+ calledToken = token // 记录调用参数
+ if token == "valid-token" {
+ ExpiresAt := time.Now().Unix() + 3600
+ return &server.AuthInfo{
+ Token: token,
+ ClientID: "test-client",
+ Scopes: []string{"read", "write"},
+ ExpiresAt: &ExpiresAt,
+ }, nil
+ }
+ return nil, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "Invalid token", "")
+ }
+ provider := NewProxyOAuthServerProvider(options)
+
+ _, err := provider.VerifyAccessToken("invalid-token")
+ t.Logf("error: %v", err)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if calledToken != "invalid-token" {
+ t.Errorf("expected VerifyAccessToken called with 'invalid-token', got %q", calledToken)
+ }
+ var invalidTokenErr oauthErrors.OAuthError
+ if !errors.As(err, &invalidTokenErr) {
+ t.Errorf("expected error to be of type oauthErrors.OAuthError, got %T", err)
+ }
+ if invalidTokenErr.ErrorCode != oauthErrors.ErrInvalidToken.Error() {
+ t.Errorf("expected OAuthError with code %s, got %s", oauthErrors.ErrInvalidToken.Error(), invalidTokenErr.ErrorCode)
+ }
+ })
+
+ t.Run("Passes through InsufficientScopeError", func(t *testing.T) {
+ var calledToken string
+ options := baseOptions // 复制全局配置
+ options.VerifyAccessToken = func(token string) (*server.AuthInfo, error) {
+ calledToken = token
+ return nil, oauthErrors.NewOAuthError(oauthErrors.ErrInsufficientScope, "Required scopes: read, write", "")
+ }
+ provider := NewProxyOAuthServerProvider(options)
+
+ _, err := provider.VerifyAccessToken("token-with-insufficient-scope")
+ t.Logf("error: %v", err)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ var insufficientScopeErr oauthErrors.OAuthError
+ if !errors.As(err, &insufficientScopeErr) {
+ t.Errorf("expected error to be of type oauthErrors.OAuthError, got %T", err)
+ }
+ if insufficientScopeErr.ErrorCode != "insufficient_scope" {
+ t.Errorf("expected OAuthError with code %s, got %s", "insufficient_scope", insufficientScopeErr.ErrorCode)
+ }
+ if insufficientScopeErr.Message != "Required scopes: read, write" {
+ t.Errorf("expected OAuthError with description %s, got %s", "Required scopes: read, write", insufficientScopeErr.Message)
+ }
+ if calledToken != "token-with-insufficient-scope" {
+ t.Errorf("expected VerifyAccessToken called with %s, got %s", "token-with-insufficient-scope", calledToken)
+ }
+ })
+
+ t.Run("Passes through unexpected errors", func(t *testing.T) {
+ var calledToken string
+ // Copy global configuration
+ options := baseOptions
+ options.VerifyAccessToken = func(token string) (*server.AuthInfo, error) {
+ calledToken = token
+ return baseOptions.VerifyAccessToken(token)
+ }
+ provider := NewProxyOAuthServerProvider(options)
+
+ // Invoke VerifyAccessToken
+ _, err := provider.VerifyAccessToken("valid-token-unexpected")
+ t.Logf("error: %v", err) // 调试输出
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ var oauthErr oauthErrors.OAuthError
+ if errors.As(err, &oauthErr) {
+ t.Errorf("expected error to be non-OAuth error, got oauthErrors.OAuthError")
+ }
+ if err.Error() != "unexpected error" {
+ t.Errorf("expected error message %s, got %s", "unexpected error", err.Error())
+ }
+ if calledToken != "valid-token-unexpected" {
+ t.Errorf("expected VerifyAccessToken called with %s, got %s", "valid-token-unexpected", calledToken)
+ }
+ })
+ })
+}
diff --git a/internal/auth/server/router/router.go b/internal/auth/server/router/router.go
new file mode 100644
index 0000000..6e0ac00
--- /dev/null
+++ b/internal/auth/server/router/router.go
@@ -0,0 +1,391 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package router
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/handler"
+)
+
+// AuthRouterOptions holds configuration options for the MCP authentication router.
+// It configures how OAuth 2.1 endpoints (/authorize, /token, /revoke, /register) are exposed.
+type AuthRouterOptions struct {
+ // Provider is the OAuth server implementation.
+ // It manages client registration, authorization codes, tokens, and verification.
+ Provider server.OAuthServerProvider
+
+ // IssuerUrl is the OAuth issuer identifier (RFC 8414).
+ // Typically something like "https://auth.example.com".
+ IssuerUrl *url.URL
+
+ // BaseUrl is the base URL of this service, used to construct endpoint URLs
+ // such as /authorize, /token, etc.
+ BaseUrl *url.URL
+
+ // ServiceDocumentationUrl points to human-readable documentation about the service,
+ // usually an API docs page.
+ ServiceDocumentationUrl *url.URL
+
+ // ScopesSupported lists all scopes supported by this authorization server,
+ // for example: ["read", "write"].
+ ScopesSupported []string
+
+ // ResourceName is an optional logical name for the protected resource/API.
+ ResourceName *string
+
+ // AuthorizationOptions configures the /authorize endpoint (validation, rate limiting, etc.).
+ AuthorizationOptions *handler.AuthorizationHandlerOptions
+
+ // ClientRegistrationOptions configures the /register endpoint for dynamic client registration (RFC 7591).
+ ClientRegistrationOptions *handler.ClientRegistrationHandlerOptions
+
+ // RevocationOptions configures the /revoke endpoint for token revocation (RFC 7009).
+ RevocationOptions *handler.RevocationHandlerOptions
+
+ // TokenOptions configures the /token endpoint for issuing tokens (supports auth code flow, PKCE, etc.).
+ TokenOptions *handler.TokenHandlerOptions
+}
+
+// AuthMetadataOptions holds configuration options for the MCP authentication metadata endpoints.
+// It controls what is published via OAuth 2.1 Authorization Server Metadata (RFC 8414).
+type AuthMetadataOptions struct {
+ // OAuthMetadata contains the full OAuth 2.1 Authorization Server Metadata,
+ // including authorization_endpoint, token_endpoint, scopes_supported, etc.
+ OAuthMetadata auth.OAuthMetadata
+
+ // ResourceServerUrl points to the protected resource server,
+ // used by clients to discover where to send API requests.
+ ResourceServerUrl *url.URL
+
+ // ServiceDocumentationUrl points to human-readable documentation about the service.
+ ServiceDocumentationUrl *url.URL
+
+ // ScopesSupported lists the scopes supported by this resource server.
+ ScopesSupported []string
+
+ // ResourceName is an optional logical name for the resource server.
+ ResourceName *string
+}
+
+// checkIssuerUrl validates the issuer URL according to RFC 8414.
+func checkIssuerUrl(issuer *url.URL) error {
+ // Technically RFC 8414 does not permit a localhost HTTPS exemption,
+ // but this will be necessary for ease of testing
+ if issuer.Scheme != "https" && issuer.Hostname() != "localhost" && issuer.Hostname() != "127.0.0.1" {
+ return fmt.Errorf("issuer URL must be HTTPS")
+ }
+ if issuer.Fragment != "" {
+ return fmt.Errorf("issuer URL must not have a fragment: %s", issuer.String())
+ }
+ if issuer.RawQuery != "" {
+ return fmt.Errorf("issuer URL must not have a query string: %s", issuer.String())
+ }
+ return nil
+}
+
+// supportsClientRegistration checks if the provider supports dynamic client registration
+func supportsClientRegistration(provider server.OAuthServerProvider) bool {
+ if provider == nil {
+ return false
+ }
+
+ clientsStore := provider.ClientsStore()
+ if clientsStore == nil {
+ return false
+ }
+ // Check if the clients store supports registration
+ return clientsStore.SupportsRegistration()
+}
+
+// supportsTokenRevocation checks if the provider supports token revocation
+func supportsTokenRevocation(provider server.OAuthServerProvider) bool {
+ if provider == nil {
+ return false
+ }
+ // Use type assertion to check if the provider implements SupportTokenRevocation interface
+ _, ok := provider.(server.SupportTokenRevocation)
+ return ok
+}
+
+// CreateOAuthMetadata generates OAuth 2.1 compliant Authorization Server Metadata.
+func CreateOAuthMetadata(options struct {
+ Provider server.OAuthServerProvider
+ IssuerUrl *url.URL
+ BaseUrl *url.URL
+ ServiceDocumentationUrl *url.URL
+ ScopesSupported []string
+}) (auth.OAuthMetadata, error) {
+ if options.Provider == nil {
+ return auth.OAuthMetadata{}, fmt.Errorf("provider is required")
+ }
+
+ issuer := options.IssuerUrl
+ baseUrl := options.BaseUrl
+
+ // Validate issuer URL
+ if err := checkIssuerUrl(issuer); err != nil {
+ return auth.OAuthMetadata{}, err
+ }
+
+ // Determine base URL for endpoints
+ var baseUrlForEndpoints *url.URL
+ if baseUrl != nil {
+ baseUrlForEndpoints = baseUrl
+ } else {
+ baseUrlForEndpoints = issuer
+ }
+
+ // Required endpoints
+ authorizationEndpoint := "/authorize"
+ tokenEndpoint := "/token"
+
+ authEndpointUrl, _ := url.Parse(authorizationEndpoint)
+ tokenEndpointUrl, _ := url.Parse(tokenEndpoint)
+
+ metadata := auth.OAuthMetadata{
+ // Core fields
+ Issuer: issuer.String(),
+ AuthorizationEndpoint: baseUrlForEndpoints.ResolveReference(authEndpointUrl).String(),
+ TokenEndpoint: baseUrlForEndpoints.ResolveReference(tokenEndpointUrl).String(),
+
+ // OAuth 2.1 requires PKCE support
+ ResponseTypesSupported: []string{"code"}, // OAuth 2.1 removes implicit flow
+ CodeChallengeMethodsSupported: []string{"S256"}, // OAuth 2.1 requires S256, plain is deprecated
+
+ // Token endpoint authentication methods
+ TokenEndpointAuthMethodsSupported: []string{"client_secret_post", "client_secret_basic"},
+
+ // OAuth 2.1 supported grant types
+ GrantTypesSupported: []string{"authorization_code", "refresh_token"},
+
+ // Optional fields
+ ScopesSupported: options.ScopesSupported,
+ }
+
+ // Add service documentation if provided
+ if options.ServiceDocumentationUrl != nil {
+ serviceDoc := options.ServiceDocumentationUrl.String()
+ metadata.ServiceDocumentation = &serviceDoc
+ }
+
+ // Check for optional endpoints based on provider capabilities
+ if supportsTokenRevocation(options.Provider) {
+ revocationEndpoint := "/revoke"
+ revEndpointUrl, _ := url.Parse(revocationEndpoint)
+ revEndpoint := baseUrlForEndpoints.ResolveReference(revEndpointUrl).String()
+ metadata.RevocationEndpoint = &revEndpoint
+ metadata.RevocationEndpointAuthMethodsSupported = []string{"client_secret_post", "client_secret_basic"}
+ }
+
+ if supportsClientRegistration(options.Provider) {
+ registrationEndpoint := "/register"
+ regEndpointUrl, _ := url.Parse(registrationEndpoint)
+ regEndpoint := baseUrlForEndpoints.ResolveReference(regEndpointUrl).String()
+ metadata.RegistrationEndpoint = ®Endpoint
+ }
+
+ return metadata, nil
+}
+
+// McpAuthRouter sets up OAuth 2.1 compliant MCP authorization server endpoints
+func McpAuthRouter(mux *http.ServeMux, options AuthRouterOptions) error {
+ // Create OAuth metadata with error handling
+ oauthMetadata, err := CreateOAuthMetadata(struct {
+ Provider server.OAuthServerProvider
+ IssuerUrl *url.URL
+ BaseUrl *url.URL
+ ServiceDocumentationUrl *url.URL
+ ScopesSupported []string
+ }{
+ Provider: options.Provider,
+ IssuerUrl: options.IssuerUrl,
+ BaseUrl: options.BaseUrl,
+ ServiceDocumentationUrl: options.ServiceDocumentationUrl,
+ ScopesSupported: options.ScopesSupported,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create OAuth metadata: %w", err)
+ }
+
+ // Authorization endpoint (GET only for OAuth 2.1)
+ authorizationURL, _ := url.Parse(oauthMetadata.AuthorizationEndpoint)
+ authzOptions := handler.AuthorizationHandlerOptions{
+ Provider: options.Provider,
+ }
+ if options.AuthorizationOptions != nil && options.AuthorizationOptions.RateLimit != nil {
+ authzOptions.RateLimit = options.AuthorizationOptions.RateLimit
+ }
+ mux.Handle(authorizationURL.Path, methodRestrictedHandler("GET", handler.AuthorizationHandler(authzOptions)))
+
+ // Token endpoint (POST only for OAuth 2.1)
+ tokenURL, _ := url.Parse(oauthMetadata.TokenEndpoint)
+ tokenOptions := handler.TokenHandlerOptions{Provider: options.Provider}
+ if options.TokenOptions != nil {
+ if options.TokenOptions.RateLimit != nil {
+ tokenOptions.RateLimit = options.TokenOptions.RateLimit
+ }
+ }
+ mux.Handle(tokenURL.Path, methodRestrictedHandler("POST", handler.TokenHandler(tokenOptions)))
+
+ // Metadata endpoints
+ issuerURL, _ := url.Parse(oauthMetadata.Issuer)
+ resourceURL := options.BaseUrl
+ if resourceURL == nil {
+ resourceURL = issuerURL
+ }
+ if err := McpAuthMetadataRouter(mux, AuthMetadataOptions{
+ OAuthMetadata: oauthMetadata,
+ ResourceServerUrl: resourceURL,
+ ServiceDocumentationUrl: options.ServiceDocumentationUrl,
+ ScopesSupported: options.ScopesSupported,
+ ResourceName: options.ResourceName,
+ }); err != nil {
+ return fmt.Errorf("failed to setup metadata router: %w", err)
+ }
+
+ // Dynamic client registration (optional, POST only)
+ if oauthMetadata.RegistrationEndpoint != nil {
+ // Ensure ClientsStore() is not nil before mounting /register
+ if clientsStore := options.Provider.ClientsStore(); clientsStore != nil {
+ registrationURL, _ := url.Parse(*oauthMetadata.RegistrationEndpoint)
+ regOpts := handler.ClientRegistrationHandlerOptions{
+ ClientsStore: clientsStore,
+ }
+ if options.ClientRegistrationOptions != nil {
+ regOpts = *options.ClientRegistrationOptions
+ regOpts.ClientsStore = clientsStore
+ } else {
+ // OAuth 2.1 recommended rate limiting for client registration
+ regOpts.RateLimit = &handler.RegisterRateLimitConfig{
+ WindowMs: 60000,
+ Max: 10,
+ }
+ }
+ mux.Handle(registrationURL.Path, methodRestrictedHandler("POST", handler.ClientRegistrationHandler(regOpts)))
+ }
+ }
+
+ // Token revocation endpoint (optional, POST only)
+ if oauthMetadata.RevocationEndpoint != nil {
+ revocationURL, _ := url.Parse(*oauthMetadata.RevocationEndpoint)
+
+ revOpts := handler.RevocationHandlerOptions{
+ Provider: options.Provider,
+ }
+ if options.RevocationOptions != nil && options.RevocationOptions.RateLimit != nil {
+ revOpts.RateLimit = options.RevocationOptions.RateLimit
+ }
+
+ mux.Handle(revocationURL.Path, methodRestrictedHandler("POST", handler.RevocationHandler(revOpts)))
+ }
+
+ return nil
+}
+
+// McpAuthMetadataRouter sets up OAuth 2.1 compliant metadata endpoints
+func McpAuthMetadataRouter(mux *http.ServeMux, options AuthMetadataOptions) error {
+ issuerURL, _ := url.Parse(options.OAuthMetadata.Issuer)
+ if err := checkIssuerUrl(issuerURL); err != nil {
+ return fmt.Errorf("invalid issuer URL in metadata: %w", err)
+ }
+
+ // Create protected resource metadata
+ protectedResourceMetadata := auth.OAuthProtectedResourceMetadata{
+ Resource: options.ResourceServerUrl.String(),
+ AuthorizationServers: []string{
+ options.OAuthMetadata.Issuer,
+ },
+ ScopesSupported: options.ScopesSupported,
+ }
+
+ // Add optional fields
+ if options.ResourceName != nil {
+ protectedResourceMetadata.ResourceName = options.ResourceName
+ }
+
+ if options.ServiceDocumentationUrl != nil {
+ resourceDoc := options.ServiceDocumentationUrl.String()
+ protectedResourceMetadata.ResourceDocumentation = &resourceDoc
+ }
+
+ // Protected resource metadata endpoint (GET only)
+ mux.Handle("/.well-known/oauth-protected-resource",
+ methodRestrictedHandler("GET", handler.MetadataHandler(protectedResourceMetadata)))
+
+ // Authorization server metadata endpoint (GET only, for backward compatibility)
+ mux.Handle("/.well-known/oauth-authorization-server",
+ methodRestrictedHandler("GET", handler.MetadataHandler(options.OAuthMetadata)))
+
+ return nil
+}
+
+// GetOAuthProtectedResourceMetadataUrl constructs the OAuth 2.0 Protected Resource Metadata URL from a given server URL
+func GetOAuthProtectedResourceMetadataUrl(serverUrl *url.URL) string {
+ metadataUrl, _ := url.Parse("/.well-known/oauth-protected-resource")
+ return serverUrl.ResolveReference(metadataUrl).String()
+}
+
+// InstallMCPAuthRoutes convenience function to simplify OAuth 2.1 compliant route installation
+func InstallMCPAuthRoutes(
+ mux *http.ServeMux,
+ issuerBaseURL string,
+ resourceServerURL string,
+ provider server.OAuthServerProvider,
+ scopesSupported []string,
+ resourceName *string,
+ serviceDocURL *string,
+) error {
+ issuerURL, err := url.Parse(issuerBaseURL)
+ if err != nil {
+ return fmt.Errorf("invalid issuer URL: %w", err)
+ }
+
+ var baseURL *url.URL
+ if resourceServerURL != "" {
+ baseURL, err = url.Parse(resourceServerURL)
+ if err != nil {
+ return fmt.Errorf("invalid resource server URL: %w", err)
+ }
+ }
+
+ var serviceDocumentationUrl *url.URL
+ if serviceDocURL != nil {
+ serviceDocumentationUrl, err = url.Parse(*serviceDocURL)
+ if err != nil {
+ return fmt.Errorf("invalid service documentation URL: %w", err)
+ }
+ }
+
+ options := AuthRouterOptions{
+ Provider: provider,
+ IssuerUrl: issuerURL,
+ BaseUrl: baseURL,
+ ServiceDocumentationUrl: serviceDocumentationUrl,
+ ScopesSupported: scopesSupported,
+ ResourceName: resourceName,
+ }
+
+ return McpAuthRouter(mux, options)
+}
+
+// methodRestrictedHandler returns an HTTP handler that restricts requests
+// to the specified HTTP method. If the request method does not match
+func methodRestrictedHandler(allowedMethod string, h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != allowedMethod {
+ w.Header().Set("Allow", allowedMethod)
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ h.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/auth/server/router/router_test.go b/internal/auth/server/router/router_test.go
new file mode 100644
index 0000000..3a17147
--- /dev/null
+++ b/internal/auth/server/router/router_test.go
@@ -0,0 +1,399 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package router
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+)
+
+// fullProvider implements a fuller OAuthServerProvider used to exercise all router endpoints in tests
+type fullProvider struct{}
+
+// ClientsStore returns a store that supports dynamic registration for tests
+func (p *fullProvider) ClientsStore() *server.OAuthClientsStore {
+ return server.NewOAuthClientStoreSupportDynamicRegistration(
+ func(clientID string) (*auth.OAuthClientInformationFull, error) {
+ return &auth.OAuthClientInformationFull{
+ OAuthClientInformation: auth.OAuthClientInformation{
+ ClientID: clientID,
+ },
+ }, nil
+ },
+ func(client auth.OAuthClientInformationFull) (*auth.OAuthClientInformationFull, error) {
+ return &client, nil
+ },
+ )
+}
+
+// Authorize simulates a successful authorization response by redirecting with a mock code
+func (p *fullProvider) Authorize(client auth.OAuthClientInformationFull, params server.AuthorizationParams, w http.ResponseWriter, r *http.Request) error {
+ u, _ := url.Parse(params.RedirectURI)
+ q := u.Query()
+ q.Set("code", "mock_auth_code")
+ if params.State != "" {
+ q.Set("state", params.State)
+ }
+ u.RawQuery = q.Encode()
+ http.Redirect(w, r, u.String(), http.StatusFound)
+ return nil
+}
+
+// ChallengeForAuthorizationCode returns a fixed PKCE challenge for testing
+func (p *fullProvider) ChallengeForAuthorizationCode(client auth.OAuthClientInformationFull, code string) (string, error) {
+ return "mock_challenge", nil
+}
+
+// ExchangeAuthorizationCode returns mock tokens for a valid authorization code exchange
+func (p *fullProvider) ExchangeAuthorizationCode(client auth.OAuthClientInformationFull, code string, verifier *string, redirect *string, resource *url.URL) (*auth.OAuthTokens, error) {
+ expires := int64(3600)
+ rt := "mock_refresh_token"
+ return &auth.OAuthTokens{
+ AccessToken: "mock_access_token",
+ TokenType: "bearer",
+ ExpiresIn: &expires,
+ RefreshToken: &rt,
+ }, nil
+}
+
+// ExchangeRefreshToken returns mock tokens for a valid refresh token exchange
+func (p *fullProvider) ExchangeRefreshToken(client auth.OAuthClientInformationFull, rt string, scopes []string, resource *url.URL) (*auth.OAuthTokens, error) {
+ expires := int64(3600)
+ newRT := "new_mock_refresh_token"
+ return &auth.OAuthTokens{
+ AccessToken: "new_mock_access_token",
+ TokenType: "bearer",
+ ExpiresIn: &expires,
+ RefreshToken: &newRT,
+ }, nil
+}
+
+// VerifyAccessToken validates a token and returns mock auth info or an error
+func (p *fullProvider) VerifyAccessToken(token string) (*server.AuthInfo, error) {
+ if token == "valid_token" {
+ exp := time.Now().Add(time.Hour).Unix()
+ return &server.AuthInfo{
+ Token: token,
+ ClientID: "valid-client",
+ Scopes: []string{"read", "write"},
+ ExpiresAt: &exp,
+ }, nil
+ }
+ return nil, ErrInvalid
+}
+
+// RevokeToken implements token revocation to satisfy the interface
+func (p *fullProvider) RevokeToken(client auth.OAuthClientInformationFull, req auth.OAuthTokenRevocationRequest) error {
+ return nil
+}
+
+// minimalProvider implements the minimal surface needed for router tests without dynamic registration
+type minimalProvider struct{}
+
+// ClientsStore returns nil to indicate no dynamic client registration in minimal mode
+func (p *minimalProvider) ClientsStore() *server.OAuthClientsStore { return nil }
+
+// Authorize simulates a basic authorization redirect with a mock code
+func (p *minimalProvider) Authorize(client auth.OAuthClientInformationFull, params server.AuthorizationParams, w http.ResponseWriter, r *http.Request) error {
+ u, _ := url.Parse(params.RedirectURI)
+ q := u.Query()
+ q.Set("code", "mock_auth_code")
+ u.RawQuery = q.Encode()
+ http.Redirect(w, r, u.String(), http.StatusFound)
+ return nil
+}
+
+// ChallengeForAuthorizationCode returns a fixed PKCE challenge in minimal mode
+func (p *minimalProvider) ChallengeForAuthorizationCode(client auth.OAuthClientInformationFull, code string) (string, error) {
+ return "mock_challenge", nil
+}
+
+// ExchangeAuthorizationCode returns a basic mock access token for code exchange
+func (p *minimalProvider) ExchangeAuthorizationCode(client auth.OAuthClientInformationFull, code string, verifier *string, redirect *string, resource *url.URL) (*auth.OAuthTokens, error) {
+ expires := int64(3600)
+ return &auth.OAuthTokens{AccessToken: "mock_access_token", TokenType: "bearer", ExpiresIn: &expires}, nil
+}
+
+// ExchangeRefreshToken returns a basic mock access token for refresh exchange
+func (p *minimalProvider) ExchangeRefreshToken(client auth.OAuthClientInformationFull, rt string, scopes []string, resource *url.URL) (*auth.OAuthTokens, error) {
+ expires := int64(3600)
+ return &auth.OAuthTokens{AccessToken: "new_mock_access_token", TokenType: "bearer", ExpiresIn: &expires}, nil
+}
+
+// VerifyAccessToken always returns a valid mock auth info in minimal mode
+func (p *minimalProvider) VerifyAccessToken(token string) (*server.AuthInfo, error) {
+ exp := time.Now().Add(time.Hour).Unix()
+ return &server.AuthInfo{Token: token, ClientID: "valid-client", Scopes: []string{"read"}, ExpiresAt: &exp}, nil
+}
+
+// RevokeToken implements a no-op revocation to satisfy the embedded interface
+func (p *minimalProvider) RevokeToken(client auth.OAuthClientInformationFull, req auth.OAuthTokenRevocationRequest) error {
+ return nil
+}
+
+func Test_McpAuthRouter_RouterCreation_Validation(t *testing.T) {
+ mux := http.NewServeMux()
+ issuerHTTP, _ := url.Parse("http://auth.example.com")
+ err := McpAuthRouter(mux, AuthRouterOptions{
+ Provider: &fullProvider{},
+ IssuerUrl: issuerHTTP,
+ })
+ if err == nil {
+ t.Fatalf("expected error for non-HTTPS issuer")
+ }
+
+ muxOK := http.NewServeMux()
+ issuerHTTPS, _ := url.Parse("https://auth.example.com")
+ if err := McpAuthRouter(muxOK, AuthRouterOptions{
+ Provider: &fullProvider{},
+ IssuerUrl: issuerHTTPS,
+ }); err != nil {
+ t.Fatalf("unexpected error for valid https issuer: %v", err)
+ }
+}
+
+func Test_Metadata_AuthorizationServer_Full(t *testing.T) {
+ mux := http.NewServeMux()
+
+ issuer, _ := url.Parse("https://auth.example.com/")
+ if err := McpAuthRouter(mux, AuthRouterOptions{
+ Provider: &fullProvider{},
+ IssuerUrl: issuer,
+ ServiceDocumentationUrl: mustParseURL("https://docs.example.com"),
+ ScopesSupported: []string{"read", "write"},
+ }); err != nil {
+ t.Fatalf("router init failed: %v", err)
+ }
+
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ res, err := http.Get(ts.URL + "/.well-known/oauth-authorization-server")
+ if err != nil {
+ t.Fatalf("GET metadata failed: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != 200 {
+ t.Fatalf("expected 200, got %d", res.StatusCode)
+ }
+
+ var body map[string]any
+ _ = json.NewDecoder(res.Body).Decode(&body)
+
+ expectStr(t, body, "issuer", "https://auth.example.com/")
+ expectStr(t, body, "authorization_endpoint", "https://auth.example.com/authorize")
+ expectStr(t, body, "token_endpoint", "https://auth.example.com/token")
+
+ expectArr(t, body, "response_types_supported", []string{"code"})
+ expectArr(t, body, "grant_types_supported", []string{"authorization_code", "refresh_token"})
+ expectArr(t, body, "code_challenge_methods_supported", []string{"S256"})
+ expectArr(t, body, "token_endpoint_auth_methods_supported", containsAny("client_secret_post", "client_secret_basic"))
+
+ // fullProvider: typically has revoke/register endpoints (depends on router implementation)
+ // Not asserting presence to avoid coupling with specific implementation; add expectStr(...) if needed
+}
+
+func Test_Metadata_ProtectedResource_Full(t *testing.T) {
+ mux := http.NewServeMux()
+
+ issuer, _ := url.Parse("https://auth.example.com/")
+ if err := McpAuthRouter(mux, AuthRouterOptions{
+ Provider: &fullProvider{},
+ IssuerUrl: issuer,
+ ServiceDocumentationUrl: mustParseURL("https://docs.example.com/"),
+ ScopesSupported: []string{"read", "write"},
+ ResourceName: strPtr("Test API"),
+ }); err != nil {
+ t.Fatalf("router init failed: %v", err)
+ }
+
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ res, err := http.Get(ts.URL + "/.well-known/oauth-protected-resource")
+ if err != nil {
+ t.Fatalf("GET resource metadata failed: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != 200 {
+ t.Fatalf("expected 200, got %d", res.StatusCode)
+ }
+
+ var body map[string]any
+ _ = json.NewDecoder(res.Body).Decode(&body)
+
+ expectStr(t, body, "resource", "https://auth.example.com/") // depends on router implementation
+ expectArr(t, body, "authorization_servers", []string{"https://auth.example.com/"})
+ expectArr(t, body, "scopes_supported", []string{"read", "write"})
+ expectStr(t, body, "resource_name", "Test API")
+ expectStr(t, body, "resource_documentation", "https://docs.example.com/")
+}
+
+func Test_Metadata_Minimal_NoOptionalFields(t *testing.T) {
+ mux := http.NewServeMux()
+
+ issuer, _ := url.Parse("https://auth.example.com")
+ if err := McpAuthRouter(mux, AuthRouterOptions{
+ Provider: &minimalProvider{},
+ IssuerUrl: issuer,
+ }); err != nil {
+ t.Fatalf("router init failed: %v", err)
+ }
+
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ // Authorization server metadata: optional fields omitted
+ as, _ := http.Get(ts.URL + "/.well-known/oauth-authorization-server")
+ defer as.Body.Close()
+ var a map[string]any
+ _ = json.NewDecoder(as.Body).Decode(&a)
+ if _, ok := a["service_documentation"]; ok {
+ t.Fatalf("service_documentation should be omitted")
+ }
+ if _, ok := a["scopes_supported"]; ok {
+ t.Fatalf("scopes_supported should be omitted")
+ }
+
+ // Protected resource metadata: optional fields omitted
+ pr, _ := http.Get(ts.URL + "/.well-known/oauth-protected-resource")
+ defer pr.Body.Close()
+ var p map[string]any
+ _ = json.NewDecoder(pr.Body).Decode(&p)
+ if _, ok := p["scopes_supported"]; ok {
+ t.Fatalf("scopes_supported should be omitted")
+ }
+ if _, ok := p["resource_name"]; ok {
+ t.Fatalf("resource_name should be omitted")
+ }
+ if _, ok := p["resource_documentation"]; ok {
+ t.Fatalf("resource_documentation should be omitted")
+ }
+}
+
+func Test_Routes_Register_And_Revoke_Presence_MinimalVsFull(t *testing.T) {
+ issuer, _ := url.Parse("https://auth.example.com")
+
+ // full provider: registers /register endpoint
+ muxFull := http.NewServeMux()
+ if err := McpAuthRouter(muxFull, AuthRouterOptions{
+ Provider: &fullProvider{},
+ IssuerUrl: issuer,
+ }); err != nil {
+ t.Fatalf("init full failed: %v", err)
+ }
+ tsFull := httptest.NewServer(muxFull)
+ defer tsFull.Close()
+
+ // Test /register exists
+ r1, _ := http.PostForm(tsFull.URL+"/register", url.Values{
+ "redirect_uris": {"https://example.com/callback"},
+ })
+ _ = r1.Body.Close()
+ if r1.StatusCode == http.StatusNotFound {
+ t.Fatalf("full: /register should exist (got 404)")
+ }
+
+ // minimal provider: should return 404
+ muxMin := http.NewServeMux()
+ if err := McpAuthRouter(muxMin, AuthRouterOptions{
+ Provider: &minimalProvider{},
+ IssuerUrl: issuer,
+ }); err != nil {
+ t.Fatalf("init minimal failed: %v", err)
+ }
+ tsMin := httptest.NewServer(muxMin)
+ defer tsMin.Close()
+
+ // Test /register does not exist
+ mr, _ := http.PostForm(tsMin.URL+"/register", url.Values{
+ "redirect_uris": {"https://example.com/callback"},
+ })
+ _ = mr.Body.Close()
+ if mr.StatusCode != http.StatusNotFound {
+ t.Fatalf("minimal: /register should be 404 when ClientsStore==nil, got %d", mr.StatusCode)
+ }
+}
+
+// ErrInvalid is a sentinel error used by tests to simulate token verification failures
+var ErrInvalid = &struct{ error }{}
+
+// mustParseURL parses a URL and panics on error for test setup convenience
+func mustParseURL(s string) *url.URL { u, _ := url.Parse(s); return u }
+
+// strPtr returns a pointer to the provided string
+func strPtr(s string) *string { return &s }
+
+// expectStr asserts a string field in a metadata map
+func expectStr(t *testing.T, m map[string]any, key, want string) {
+ t.Helper()
+ v, ok := m[key]
+ if !ok {
+ t.Fatalf("missing key %q", key)
+ }
+ vs, _ := v.(string)
+ if vs != want {
+ t.Fatalf("%s mismatch: got %q want %q", key, vs, want)
+ }
+}
+
+// expectArr asserts a string slice field in a metadata map
+func expectArr(t *testing.T, m map[string]any, key string, want []string) {
+ t.Helper()
+ v, ok := m[key]
+ if !ok {
+ t.Fatalf("missing key %q", key)
+ }
+ arr, ok := v.([]any)
+ if !ok {
+ t.Fatalf("%s is not array", key)
+ }
+ got := make([]string, 0, len(arr))
+ for _, x := range arr {
+ if s, ok := x.(string); ok {
+ got = append(got, s)
+ }
+ }
+ if len(want) == 1 && strings.HasPrefix(want[0], "__any__:") {
+ needle := strings.TrimPrefix(want[0], "__any__:")
+ found := false
+ for _, g := range got {
+ if g == needle {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("%s expected to contain %q, got %v", key, needle, got)
+ }
+ return
+ }
+ if len(got) != len(want) {
+ t.Fatalf("%s length mismatch: got %v want %v", key, got, want)
+ }
+ for i := range got {
+ if got[i] != want[i] {
+ t.Fatalf("%s[%d] mismatch: got %q want %q", key, i, got[i], want[i])
+ }
+ }
+}
+
+// containsAny encodes a loose expectation for any one of the provided values
+func containsAny(values ...string) []string {
+ if len(values) == 0 {
+ return nil
+ }
+ return []string{"__any__:" + values[0]}
+}
diff --git a/internal/auth/server/token_verifier.go b/internal/auth/server/token_verifier.go
new file mode 100644
index 0000000..11f4553
--- /dev/null
+++ b/internal/auth/server/token_verifier.go
@@ -0,0 +1,845 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package server
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ oauthErrors "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+
+ "github.com/lestrrat-go/jwx/v2/jwk"
+ "github.com/lestrrat-go/jwx/v2/jws"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+)
+
+// standardClaims are filtered out of Extra because they are either mapped to
+// AuthInfo first-class fields or are not useful as extra metadata.
+// Mapped fields: client_id -> ClientID, sub -> Subject, scope -> Scopes,
+// exp -> ExpiresAt, aud -> Resource (via extractResource)
+var standardClaims = map[string]bool{
+ "client_id": true,
+ "sub": true,
+ "scope": true,
+ "exp": true,
+ "aud": true,
+ // Other common standard/housekeeping claims not needed in Extra
+ "iss": true,
+ "iat": true,
+ "jti": true,
+ "kid": true,
+}
+
+type TokenVerifierInterface interface {
+ VerifyAccessToken(ctx context.Context, token string) (AuthInfo, error)
+}
+
+// LocalJWKSConfig local JWKS configuration
+type LocalJWKSConfig struct {
+ JWKS string // Local JWKS JSON string
+ File string // Local JWKS file path
+}
+
+// RemoteJWKSConfig remote JWKS configuration
+type RemoteJWKSConfig struct {
+ URLs []string // Remote JWKS URLs
+ IssuerToURL map[string]string // Mapping from issuer (iss) to remote URL
+ RefreshInterval time.Duration // Refresh interval
+}
+
+// TokenVerifierConfig configuration for TokenVerifier
+type TokenVerifierConfig struct {
+ Local *LocalJWKSConfig // Local JWKS configuration
+ Remote *RemoteJWKSConfig // Remote JWKS configuration
+ Introspection *IntrospectionConfig // Remote introspection configuration (RFC7662)
+}
+
+// IntrospectionCredentials client credentials for introspection
+type IntrospectionCredentials struct {
+ ClientID string
+ ClientSecret string
+}
+
+// IntrospectionConfig remote introspection configuration
+type IntrospectionConfig struct {
+ // Default introspection endpoint (optional). Used when no issuer-bound endpoint is found.
+ Endpoint string
+ // Endpoint selection by issuer (multi-tenant).
+ IssuerToEndpoint map[string]string
+
+ // Default credentials and per-issuer credentials (optional).
+ DefaultCredentials *IntrospectionCredentials
+ IssuerCredentials map[string]IntrospectionCredentials
+
+ // HTTP timeout
+ Timeout time.Duration
+
+ // Cache TTL (positive) and negative cache TTL (for inactive tokens or 4xx/401, etc.).
+ CacheTTL time.Duration
+ NegativeCacheTTL time.Duration
+
+ // Whether to fall back to introspection on JWT verification failure.
+ UseOnJWTFail bool
+}
+
+// TokenVerifier holds verification configuration and helpers
+type TokenVerifier struct {
+ localKeySet jwk.Set // Local JWKS key set
+ cache *jwk.Cache // Remote JWKS cache (jwx v2)
+ issuerToURL map[string]string // Mapping from issuer (iss) to remote URL
+ isRemote bool // Whether remote JWKS mode is enabled
+
+ // RFC7662 introspection
+ introspectionEnabled bool
+ httpClient *http.Client
+ defaultIntrospectEP string
+ issuerToIntrospectEP map[string]string
+ defaultCreds *IntrospectionCredentials
+ issuerCreds map[string]IntrospectionCredentials
+ useIntrospectionOnFail bool
+
+ // Simple in-memory cache
+ introspectCache map[string]introspectionCacheEntry
+ introspectCacheMu sync.RWMutex
+ cacheTTL time.Duration
+ negativeCacheTTL time.Duration
+}
+
+type TokenVerifierFunc func(ctx context.Context, token string) (AuthInfo, error)
+
+func (f TokenVerifierFunc) VerifyAccessToken(ctx context.Context, token string) (AuthInfo, error) {
+ return f(ctx, token)
+}
+
+// NewLocalTokenVerifier creates a TokenVerifier that uses only local JWKS
+func newLocalTokenVerifier(ctx context.Context, cfg LocalJWKSConfig) (*TokenVerifier, error) {
+ verifier := &TokenVerifier{}
+
+ defaultSet := jwk.NewSet()
+
+ // Load JWKS from string
+ if cfg.JWKS != "" {
+ set, err := jwk.Parse([]byte(cfg.JWKS))
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse local JWKS: %w", err)
+ }
+ for i := 0; i < set.Len(); i++ {
+ key, _ := set.Key(i)
+ _ = defaultSet.AddKey(key)
+ }
+ }
+
+ // Load JWKS from file
+ if cfg.File != "" {
+ set, err := jwk.ReadFile(cfg.File)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse local JWKS file: %w", err)
+ }
+ for i := 0; i < set.Len(); i++ {
+ key, _ := set.Key(i)
+ _ = defaultSet.AddKey(key)
+ }
+ }
+
+ if defaultSet.Len() == 0 {
+ return nil, fmt.Errorf("must provide JWKS or File")
+ }
+
+ verifier.localKeySet = defaultSet
+ return verifier, nil
+}
+
+// NewRemoteTokenVerifier creates a TokenVerifier that uses only remote JWKS
+func newRemoteTokenVerifier(ctx context.Context, cfg RemoteJWKSConfig) (*TokenVerifier, error) {
+ if len(cfg.URLs) == 0 {
+ return nil, fmt.Errorf("must provide at least one RemoteURL")
+ }
+
+ // jwx v2 cache
+ cache := jwk.NewCache(ctx)
+ for _, url_ := range cfg.URLs {
+ _ = cache.Register(url_)
+ }
+
+ return &TokenVerifier{
+ cache: cache,
+ issuerToURL: func() map[string]string {
+ if cfg.IssuerToURL == nil {
+ return nil
+ }
+ m := make(map[string]string, len(cfg.IssuerToURL))
+ for k, v := range cfg.IssuerToURL {
+ m[k] = v
+ }
+ return m
+ }(),
+ isRemote: true,
+ }, nil
+}
+
+// NewIntrospectionTokenVerifier creates a TokenVerifier that uses only RFC7662 introspection
+func newIntrospectionTokenVerifier(ctx context.Context, cfg IntrospectionConfig) (*TokenVerifier, error) {
+ verifier := &TokenVerifier{}
+
+ to := cfg.Timeout
+ if to <= 0 {
+ to = 5 * time.Second
+ }
+ verifier.httpClient = &http.Client{Timeout: to}
+ verifier.defaultIntrospectEP = cfg.Endpoint
+ // Copy IssuerToEndpoint
+ if cfg.IssuerToEndpoint != nil {
+ verifier.issuerToIntrospectEP = make(map[string]string, len(cfg.IssuerToEndpoint))
+ for k, v := range cfg.IssuerToEndpoint {
+ verifier.issuerToIntrospectEP[k] = v
+ }
+ }
+ // Copy DefaultCredentials
+ if cfg.DefaultCredentials != nil {
+ dc := *cfg.DefaultCredentials
+ verifier.defaultCreds = &dc
+ }
+ // Copy IssuerCredentials
+ if cfg.IssuerCredentials != nil {
+ verifier.issuerCreds = make(map[string]IntrospectionCredentials, len(cfg.IssuerCredentials))
+ for k, v := range cfg.IssuerCredentials {
+ verifier.issuerCreds[k] = v
+ }
+ }
+ verifier.useIntrospectionOnFail = cfg.UseOnJWTFail
+ verifier.cacheTTL = cfg.CacheTTL
+ if verifier.cacheTTL <= 0 {
+ verifier.cacheTTL = 60 * time.Second
+ }
+ verifier.negativeCacheTTL = cfg.NegativeCacheTTL
+ if verifier.negativeCacheTTL <= 0 {
+ verifier.negativeCacheTTL = 15 * time.Second
+ }
+ verifier.introspectionEnabled = true
+ verifier.introspectCache = make(map[string]introspectionCacheEntry)
+ return verifier, nil
+}
+
+// NewTokenVerifier creates a comprehensive TokenVerifier.
+// Provide any one or more configurations. The SDK will automatically prefer Local → Remote → Introspection (if enabled).
+func NewTokenVerifier(ctx context.Context, cfg TokenVerifierConfig) (*TokenVerifier, error) {
+ var verifier *TokenVerifier
+ var err error
+
+ if cfg.Remote != nil && len(cfg.Remote.URLs) > 0 {
+ verifier, err = newRemoteTokenVerifier(ctx, *cfg.Remote)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if cfg.Local != nil && (cfg.Local.JWKS != "" || cfg.Local.File != "") {
+ localVerifier, err := newLocalTokenVerifier(ctx, *cfg.Local)
+ if err != nil {
+ return nil, err
+ }
+
+ if verifier != nil {
+ verifier.localKeySet = localVerifier.localKeySet
+ } else {
+ verifier = localVerifier
+ }
+ }
+
+ if verifier == nil {
+ // If no JWKS is provided, allow constructing an introspection-only mode
+ if cfg.Introspection != nil {
+ return newIntrospectionTokenVerifier(ctx, *cfg.Introspection)
+ }
+ return nil, errors.New("no verification method configured: configure Local JWKS (Local), or Remote JWKS (Remote), or Introspection")
+ }
+
+ // Initialize introspection (optional)
+ if cfg.Introspection != nil {
+ to := cfg.Introspection.Timeout
+ if to <= 0 {
+ to = 5 * time.Second
+ }
+ verifier.httpClient = &http.Client{Timeout: to}
+ verifier.defaultIntrospectEP = cfg.Introspection.Endpoint
+ // Copy IssuerToEndpoint to avoid external mutations
+ if cfg.Introspection.IssuerToEndpoint != nil {
+ verifier.issuerToIntrospectEP = make(map[string]string, len(cfg.Introspection.IssuerToEndpoint))
+ for k, v := range cfg.Introspection.IssuerToEndpoint {
+ verifier.issuerToIntrospectEP[k] = v
+ }
+ }
+ // Copy DefaultCredentials
+ if cfg.Introspection.DefaultCredentials != nil {
+ dc := *cfg.Introspection.DefaultCredentials
+ verifier.defaultCreds = &dc
+ }
+ // Copy IssuerCredentials
+ if cfg.Introspection.IssuerCredentials != nil {
+ verifier.issuerCreds = make(map[string]IntrospectionCredentials, len(cfg.Introspection.IssuerCredentials))
+ for k, v := range cfg.Introspection.IssuerCredentials {
+ verifier.issuerCreds[k] = v
+ }
+ }
+ verifier.useIntrospectionOnFail = cfg.Introspection.UseOnJWTFail
+ verifier.cacheTTL = cfg.Introspection.CacheTTL
+ if verifier.cacheTTL <= 0 {
+ verifier.cacheTTL = 60 * time.Second
+ }
+ verifier.negativeCacheTTL = cfg.Introspection.NegativeCacheTTL
+ if verifier.negativeCacheTTL <= 0 {
+ verifier.negativeCacheTTL = 15 * time.Second
+ }
+ verifier.introspectionEnabled = true
+ verifier.introspectCache = make(map[string]introspectionCacheEntry)
+ }
+
+ // Do not set an explicit "mode"; choose dynamically during Verify based on configuration
+
+ return verifier, nil
+}
+
+// VerifyAccessToken verifies an access token and returns AuthInfo or error
+func (v *TokenVerifier) VerifyAccessToken(ctx context.Context, tokenStr string) (AuthInfo, error) {
+ // If no JWKS configured and introspection is enabled: directly use introspection (works for opaque/JWT)
+ if v.localKeySet == nil && !v.isRemote && v.introspectionEnabled {
+ ai, err := v.introspectAccessToken(ctx, tokenStr, "")
+ if err != nil {
+ return AuthInfo{}, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "failed to verify token", "")
+ }
+ return ai, nil
+ }
+
+ // Parse token first (without verifying signature) to obtain iss; if parsing fails and introspection is enabled, try introspection directly (supports opaque tokens).
+ unverifiedToken, err := jwt.ParseInsecure([]byte(tokenStr))
+ if err != nil {
+ if v.introspectionEnabled {
+ if ai, ierr := v.introspectAccessToken(ctx, tokenStr, ""); ierr == nil {
+ return ai, nil
+ }
+ }
+ return AuthInfo{}, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "malformed token: cannot parse header/payload; if you are using opaque tokens, enable Introspection", "")
+ }
+
+ // Extract issuer (iss)
+ iss := unverifiedToken.Issuer()
+ if iss == "" {
+ return AuthInfo{}, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "missing issuer (iss) in token", "")
+ }
+
+ // Extract kid from JWS header
+ kid, err := extractKIDFromHeader(tokenStr)
+ if err != nil || kid == "" {
+ // Try introspection fallback
+ if v.introspectionEnabled && v.useIntrospectionOnFail {
+ if ai, ierr := v.introspectAccessToken(ctx, tokenStr, iss); ierr == nil {
+ return ai, nil
+ }
+ }
+ return AuthInfo{}, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "missing key id (kid) in JWS header; if your tokens omit kid, ensure the JWKS only contains one key or use Introspection fallback", "")
+ }
+
+ // Try to obtain target keySet
+ keySet, err := v.getTargetKeySet(ctx, iss, kid)
+ if err != nil {
+ return AuthInfo{}, err
+ }
+
+ // Validate token with key set and basic claims, allowing time skew
+ token, err := jwt.Parse([]byte(tokenStr),
+ jwt.WithKeySet(keySet),
+ jwt.WithValidate(true),
+ jwt.WithAcceptableSkew(30*time.Second),
+ // RFC 9068: exp and iat are validated automatically; here we only require presence for other claims
+ jwt.WithRequiredClaim("exp"),
+ jwt.WithRequiredClaim("aud"),
+ jwt.WithRequiredClaim("sub"),
+ jwt.WithRequiredClaim("iat"),
+ )
+ if err != nil || token == nil {
+ // On JWT signature/claims validation failure, optionally fall back to introspection
+ if v.introspectionEnabled && v.useIntrospectionOnFail {
+ if ai, ierr := v.introspectAccessToken(ctx, tokenStr, iss); ierr == nil {
+ return ai, nil
+ }
+ }
+ return AuthInfo{}, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "signature validation failed or claims invalid; ensure JWKS is configured for issuer or enable Introspection fallback", "")
+ }
+
+ // Ensure non-empty subject
+ if sub := token.Subject(); sub == "" {
+ return AuthInfo{}, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "missing required 'sub' claim", "")
+ }
+
+ authInfo, err := v.convertJWTToAuthInfo(token, tokenStr)
+ if err != nil {
+ return AuthInfo{}, err
+ }
+ return authInfo, nil
+}
+
+func (v *TokenVerifier) getTargetKeySet(ctx context.Context, iss, kid string) (jwk.Set, error) {
+ // Prefer local JWKS
+ if v.localKeySet != nil {
+ if _, ok := v.localKeySet.LookupKeyID(kid); ok {
+ return v.localKeySet, nil
+ }
+ }
+
+ // If remote mode enabled, try remote JWKS
+ if v.isRemote {
+ if url_, ok := v.issuerToURL[iss]; ok {
+ if v.cache != nil {
+ if keySet, err := v.cache.Get(ctx, url_); err == nil {
+ if _, ok := keySet.LookupKeyID(kid); !ok {
+ if refreshed, ferr := jwk.Fetch(ctx, url_); ferr == nil {
+ if _, ok2 := refreshed.LookupKeyID(kid); ok2 {
+ return refreshed, nil
+ }
+ }
+ }
+ return keySet, nil
+ }
+ }
+ keySet, err := jwk.Fetch(ctx, url_)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch remote JWKS for issuer %s (url=%s): %w", iss, url_, err)
+ }
+ return keySet, nil
+ }
+ return nil, fmt.Errorf("no remote JWKS URL found for issuer %s: provide Remote.IssuerToURL mapping", iss)
+ }
+
+ return nil, fmt.Errorf("no JWKS found for issuer %s: neither Local nor Remote key set available", iss)
+}
+
+// ---- RFC7662 introspection implementation ----
+
+type introspectionCacheEntry struct {
+ authInfo AuthInfo
+ inactive bool
+ expiresAt time.Time
+}
+
+func (v *TokenVerifier) resolveIntrospectionEndpoint(issuer string) (string, *IntrospectionCredentials) {
+ ep := ""
+ if issuer != "" && v.issuerToIntrospectEP != nil {
+ if e, ok := v.issuerToIntrospectEP[issuer]; ok {
+ ep = e
+ }
+ }
+ if ep == "" {
+ ep = v.defaultIntrospectEP
+ }
+ var creds *IntrospectionCredentials
+ if issuer != "" && v.issuerCreds != nil {
+ if c, ok := v.issuerCreds[issuer]; ok {
+ cc := c
+ creds = &cc
+ }
+ }
+ if creds == nil {
+ creds = v.defaultCreds
+ }
+ return ep, creds
+}
+
+func (v *TokenVerifier) introspectionCacheKey(endpoint, token string) string {
+ return endpoint + "|" + token
+}
+
+func (v *TokenVerifier) loadFromIntrospectionCache(key string) (introspectionCacheEntry, bool) {
+ v.introspectCacheMu.RLock()
+ defer v.introspectCacheMu.RUnlock()
+ entry, ok := v.introspectCache[key]
+ if !ok {
+ return introspectionCacheEntry{}, false
+ }
+ if time.Now().After(entry.expiresAt) {
+ return introspectionCacheEntry{}, false
+ }
+ return entry, true
+}
+
+func (v *TokenVerifier) storeToIntrospectionCache(key string, entry introspectionCacheEntry) {
+ v.introspectCacheMu.Lock()
+ v.introspectCache[key] = entry
+ v.introspectCacheMu.Unlock()
+}
+
+func (v *TokenVerifier) introspectAccessToken(ctx context.Context, tokenStr, issuer string) (AuthInfo, error) {
+ if !v.introspectionEnabled {
+ return AuthInfo{}, errors.New("introspection not enabled")
+ }
+ endpoint, creds := v.resolveIntrospectionEndpoint(issuer)
+ if endpoint == "" {
+ return AuthInfo{}, errors.New("no introspection endpoint configured: set Introspection.Endpoint or IssuerToEndpoint for the issuer")
+ }
+
+ key := v.introspectionCacheKey(endpoint, tokenStr)
+ if entry, ok := v.loadFromIntrospectionCache(key); ok {
+ if entry.inactive {
+ return AuthInfo{}, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "inactive token", "")
+ }
+ return entry.authInfo, nil
+ }
+
+ form := url.Values{}
+ form.Set("token", tokenStr)
+ form.Set("token_type_hint", "access_token")
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
+ if err != nil {
+ return AuthInfo{}, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if creds != nil && creds.ClientID != "" {
+ basic := creds.ClientID + ":" + creds.ClientSecret
+ req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(basic)))
+ }
+
+ resp, err := v.httpClient.Do(req)
+ if err != nil {
+ return AuthInfo{}, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ v.storeToIntrospectionCache(key, introspectionCacheEntry{inactive: true, expiresAt: time.Now().Add(v.negativeCacheTTL)})
+ return AuthInfo{}, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "introspection request failed", "")
+ }
+
+ var payload map[string]interface{}
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return AuthInfo{}, err
+ }
+ active, _ := payload["active"].(bool)
+ if !active {
+ v.storeToIntrospectionCache(key, introspectionCacheEntry{inactive: true, expiresAt: time.Now().Add(v.negativeCacheTTL)})
+ return AuthInfo{}, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "inactive token", "")
+ }
+
+ ai, err := v.convertIntrospectionToAuthInfo(payload, tokenStr)
+ if err != nil {
+ return AuthInfo{}, err
+ }
+
+ ttl := v.cacheTTL
+ if expV, ok := payload["exp"]; ok {
+ switch t := expV.(type) {
+ case float64:
+ expTs := time.Unix(int64(t), 0)
+ if expTs.After(time.Now()) {
+ rem := time.Until(expTs)
+ if rem < ttl {
+ ttl = rem
+ }
+ }
+ case json.Number:
+ if v, err2 := t.Int64(); err2 == nil {
+ expTs := time.Unix(v, 0)
+ if expTs.After(time.Now()) {
+ rem := time.Until(expTs)
+ if rem < ttl {
+ ttl = rem
+ }
+ }
+ }
+ }
+ }
+ v.storeToIntrospectionCache(key, introspectionCacheEntry{authInfo: ai, expiresAt: time.Now().Add(ttl)})
+ return ai, nil
+}
+
+func (v *TokenVerifier) convertIntrospectionToAuthInfo(payload map[string]interface{}, tokenStr string) (AuthInfo, error) {
+ var ai AuthInfo
+ ai.Token = tokenStr
+ if cid, _ := payload["client_id"].(string); cid != "" {
+ ai.ClientID = cid
+ }
+ if sc, ok := payload["scope"]; ok {
+ ai.Scopes = parseScopesFromRaw(sc)
+ }
+ switch exp := payload["exp"].(type) {
+ case float64:
+ ts := int64(exp)
+ ai.ExpiresAt = &ts
+ case json.Number:
+ if v, err := exp.Int64(); err == nil {
+ ai.ExpiresAt = &v
+ }
+ }
+ if r, err := extractResourceFromIntrospection(payload["aud"]); err == nil {
+ ai.Resource = r
+ }
+
+ extra := make(map[string]interface{})
+ for k, v := range payload {
+ if standardClaims[k] {
+ continue
+ }
+ switch k {
+ case "active", "username", "token_type", "token_type_hint":
+ continue
+ case "client_id", "scope", "exp", "aud", "iss", "sub", "iat", "jti":
+ continue
+ default:
+ extra[k] = v
+ }
+ }
+ if len(extra) > 0 {
+ ai.Extra = extra
+ }
+ return ai, nil
+}
+
+func parseScopesFromRaw(raw interface{}) []string {
+ switch s := raw.(type) {
+ case string:
+ if s == "" {
+ return nil
+ }
+ return strings.Split(s, " ")
+ case []interface{}:
+ var scopes []string
+ for _, v := range s {
+ if str, ok := v.(string); ok && str != "" {
+ scopes = append(scopes, str)
+ }
+ }
+ if len(scopes) == 0 {
+ return nil
+ }
+ return scopes
+ default:
+ return nil
+ }
+}
+
+func extractResourceFromIntrospection(audRaw interface{}) (*url.URL, error) {
+ if audRaw == nil {
+ return nil, nil
+ }
+ var candidates []string
+ switch v := audRaw.(type) {
+ case string:
+ if v != "" {
+ candidates = []string{v}
+ }
+ case []interface{}:
+ for _, it := range v {
+ if s, ok := it.(string); ok && s != "" {
+ candidates = append(candidates, s)
+ }
+ }
+ case []string:
+ candidates = v
+ }
+ for _, c := range candidates {
+ looksLikeURL := strings.HasPrefix(c, "http://") || strings.HasPrefix(c, "https://") || strings.Contains(c, "://")
+ if !looksLikeURL {
+ continue
+ }
+ u, err := url.Parse(c)
+ if err != nil || u == nil || u.Scheme == "" || u.Host == "" {
+ continue
+ }
+ u.Fragment = "" // Remove fragment (per RFC 8707)
+ return u, nil
+ }
+ return nil, nil
+}
+
+// extractKIDFromHeader 从 JWS Header 提取 kid
+func extractKIDFromHeader(tokenStr string) (string, error) {
+ msg, err := jws.Parse([]byte(tokenStr))
+ if err != nil {
+ return "", fmt.Errorf("failed to parse JWS: %w", err)
+ }
+ sigs := msg.Signatures()
+ if len(sigs) == 0 {
+ return "", errors.New("no signatures found in JWS")
+ }
+
+ // Prefer protected headers
+ if ph := sigs[0].ProtectedHeaders(); ph != nil {
+ if v, ok := ph.Get(jws.KeyIDKey); ok {
+ if kid, ok2 := v.(string); ok2 && kid != "" {
+ return kid, nil
+ }
+ }
+ }
+ return "", errors.New("missing kid in JWS header")
+}
+
+// convertJWTToAuthInfo converts jwt.Token to AuthInfo structure.
+func (v *TokenVerifier) convertJWTToAuthInfo(token jwt.Token, tokenStr string) (AuthInfo, error) {
+ authInfo := AuthInfo{Token: tokenStr}
+
+ // Write exp -> ExpiresAt (must be done first)
+ if exp := token.Expiration(); !exp.IsZero() {
+ ts := exp.Unix()
+ authInfo.ExpiresAt = &ts
+ } else {
+ // This case should not be reached because WithRequiredClaim("exp") was used in Parse
+ // But for robustness, return invalid_token for clarity
+ return AuthInfo{}, oauthErrors.NewOAuthError(oauthErrors.ErrInvalidToken, "missing exp claim", "")
+ }
+
+ // Extract OAuth-related fields
+ var err error
+ if authInfo.ClientID, err = extractClientID(token); err != nil {
+ return AuthInfo{}, err
+ }
+ if authInfo.Resource, err = extractResource(token); err != nil {
+ return AuthInfo{}, err
+ }
+ if authInfo.Scopes, err = extractScopes(token); err != nil {
+ return AuthInfo{}, err
+ }
+
+ // Write subject -> AuthInfo.Subject
+ if s := token.Subject(); s != "" {
+ authInfo.Subject = s
+ }
+
+ // Other custom claims
+ authInfo.Extra = extractExtra(token)
+ return authInfo, nil
+}
+
+// extractClientID extracts client ID (optional)
+func extractClientID(token jwt.Token) (string, error) {
+ // client_id is not mandatory for access tokens (RFC9068)
+ if v, ok := token.Get("client_id"); ok {
+ if s, ok2 := v.(string); ok2 && s != "" {
+ return s, nil
+ }
+ }
+ // Fallback to azp (often used in OIDC)
+ if v, ok := token.Get("azp"); ok {
+ if s, ok2 := v.(string); ok2 && s != "" {
+ return s, nil
+ }
+ }
+ // Missing client identifier is acceptable
+ return "", nil
+}
+
+// extractScopes extracts scopes from various claim formats (scope/scp). Missing is acceptable.
+func extractScopes(token jwt.Token) ([]string, error) {
+ var raw interface{}
+ // Prefer RFC6749 style "scope" (space-delimited string or array)
+ if v, ok := token.Get("scope"); ok {
+ raw = v
+ } else {
+ // Fallback to "scp" (array of strings used by some providers)
+ if v2, ok2 := token.Get("scp"); ok2 {
+ raw = v2
+ } else {
+ // No scopes present → treat as empty without error
+ return nil, nil
+ }
+ }
+
+ switch s := raw.(type) {
+ case string:
+ if s == "" {
+ return nil, nil
+ }
+ return strings.Split(s, " "), nil
+ case []string:
+ if len(s) == 0 {
+ return nil, nil
+ }
+ return s, nil
+ case []interface{}:
+ if len(s) == 0 {
+ return nil, nil
+ }
+ var scopes []string
+ for _, v := range s {
+ if str, ok := v.(string); ok {
+ scopes = append(scopes, str)
+ }
+ }
+ if len(scopes) == 0 {
+ return nil, nil
+ }
+ return scopes, nil
+ default:
+ // Unknown format → ignore rather than failing hard for compatibility
+ return nil, nil
+ }
+}
+
+// extractResource extracts resource information
+func extractResource(token jwt.Token) (*url.URL, error) {
+ aud := token.Audience()
+ if len(aud) == 0 {
+ return nil, fmt.Errorf("missing required 'aud' claim")
+ }
+
+ // Iterate to find the first value that looks like a URL and is parseable as HTTP(S);
+ // if none are URLs, return nil to indicate no resource indicator provided.
+ for _, candidate := range aud {
+ if candidate == "" {
+ continue
+ }
+ looksLikeURL := strings.HasPrefix(candidate, "http://") || strings.HasPrefix(candidate, "https://") || strings.Contains(candidate, "://")
+ if !looksLikeURL {
+ continue
+ }
+ resourceURL, err := url.Parse(candidate)
+ if err != nil || resourceURL == nil {
+ continue
+ }
+ if resourceURL.Scheme == "" || resourceURL.Host == "" {
+ continue
+ }
+ resourceURL.Fragment = "" // Remove fragment (per RFC 8707)
+ return resourceURL, nil
+ }
+ return nil, nil
+}
+
+// extractExtra extracts custom claims to Extra map
+func extractExtra(token jwt.Token) map[string]interface{} {
+ all, _ := token.AsMap(context.Background())
+ if len(all) == 0 {
+ return nil
+ }
+
+ extra := make(map[string]interface{})
+ for key, value := range all {
+ if standardClaims[key] {
+ continue
+ }
+ switch key {
+ case "active", "username", "token_type", "token_type_hint":
+ // Known noise in introspection/JWT contexts; exclude from Extra
+ continue
+ default:
+ extra[key] = value
+ }
+ }
+ if len(extra) == 0 {
+ return nil
+ }
+ return extra
+}
+
+// Note: TokenVerifier is statically configured. After initialization, it does not support dynamically adding issuer mappings or clearing the local KeySet.
diff --git a/internal/auth/server/token_verifier_test.go b/internal/auth/server/token_verifier_test.go
new file mode 100644
index 0000000..c415697
--- /dev/null
+++ b/internal/auth/server/token_verifier_test.go
@@ -0,0 +1,840 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package server
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwa"
+ "github.com/lestrrat-go/jwx/v2/jwk"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Test helper functions
+
+// generateRSAKey generates a new RSA key pair for testing
+func generateRSAKey() (*rsa.PrivateKey, error) {
+ return rsa.GenerateKey(rand.Reader, 2048)
+}
+
+// createTestJWK creates a test JWK from RSA key
+func createTestJWK(privateKey *rsa.PrivateKey, keyID string) (jwk.Key, error) {
+ // Import the public key part only
+ publicKey := &privateKey.PublicKey
+ key, err := jwk.FromRaw(publicKey)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := key.Set(jwk.KeyIDKey, keyID); err != nil {
+ return nil, err
+ }
+
+ if err := key.Set(jwk.AlgorithmKey, "RS256"); err != nil {
+ return nil, err
+ }
+
+ if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil {
+ return nil, err
+ }
+
+ return key, nil
+}
+
+// createTestToken creates a test JWT token
+func createTestToken(privateKey *rsa.PrivateKey, keyID string, claims map[string]interface{}) (string, error) {
+ key, err := jwk.FromRaw(privateKey)
+ if err != nil {
+ return "", err
+ }
+
+ if err := key.Set(jwk.KeyIDKey, keyID); err != nil {
+ return "", err
+ }
+
+ now := time.Now()
+ token := jwt.New()
+
+ // Set standard claims
+ _ = token.Set(jwt.IssuerKey, "https://example.com")
+ _ = token.Set(jwt.SubjectKey, "user123")
+ _ = token.Set(jwt.AudienceKey, []string{"https://api.example.com"})
+ _ = token.Set(jwt.ExpirationKey, now.Add(time.Hour))
+ _ = token.Set(jwt.IssuedAtKey, now)
+ _ = token.Set(jwt.JwtIDKey, "jti-123")
+ _ = token.Set("client_id", "test-client")
+ _ = token.Set("scope", "read write")
+ _ = token.Set("kid", keyID)
+
+ // Set custom claims
+ for k, v := range claims {
+ _ = token.Set(k, v)
+ }
+
+ signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key))
+ if err != nil {
+ return "", err
+ }
+
+ return string(signed), nil
+}
+
+// createTestJWKS creates a test JWKS JSON string
+func createTestJWKS(keys ...jwk.Key) string {
+ set := jwk.NewSet()
+ for _, key := range keys {
+ _ = set.AddKey(key)
+ }
+
+ buf, _ := json.Marshal(set)
+ return string(buf)
+}
+
+// Test fixtures
+func setupTestKeys(t *testing.T) (*rsa.PrivateKey, jwk.Key, string) {
+ privateKey, err := generateRSAKey()
+ require.NoError(t, err)
+
+ publicKey, err := createTestJWK(privateKey, "test-key-1")
+ require.NoError(t, err)
+
+ jwksJSON := createTestJWKS(publicKey)
+
+ return privateKey, publicKey, jwksJSON
+}
+
+func TestTokenVerifierFunc_VerifyAccessToken(t *testing.T) {
+ ctx := context.Background()
+
+ // Define a fake verifier function
+ fn := TokenVerifierFunc(func(ctx context.Context, token string) (AuthInfo, error) {
+ if token == "valid" {
+ return AuthInfo{Token: token, ClientID: "test-client"}, nil
+ }
+ return AuthInfo{}, errors.New("invalid token")
+ })
+
+ // Success path
+ authInfo, err := fn.VerifyAccessToken(ctx, "valid")
+ assert.NoError(t, err)
+ assert.Equal(t, "valid", authInfo.Token)
+ assert.Equal(t, "test-client", authInfo.ClientID)
+
+ // Failure path
+ authInfo, err = fn.VerifyAccessToken(ctx, "invalid")
+ assert.Error(t, err)
+ assert.Empty(t, authInfo.Token)
+}
+
+// Tests for NewLocalTokenVerifier
+
+func TestNewLocalTokenVerifier_WithJWKSString(t *testing.T) {
+ ctx := context.Background()
+ _, _, jwksJSON := setupTestKeys(t)
+
+ cfg := LocalJWKSConfig{
+ JWKS: jwksJSON,
+ }
+
+ verifier, err := newLocalTokenVerifier(ctx, cfg)
+ assert.NoError(t, err)
+ assert.NotNil(t, verifier)
+ assert.NotNil(t, verifier.localKeySet)
+ assert.False(t, verifier.isRemote)
+ assert.Equal(t, 1, verifier.localKeySet.Len())
+}
+
+func TestNewLocalTokenVerifier_WithFile(t *testing.T) {
+ ctx := context.Background()
+ _, _, jwksJSON := setupTestKeys(t)
+
+ // Create temporary file
+ tmpFile, err := os.CreateTemp("", "jwks-*.json")
+ require.NoError(t, err)
+ defer os.Remove(tmpFile.Name())
+
+ _, err = tmpFile.WriteString(jwksJSON)
+ require.NoError(t, err)
+ _ = tmpFile.Close()
+
+ cfg := LocalJWKSConfig{
+ File: tmpFile.Name(),
+ }
+
+ verifier, err := newLocalTokenVerifier(ctx, cfg)
+ assert.NoError(t, err)
+ assert.NotNil(t, verifier)
+ assert.Equal(t, 1, verifier.localKeySet.Len())
+}
+
+func TestNewLocalTokenVerifier_WithBothJWKSAndFile(t *testing.T) {
+ ctx := context.Background()
+
+ // Create two different keys
+ _, _, jwksJSON1 := setupTestKeys(t)
+
+ privateKey2, err := generateRSAKey()
+ require.NoError(t, err)
+ publicKey2, err := createTestJWK(privateKey2, "test-key-2")
+ require.NoError(t, err)
+ jwksJSON2 := createTestJWKS(publicKey2)
+
+ // Create temporary file with second key
+ tmpFile, err := os.CreateTemp("", "jwks-*.json")
+ require.NoError(t, err)
+ defer os.Remove(tmpFile.Name())
+
+ _, err = tmpFile.WriteString(jwksJSON2)
+ require.NoError(t, err)
+ _ = tmpFile.Close()
+
+ cfg := LocalJWKSConfig{
+ JWKS: jwksJSON1,
+ File: tmpFile.Name(),
+ }
+
+ verifier, err := newLocalTokenVerifier(ctx, cfg)
+ assert.NoError(t, err)
+ assert.NotNil(t, verifier)
+ assert.Equal(t, 2, verifier.localKeySet.Len()) // Should have both keys
+}
+
+func TestNewLocalTokenVerifier_EmptyConfig(t *testing.T) {
+ ctx := context.Background()
+ cfg := LocalJWKSConfig{}
+
+ verifier, err := newLocalTokenVerifier(ctx, cfg)
+ assert.Error(t, err)
+ assert.Nil(t, verifier)
+ assert.Contains(t, err.Error(), "must provide JWKS or File")
+}
+
+func TestNewLocalTokenVerifier_InvalidJWKS(t *testing.T) {
+ ctx := context.Background()
+ cfg := LocalJWKSConfig{
+ JWKS: "invalid-json",
+ }
+
+ verifier, err := newLocalTokenVerifier(ctx, cfg)
+ assert.Error(t, err)
+ assert.Nil(t, verifier)
+ assert.Contains(t, err.Error(), "failed to parse local JWKS")
+}
+
+// Tests for NewRemoteTokenVerifier
+
+func TestNewRemoteTokenVerifier_Success(t *testing.T) {
+ ctx := context.Background()
+ _, _, jwksJSON := setupTestKeys(t)
+
+ // Create test server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(jwksJSON))
+ }))
+ defer server.Close()
+
+ cfg := RemoteJWKSConfig{
+ URLs: []string{server.URL},
+ IssuerToURL: map[string]string{
+ "https://example.com": server.URL,
+ },
+ RefreshInterval: time.Minute,
+ }
+
+ verifier, err := newRemoteTokenVerifier(ctx, cfg)
+ assert.NoError(t, err)
+ assert.NotNil(t, verifier)
+ assert.True(t, verifier.isRemote)
+ assert.NotNil(t, verifier.cache)
+ assert.Equal(t, server.URL, verifier.issuerToURL["https://example.com"])
+}
+
+func TestNewRemoteTokenVerifier_EmptyURLs(t *testing.T) {
+ ctx := context.Background()
+ cfg := RemoteJWKSConfig{}
+
+ verifier, err := newRemoteTokenVerifier(ctx, cfg)
+ assert.Error(t, err)
+ assert.Nil(t, verifier)
+ assert.Contains(t, err.Error(), "must provide at least one RemoteURL")
+}
+
+func TestNewRemoteTokenVerifier_DefaultRefreshInterval(t *testing.T) {
+ ctx := context.Background()
+ _, _, jwksJSON := setupTestKeys(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(jwksJSON))
+ }))
+ defer server.Close()
+
+ cfg := RemoteJWKSConfig{
+ URLs: []string{server.URL},
+ // RefreshInterval is 0, should use default (60 minutes)
+ }
+
+ verifier, err := newRemoteTokenVerifier(ctx, cfg)
+ assert.NoError(t, err)
+ assert.NotNil(t, verifier)
+}
+
+// Tests for NewTokenVerifier
+
+func TestNewTokenVerifier_LocalOnly(t *testing.T) {
+ ctx := context.Background()
+ _, _, jwksJSON := setupTestKeys(t)
+
+ cfg := TokenVerifierConfig{
+ Local: &LocalJWKSConfig{
+ JWKS: jwksJSON,
+ },
+ }
+
+ verifier, err := NewTokenVerifier(ctx, cfg)
+ assert.NoError(t, err)
+ assert.NotNil(t, verifier)
+ assert.NotNil(t, verifier.localKeySet)
+ assert.False(t, verifier.isRemote)
+}
+
+func TestNewTokenVerifier_RemoteOnly(t *testing.T) {
+ ctx := context.Background()
+ _, _, jwksJSON := setupTestKeys(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(jwksJSON))
+ }))
+ defer server.Close()
+
+ cfg := TokenVerifierConfig{
+ Remote: &RemoteJWKSConfig{
+ URLs: []string{server.URL},
+ },
+ }
+
+ verifier, err := NewTokenVerifier(ctx, cfg)
+ assert.NoError(t, err)
+ assert.NotNil(t, verifier)
+ assert.True(t, verifier.isRemote)
+ assert.NotNil(t, verifier.cache)
+}
+
+func TestNewTokenVerifier_Combined(t *testing.T) {
+ ctx := context.Background()
+ _, _, jwksJSON := setupTestKeys(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(jwksJSON))
+ }))
+ defer server.Close()
+
+ cfg := TokenVerifierConfig{
+ Local: &LocalJWKSConfig{
+ JWKS: jwksJSON,
+ },
+ Remote: &RemoteJWKSConfig{
+ URLs: []string{server.URL},
+ },
+ }
+
+ verifier, err := NewTokenVerifier(ctx, cfg)
+ assert.NoError(t, err)
+ assert.NotNil(t, verifier)
+ assert.True(t, verifier.isRemote)
+ assert.NotNil(t, verifier.cache)
+ assert.NotNil(t, verifier.localKeySet)
+}
+
+// Tests for newIntrospectionTokenVerifier
+
+func TestNewIntrospectionTokenVerifier_Success(t *testing.T) {
+ ctx := context.Background()
+
+ cfg := IntrospectionConfig{
+ Endpoint: "http://example.test/introspect",
+ Timeout: 2 * time.Second,
+ CacheTTL: 3 * time.Second,
+ NegativeCacheTTL: 1 * time.Second,
+ UseOnJWTFail: true,
+ IssuerToEndpoint: map[string]string{
+ "https://issuer.example": "http://example.test/iss-introspect",
+ },
+ DefaultCredentials: &IntrospectionCredentials{ClientID: "cid", ClientSecret: "sec"},
+ IssuerCredentials: map[string]IntrospectionCredentials{
+ "https://issuer.example": {ClientID: "icid", ClientSecret: "isec"},
+ },
+ }
+
+ v, err := newIntrospectionTokenVerifier(ctx, cfg)
+ require.NoError(t, err)
+ require.NotNil(t, v)
+
+ assert.True(t, v.introspectionEnabled)
+ assert.NotNil(t, v.httpClient)
+ assert.Equal(t, cfg.Endpoint, v.defaultIntrospectEP)
+ assert.Equal(t, cfg.CacheTTL, v.cacheTTL)
+ assert.Equal(t, cfg.NegativeCacheTTL, v.negativeCacheTTL)
+ assert.Equal(t, cfg.UseOnJWTFail, v.useIntrospectionOnFail)
+ assert.Equal(t, cfg.IssuerToEndpoint["https://issuer.example"], v.issuerToIntrospectEP["https://issuer.example"])
+ require.NotNil(t, v.defaultCreds)
+ assert.Equal(t, "cid", v.defaultCreds.ClientID)
+ assert.Equal(t, "sec", v.defaultCreds.ClientSecret)
+ assert.Equal(t, "icid", v.issuerCreds["https://issuer.example"].ClientID)
+ assert.Equal(t, "isec", v.issuerCreds["https://issuer.example"].ClientSecret)
+}
+
+func TestNewIntrospectionTokenVerifier_Defaults(t *testing.T) {
+ ctx := context.Background()
+
+ cfg := IntrospectionConfig{
+ Endpoint: "http://example.test/introspect",
+ // Timeout, CacheTTL, NegativeCacheTTL left as zero to trigger defaults
+ }
+
+ v, err := newIntrospectionTokenVerifier(ctx, cfg)
+ require.NoError(t, err)
+ require.NotNil(t, v)
+
+ // Default timeouts
+ require.NotNil(t, v.httpClient)
+ assert.Equal(t, 5*time.Second, v.httpClient.Timeout)
+ assert.Equal(t, 60*time.Second, v.cacheTTL)
+ assert.Equal(t, 15*time.Second, v.negativeCacheTTL)
+}
+
+// Ensure NewTokenVerifier (introspection-only) constructs an introspection-enabled verifier
+func TestNewTokenVerifier_IntrospectionOnly_Constructed(t *testing.T) {
+ ctx := context.Background()
+
+ v, err := NewTokenVerifier(ctx, TokenVerifierConfig{
+ Introspection: &IntrospectionConfig{Endpoint: "http://example.test/introspect"},
+ })
+ require.NoError(t, err)
+ require.NotNil(t, v)
+
+ assert.True(t, v.introspectionEnabled)
+ assert.Nil(t, v.localKeySet)
+ assert.False(t, v.isRemote)
+}
+
+// Tests for VerifyAccessToken
+
+func TestVerifyAccessToken_LocalSuccess(t *testing.T) {
+ ctx := context.Background()
+ privateKey, _, jwksJSON := setupTestKeys(t)
+
+ cfg := LocalJWKSConfig{
+ JWKS: jwksJSON,
+ }
+
+ verifier, err := newLocalTokenVerifier(ctx, cfg)
+ require.NoError(t, err)
+
+ // Create valid token
+ tokenStr, err := createTestToken(privateKey, "test-key-1", map[string]interface{}{
+ "custom_claim": "custom_value",
+ })
+ require.NoError(t, err)
+
+ authInfo, err := verifier.VerifyAccessToken(ctx, tokenStr)
+ assert.NoError(t, err)
+ assert.Equal(t, tokenStr, authInfo.Token)
+ assert.Equal(t, "test-client", authInfo.ClientID)
+ assert.Equal(t, []string{"read", "write"}, authInfo.Scopes)
+ assert.NotNil(t, authInfo.Resource)
+ assert.Equal(t, "https://api.example.com", authInfo.Resource.String())
+ assert.Equal(t, "custom_value", authInfo.Extra["custom_claim"])
+}
+
+func TestVerifyAccessToken_InvalidToken(t *testing.T) {
+ ctx := context.Background()
+ _, _, jwksJSON := setupTestKeys(t)
+
+ cfg := LocalJWKSConfig{
+ JWKS: jwksJSON,
+ }
+
+ verifier, err := newLocalTokenVerifier(ctx, cfg)
+ require.NoError(t, err)
+
+ authInfo, err := verifier.VerifyAccessToken(ctx, "invalid-token")
+ assert.Error(t, err)
+ assert.Empty(t, authInfo)
+}
+
+func TestVerifyAccessToken_ExpiredToken(t *testing.T) {
+ ctx := context.Background()
+ privateKey, _, jwksJSON := setupTestKeys(t)
+
+ cfg := LocalJWKSConfig{
+ JWKS: jwksJSON,
+ }
+
+ verifier, err := newLocalTokenVerifier(ctx, cfg)
+ require.NoError(t, err)
+
+ // Create expired token
+ key, err := jwk.FromRaw(privateKey)
+ require.NoError(t, err)
+
+ err = key.Set(jwk.KeyIDKey, "test-key-1")
+ require.NoError(t, err)
+
+ now := time.Now()
+ token := jwt.New()
+
+ _ = token.Set(jwt.IssuerKey, "https://example.com")
+ _ = token.Set(jwt.SubjectKey, "user123")
+ _ = token.Set(jwt.AudienceKey, []string{"https://api.example.com"})
+ _ = token.Set(jwt.ExpirationKey, now.Add(-time.Hour)) // Expired 1 hour ago
+ _ = token.Set(jwt.IssuedAtKey, now.Add(-2*time.Hour))
+ _ = token.Set(jwt.JwtIDKey, "jti-123")
+ _ = token.Set("client_id", "test-client")
+ _ = token.Set("scope", "read write")
+ _ = token.Set("kid", "test-key-1")
+
+ signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key))
+ require.NoError(t, err)
+
+ authInfo, err := verifier.VerifyAccessToken(ctx, string(signed))
+ assert.Error(t, err)
+ assert.Empty(t, authInfo)
+}
+
+func TestVerifyAccessToken_MissingRequiredClaims(t *testing.T) {
+ ctx := context.Background()
+ privateKey, _, jwksJSON := setupTestKeys(t)
+
+ cfg := LocalJWKSConfig{
+ JWKS: jwksJSON,
+ }
+
+ verifier, err := newLocalTokenVerifier(ctx, cfg)
+ require.NoError(t, err)
+
+ // Create token missing required claims
+ key, err := jwk.FromRaw(privateKey)
+ require.NoError(t, err)
+
+ err = key.Set(jwk.KeyIDKey, "test-key-1")
+ require.NoError(t, err)
+
+ token := jwt.New()
+ _ = token.Set(jwt.IssuerKey, "https://example.com")
+ // Missing other required claims
+ _ = token.Set("kid", "test-key-1")
+
+ signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key))
+ require.NoError(t, err)
+
+ authInfo, err := verifier.VerifyAccessToken(ctx, string(signed))
+ assert.Error(t, err)
+ assert.Empty(t, authInfo)
+}
+
+func TestVerifyAccessToken_NoMatchingKey(t *testing.T) {
+ ctx := context.Background()
+ privateKey, _, jwksJSON := setupTestKeys(t)
+
+ cfg := LocalJWKSConfig{
+ JWKS: jwksJSON,
+ }
+
+ verifier, err := newLocalTokenVerifier(ctx, cfg)
+ require.NoError(t, err)
+
+ // Create token with different key ID
+ tokenStr, err := createTestToken(privateKey, "different-key-id", nil)
+ require.NoError(t, err)
+
+ authInfo, err := verifier.VerifyAccessToken(ctx, tokenStr)
+ assert.Error(t, err)
+ assert.Empty(t, authInfo)
+}
+
+// Tests for extractScopes
+
+func TestExtractScopes_StringFormat(t *testing.T) {
+ token := jwt.New()
+ _ = token.Set("scope", "read write admin")
+
+ scopes, err := extractScopes(token)
+ assert.NoError(t, err)
+ assert.Equal(t, []string{"read", "write", "admin"}, scopes)
+}
+
+func TestExtractScopes_ArrayFormat(t *testing.T) {
+ token := jwt.New()
+ _ = token.Set("scope", []string{"read", "write", "admin"})
+
+ scopes, err := extractScopes(token)
+ assert.NoError(t, err)
+ assert.Equal(t, []string{"read", "write", "admin"}, scopes)
+}
+
+func TestExtractScopes_EmptyString(t *testing.T) {
+ token := jwt.New()
+ _ = token.Set("scope", "")
+
+ scopes, err := extractScopes(token)
+ assert.NoError(t, err)
+ assert.Empty(t, scopes)
+}
+
+func TestExtractScopes_EmptyArray(t *testing.T) {
+ token := jwt.New()
+ _ = token.Set("scope", []string{})
+
+ scopes, err := extractScopes(token)
+ assert.NoError(t, err)
+ assert.Empty(t, scopes)
+}
+
+// Tests for extractResource
+
+func TestExtractResource_ValidURL(t *testing.T) {
+ token := jwt.New()
+ _ = token.Set(jwt.AudienceKey, []string{"https://api.example.com/resource"})
+
+ resource, err := extractResource(token)
+ assert.NoError(t, err)
+ assert.NotNil(t, resource)
+ assert.Equal(t, "https://api.example.com/resource", resource.String())
+}
+
+func TestExtractResource_URLWithFragment(t *testing.T) {
+ token := jwt.New()
+ _ = token.Set(jwt.AudienceKey, []string{"https://api.example.com/resource#fragment"})
+
+ resource, err := extractResource(token)
+ assert.NoError(t, err)
+ assert.NotNil(t, resource)
+ assert.Equal(t, "https://api.example.com/resource", resource.String()) // Fragment should be removed
+}
+
+func TestExtractResource_MissingAudience(t *testing.T) {
+ token := jwt.New()
+
+ resource, err := extractResource(token)
+ assert.Error(t, err)
+ assert.Nil(t, resource)
+}
+
+// Tests for extractExtra
+
+func TestExtractExtra_WithCustomClaims(t *testing.T) {
+ token := jwt.New()
+ _ = token.Set(jwt.IssuerKey, "https://example.com") // Standard claim
+ _ = token.Set("custom_claim1", "value1") // Custom claim
+ _ = token.Set("custom_claim2", 123) // Custom claim
+ _ = token.Set("client_id", "test-client") // Standard claim
+
+ extra := extractExtra(token)
+ assert.NotNil(t, extra)
+ assert.Equal(t, "value1", extra["custom_claim1"])
+ assert.Equal(t, 123, extra["custom_claim2"])
+ assert.NotContains(t, extra, "iss") // Standard claims should be excluded
+ assert.NotContains(t, extra, "client_id") // Standard claims should be excluded
+}
+
+func TestExtractExtra_NoCustomClaims(t *testing.T) {
+ token := jwt.New()
+ _ = token.Set(jwt.IssuerKey, "https://example.com")
+ _ = token.Set("client_id", "test-client")
+
+ extra := extractExtra(token)
+ assert.Nil(t, extra) // Should return nil for omitempty
+}
+
+// Integration tests
+
+func TestVerifyAccessToken_RemoteJWKS(t *testing.T) {
+ ctx := context.Background()
+ privateKey, _, jwksJSON := setupTestKeys(t)
+
+ // Create test server for JWKS
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(jwksJSON))
+ }))
+ defer server.Close()
+
+ cfg := RemoteJWKSConfig{
+ URLs: []string{server.URL},
+ IssuerToURL: map[string]string{
+ "https://example.com": server.URL,
+ },
+ }
+
+ verifier, err := newRemoteTokenVerifier(ctx, cfg)
+ require.NoError(t, err)
+
+ // Create valid token
+ tokenStr, err := createTestToken(privateKey, "test-key-1", nil)
+ require.NoError(t, err)
+
+ authInfo, err := verifier.VerifyAccessToken(ctx, tokenStr)
+ assert.NoError(t, err)
+ assert.Equal(t, tokenStr, authInfo.Token)
+ assert.Equal(t, "test-client", authInfo.ClientID)
+}
+
+func TestVerifyAccessToken_MixedMode_LocalKeyFound(t *testing.T) {
+ ctx := context.Background()
+ privateKey, _, jwksJSON := setupTestKeys(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(jwksJSON))
+ }))
+ defer server.Close()
+
+ cfg := TokenVerifierConfig{
+ Local: &LocalJWKSConfig{
+ JWKS: jwksJSON,
+ },
+ Remote: &RemoteJWKSConfig{
+ URLs: []string{server.URL},
+ IssuerToURL: map[string]string{
+ "https://example.com": server.URL,
+ },
+ },
+ }
+
+ verifier, err := NewTokenVerifier(ctx, cfg)
+ require.NoError(t, err)
+
+ // Create valid token
+ tokenStr, err := createTestToken(privateKey, "test-key-1", nil)
+ require.NoError(t, err)
+
+ authInfo, err := verifier.VerifyAccessToken(ctx, tokenStr)
+ assert.NoError(t, err)
+ assert.Equal(t, tokenStr, authInfo.Token)
+}
+
+// Introspection-only mode: no JWKS configured, only introspection is used.
+func TestVerifyAccessToken_IntrospectionOnly_Mode(t *testing.T) {
+ ctx := context.Background()
+
+ // Fake introspection endpoint which returns active token and minimal payload
+ introspectCalls := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ introspectCalls++
+ _ = r.ParseForm()
+ token := r.FormValue("token")
+ // return an active response regardless of token content
+ w.Header().Set("Content-Type", "application/json")
+ resp := map[string]interface{}{
+ "active": true,
+ "scope": "read write",
+ "client_id": "cli-123",
+ "exp": float64(time.Now().Add(5 * time.Minute).Unix()),
+ "aud": "https://api.example.com",
+ }
+ // echo part to ensure parser tolerates arbitrary fields
+ if token != "" {
+ resp["token_hash"] = len(token)
+ }
+ _ = json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ v, err := NewTokenVerifier(ctx, TokenVerifierConfig{
+ Introspection: &IntrospectionConfig{
+ Endpoint: server.URL,
+ Timeout: 2 * time.Second,
+ CacheTTL: 2 * time.Second,
+ NegativeCacheTTL: 1 * time.Second,
+ UseOnJWTFail: true,
+ },
+ })
+ require.NoError(t, err)
+
+ // Opaque token scenario
+ ai, err := v.VerifyAccessToken(ctx, "opaque-token-abc")
+ assert.NoError(t, err)
+ assert.Equal(t, "cli-123", ai.ClientID)
+ assert.ElementsMatch(t, []string{"read", "write"}, ai.Scopes)
+ assert.NotNil(t, ai.ExpiresAt)
+
+ // Cache hit path
+ ai2, err := v.VerifyAccessToken(ctx, "opaque-token-abc")
+ assert.NoError(t, err)
+ assert.Equal(t, ai.ClientID, ai2.ClientID)
+ assert.LessOrEqual(t, introspectCalls, 2) // first call + maybe cache check
+}
+
+// Key rotation: first JWKS does not contain target kid, second fetch returns rotated JWKS.
+func TestVerifyAccessToken_RemoteJWKS_KeyRotation_RefreshOnKidMiss(t *testing.T) {
+ ctx := context.Background()
+
+ // old key (won't match token)
+ oldPriv, err := generateRSAKey()
+ require.NoError(t, err)
+ oldPub, err := createTestJWK(oldPriv, "old-key")
+ require.NoError(t, err)
+
+ // new key (used to sign token)
+ newPriv, err := generateRSAKey()
+ require.NoError(t, err)
+ newPub, err := createTestJWK(newPriv, "new-key")
+ require.NoError(t, err)
+
+ jwksOld := createTestJWKS(oldPub)
+ jwksNew := createTestJWKS(newPub)
+
+ // JWKS server: first call -> old, subsequent -> new
+ callCount := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if callCount == 0 {
+ _, _ = w.Write([]byte(jwksOld))
+ } else {
+ _, _ = w.Write([]byte(jwksNew))
+ }
+ callCount++
+ }))
+ defer server.Close()
+
+ cfg := RemoteJWKSConfig{
+ URLs: []string{server.URL},
+ IssuerToURL: map[string]string{
+ "https://example.com": server.URL,
+ },
+ RefreshInterval: time.Minute,
+ }
+
+ verifier, err := newRemoteTokenVerifier(ctx, cfg)
+ require.NoError(t, err)
+
+ // Token signed by new key (kid=new-key). First cache lookup sees old JWKS
+ tokenStr, err := createTestToken(newPriv, "new-key", nil)
+ require.NoError(t, err)
+
+ authInfo, err := verifier.VerifyAccessToken(ctx, tokenStr)
+ assert.NoError(t, err)
+ assert.Equal(t, tokenStr, authInfo.Token)
+
+ // Expect at least two server calls: initial cache fetch + forced refresh
+ assert.GreaterOrEqual(t, callCount, 2)
+}
diff --git a/internal/auth/server/types.go b/internal/auth/server/types.go
new file mode 100644
index 0000000..78015d2
--- /dev/null
+++ b/internal/auth/server/types.go
@@ -0,0 +1,40 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package server
+
+import "net/url"
+
+// AuthInfo holds information about a validated access token
+// and is provided to request handlers
+type AuthInfo struct {
+ // Token is the original access token string
+ Token string `json:"token"`
+
+ // ClientID is the client identifier associated with this token
+ ClientID string `json:"clientId"`
+
+ // Subject is the principal (end-user or client) the token represents
+ // Typically comes from the JWT 'sub' claim or introspection response
+ Subject string `json:"subject,omitempty"`
+
+ // Scopes are the permission scopes granted with this token
+ Scopes []string `json:"scopes"`
+
+ // ExpiresAt is the token expiration time in seconds since Unix epoch
+ // If nil it means no expiration was provided
+ ExpiresAt *int64 `json:"expiresAt,omitempty"`
+
+ // Resource is the RFC 8707 resource server identifier for which this token is valid
+ // If set it must match the resource identifier of the MCP server excluding any fragment
+ // If nil it means no resource was provided
+ Resource *url.URL `json:"resource,omitempty"`
+
+ // Extra contains any additional data attached to this token
+ // Used for passing custom claims or metadata alongside authentication info
+ // If nil it means no extra data was provided
+ Extra map[string]interface{} `json:"extra,omitempty"`
+}
diff --git a/internal/auth/types.go b/internal/auth/types.go
new file mode 100644
index 0000000..b7b6d3d
--- /dev/null
+++ b/internal/auth/types.go
@@ -0,0 +1,271 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package auth
+
+import "net/http"
+
+// OAuthClientMetadata defines RFC 7591 OAuth 2.0 Dynamic Client Registration metadata
+type OAuthClientMetadata struct {
+ RedirectURIs []string `json:"redirect_uris"` // Allowed redirect URIs for the client
+ TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` // Client auth method at token endpoint
+ GrantTypes []string `json:"grant_types,omitempty"` // Supported grant types
+ ResponseTypes []string `json:"response_types,omitempty"` // Supported response types
+ ClientName *string `json:"client_name,omitempty"` // Human readable client name
+ ClientURI *string `json:"client_uri,omitempty"` // Client homepage URL
+ LogoURI *string `json:"logo_uri,omitempty"` // Client logo URL
+ Scope *string `json:"scope,omitempty"` // Default requested scopes as space separated string
+ Contacts []string `json:"contacts,omitempty"` // Admin contact emails
+ TosURI *string `json:"tos_uri,omitempty"` // Terms of service URL
+ PolicyURI *string `json:"policy_uri,omitempty"` // Privacy policy URL
+ JwksURI *string `json:"jwks_uri,omitempty"` // URL to client JWKS
+ Jwks interface{} `json:"jwks,omitempty"` // Inline JWKS object
+ SoftwareID *string `json:"software_id,omitempty"` // Software identifier
+ SoftwareVersion *string `json:"software_version,omitempty"` // Software version
+ SoftwareStatement *string `json:"software_statement,omitempty"` // Software statement assertion
+}
+
+// OAuthClientInformation defines RFC 7591 OAuth 2.0 Dynamic Client Registration client information
+type OAuthClientInformation struct {
+ ClientID string `json:"client_id"` // Issued client identifier
+ ClientSecret string `json:"client_secret,omitempty"` // Issued client secret if applicable
+ ClientIDIssuedAt *int64 `json:"client_id_issued_at,omitempty"` // Issue time in seconds since epoch
+ ClientSecretExpiresAt *int64 `json:"client_secret_expires_at,omitempty"` // Secret expiry time in seconds since epoch
+}
+
+// OAuthClientInformationFull defines RFC 7591 OAuth 2.0 Dynamic Client Registration full response
+type OAuthClientInformationFull struct {
+ OAuthClientMetadata
+ OAuthClientInformation
+}
+
+// OAuthProtectedResourceMetadata defines RFC 9728 OAuth Protected Resource metadata
+type OAuthProtectedResourceMetadata struct {
+ Resource string `json:"resource"` // Resource identifier URI
+ AuthorizationServers []string `json:"authorization_servers,omitempty"` // Authorization server issuers supporting this resource
+ JWKSURI *string `json:"jwks_uri,omitempty"` // JWKS URI used by the resource
+ ScopesSupported []string `json:"scopes_supported,omitempty"` // Supported scopes
+ BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` // Supported bearer presentation methods
+ ResourceSigningAlgs []string `json:"resource_signing_alg_values_supported,omitempty"` // Supported signing algorithms
+ ResourceName *string `json:"resource_name,omitempty"` // Human friendly resource name
+ ResourceDocumentation *string `json:"resource_documentation,omitempty"` // Documentation URL
+ ResourcePolicyURI *string `json:"resource_policy_uri,omitempty"` // Policy URL
+ ResourceTOSURI *string `json:"resource_tos_uri,omitempty"` // Terms of service URL
+ TLSCertBoundAT *bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` // Whether MTLS bound AT are required or supported
+ AuthzDetailsTypes []string `json:"authorization_details_types_supported,omitempty"` // Supported authorization details types
+ DPoPSigningAlgs []string `json:"dpop_signing_alg_values_supported,omitempty"` // Supported DPoP signing algorithms
+ DPoPBoundATRequired *bool `json:"dpop_bound_access_tokens_required,omitempty"` // Whether DPoP bound access tokens are required
+}
+
+// OAuthTokens defines the OAuth 2.1 token response
+type OAuthTokens struct {
+ AccessToken string `json:"access_token"` // Access token value required non empty
+ IDToken *string `json:"id_token,omitempty"` // OIDC ID token optional non empty if present
+ TokenType string `json:"token_type"` // Token type for example Bearer required non empty
+ ExpiresIn *int64 `json:"expires_in,omitempty"` // Access token lifetime in seconds optional positive if present
+ Scope *string `json:"scope,omitempty"` // Granted scope as space separated string optional non empty if present
+ RefreshToken *string `json:"refresh_token,omitempty"` // Refresh token optional non empty if present
+}
+
+// OAuthTokenRevocationRequest represents a token revocation request payload
+type OAuthTokenRevocationRequest struct {
+ Token string `json:"token"` // Token to revoke
+ TokenTypeHint string `json:"token_type_hint,omitempty"` // Optional token type hint
+}
+
+// AuthorizationServerMetadata represents OAuth 2.0 or OpenID Connect server metadata
+type AuthorizationServerMetadata interface {
+ // GetIssuer returns the issuer identifier
+ GetIssuer() string
+ // GetAuthorizationEndpoint returns the authorization endpoint URL
+ GetAuthorizationEndpoint() string
+ // GetTokenEndpoint returns the token endpoint URL
+ GetTokenEndpoint() string
+ // GetResponseTypesSupported returns supported response types
+ GetResponseTypesSupported() []string
+ // GetGrantTypesSupported returns supported grant types
+ GetGrantTypesSupported() []string
+ // GetTokenEndpointAuthMethodsSupported returns supported client auth methods for the token endpoint
+ GetTokenEndpointAuthMethodsSupported() []string
+}
+
+// OAuthMetadata defines OAuth 2.0 Authorization Server Metadata per RFC 8414
+type OAuthMetadata struct {
+ Issuer string `json:"issuer"` // Issuer identifier
+ AuthorizationEndpoint string `json:"authorization_endpoint"` // Authorization endpoint URL
+ TokenEndpoint string `json:"token_endpoint"` // Token endpoint URL
+ RegistrationEndpoint *string `json:"registration_endpoint,omitempty"` // Dynamic client registration endpoint
+ ScopesSupported []string `json:"scopes_supported,omitempty"` // Supported scopes
+ ResponseTypesSupported []string `json:"response_types_supported"` // Supported response types
+ ResponseModesSupported []string `json:"response_modes_supported,omitempty"` // Supported response modes
+ GrantTypesSupported []string `json:"grant_types_supported,omitempty"` // Supported grant types
+ TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` // Supported token endpoint auth methods
+ TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` // Supported signing algs for client auth
+ ServiceDocumentation *string `json:"service_documentation,omitempty"` // Service documentation URL
+ RevocationEndpoint *string `json:"revocation_endpoint,omitempty"` // Token revocation endpoint
+ RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` // Supported auth methods for revocation
+ RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` // Supported signing algs for revocation
+ IntrospectionEndpoint *string `json:"introspection_endpoint,omitempty"` // Token introspection endpoint
+ IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` // Supported auth methods for introspection
+ IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` // Supported signing algs for introspection
+ CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` // Supported PKCE methods
+}
+
+// GetIssuer returns the issuer identifier
+func (m OAuthMetadata) GetIssuer() string {
+ return m.Issuer
+}
+
+// GetAuthorizationEndpoint returns the authorization endpoint URL
+func (m OAuthMetadata) GetAuthorizationEndpoint() string {
+ return m.AuthorizationEndpoint
+}
+
+// GetTokenEndpoint returns the token endpoint URL
+func (m OAuthMetadata) GetTokenEndpoint() string {
+ return m.TokenEndpoint
+}
+
+// GetResponseTypesSupported returns supported response types
+func (m OAuthMetadata) GetResponseTypesSupported() []string {
+ return m.ResponseTypesSupported
+}
+
+// GetGrantTypesSupported returns supported grant types
+func (m OAuthMetadata) GetGrantTypesSupported() []string {
+ return m.GrantTypesSupported
+}
+
+// GetTokenEndpointAuthMethodsSupported returns supported client auth methods for the token endpoint
+func (m OAuthMetadata) GetTokenEndpointAuthMethodsSupported() []string {
+ return m.TokenEndpointAuthMethodsSupported
+}
+
+// OpenIdProviderMetadata defines OpenID Connect Discovery 1.0 provider metadata
+type OpenIdProviderMetadata struct {
+ Issuer string `json:"issuer"` // Issuer identifier
+ AuthorizationEndpoint string `json:"authorization_endpoint"` // Authorization endpoint URL
+ TokenEndpoint string `json:"token_endpoint"` // Token endpoint URL
+ UserinfoEndpoint *string `json:"userinfo_endpoint,omitempty"` // Userinfo endpoint URL
+ JwksURI string `json:"jwks_uri"` // JWKS URI
+ RegistrationEndpoint *string `json:"registration_endpoint,omitempty"` // Dynamic client registration endpoint
+ ScopesSupported []string `json:"scopes_supported,omitempty"` // Supported scopes
+ ResponseTypesSupported []string `json:"response_types_supported"` // Supported response types
+ ResponseModesSupported []string `json:"response_modes_supported,omitempty"` // Supported response modes
+ GrantTypesSupported []string `json:"grant_types_supported,omitempty"` // Supported grant types
+ AcrValuesSupported []string `json:"acr_values_supported,omitempty"` // Supported ACR values
+ SubjectTypesSupported []string `json:"subject_types_supported"` // Supported subject types
+ IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` // Supported ID token signing algs
+ IdTokenEncryptionAlgValuesSupported []string `json:"id_token_encryption_alg_values_supported,omitempty"` // Supported ID token encryption algs
+ IdTokenEncryptionEncValuesSupported []string `json:"id_token_encryption_enc_values_supported,omitempty"` // Supported ID token encryption enc values
+ UserinfoSigningAlgValuesSupported []string `json:"userinfo_signing_alg_values_supported,omitempty"` // Supported userinfo signing algs
+ UserinfoEncryptionAlgValuesSupported []string `json:"userinfo_encryption_alg_values_supported,omitempty"` // Supported userinfo encryption algs
+ UserinfoEncryptionEncValuesSupported []string `json:"userinfo_encryption_enc_values_supported,omitempty"` // Supported userinfo encryption enc values
+ RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported,omitempty"` // Supported request object signing algs
+ RequestObjectEncryptionAlgValuesSupported []string `json:"request_object_encryption_alg_values_supported,omitempty"` // Supported request object encryption algs
+ RequestObjectEncryptionEncValuesSupported []string `json:"request_object_encryption_enc_values_supported,omitempty"` // Supported request object encryption enc values
+ TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` // Supported token endpoint auth methods
+ TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` // Supported signing algs for token endpoint auth
+ DisplayValuesSupported []string `json:"display_values_supported,omitempty"` // Supported display values
+ ClaimTypesSupported []string `json:"claim_types_supported,omitempty"` // Supported claim types
+ ClaimsSupported []string `json:"claims_supported,omitempty"` // Supported claims
+ ServiceDocumentation *string `json:"service_documentation,omitempty"` // Service documentation URL
+ ClaimsLocalesSupported []string `json:"claims_locales_supported,omitempty"` // Supported claims locales
+ UiLocalesSupported []string `json:"ui_locales_supported,omitempty"` // Supported UI locales
+ ClaimsParameterSupported *bool `json:"claims_parameter_supported,omitempty"` // Whether claims parameter is supported
+ RequestParameterSupported *bool `json:"request_parameter_supported,omitempty"` // Whether request parameter is supported
+ RequestUriParameterSupported *bool `json:"request_uri_parameter_supported,omitempty"` // Whether request_uri is supported
+ RequireRequestUriRegistration *bool `json:"require_request_uri_registration,omitempty"` // Whether request_uri registration is required
+ OpPolicyUri *string `json:"op_policy_uri,omitempty"` // OP policy URL
+ OpTosUri *string `json:"op_tos_uri,omitempty"` // OP terms of service URL
+}
+
+// AuthOptions contains configuration options for the OAuth authorization process
+type AuthOptions struct {
+ ServerUrl string // OAuth server URL
+ ResourceMetadataUrl *string // Resource metadata URL
+ AuthorizationCode *string // Authorization code to exchange
+ Scope *string // Requested scopes as space separated string
+ ProtocolVersion *string // OAuth protocol version string
+ FetchFn FetchFunc // Custom HTTP request function
+}
+
+// DiscoveryOptions contains options for discovering OAuth server metadata
+type DiscoveryOptions struct {
+ ServerUrl string // Base server URL for discovery
+ ResourceMetadataUrl *string // Resource metadata URL for RFC 9728
+ FetchFn FetchFunc // Custom HTTP request function
+ ProtocolVersion *string // Protocol version hint
+}
+
+// GetIssuer returns the issuer identifier
+func (m OpenIdProviderMetadata) GetIssuer() string {
+ return m.Issuer
+}
+
+// GetAuthorizationEndpoint returns the authorization endpoint URL
+func (m OpenIdProviderMetadata) GetAuthorizationEndpoint() string {
+ return m.AuthorizationEndpoint
+}
+
+// GetTokenEndpoint returns the token endpoint URL
+func (m OpenIdProviderMetadata) GetTokenEndpoint() string {
+ return m.TokenEndpoint
+}
+
+// GetResponseTypesSupported returns supported response types
+func (m OpenIdProviderMetadata) GetResponseTypesSupported() []string {
+ return m.ResponseTypesSupported
+}
+
+// GetGrantTypesSupported returns supported grant types
+func (m OpenIdProviderMetadata) GetGrantTypesSupported() []string {
+ return m.GrantTypesSupported
+}
+
+// GetTokenEndpointAuthMethodsSupported returns supported client auth methods for the token endpoint
+func (m OpenIdProviderMetadata) GetTokenEndpointAuthMethodsSupported() []string {
+ return m.TokenEndpointAuthMethodsSupported
+}
+
+// OpenIdProviderDiscoveryMetadata merges OpenID Provider metadata with OAuth 2.0 fields for discovery
+type OpenIdProviderDiscoveryMetadata struct {
+ OpenIdProviderMetadata // Embedded OIDC provider metadata
+ CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` // Supported PKCE methods
+}
+
+// GetIssuer returns the issuer identifier
+func (m OpenIdProviderDiscoveryMetadata) GetIssuer() string {
+ return m.OpenIdProviderMetadata.Issuer
+}
+
+// GetAuthorizationEndpoint returns the authorization endpoint URL
+func (m OpenIdProviderDiscoveryMetadata) GetAuthorizationEndpoint() string {
+ return m.OpenIdProviderMetadata.AuthorizationEndpoint
+}
+
+// GetTokenEndpoint returns the token endpoint URL
+func (m OpenIdProviderDiscoveryMetadata) GetTokenEndpoint() string {
+ return m.OpenIdProviderMetadata.TokenEndpoint
+}
+
+// GetResponseTypesSupported returns supported response types
+func (m OpenIdProviderDiscoveryMetadata) GetResponseTypesSupported() []string {
+ return m.OpenIdProviderMetadata.ResponseTypesSupported
+}
+
+// GetGrantTypesSupported returns supported grant types
+func (m OpenIdProviderDiscoveryMetadata) GetGrantTypesSupported() []string {
+ return m.OpenIdProviderMetadata.GrantTypesSupported
+}
+
+// GetTokenEndpointAuthMethodsSupported returns supported client auth methods for the token endpoint
+func (m OpenIdProviderDiscoveryMetadata) GetTokenEndpointAuthMethodsSupported() []string {
+ return m.OpenIdProviderMetadata.TokenEndpointAuthMethodsSupported
+}
+
+// FetchFunc is a customizable HTTP fetch function used by discovery and auth flows
+type FetchFunc func(url string, req *http.Request) (*http.Response, error)
diff --git a/internal/auth/utils.go b/internal/auth/utils.go
new file mode 100644
index 0000000..01e5ac7
--- /dev/null
+++ b/internal/auth/utils.go
@@ -0,0 +1,94 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package auth
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+// Utilities for handling OAuth resource URIs.
+
+// ResourceURLFromServerURL converts a server URL to a resource URL by removing the fragment.
+func ResourceURLFromServerURL(u interface{}) (*url.URL, error) {
+ var resourceURL *url.URL
+ var err error
+
+ switch v := u.(type) {
+ case string:
+ resourceURL, err = url.Parse(v)
+ if err != nil {
+ return nil, err
+ }
+ case *url.URL:
+ resourceURL, err = url.Parse(v.String())
+ if err != nil {
+ return nil, err
+ }
+ default:
+ return nil, fmt.Errorf("unsupported URL type")
+ }
+
+ // Remove fragment
+ resourceURL.Fragment = ""
+ return resourceURL, nil
+}
+
+// CheckResourceAllowedParams represents the parameters for CheckResourceAllowed function
+type CheckResourceAllowedParams struct {
+ RequestedResource interface{} // URL string or *url.URL
+ ConfiguredResource interface{} // URL string or *url.URL
+}
+
+// CheckResourceAllowed checks if a requested resource URL matches a configured resource URL.
+func CheckResourceAllowed(params CheckResourceAllowedParams) (bool, error) {
+ requested, err := parseURL(params.RequestedResource)
+ if err != nil {
+ return false, err
+ }
+
+ configured, err := parseURL(params.ConfiguredResource)
+ if err != nil {
+ return false, err
+ }
+
+ // Compare the origin (scheme, domain, and port)
+ if requested.Scheme != configured.Scheme ||
+ requested.Host != configured.Host {
+ return false, nil
+ }
+
+ // Handle cases like requested=/foo and configured=/foo/
+ if len(requested.Path) < len(configured.Path) {
+ return false, nil
+ }
+
+ requestedPath := requested.Path
+ if !strings.HasSuffix(requestedPath, "/") {
+ requestedPath = requestedPath + "/"
+ }
+
+ configuredPath := configured.Path
+ if !strings.HasSuffix(configuredPath, "/") {
+ configuredPath = configuredPath + "/"
+ }
+
+ return strings.HasPrefix(requestedPath, configuredPath), nil
+}
+
+// parseURL is a helper function to parse URL from string or *url.URL
+func parseURL(u interface{}) (*url.URL, error) {
+ switch v := u.(type) {
+ case string:
+ return url.Parse(v)
+ case *url.URL:
+ return url.Parse(v.String())
+ default:
+ return nil, fmt.Errorf("unsupported URL type")
+ }
+}
diff --git a/internal/auth/utils_test.go b/internal/auth/utils_test.go
new file mode 100644
index 0000000..1dac578
--- /dev/null
+++ b/internal/auth/utils_test.go
@@ -0,0 +1,228 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package auth
+
+import (
+ "net/url"
+ "testing"
+)
+
+func TestResourceURLFromServerURL_JSParity(t *testing.T) {
+ t.Run("remove fragments", func(t *testing.T) {
+ got, err := ResourceURLFromServerURL("https://example.com/path#fragment")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.String() != "https://example.com/path" {
+ t.Fatalf("got %q, want %q", got.String(), "https://example.com/path")
+ }
+
+ got, err = ResourceURLFromServerURL("https://example.com#fragment")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.String() != "https://example.com" && got.String() != "https://example.com/" {
+ t.Fatalf("got %q, want https://example.com(/)", got.String())
+ }
+
+ got, err = ResourceURLFromServerURL("https://example.com/path?query=1#fragment")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.String() != "https://example.com/path?query=1" {
+ t.Fatalf("got %q, want %q", got.String(), "https://example.com/path?query=1")
+ }
+ })
+
+ t.Run("no fragment -> unchanged", func(t *testing.T) {
+ cases := []string{
+ "https://example.com",
+ "https://example.com/path",
+ "https://example.com/path?query=1",
+ }
+ for _, in := range cases {
+ got, err := ResourceURLFromServerURL(in)
+ if err != nil {
+ t.Fatalf("unexpected error for %q: %v", in, err)
+ }
+ // url.String() may print with or without a trailing slash when the path is rooted; both are accepted.
+ want := in
+ if got.String() != want && !(want == "https://example.com" && got.String() == "https://example.com/") {
+ t.Fatalf("got %q, want %q", got.String(), want)
+ }
+ }
+ })
+
+ t.Run("keep everything else unchanged (except fragment)", func(t *testing.T) {
+ got, err := ResourceURLFromServerURL("https://example.com:443/path")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.String() != "https://example.com:443/path" {
+ t.Fatalf("got %q, want %q", got.String(), "https://example.com:443/path")
+ }
+
+ got, err = ResourceURLFromServerURL("https://example.com:8080/path")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.String() != "https://example.com:8080/path" {
+ t.Fatalf("got %q, want %q", got.String(), "https://example.com:8080/path")
+ }
+
+ got, err = ResourceURLFromServerURL("https://example.com/?foo=bar&baz=qux")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.String() != "https://example.com/?foo=bar&baz=qux" && got.String() != "https://example.com?foo=bar&baz=qux" {
+ t.Fatalf("got %q, want https://example.com/?foo=bar&baz=qux", got.String())
+ }
+
+ got, err = ResourceURLFromServerURL("https://example.com/")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.String() != "https://example.com/" {
+ t.Fatalf("got %q, want %q", got.String(), "https://example.com/")
+ }
+
+ got, err = ResourceURLFromServerURL("https://example.com/path/")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.String() != "https://example.com/path/" {
+ t.Fatalf("got %q, want %q", got.String(), "https://example.com/path/")
+ }
+ })
+
+ t.Run("unsupported type -> error", func(t *testing.T) {
+ _, err := ResourceURLFromServerURL(123)
+ if err == nil {
+ t.Fatal("expected error for unsupported type")
+ }
+ })
+}
+
+func TestCheckResourceAllowed_JSParity(t *testing.T) {
+ type args struct {
+ req interface{}
+ cfg interface{}
+ }
+ tests := []struct {
+ name string
+ a args
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "identical URLs",
+ a: args{"https://example.com/path", "https://example.com/path"},
+ want: true,
+ },
+ {
+ name: "identical origins at root",
+ a: args{"https://example.com/", "https://example.com/"},
+ want: true,
+ },
+ {
+ name: "different paths -> false",
+ a: args{"https://example.com/path1", "https://example.com/path2"},
+ want: false,
+ },
+ {
+ name: "requested root vs configured path -> false",
+ a: args{"https://example.com/", "https://example.com/path"},
+ want: false,
+ },
+ {
+ name: "different domain -> false",
+ a: args{"https://example.com/path", "https://example.org/path"},
+ want: false,
+ },
+ {
+ name: "different port -> false",
+ a: args{"https://example.com:8080/path", "https://example.com/path"},
+ want: false,
+ },
+ {
+ name: "path prefix but not by segment (mcpxxxx vs mcp) -> false",
+ a: args{"https://example.com/mcpxxxx", "https://example.com/mcp"},
+ want: false,
+ },
+ {
+ name: "requested shorter than configured -> false",
+ a: args{"https://example.com/folder", "https://example.com/folder/subfolder"},
+ want: false,
+ },
+ {
+ name: "requested is subpath of configured -> true",
+ a: args{"https://example.com/api/v1", "https://example.com/api"},
+ want: true,
+ },
+ {
+ name: "trailing slash handling: requested has slash, configured no slash -> true",
+ a: args{"https://example.com/mcp/", "https://example.com/mcp"},
+ want: true,
+ },
+ {
+ name: "trailing slash handling: requested no slash, configured has slash -> false (requested shorter)",
+ a: args{"https://example.com/folder", "https://example.com/folder/"},
+ want: false,
+ },
+ {
+ name: "invalid requested URL -> error",
+ a: args{"https://%zz", "https://example.com/path"},
+ wantErr: true,
+ },
+ {
+ name: "invalid configured URL -> error",
+ a: args{"https://example.com/path", "://bad_url"},
+ wantErr: true,
+ },
+ {
+ name: "unsupported requested type -> error",
+ a: args{123, "https://example.com/path"},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := CheckResourceAllowed(CheckResourceAllowedParams{
+ RequestedResource: tt.a.req,
+ ConfiguredResource: tt.a.cfg,
+ })
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("expected error, got nil (got=%v)", got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != tt.want {
+ t.Fatalf("got %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestResourceURLFromServerURL_URLInput(t *testing.T) {
+ u, _ := url.Parse("https://example.com/A/B#frag")
+ got, err := ResourceURLFromServerURL(u)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.Fragment != "" {
+ t.Fatalf("fragment not removed: %q", got.Fragment)
+ }
+ if got.Path != "/A/B" {
+ t.Fatalf("path changed: %q", got.Path)
+ }
+}
diff --git a/internal/errors/auth.go b/internal/errors/auth.go
new file mode 100644
index 0000000..8bf7eaa
--- /dev/null
+++ b/internal/errors/auth.go
@@ -0,0 +1,97 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package errors
+
+import (
+ "errors"
+)
+
+// OAuthErrorCode represents an OAuth 2.1 error code
+type OAuthErrorCode error
+
+// OAuthError represents a structured OAuth 2.1 error
+type OAuthError struct {
+ ErrorCode string
+ Message string
+ ErrorURI string
+}
+
+// OAuthErrorResponse represents the JSON response for OAuth errors
+type OAuthErrorResponse struct {
+ Error string `json:"error"`
+ ErrorDescription string `json:"error_description,omitempty"`
+ ErrorURI string `json:"error_uri,omitempty"`
+}
+
+// Standard OAuth error codes
+var (
+ ErrInvalidRequest OAuthErrorCode = errors.New("invalid_request")
+ ErrInvalidClient OAuthErrorCode = errors.New("invalid_client")
+ ErrInvalidGrant OAuthErrorCode = errors.New("invalid_grant")
+ ErrUnauthorizedClient OAuthErrorCode = errors.New("unauthorized_client")
+ ErrUnsupportedGrantType OAuthErrorCode = errors.New("unsupported_grant_type")
+ ErrInvalidScope OAuthErrorCode = errors.New("invalid_scope")
+ ErrAccessDenied OAuthErrorCode = errors.New("access_denied")
+ ErrServerError OAuthErrorCode = errors.New("server_error")
+ ErrTemporarilyUnavailable OAuthErrorCode = errors.New("temporarily_unavailable")
+ ErrUnsupportedResponseType OAuthErrorCode = errors.New("unsupported_response_type")
+ ErrUnsupportedTokenType OAuthErrorCode = errors.New("unsupported_token_type")
+ ErrInvalidToken OAuthErrorCode = errors.New("invalid_token")
+ ErrMethodNotAllowed OAuthErrorCode = errors.New("method_not_allowed")
+ ErrTooManyRequests OAuthErrorCode = errors.New("too_many_requests")
+ ErrInvalidClientMetadata OAuthErrorCode = errors.New("invalid_client_metadata")
+ ErrInsufficientScope OAuthErrorCode = errors.New("insufficient_scope")
+)
+
+// OAuthErrorMapping maps error strings to their corresponding OAuthErrorCode
+// This replaces the need for large switch statements when parsing error responses
+var OAuthErrorMapping = map[string]OAuthErrorCode{
+ "invalid_request": ErrInvalidRequest,
+ "invalid_client": ErrInvalidClient,
+ "invalid_grant": ErrInvalidGrant,
+ "unauthorized_client": ErrUnauthorizedClient,
+ "unsupported_grant_type": ErrUnsupportedGrantType,
+ "invalid_scope": ErrInvalidScope,
+ "access_denied": ErrAccessDenied,
+ "server_error": ErrServerError,
+ "temporarily_unavailable": ErrTemporarilyUnavailable,
+ "unsupported_response_type": ErrUnsupportedResponseType,
+ "unsupported_token_type": ErrUnsupportedTokenType,
+ "invalid_token": ErrInvalidToken,
+ "method_not_allowed": ErrMethodNotAllowed,
+ "too_many_requests": ErrTooManyRequests,
+ "invalid_client_metadata": ErrInvalidClientMetadata,
+ "insufficient_scope": ErrInsufficientScope,
+}
+
+// NewOAuthError creates a new OAuthError
+func NewOAuthError(errCode OAuthErrorCode, message string, uri string) OAuthError {
+ err := OAuthError{
+ ErrorCode: errCode.Error(),
+ }
+ if uri != "" {
+ err.ErrorURI = uri
+ }
+ if message != "" {
+ err.Message = message
+ }
+ return err
+}
+
+// ToResponseStruct converts OAuthError into OAuthErrorResponse for JSON encoding
+func (o OAuthError) ToResponseStruct() *OAuthErrorResponse {
+ return &OAuthErrorResponse{
+ Error: o.ErrorCode,
+ ErrorDescription: o.Message,
+ ErrorURI: o.ErrorURI,
+ }
+}
+
+// Error implements the error interface
+func (o OAuthError) Error() string {
+ return o.ErrorCode
+}
diff --git a/internal/errors/auth_test.go b/internal/errors/auth_test.go
new file mode 100644
index 0000000..c771627
--- /dev/null
+++ b/internal/errors/auth_test.go
@@ -0,0 +1,48 @@
+// Tencent is pleased to support the open source community by making trpc-mcp-go available.
+//
+// Copyright (C) 2025 Tencent. All rights reserved.
+//
+// trpc-mcp-go is licensed under the Apache License Version 2.0.
+
+package errors_test
+
+import (
+ "testing"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/errors"
+)
+
+func TestNewOAuthError(t *testing.T) {
+ err := errors.NewOAuthError(errors.ErrInvalidRequest, "missing parameter", "https://example.com/docs")
+
+ if err.ErrorCode != "invalid request" {
+ t.Errorf("expected error code 'invalid request', got %s", err.ErrorCode)
+ }
+ if err.Message != "missing parameter" {
+ t.Errorf("expected message 'missing parameter', got %s", err.Message)
+ }
+ if err.ErrorURI != "https://example.com/docs" {
+ t.Errorf("expected URI 'https://example.com/docs', got %s", err.ErrorURI)
+ }
+}
+
+func TestToResponseStruct(t *testing.T) {
+ err := errors.NewOAuthError(errors.ErrInvalidClient, "bad client id", "")
+ resp := err.ToResponseStruct()
+
+ if resp.Error != "invalid client" {
+ t.Errorf("expected 'invalid client', got %s", resp.Error)
+ }
+ if resp.ErrorDescription != "bad client id" {
+ t.Errorf("expected description 'bad client id', got %s", resp.ErrorDescription)
+ }
+ if resp.ErrorURI != "" {
+ t.Errorf("expected empty URI, got %s", resp.ErrorURI)
+ }
+}
+
+func TestErrorMethod(t *testing.T) {
+ err := errors.NewOAuthError(errors.ErrServerError, "internal failure", "")
+ if err.Error() != "server error" {
+ t.Errorf("expected 'server error', got %s", err.Error())
+ }
+}
diff --git a/internal/errors/errors.go b/internal/errors/errors.go
index b45aa1a..ef975c7 100644
--- a/internal/errors/errors.go
+++ b/internal/errors/errors.go
@@ -7,7 +7,9 @@
// Package mcperrors defines common error types and constants
package errors
-import "errors"
+import (
+ "errors"
+)
// Common errors
var (
diff --git a/server.go b/server.go
index 04c3115..5b761f4 100644
--- a/server.go
+++ b/server.go
@@ -12,8 +12,16 @@ import (
"errors"
"fmt"
"net/http"
+ "net/url"
"sync"
"sync/atomic"
+
+ "golang.org/x/time/rate"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
+ sh "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/handler"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/middleware"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server/router"
)
// Common errors
@@ -55,6 +63,110 @@ const (
// Multiple HTTPContextFunc will be executed in the order they are registered.
type HTTPContextFunc func(ctx context.Context, r *http.Request) context.Context
+// AuditConfig defines configuration for audit middleware
+type AuditConfig struct {
+ // Whether to enable audit logging
+ Enabled bool
+
+ // Audit level: none, basic, detailed, full
+ Level string
+
+ // Whether to hash sensitive data
+ HashSensitiveData bool
+
+ // Whether to include request/response body
+ IncludeRequestBody bool
+ IncludeResponseBody bool
+
+ // Custom endpoint patterns to audit
+ EndpointPatterns []string
+
+ // Patterns to exclude from auditing
+ ExcludePatterns []string
+
+ // Custom metadata extractor function
+ MetadataExtractor func(*http.Request) map[string]interface{}
+
+ // Custom risk assessor function
+ RiskAssessor func(map[string]interface{}) (string, []string)
+}
+
+// BearerAuthConfig defines configuration for Bearer token authentication
+type BearerAuthConfig struct {
+ Enabled bool
+
+ // Required: Token validator
+ Verifier server.TokenVerifierInterface
+
+ // Optional: List of required scopes
+ RequiredScopes []string
+
+ // Optional: Write resource_metadata for WWW-Authenticate
+ ResourceMetadataURL *string
+
+ // Optional: Restrict accepted issuer (extra authorization check)
+ Issuer string
+
+ // Optional: Restrict accepted audiences/resources (extra authorization check)
+ Audience []string
+}
+
+// OAuthRoutesConfig defines configuration for OAuth 2.1 server routes.
+type OAuthRoutesConfig struct {
+ // OAuth server implementation
+ Provider server.OAuthServerProvider
+
+ // Canonical issuer identifier (iss claim)
+ IssuerURL *url.URL
+
+ // Root URL for OAuth endpoints
+ BaseURL *url.URL
+
+ // Optional link to service documentation
+ ServiceDocumentationURL *url.URL
+
+ // Supported OAuth scopes
+ ScopesSupported []string
+
+ // Optional human-readable resource name
+ ResourceName *string
+
+ // Rate limit for /authorize endpoint
+ AuthorizationRateLimit *rate.Limiter
+
+ // Rate limit for /token endpoint
+ TokenRateLimit *rate.Limiter
+
+ // Resolve client_id from refresh token
+ ResolveClientIDFromRT func(rt string) (string, bool)
+
+ // Rate limit for dynamic client registration
+ RegistrationRateLimit *sh.RegisterRateLimitConfig
+
+ // Rate limit for token revocation
+ RevocationRateLimit *sh.RevocationRateLimitConfig
+}
+
+// OAuthMetadataConfig defines configuration for exposing OAuth server metadata.
+type OAuthMetadataConfig struct {
+ // Core OAuth server metadata
+ OAuthMetadata OAuthMetadata
+
+ // Optional resource server URL
+ ResourceServerURL *url.URL
+
+ // Optional service documentation URL
+ ServiceDocumentationURL *url.URL
+
+ // Scopes advertised in metadata
+ ScopesSupported []string
+
+ // Optional human-readable resource name
+ ResourceName *string
+}
+
+type OAuthMetadata = auth.OAuthMetadata
+
// serverConfig stores all server configuration options
type serverConfig struct {
// Basic configuration
@@ -74,11 +186,20 @@ type serverConfig struct {
// HTTP context functions for extracting information from HTTP requests
httpContextFuncs []HTTPContextFunc
+ // Audit middleware configuration
+ auditConfig *AuditConfig
+
+ // Bearer authenticate configuration
+ bearerAuth *BearerAuthConfig
+
// Tool list filter function
toolListFilter ToolListFilter
// Method name modifier for external customization.
methodNameModifier MethodNameModifier
+
+ // Route installers for adding extra endpoints (e.g. OAuth, metadata).
+ routerInstallers []func(*http.ServeMux) error
}
// ServerNotificationHandler defines a function that handles notifications on the server side.
@@ -100,6 +221,7 @@ type Server struct {
requestID atomic.Int64 // Request ID counter for generating unique request IDs.
notificationHandlers map[string]ServerNotificationHandler // Map of notification handlers by method name.
notificationMu sync.RWMutex // Mutex for notification handlers map.
+ rootHandler http.Handler // Server's top-level HTTP handler including the core MCP endpoint and any extra routes.
}
// NewServer creates a new MCP server
@@ -113,6 +235,8 @@ func NewServer(name, version string, options ...ServerOption) *Server {
postSSEEnabled: true,
getSSEEnabled: true,
notificationBufferSize: defaultNotificationBufferSize,
+ auditConfig: nil,
+ bearerAuth: nil,
}
// Create server with provided serverInfo
@@ -155,7 +279,6 @@ func (s *Server) initComponents() {
if s.config.methodNameModifier != nil {
toolManager.withMethodNameModifier(s.config.methodNameModifier)
}
- // Only set tool list filter if not nil.
if s.config.toolListFilter != nil {
toolManager.withToolListFilter(s.config.toolListFilter)
}
@@ -165,7 +288,6 @@ func (s *Server) initComponents() {
resourceManager := newResourceManager()
s.resourceManager = resourceManager
- // Create prompt manager.
promptManager := newPromptManager()
s.promptManager = promptManager
@@ -207,12 +329,39 @@ func (s *Server) initComponents() {
// Inject logger into httpServerHandler if provided.
if s.logger != nil {
- // This is the httpServerHandler option version.
httpOptions = append(httpOptions, withServerTransportLogger(s.logger))
}
+ // Enable Bearer token auth middleware if configured
+ if s.config.bearerAuth != nil && s.config.bearerAuth.Enabled {
+ authWrap := convertToAuthMiddleware(s.config.bearerAuth)
+ httpOptions = append(httpOptions, withTransportAuthEnabled(authWrap))
+ }
+
+ // Enable audit logging if configured
+ if s.config.auditConfig != nil && s.config.auditConfig.Enabled {
+ auditOpts := convertToMiddlewareOptions(s.config.auditConfig)
+ httpOptions = append(httpOptions, withTransportAuditEnabled(
+ middleware.AuditMiddleware(auditOpts),
+ ))
+ }
+
// Create HTTP handler.
s.httpHandler = newHTTPServerHandler(s.mcpHandler, s.config.path, httpOptions...)
+
+ mux := http.NewServeMux()
+ mux.Handle(s.config.path+"/", s.httpHandler)
+
+ // Install additional routes (OAuth, resource metadata, .well-known, etc.)
+ for _, install := range s.config.routerInstallers {
+ _ = install(mux)
+ }
+
+ // Exposing mux externally
+ s.customServer = &http.Server{Addr: s.config.addr, Handler: mux}
+
+ // Expose mux as the server's root handler
+ s.rootHandler = mux
}
// ServerOption server option function.
@@ -271,6 +420,34 @@ func WithHTTPContextFunc(fn HTTPContextFunc) ServerOption {
}
}
+// WithAudit enables audit logging for the server with the specified configuration.
+// The audit middleware will log all HTTP requests and responses based on the configuration.
+//
+// Example:
+//
+// server := mcp.NewServer("my-server", "1.0",
+// mcp.WithAudit(&mcp.AuditConfig{
+// Enabled: true,
+// Level: "detailed",
+// EndpointPatterns: []string{"/mcp/", "/oauth2/"},
+// HashSensitiveData: true,
+// }),
+// )
+func WithAudit(config *AuditConfig) ServerOption {
+ return func(s *Server) {
+ s.config.auditConfig = config
+ }
+}
+
+// WithBearerAuth configures the server to use Bearer token authentication.
+// The provided BearerAuthConfig specifies how tokens are verified,
+// what scopes are required, and optionally, metadata for WWW-Authenticate responses.
+func WithBearerAuth(config *BearerAuthConfig) ServerOption {
+ return func(s *Server) {
+ s.config.bearerAuth = config
+ }
+}
+
// WithStatelessMode sets whether the server uses stateless mode
// In stateless mode, the server won't generate session IDs and won't validate session IDs in client requests
// Each request will use a temporary session, which is only valid during request processing
@@ -313,10 +490,68 @@ func WithServerAddress(addr string) ServerOption {
}
}
+// WithOAuthRoutes installs standard OAuth 2.1 endpoints into the server,
+// such as /authorize, /token, /revoke, and /register, depending on the
+// provided AuthRouterOptions and the provider's capabilities.
+func WithOAuthRoutes(cfg OAuthRoutesConfig) ServerOption {
+ return withHTTPRoutes(func(mux *http.ServeMux) error {
+ base := cfg.BaseURL
+ if base == nil {
+ base = cfg.IssuerURL
+ }
+
+ opts := router.AuthRouterOptions{
+ Provider: cfg.Provider,
+ IssuerUrl: cfg.IssuerURL,
+ BaseUrl: base,
+ ServiceDocumentationUrl: cfg.ServiceDocumentationURL,
+ ScopesSupported: cfg.ScopesSupported,
+ ResourceName: cfg.ResourceName,
+
+ AuthorizationOptions: &sh.AuthorizationHandlerOptions{
+ Provider: cfg.Provider,
+ RateLimit: cfg.AuthorizationRateLimit,
+ },
+ TokenOptions: &sh.TokenHandlerOptions{
+ Provider: cfg.Provider,
+ RateLimit: cfg.TokenRateLimit,
+ },
+ ClientRegistrationOptions: &sh.ClientRegistrationHandlerOptions{
+ ClientsStore: cfg.Provider.ClientsStore(),
+ RateLimit: cfg.RegistrationRateLimit,
+ },
+ RevocationOptions: &sh.RevocationHandlerOptions{
+ Provider: cfg.Provider,
+ RateLimit: cfg.RevocationRateLimit,
+ },
+ }
+ return router.McpAuthRouter(mux, opts)
+ })
+}
+
+// WithOAuthMetadata installs the .well-known OAuth metadata endpoints
+// (e.g. /.well-known/oauth-authorization-server and
+// /.well-known/oauth-protected-resource) into the server.
+// The returned metadata is constructed from the given AuthMetadataOptions.
+func WithOAuthMetadata(cfg OAuthMetadataConfig) ServerOption {
+ return withHTTPRoutes(func(mux *http.ServeMux) error {
+ opts := router.AuthMetadataOptions{
+ OAuthMetadata: cfg.OAuthMetadata,
+ ResourceServerUrl: cfg.ResourceServerURL,
+ ServiceDocumentationUrl: cfg.ServiceDocumentationURL,
+ ScopesSupported: cfg.ScopesSupported,
+ ResourceName: cfg.ResourceName,
+ }
+ return router.McpAuthMetadataRouter(mux, opts)
+ })
+}
+
// Start starts the server
func (s *Server) Start() error {
if s.customServer != nil {
- s.customServer.Handler = s.Handler()
+ if s.customServer.Handler == nil {
+ s.customServer.Handler = s.Handler()
+ }
return s.customServer.ListenAndServe()
}
return http.ListenAndServe(s.config.addr, s.Handler())
@@ -570,9 +805,18 @@ func (s *Server) GetActiveSessions() ([]string, error) {
return s.getActiveSessions()
}
-// Handler returns the http.Handler for the server.
-// This can be used to integrate the MCP server into existing HTTP servers.
+// Handler returns the top-level http.Handler exposed by the server.
+// This handler always includes the core MCP endpoint (e.g., /mcp).
+// Depending on the configured ServerOptions, it may also include
+// additional routes such as OAuth endpoints and .well-known metadata.
+//
+// You can pass this directly to an http.Server, or mount it into
+// an existing HTTP mux as the unified entry point for MCP and
+// any configured auxiliary endpoints.
func (s *Server) Handler() http.Handler {
+ if s.rootHandler == nil {
+ return s.rootHandler
+ }
return s.httpHandler
}
@@ -657,3 +901,83 @@ func (s *Server) handleServerNotification(ctx context.Context, notification *JSO
}
return nil
}
+
+// convertToMiddlewareOptions converts AuditConfig to AuditMiddlewareOptions
+func convertToMiddlewareOptions(config *AuditConfig) *middleware.AuditMiddlewareOptions {
+ level := middleware.AuditLevelBasic
+ switch config.Level {
+ case "detailed":
+ level = middleware.AuditLevelDetailed
+ case "full":
+ level = middleware.AuditLevelFull
+ }
+
+ return &middleware.AuditMiddlewareOptions{
+ Level: level,
+ HashSensitiveData: config.HashSensitiveData,
+ IncludeRequestBody: config.IncludeRequestBody,
+ IncludeResponseBody: config.IncludeResponseBody,
+ EndpointPatterns: config.EndpointPatterns,
+ ExcludePatterns: config.ExcludePatterns,
+ MetadataExtractor: config.MetadataExtractor,
+ }
+}
+
+// convertToAuthMiddleware converts BearerAuthConfig to an HTTP middleware wrapper using RequireBearerAuth
+// It also adapts the auth info stored by middleware into the server-level context so downstream code can use server.GetAuthInfo
+func convertToAuthMiddleware(config *BearerAuthConfig) func(http.Handler) http.Handler {
+ if config == nil || !config.Enabled || config.Verifier == nil {
+ return nil
+ }
+
+ opts := middleware.BearerAuthMiddlewareOptions{
+ Verifier: config.Verifier,
+ RequiredScopes: config.RequiredScopes,
+ ResourceMetadataURL: config.ResourceMetadataURL,
+ Issuer: config.Issuer,
+ Audience: config.Audience,
+ }
+
+ bearer := middleware.RequireBearerAuth(opts)
+
+ // Compose an adapter that maps middleware.AuthInfoKey -> server.WithAuthInfo
+ return func(next http.Handler) http.Handler {
+ // Wrap the downstream handler to translate context
+ adapter := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if v := r.Context().Value(middleware.AuthInfoKey); v != nil {
+ if ai, ok := v.(server.AuthInfo); ok {
+ // Avoid token transparent transmission
+ aiCopy := ai
+ aiCopy.Token = ""
+ r = r.WithContext(server.WithAuthInfo(r.Context(), &aiCopy))
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+ return bearer(adapter)
+ }
+}
+
+// withTransportAuditEnabled enables audit logging by wrapping the handler with given middleware
+func withTransportAuditEnabled(wrap func(http.Handler) http.Handler) func(*httpServerHandler) {
+ return func(h *httpServerHandler) {
+ h.auditEnabled = (wrap != nil)
+ h.auditWrap = wrap
+ }
+}
+
+// withTransportAuthEnabled enables bearer authentication by wrapping the handler with given middleware
+func withTransportAuthEnabled(wrap func(http.Handler) http.Handler) func(*httpServerHandler) {
+ return func(h *httpServerHandler) {
+ h.authEnabled = (wrap != nil)
+ h.authWrap = wrap
+ }
+}
+
+// withHTTPRoutes registers a custom installer function that can
+// attach additional HTTP routes to the server's root mux.
+func withHTTPRoutes(install func(*http.ServeMux) error) ServerOption {
+ return func(s *Server) {
+ s.config.routerInstallers = append(s.config.routerInstallers, install)
+ }
+}
diff --git a/stdio_server.go b/stdio_server.go
index 3891ffa..afba947 100644
--- a/stdio_server.go
+++ b/stdio_server.go
@@ -582,7 +582,7 @@ type stdioServerInternal struct {
func (s *stdioServerInternal) HandleRequest(ctx context.Context, rawMessage json.RawMessage) (interface{}, error) {
var request JSONRPCRequest
if err := json.Unmarshal(rawMessage, &request); err != nil {
- return newJSONRPCErrorResponse(nil, -32700, "Parse error", nil), nil
+ return newJSONRPCErrorResponse(nil, ErrCodeParse, "Parse error", nil), nil
}
s.parent.logger.Debugf("Handling request: %s (ID: %v)", request.Method, request.ID)
@@ -611,11 +611,11 @@ func (s *stdioServerInternal) HandleRequest(ctx context.Context, rawMessage json
case MethodPing:
return s.handlePing(ctx, request)
default:
- return newJSONRPCErrorResponse(request.ID, -32601, "Method not found", nil), nil
+ return newJSONRPCErrorResponse(request.ID, ErrCodeMethodNotFound, "Method not found", nil), nil
}
if err != nil {
- return newJSONRPCErrorResponse(request.ID, -32603, "Internal error", err.Error()), nil
+ return newJSONRPCErrorResponse(request.ID, ErrCodeInternal, "Internal error", err.Error()), nil
}
// Check if result is already a JSON-RPC response or error (has jsonrpc field).
diff --git a/streamable_client.go b/streamable_client.go
index 9aba314..ba58ea6 100644
--- a/streamable_client.go
+++ b/streamable_client.go
@@ -19,6 +19,8 @@ import (
"sync"
"time"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/client"
"trpc.group/trpc-go/trpc-mcp-go/internal/httputil"
"trpc.group/trpc-go/trpc-mcp-go/internal/retry"
)
@@ -85,6 +87,9 @@ type streamableHTTPClientTransport struct {
// Client reference for accessing rootsProvider.
client *Client
+
+ // OAuth client provider
+ oauthProvider client.OAuthClientProvider
}
// NotificationHandler is a handler for notifications.
@@ -191,6 +196,13 @@ func withTransportHTTPReqHandlerOption(option HTTPReqHandlerOption) transportOpt
}
}
+// withTransportOAuthProvider adds an option for OAuth client provider
+func withTransportOAuthProvider(p client.OAuthClientProvider) transportOption {
+ return func(t *streamableHTTPClientTransport) {
+ t.oauthProvider = p
+ }
+}
+
// start is a no-op for streamableHTTPClientTransport.
func (t *streamableHTTPClientTransport) start(ctx context.Context) error {
return nil
@@ -241,6 +253,11 @@ func (t *streamableHTTPClientTransport) send(
return nil, fmt.Errorf("%w: %v", ErrRequestSerialization, err)
}
+ ctx, err = t.ensureAuth(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("authentication failed: %w", err)
+ }
+
// Create HTTP request
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, t.serverURL.String(), bytes.NewReader(reqBytes))
if err != nil {
@@ -250,6 +267,7 @@ func (t *streamableHTTPClientTransport) send(
httpReq.URL.Path = t.path
}
+ t.setBasicHeaders(httpReq)
// Set request headers - accept both SSE and JSON responses
httpReq.Header.Set(httputil.ContentTypeHeader, httputil.ContentTypeJSON)
httpReq.Header.Set(httputil.AcceptHeader, httputil.ContentTypeJSON+", "+httputil.ContentTypeSSE)
@@ -264,6 +282,13 @@ func (t *streamableHTTPClientTransport) send(
httpReq.Header.Set(httputil.LastEventIDHeader, t.lastEventID)
}
+ // Authorization from ctx
+ t.setAuthorizationHeader(ctx, httpReq)
+
+ if authInfo, ok := client.GetAuthInfo(ctx); ok && authInfo != nil && authInfo.AccessToken != "" {
+ httpReq.Header.Set("Authorization", "Bearer "+authInfo.AccessToken)
+ }
+
// Add custom headers
for key, values := range t.httpHeaders {
for _, value := range values {
@@ -277,6 +302,51 @@ func (t *streamableHTTPClientTransport) send(
return nil, fmt.Errorf("%w: %v", ErrHTTPRequestFailed, err)
}
+ // 204/400: try once with fresh auth (rebuild request inside)
+ if httpResp.StatusCode == http.StatusNoContent || httpResp.StatusCode == http.StatusBadRequest {
+ httpResp.Body.Close()
+ return t.retryWithFreshAuth(ctx, reqBytes, options)
+ }
+
+ // 401/403: refresh/ensure auth, then REBUILD a new request and resend ONCE
+ if httpResp.StatusCode == http.StatusUnauthorized || httpResp.StatusCode == http.StatusForbidden {
+ httpResp.Body.Close()
+
+ if _, err := t.ensureAuth(ctx); err == nil {
+ httpReq2, err := http.NewRequestWithContext(ctx, http.MethodPost, t.serverURL.String(), bytes.NewReader(reqBytes))
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrHTTPRequestCreation, err)
+ }
+ if len(t.path) != 0 {
+ httpReq2.URL.Path = t.path
+ }
+
+ // headers again (same as first send)
+ t.setBasicHeaders(httpReq2)
+ httpReq2.Header.Set(httputil.ContentTypeHeader, httputil.ContentTypeJSON)
+ httpReq2.Header.Set(httputil.AcceptHeader, httputil.ContentTypeJSON+", "+httputil.ContentTypeSSE)
+ if t.sessionID != "" && !t.isStateless {
+ httpReq2.Header.Set(httputil.SessionIDHeader, t.sessionID)
+ }
+ if options != nil && options.lastEventID != "" {
+ httpReq2.Header.Set(httputil.LastEventIDHeader, options.lastEventID)
+ } else if t.lastEventID != "" {
+ httpReq2.Header.Set(httputil.LastEventIDHeader, t.lastEventID)
+ }
+ t.setAuthorizationHeader(ctx, httpReq2) // new token if refreshed
+ for k, values := range t.httpHeaders {
+ for _, value := range values {
+ httpReq2.Header.Add(k, value)
+ }
+ }
+
+ httpResp, err = t.httpReqHandler.Handle(ctx, t.httpClient, httpReq2)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrHTTPRequestFailed, err)
+ }
+ }
+ }
+
// Handle session ID
if sessionID := httpResp.Header.Get(httputil.SessionIDHeader); sessionID != "" {
t.setSessionID(sessionID)
@@ -634,6 +704,11 @@ func (t *streamableHTTPClientTransport) connectGetSSE(ctx context.Context) error
return fmt.Errorf("cannot establish GET SSE connection: session ID is empty")
}
+ ctx, err := t.ensureAuth(ctx)
+ if err != nil {
+ return fmt.Errorf("authentication failed: %w", err)
+ }
+
// Build GET request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.serverURL.String(), nil)
if err != nil {
@@ -650,6 +725,10 @@ func (t *streamableHTTPClientTransport) connectGetSSE(ctx context.Context) error
req.Header.Set(httputil.LastEventIDHeader, t.lastEventID)
}
+ if authInfo, ok := client.GetAuthInfo(ctx); ok && authInfo != nil && authInfo.AccessToken != "" {
+ req.Header.Set("Authorization", "Bearer "+authInfo.AccessToken)
+ }
+
// Add custom headers
for key, values := range t.httpHeaders {
for _, value := range values {
@@ -867,6 +946,10 @@ func (t *streamableHTTPClientTransport) sendResponseToServer(response interface{
}
}
+ if authInfo, ok := client.GetAuthInfo(ctx); ok && authInfo != nil && authInfo.AccessToken != "" {
+ httpReq.Header.Set("Authorization", "Bearer "+authInfo.AccessToken)
+ }
+
// Add session ID if available
if t.sessionID != "" {
httpReq.Header.Set(httputil.SessionIDHeader, t.sessionID) // Use correct MCP protocol header: Mcp-Session-Id.
@@ -972,3 +1055,150 @@ func (t *streamableHTTPClientTransport) establishGetSSEConnection() {
t.establishGetSSE()
}
+
+// ensureAuth ensures that the current request context carries valid authentication information
+func (t *streamableHTTPClientTransport) ensureAuth(ctx context.Context) (context.Context, error) {
+ if t.oauthProvider == nil {
+ return ctx, nil
+ }
+
+ if info, ok := client.GetAuthInfo(ctx); ok && info != nil && !client.IsTokenExpired(info) {
+ return ctx, nil
+ }
+
+ _, err := client.Auth(t.oauthProvider, auth.AuthOptions{
+ ServerUrl: t.serverURL.String(),
+ })
+ if err != nil {
+ return client.WithAuthErr(ctx, err), err
+ }
+
+ tokens, terr := t.oauthProvider.Tokens()
+ if terr != nil {
+ return client.WithAuthErr(ctx, err), terr
+ }
+ if tokens == nil {
+ return client.WithAuthErr(ctx, fmt.Errorf("no tokens after auth")), fmt.Errorf("no tokens")
+ }
+ info := client.ConvertTokensToAuthInfo(tokens)
+ return client.WithAuthInfo(ctx, info), nil
+}
+
+// setBasicHeaders sets the basic headers that are common to all HTTP requests
+func (t *streamableHTTPClientTransport) setBasicHeaders(req *http.Request) {
+ // Set content type and accept headers
+ req.Header.Set(httputil.ContentTypeHeader, httputil.ContentTypeJSON)
+ req.Header.Set(httputil.AcceptHeader, httputil.ContentTypeJSON+", "+httputil.ContentTypeSSE)
+
+ // Set session ID if available and not in stateless mode
+ if t.sessionID != "" && !t.isStateless {
+ req.Header.Set(httputil.SessionIDHeader, t.sessionID)
+ }
+
+ // Set last event ID if available
+ if t.lastEventID != "" {
+ req.Header.Set(httputil.LastEventIDHeader, t.lastEventID)
+ }
+
+ // Set path if specified
+ if len(t.path) != 0 {
+ req.URL.Path = t.path
+ }
+
+ // Add custom headers
+ for key, values := range t.httpHeaders {
+ for _, value := range values {
+ req.Header.Add(key, value)
+ }
+ }
+}
+
+// setAuthorizationHeader sets the Authorization header using context auth info
+func (t *streamableHTTPClientTransport) setAuthorizationHeader(ctx context.Context, req *http.Request) {
+ if authInfo, ok := client.GetAuthInfo(ctx); ok && authInfo != nil && authInfo.AccessToken != "" {
+ req.Header.Set("Authorization", "Bearer "+authInfo.AccessToken)
+ }
+}
+
+// retryWithFreshAuth handles retry logic with fresh authentication
+func (t *streamableHTTPClientTransport) retryWithFreshAuth(ctx context.Context, reqBytes []byte, options *streamOptions) (*json.RawMessage, error) {
+ ctx, err := t.ensureAuth(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("re-authentication failed: %w", err)
+ }
+
+ // Create a new HTTP request
+ httpReq2, err := http.NewRequestWithContext(ctx, http.MethodPost, t.serverURL.String(), bytes.NewReader(reqBytes))
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrHTTPRequestCreation, err)
+ }
+
+ // Reset all headers
+ t.setBasicHeaders(httpReq2)
+
+ // Set up Authorization using the new context
+ t.setAuthorizationHeader(ctx, httpReq2)
+
+ // Process options specific to this request
+ if options != nil && options.lastEventID != "" {
+ httpReq2.Header.Set(httputil.LastEventIDHeader, options.lastEventID)
+ }
+
+ // Send a retry request
+ httpResp, err := t.httpReqHandler.Handle(ctx, t.httpClient, httpReq2)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrHTTPRequestFailed, err)
+ }
+ defer httpResp.Body.Close()
+
+ // Handle session IDs
+ if sessionID := httpResp.Header.Get(httputil.SessionIDHeader); sessionID != "" {
+ t.setSessionID(sessionID)
+ t.isStateless = false
+ }
+
+ // Check the content type
+ contentType := httpResp.Header.Get(httputil.ContentTypeHeader)
+ if strings.Contains(contentType, httputil.ContentTypeSSE) {
+ // Handle SSE Responses, reqID is set to nil because this is a retry
+ return t.handleSSEResponse(ctx, httpResp, nil, options)
+ }
+
+ // Check status code
+ if httpResp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%w: status code %d", ErrHTTPRequestFailed, httpResp.StatusCode)
+ }
+
+ // Read the response body
+ respBytes, err := io.ReadAll(httpResp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Parse JSON response
+ var jsonResp map[string]interface{}
+ if err := json.Unmarshal(respBytes, &jsonResp); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrResponseParsing, err)
+ }
+
+ // Check if it is an error response
+ if _, hasError := jsonResp["error"]; hasError {
+ rawMessage := json.RawMessage(respBytes)
+ return &rawMessage, nil
+ }
+
+ // Extraction results section
+ resultData, ok := jsonResp["result"]
+ if !ok {
+ return nil, ErrMissingResultField
+ }
+
+ // Serialized result is JSON
+ resultBytes, err := json.Marshal(resultData)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrResponseSerialization, err)
+ }
+
+ rawMessage := json.RawMessage(resultBytes)
+ return &rawMessage, nil
+}
diff --git a/streamable_server.go b/streamable_server.go
index 450041a..48637b3 100644
--- a/streamable_server.go
+++ b/streamable_server.go
@@ -11,10 +11,12 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "strings"
"sync"
"sync/atomic"
"time"
+ "trpc.group/trpc-go/trpc-mcp-go/internal/auth/server"
"trpc.group/trpc-go/trpc-mcp-go/internal/httputil"
"trpc.group/trpc-go/trpc-mcp-go/internal/sseutil"
)
@@ -73,6 +75,18 @@ type httpServerHandler struct {
// Response manager for server-to-client requests.
responseManager *responseManager
+
+ // Enable bearer auth middleware if true
+ authEnabled bool
+
+ // Auth middleware wrapper applied if authEnabled
+ authWrap func(http.Handler) http.Handler
+
+ // Enable audit logging if true
+ auditEnabled bool
+
+ // Audit middleware wrapper applied if auditEnabled
+ auditWrap func(http.Handler) http.Handler
}
// getSSEConnection represents a GET SSE connection
@@ -90,6 +104,8 @@ type getSSEConnection struct {
sseResponder *sseResponder
}
+// ServerAuthConfig and NewAuthHTTPContextFunc were removed (replaced by RequireBearerAuth middleware)
+
// newHTTPServerHandler creates an HTTP server handler
func newHTTPServerHandler(handler requestHandler, serverPath string, options ...func(*httpServerHandler)) *httpServerHandler {
h := &httpServerHandler{
@@ -195,29 +211,49 @@ func withTransportHTTPContextFuncs(funcs []HTTPContextFunc) func(*httpServerHand
// ServeHTTP implements the http.Handler interface
func (h *httpServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- if !h.isValidPath(r.URL.Path) {
- if h.serverPath == "" {
- http.Error(w, fmt.Sprintf("Path not found: %s (expected: %s)", r.URL.Path, h.serverPath), http.StatusNotFound)
+ if len(h.httpContextFuncs) > 0 {
+ enriched := r.Context()
+ for _, fn := range h.httpContextFuncs {
+ enriched = fn(enriched, r)
}
- return
+ r = r.WithContext(enriched)
}
- switch r.Method {
- case http.MethodPost:
- h.handlePost(r.Context(), w, r)
- case http.MethodGet:
- if !h.enableGetSSE {
- w.Header().Set("Allow", "POST, DELETE")
- http.Error(w, "GET method not enabled", http.StatusMethodNotAllowed)
+ var core http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !h.isValidPath(r.URL.Path) {
+ if h.serverPath == "" {
+ http.Error(w, fmt.Sprintf("Path not found: %s (expected: %s)", r.URL.Path, h.serverPath), http.StatusNotFound)
+ }
return
}
- h.handleGet(r.Context(), w, r)
- case http.MethodDelete:
- h.handleDelete(r.Context(), w, r)
- default:
- w.Header().Set("Allow", "POST, GET, DELETE")
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
+ switch r.Method {
+ case http.MethodPost:
+ h.handlePost(r.Context(), w, r)
+ case http.MethodGet:
+ if !h.enableGetSSE {
+ w.Header().Set("Allow", "POST, DELETE")
+ http.Error(w, "GET method not enabled", http.StatusMethodNotAllowed)
+ return
+ }
+ h.handleGet(r.Context(), w, r)
+ case http.MethodDelete:
+ h.handleDelete(r.Context(), w, r)
+ default:
+ w.Header().Set("Allow", "POST, GET, DELETE")
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+ })
+
+ // Apply auth middleware first, then audit middleware
+ if h.auditEnabled && h.auditWrap != nil {
+ core = h.auditWrap(core)
+ }
+ if h.authEnabled && h.authWrap != nil {
+ core = h.authWrap(core)
}
+
+ core.ServeHTTP(w, r)
}
type baseMessage struct {
@@ -338,6 +374,9 @@ func (h *httpServerHandler) handlePostRequest(ctx context.Context, w http.Respon
if session != nil {
reqCtx = setSessionToContext(reqCtx, session)
}
+ if authInfo, ok := server.GetAuthInfo(ctx); ok {
+ reqCtx = server.WithAuthInfo(reqCtx, authInfo)
+ }
resp, err := h.requestHandler.handleRequest(reqCtx, &req, session)
if err != nil {
h.logger.Infof("Request processing failed: %v", err)
@@ -365,6 +404,9 @@ func (h *httpServerHandler) handlePostRequest(ctx context.Context, w http.Respon
if session != nil {
reqCtx = setSessionToContext(reqCtx, session)
}
+ if authInfo, ok := server.GetAuthInfo(ctx); ok {
+ reqCtx = server.WithAuthInfo(reqCtx, authInfo)
+ }
resp, err := h.requestHandler.handleRequest(reqCtx, &req, session)
if err != nil {
h.logger.Infof("Request processing failed: %v", err)
@@ -556,6 +598,12 @@ func (h *httpServerHandler) handleGet(ctx context.Context, w http.ResponseWriter
return
}
+ // Perform authentication context checks,
+ enrichedCtx := ctx
+ for _, fn := range h.httpContextFuncs {
+ enrichedCtx = fn(enrichedCtx, r)
+ }
+
// Check if streaming is supported
flusher, ok := w.(http.Flusher)
if !ok {
@@ -570,7 +618,7 @@ func (h *httpServerHandler) handleGet(ctx context.Context, w http.ResponseWriter
flusher.Flush()
// Create context, for canceling connection
- connCtx, cancelConn := context.WithCancel(ctx)
+ connCtx, cancelConn := context.WithCancel(enrichedCtx)
localCancelFunc = cancelConn // Assign to the variable captured by defer
// Check if there's already a GET SSE connection
@@ -775,7 +823,10 @@ func (h *httpServerHandler) isValidPath(requestPath string) bool {
if h.serverPath == "" {
return true
}
- return requestPath == h.serverPath
+ sp := strings.TrimSuffix(h.serverPath, "/")
+ rp := strings.TrimSuffix(requestPath, "/")
+
+ return rp == sp || strings.HasPrefix(requestPath, sp+"/")
}
// responseManager manages pending requests and their response channels.