extauth: refactor enterprise auth tests (#879)
This commit is contained in:
parent
f71c803175
commit
f36591bf8a
5 changed files with 677 additions and 742 deletions
|
|
@ -1,43 +1,37 @@
|
|||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package extauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// TestNewEnterpriseHandler_Validation tests validation in NewEnterpriseHandler.
|
||||
func TestNewEnterpriseHandler_Validation(t *testing.T) {
|
||||
validConfig := &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "idp_client_id",
|
||||
},
|
||||
func validEnterpriseHandlerConfig() *EnterpriseHandlerConfig {
|
||||
return &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{ClientID: "idp_client_id"},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "mcp_client_id",
|
||||
},
|
||||
MCPCredentials: &oauthex.ClientCredentials{ClientID: "mcp_client_id"},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
||||
token := &oauth2.Token{
|
||||
AccessToken: "mock_access_token",
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
token := &oauth2.Token{AccessToken: "mock", TokenType: "Bearer"}
|
||||
return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEnterpriseHandler_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *EnterpriseHandlerConfig
|
||||
|
|
@ -50,151 +44,101 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "missing IdPIssuerURL",
|
||||
config: &EnterpriseHandlerConfig{
|
||||
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||
},
|
||||
config: func() *EnterpriseHandlerConfig {
|
||||
c := validEnterpriseHandlerConfig()
|
||||
c.IdPIssuerURL = ""
|
||||
return c
|
||||
}(),
|
||||
wantError: "IdPIssuerURL is required",
|
||||
},
|
||||
{
|
||||
name: "nil IdPCredentials",
|
||||
config: &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: nil,
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||
},
|
||||
config: func() *EnterpriseHandlerConfig {
|
||||
c := validEnterpriseHandlerConfig()
|
||||
c.IdPCredentials = nil
|
||||
return c
|
||||
}(),
|
||||
wantError: "IdPCredentials is required",
|
||||
},
|
||||
{
|
||||
name: "invalid IdPCredentials - empty ClientID",
|
||||
config: &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "", // Invalid - empty
|
||||
},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||
},
|
||||
config: func() *EnterpriseHandlerConfig {
|
||||
c := validEnterpriseHandlerConfig()
|
||||
c.IdPCredentials = &oauthex.ClientCredentials{ClientID: ""}
|
||||
return c
|
||||
}(),
|
||||
wantError: "invalid IdPCredentials",
|
||||
},
|
||||
{
|
||||
name: "invalid IdPCredentials - empty ClientSecret in ClientSecretAuth",
|
||||
config: &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "idp_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "", // Invalid - empty secret when ClientSecretAuth is set
|
||||
},
|
||||
},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||
},
|
||||
config: func() *EnterpriseHandlerConfig {
|
||||
c := validEnterpriseHandlerConfig()
|
||||
c.IdPCredentials = &oauthex.ClientCredentials{
|
||||
ClientID: "idp_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: ""},
|
||||
}
|
||||
return c
|
||||
}(),
|
||||
wantError: "invalid IdPCredentials",
|
||||
},
|
||||
{
|
||||
name: "missing MCPAuthServerURL",
|
||||
config: &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
MCPAuthServerURL: "",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||
},
|
||||
config: func() *EnterpriseHandlerConfig {
|
||||
c := validEnterpriseHandlerConfig()
|
||||
c.MCPAuthServerURL = ""
|
||||
return c
|
||||
}(),
|
||||
wantError: "MCPAuthServerURL is required",
|
||||
},
|
||||
{
|
||||
name: "missing MCPResourceURI",
|
||||
config: &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "",
|
||||
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||
},
|
||||
config: func() *EnterpriseHandlerConfig {
|
||||
c := validEnterpriseHandlerConfig()
|
||||
c.MCPResourceURI = ""
|
||||
return c
|
||||
}(),
|
||||
wantError: "MCPResourceURI is required",
|
||||
},
|
||||
{
|
||||
name: "nil MCPCredentials",
|
||||
config: &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: nil,
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||
},
|
||||
config: func() *EnterpriseHandlerConfig {
|
||||
c := validEnterpriseHandlerConfig()
|
||||
c.MCPCredentials = nil
|
||||
return c
|
||||
}(),
|
||||
wantError: "MCPCredentials is required",
|
||||
},
|
||||
{
|
||||
name: "invalid MCPCredentials - empty ClientID",
|
||||
config: &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "", // Invalid - empty
|
||||
},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||
},
|
||||
config: func() *EnterpriseHandlerConfig {
|
||||
c := validEnterpriseHandlerConfig()
|
||||
c.MCPCredentials = &oauthex.ClientCredentials{ClientID: ""}
|
||||
return c
|
||||
}(),
|
||||
wantError: "invalid MCPCredentials",
|
||||
},
|
||||
{
|
||||
name: "missing IDTokenFetcher",
|
||||
config: &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||
IDTokenFetcher: nil,
|
||||
},
|
||||
config: func() *EnterpriseHandlerConfig {
|
||||
c := validEnterpriseHandlerConfig()
|
||||
c.IDTokenFetcher = nil
|
||||
return c
|
||||
}(),
|
||||
wantError: "IDTokenFetcher is required",
|
||||
},
|
||||
{
|
||||
name: "valid config - public clients (no ClientSecretAuth)",
|
||||
config: validConfig,
|
||||
name: "valid public clients",
|
||||
config: validEnterpriseHandlerConfig(),
|
||||
wantError: "",
|
||||
},
|
||||
{
|
||||
name: "valid config - confidential clients (with ClientSecretAuth)",
|
||||
config: &EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "idp_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "idp_secret",
|
||||
},
|
||||
},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "mcp_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "mcp_secret",
|
||||
},
|
||||
},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
||||
token := &oauth2.Token{
|
||||
AccessToken: "mock_access_token",
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil
|
||||
},
|
||||
},
|
||||
name: "valid confidential clients",
|
||||
config: func() *EnterpriseHandlerConfig {
|
||||
c := validEnterpriseHandlerConfig()
|
||||
c.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"}
|
||||
c.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"}
|
||||
return c
|
||||
}(),
|
||||
wantError: "",
|
||||
},
|
||||
}
|
||||
|
|
@ -221,45 +165,47 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestEnterpriseHandler_Authorize_E2E tests the complete enterprise authorization flow.
|
||||
func TestEnterpriseHandler_Authorize_E2E(t *testing.T) {
|
||||
// Set up IdP (Identity Provider) fake server with token exchange support
|
||||
idpServer := setupIdPServer(t)
|
||||
const idJAGToken = "id-jag-token-from-idp"
|
||||
|
||||
// Set up MCP authorization server with JWT bearer grant support
|
||||
mcpAuthServer := setupMCPAuthServer(t)
|
||||
|
||||
// Create enterprise handler
|
||||
handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: idpServer.URL,
|
||||
IdPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "idp_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "idp_secret",
|
||||
},
|
||||
idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"idp_client_id": {Secret: "idp_secret"},
|
||||
},
|
||||
MCPAuthServerURL: mcpAuthServer.URL,
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "mcp_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "mcp_secret",
|
||||
},
|
||||
},
|
||||
MCPScopes: []string{"read", "write"},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
||||
token := &oauth2.Token{
|
||||
AccessToken: "mock_access_token",
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil
|
||||
TokenExchangeConfig: &oauthtest.TokenExchangeConfig{
|
||||
IDJAGToken: idJAGToken,
|
||||
},
|
||||
})
|
||||
idpServer.Start(t)
|
||||
|
||||
mcpAuthServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"mcp_client_id": {Secret: "mcp_secret"},
|
||||
},
|
||||
},
|
||||
JWTBearerConfig: &oauthtest.JWTBearerConfig{
|
||||
ValidAssertions: []string{idJAGToken},
|
||||
},
|
||||
})
|
||||
mcpAuthServer.Start(t)
|
||||
|
||||
config := validEnterpriseHandlerConfig()
|
||||
config.IdPIssuerURL = idpServer.URL()
|
||||
config.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"}
|
||||
config.MCPAuthServerURL = mcpAuthServer.URL()
|
||||
config.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"}
|
||||
config.MCPScopes = []string{"read", "write"}
|
||||
config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) {
|
||||
token := &oauth2.Token{AccessToken: "mock_access_token", TokenType: "Bearer"}
|
||||
return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil
|
||||
}
|
||||
|
||||
handler, err := NewEnterpriseHandler(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEnterpriseHandler failed: %v", err)
|
||||
}
|
||||
|
||||
// Simulate a 401 response from MCP server
|
||||
req := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/api", nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
|
|
@ -268,12 +214,10 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) {
|
|||
Request: req,
|
||||
}
|
||||
|
||||
// Perform authorization
|
||||
if err := handler.Authorize(context.Background(), req, resp); err != nil {
|
||||
t.Fatalf("Authorize failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify token source is set
|
||||
tokenSource, err := handler.TokenSource(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("TokenSource failed: %v", err)
|
||||
|
|
@ -282,164 +226,22 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) {
|
|||
t.Fatal("expected token source to be set after authorization")
|
||||
}
|
||||
|
||||
// Verify we can get a token
|
||||
token, err := tokenSource.Token()
|
||||
if err != nil {
|
||||
t.Fatalf("Token() failed: %v", err)
|
||||
}
|
||||
if token.AccessToken != "mcp_access_token_from_jwt_bearer" {
|
||||
t.Errorf("unexpected access token: got %q, want %q",
|
||||
token.AccessToken, "mcp_access_token_from_jwt_bearer")
|
||||
if token.AccessToken != "test_access_token" {
|
||||
t.Errorf("AccessToken = %q, want %q", token.AccessToken, "test_access_token")
|
||||
}
|
||||
}
|
||||
|
||||
// setupIdPServer creates a fake IdP server that supports token exchange.
|
||||
func setupIdPServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
var server *httptest.Server
|
||||
|
||||
// OAuth/OIDC metadata endpoint - uses closure to get server URL
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": server.URL,
|
||||
"token_endpoint": server.URL + "/token",
|
||||
"authorization_endpoint": server.URL + "/authorize",
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
})
|
||||
})
|
||||
|
||||
// Token endpoint - supports token exchange (RFC 8693)
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
grantType := r.Form.Get("grant_type")
|
||||
if grantType != oauthex.GrantTypeTokenExchange {
|
||||
http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify client authentication
|
||||
clientID := r.Form.Get("client_id")
|
||||
clientSecret := r.Form.Get("client_secret")
|
||||
if clientID != "idp_client_id" || clientSecret != "idp_secret" {
|
||||
http.Error(w, "invalid client credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify token exchange parameters
|
||||
if r.Form.Get("requested_token_type") != oauthex.TokenTypeIDJAG {
|
||||
http.Error(w, "invalid requested_token_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Form.Get("subject_token_type") != oauthex.TokenTypeIDToken {
|
||||
http.Error(w, "invalid subject_token_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Form.Get("subject_token") == "" {
|
||||
http.Error(w, "missing subject_token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Return ID-JAG (Identity Assertion JWT Authorization Grant)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "id-jag-token-from-idp",
|
||||
"issued_token_type": oauthex.TokenTypeIDJAG,
|
||||
"token_type": "N_A",
|
||||
"expires_in": 300,
|
||||
"scope": "read write",
|
||||
})
|
||||
})
|
||||
|
||||
server = httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// setupMCPAuthServer creates a fake MCP authorization server that supports JWT bearer grant.
|
||||
func setupMCPAuthServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
var server *httptest.Server
|
||||
|
||||
// OAuth metadata endpoint - uses closure to get server URL
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": server.URL,
|
||||
"token_endpoint": server.URL + "/token",
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
})
|
||||
})
|
||||
|
||||
// Token endpoint - supports JWT bearer grant (RFC 7523)
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
grantType := r.Form.Get("grant_type")
|
||||
if grantType != "urn:ietf:params:oauth:grant-type:jwt-bearer" {
|
||||
http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify client authentication
|
||||
clientID := r.Form.Get("client_id")
|
||||
clientSecret := r.Form.Get("client_secret")
|
||||
if clientID != "mcp_client_id" || clientSecret != "mcp_secret" {
|
||||
http.Error(w, "invalid client credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify assertion (ID-JAG)
|
||||
assertion := r.Form.Get("assertion")
|
||||
if assertion != "id-jag-token-from-idp" {
|
||||
http.Error(w, "invalid assertion", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Return access token
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "mcp_access_token_from_jwt_bearer",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "read write",
|
||||
})
|
||||
})
|
||||
|
||||
server = httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// TestEnterpriseHandler_Authorize_IDTokenFetcherError tests error handling when IDTokenFetcher fails.
|
||||
func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) {
|
||||
handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "idp_client_id",
|
||||
},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "mcp_client_id",
|
||||
},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
||||
return nil, fmt.Errorf("user cancelled login")
|
||||
},
|
||||
})
|
||||
config := validEnterpriseHandlerConfig()
|
||||
config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) {
|
||||
return nil, fmt.Errorf("user cancelled login")
|
||||
}
|
||||
|
||||
handler, err := NewEnterpriseHandler(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEnterpriseHandler failed: %v", err)
|
||||
}
|
||||
|
|
@ -457,30 +259,12 @@ func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) {
|
|||
t.Fatal("expected error from Authorize, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to obtain ID token") {
|
||||
t.Errorf("expected error about ID token, got: %v", err)
|
||||
t.Errorf("error = %v, want error containing %q", err, "failed to obtain ID token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnterpriseHandler_TokenSource_BeforeAuthorization tests TokenSource before authorization.
|
||||
func TestEnterpriseHandler_TokenSource_BeforeAuthorization(t *testing.T) {
|
||||
handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{
|
||||
IdPIssuerURL: "https://idp.example.com",
|
||||
IdPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "idp_client_id",
|
||||
},
|
||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||
MCPResourceURI: "https://mcp.example.com",
|
||||
MCPCredentials: &oauthex.ClientCredentials{
|
||||
ClientID: "mcp_client_id",
|
||||
},
|
||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
||||
token := &oauth2.Token{
|
||||
AccessToken: "mock_access_token",
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil
|
||||
},
|
||||
})
|
||||
handler, err := NewEnterpriseHandler(validEnterpriseHandlerConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("NewEnterpriseHandler failed: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,468 +1,152 @@
|
|||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package extauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// TestInitiateOIDCLogin tests the OIDC authorization request generation.
|
||||
func TestInitiateOIDCLogin(t *testing.T) {
|
||||
// Create mock IdP server
|
||||
idpServer := createMockOIDCServer(t)
|
||||
defer idpServer.Close()
|
||||
config := &OIDCLoginConfig{
|
||||
IssuerURL: idpServer.URL,
|
||||
Credentials: &oauthex.ClientCredentials{
|
||||
ClientID: "test-client",
|
||||
},
|
||||
RedirectURL: "http://localhost:8080/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
HTTPClient: idpServer.Client(),
|
||||
}
|
||||
t.Run("successful initiation", func(t *testing.T) {
|
||||
authReq, _, err := initiateOIDCLogin(context.Background(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||
}
|
||||
// Validate authURL
|
||||
if authReq.authURL == "" {
|
||||
t.Error("authURL is empty")
|
||||
}
|
||||
// Parse and validate URL parameters
|
||||
u, err := url.Parse(authReq.authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse authURL: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("response_type") != "code" {
|
||||
t.Errorf("expected response_type 'code', got '%s'", q.Get("response_type"))
|
||||
}
|
||||
if q.Get("client_id") != "test-client" {
|
||||
t.Errorf("expected client_id 'test-client', got '%s'", q.Get("client_id"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "http://localhost:8080/callback" {
|
||||
t.Errorf("expected redirect_uri 'http://localhost:8080/callback', got '%s'", q.Get("redirect_uri"))
|
||||
}
|
||||
if q.Get("scope") != "openid profile email" {
|
||||
t.Errorf("expected scope 'openid profile email', got '%s'", q.Get("scope"))
|
||||
}
|
||||
if q.Get("code_challenge_method") != "S256" {
|
||||
t.Errorf("expected code_challenge_method 'S256', got '%s'", q.Get("code_challenge_method"))
|
||||
}
|
||||
// Validate state is generated
|
||||
if authReq.state == "" {
|
||||
t.Error("state is empty")
|
||||
}
|
||||
if q.Get("state") != authReq.state {
|
||||
t.Errorf("state in URL doesn't match returned state")
|
||||
}
|
||||
// Validate PKCE parameters
|
||||
if authReq.codeVerifier == "" {
|
||||
t.Error("codeVerifier is empty")
|
||||
}
|
||||
if q.Get("code_challenge") == "" {
|
||||
t.Error("code_challenge is empty")
|
||||
}
|
||||
})
|
||||
t.Run("with login_hint", func(t *testing.T) {
|
||||
configWithHint := *config
|
||||
configWithHint.LoginHint = "user@example.com"
|
||||
authReq, _, err := initiateOIDCLogin(context.Background(), &configWithHint)
|
||||
if err != nil {
|
||||
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||
}
|
||||
u, err := url.Parse(authReq.authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse authURL: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("login_hint") != "user@example.com" {
|
||||
t.Errorf("expected login_hint 'user@example.com', got '%s'", q.Get("login_hint"))
|
||||
}
|
||||
})
|
||||
t.Run("without login_hint", func(t *testing.T) {
|
||||
authReq, _, err := initiateOIDCLogin(context.Background(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||
}
|
||||
u, err := url.Parse(authReq.authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse authURL: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Has("login_hint") {
|
||||
t.Errorf("expected no login_hint parameter, but got '%s'", q.Get("login_hint"))
|
||||
}
|
||||
})
|
||||
t.Run("nil config", func(t *testing.T) {
|
||||
_, _, err := initiateOIDCLogin(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil config, got nil")
|
||||
}
|
||||
})
|
||||
t.Run("missing openid scope", func(t *testing.T) {
|
||||
badConfig := *config
|
||||
badConfig.Scopes = []string{"profile", "email"} // Missing "openid"
|
||||
_, _, err := initiateOIDCLogin(context.Background(), &badConfig)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing openid scope, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "openid") {
|
||||
t.Errorf("expected error about missing 'openid', got: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("missing required fields", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*OIDCLoginConfig)
|
||||
expectErr string
|
||||
}{
|
||||
{
|
||||
name: "missing IssuerURL",
|
||||
mutate: func(c *OIDCLoginConfig) { c.IssuerURL = "" },
|
||||
expectErr: "IssuerURL is required",
|
||||
},
|
||||
{
|
||||
name: "missing ClientID",
|
||||
mutate: func(c *OIDCLoginConfig) { c.Credentials.ClientID = "" },
|
||||
expectErr: "ClientID is required",
|
||||
},
|
||||
{
|
||||
name: "missing RedirectURL",
|
||||
mutate: func(c *OIDCLoginConfig) {
|
||||
c.RedirectURL = ""
|
||||
// Ensure ClientID is present to test RedirectURL validation
|
||||
c.Credentials = &oauthex.ClientCredentials{ClientID: "test"}
|
||||
},
|
||||
expectErr: "RedirectURL is required",
|
||||
},
|
||||
{
|
||||
name: "missing Scopes",
|
||||
mutate: func(c *OIDCLoginConfig) {
|
||||
c.Scopes = nil
|
||||
// Ensure required fields are present to test Scopes validation
|
||||
c.Credentials = &oauthex.ClientCredentials{ClientID: "test"}
|
||||
c.RedirectURL = "http://localhost:8080/callback"
|
||||
},
|
||||
expectErr: "at least one scope is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
badConfig := *config
|
||||
tt.mutate(&badConfig)
|
||||
_, _, err := initiateOIDCLogin(context.Background(), &badConfig)
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.expectErr) {
|
||||
t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
const testRedirectURL = "http://localhost:18927/callback"
|
||||
|
||||
// TestCompleteOIDCLogin tests the authorization code exchange.
|
||||
func TestCompleteOIDCLogin(t *testing.T) {
|
||||
// Create mock IdP server
|
||||
idpServer := createMockOIDCServerWithToken(t)
|
||||
defer idpServer.Close()
|
||||
config := &OIDCLoginConfig{
|
||||
IssuerURL: idpServer.URL,
|
||||
func validOIDCLoginConfig(issuerURL string) *OIDCLoginConfig {
|
||||
return &OIDCLoginConfig{
|
||||
IssuerURL: issuerURL,
|
||||
Credentials: &oauthex.ClientCredentials{
|
||||
ClientID: "test-client",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "test-secret",
|
||||
},
|
||||
},
|
||||
RedirectURL: "http://localhost:8080/callback",
|
||||
RedirectURL: testRedirectURL,
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
HTTPClient: idpServer.Client(),
|
||||
}
|
||||
t.Run("successful code exchange", func(t *testing.T) {
|
||||
// First initiate to get oauth2Config
|
||||
_, oauth2Config, err := initiateOIDCLogin(context.Background(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
token, err := completeOIDCLogin(
|
||||
context.Background(),
|
||||
config,
|
||||
oauth2Config,
|
||||
"test-auth-code",
|
||||
"test-code-verifier",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("completeOIDCLogin failed: %v", err)
|
||||
func TestPerformOIDCLogin(t *testing.T) {
|
||||
idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"test-client": {
|
||||
Secret: "test-secret",
|
||||
RedirectURIs: []string{testRedirectURL},
|
||||
},
|
||||
},
|
||||
})
|
||||
idpServer.Start(t)
|
||||
|
||||
config := validOIDCLoginConfig(idpServer.URL())
|
||||
|
||||
// fetchAuthCode visits the authorization URL on the fake IdP server,
|
||||
// follows the redirect, and extracts the authorization code and state.
|
||||
fetchAuthCode := func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
// Validate tokens
|
||||
resp, err := client.Get(args.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to visit auth URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
return nil, fmt.Errorf("expected redirect, got status %d", resp.StatusCode)
|
||||
}
|
||||
loc, err := resp.Location()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("missing Location header: %w", err)
|
||||
}
|
||||
return &auth.AuthorizationResult{
|
||||
Code: loc.Query().Get("code"),
|
||||
State: loc.Query().Get("state"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
verifyOIDCToken := func(token *oauth2.Token) error {
|
||||
idToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || idToken == "" {
|
||||
t.Error("id_token is missing or empty")
|
||||
return fmt.Errorf("id_token is missing or empty")
|
||||
}
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("id_token is not a JWT (expected 3 parts, got %d)", len(parts))
|
||||
}
|
||||
if token.AccessToken == "" {
|
||||
t.Error("AccessToken is empty")
|
||||
return fmt.Errorf("access token is empty")
|
||||
}
|
||||
if token.TokenType != "Bearer" {
|
||||
t.Errorf("expected TokenType 'Bearer', got '%s'", token.TokenType)
|
||||
return fmt.Errorf("token type = %q, want %q", token.TokenType, "Bearer")
|
||||
}
|
||||
if token.Expiry.IsZero() {
|
||||
t.Error("Expiry is zero")
|
||||
}
|
||||
})
|
||||
t.Run("missing parameters", func(t *testing.T) {
|
||||
_, oauth2Config, _ := initiateOIDCLogin(context.Background(), config)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
authCode string
|
||||
codeVerifier string
|
||||
expectErr string
|
||||
}{
|
||||
{
|
||||
name: "missing authCode",
|
||||
authCode: "",
|
||||
codeVerifier: "test-verifier",
|
||||
expectErr: "authCode is required",
|
||||
},
|
||||
{
|
||||
name: "missing codeVerifier",
|
||||
authCode: "test-code",
|
||||
codeVerifier: "",
|
||||
expectErr: "codeVerifier is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := completeOIDCLogin(
|
||||
context.Background(),
|
||||
config,
|
||||
oauth2Config,
|
||||
tt.authCode,
|
||||
tt.codeVerifier,
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.expectErr) {
|
||||
t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOIDCLoginE2E tests the complete OIDC login flow end-to-end.
|
||||
func TestOIDCLoginE2E(t *testing.T) {
|
||||
// Create mock IdP server
|
||||
idpServer := createMockOIDCServerWithToken(t)
|
||||
defer idpServer.Close()
|
||||
config := &OIDCLoginConfig{
|
||||
IssuerURL: idpServer.URL,
|
||||
Credentials: &oauthex.ClientCredentials{
|
||||
ClientID: "test-client",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "test-secret",
|
||||
},
|
||||
},
|
||||
RedirectURL: "http://localhost:8080/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
HTTPClient: idpServer.Client(),
|
||||
}
|
||||
// Step 1: Initiate login
|
||||
authReq, oauth2Config, err := initiateOIDCLogin(context.Background(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||
}
|
||||
// Step 2: Simulate user authentication and redirect
|
||||
// (In real flow, user would visit authReq.authURL and IdP would redirect back)
|
||||
// Here we just use a mock authorization code
|
||||
mockAuthCode := "mock-authorization-code"
|
||||
// Step 3: Complete login with authorization code
|
||||
token, err := completeOIDCLogin(
|
||||
context.Background(),
|
||||
config,
|
||||
oauth2Config,
|
||||
mockAuthCode,
|
||||
authReq.codeVerifier,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("completeOIDCLogin failed: %v", err)
|
||||
}
|
||||
// Validate we got an ID token
|
||||
idToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || idToken == "" {
|
||||
t.Error("Expected ID token, got empty or missing")
|
||||
}
|
||||
// Validate ID token is a JWT (has 3 parts)
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Errorf("Expected JWT with 3 parts, got %d parts", len(parts))
|
||||
}
|
||||
}
|
||||
|
||||
// createMockOIDCServer creates a mock OIDC server for testing initiateOIDCLogin.
|
||||
func createMockOIDCServer(t *testing.T) *httptest.Server {
|
||||
var serverURL string
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle OIDC discovery
|
||||
if r.URL.Path == "/.well-known/openid-configuration" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": serverURL,
|
||||
"authorization_endpoint": serverURL + "/authorize",
|
||||
"token_endpoint": serverURL + "/token",
|
||||
"jwks_uri": serverURL + "/.well-known/jwks.json",
|
||||
"response_types_supported": []string{"code"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"grant_types_supported": []string{"authorization_code"},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
serverURL = server.URL
|
||||
return server
|
||||
}
|
||||
|
||||
// createMockOIDCServerWithToken creates a mock OIDC server that also handles token exchange.
|
||||
func createMockOIDCServerWithToken(t *testing.T) *httptest.Server {
|
||||
var serverURL string
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle OIDC discovery
|
||||
if r.URL.Path == "/.well-known/openid-configuration" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": serverURL,
|
||||
"authorization_endpoint": serverURL + "/authorize",
|
||||
"token_endpoint": serverURL + "/token",
|
||||
"jwks_uri": serverURL + "/.well-known/jwks.json",
|
||||
"response_types_supported": []string{"code"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"grant_types_supported": []string{"authorization_code"},
|
||||
})
|
||||
return
|
||||
}
|
||||
// Handle token endpoint
|
||||
if r.URL.Path == "/token" {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Validate grant type
|
||||
if r.FormValue("grant_type") != "authorization_code" {
|
||||
http.Error(w, "invalid grant_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Create mock ID token (JWT)
|
||||
now := time.Now().Unix()
|
||||
idToken := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.%s.mock-signature",
|
||||
base64EncodeClaims(map[string]any{
|
||||
"iss": serverURL,
|
||||
"sub": "test-user",
|
||||
"aud": "test-client",
|
||||
"exp": now + 3600,
|
||||
"iat": now,
|
||||
"email": "test@example.com",
|
||||
}))
|
||||
// Return token response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"id_token": idToken,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
serverURL = server.URL
|
||||
return server
|
||||
}
|
||||
|
||||
// base64EncodeClaims encodes JWT claims for testing.
|
||||
func base64EncodeClaims(claims map[string]any) string {
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
return base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
}
|
||||
|
||||
// TestPerformOIDCLogin tests the combined OIDC login flow with callback.
|
||||
func TestPerformOIDCLogin(t *testing.T) {
|
||||
// Create mock IdP server
|
||||
idpServer := createMockOIDCServerWithToken(t)
|
||||
defer idpServer.Close()
|
||||
config := &OIDCLoginConfig{
|
||||
IssuerURL: idpServer.URL,
|
||||
Credentials: &oauthex.ClientCredentials{
|
||||
ClientID: "test-client",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "test-secret",
|
||||
},
|
||||
},
|
||||
RedirectURL: "http://localhost:8080/callback",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
HTTPClient: idpServer.Client(),
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("successful flow", func(t *testing.T) {
|
||||
token, err := PerformOIDCLogin(context.Background(), config,
|
||||
token, err := PerformOIDCLogin(context.Background(), config, fetchAuthCode)
|
||||
if err != nil {
|
||||
t.Fatalf("PerformOIDCLogin() error = %v", err)
|
||||
}
|
||||
if err := verifyOIDCToken(token); err != nil {
|
||||
t.Errorf("invalid token returned: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with login_hint", func(t *testing.T) {
|
||||
configWithHint := *config
|
||||
configWithHint.LoginHint = "user@example.com"
|
||||
|
||||
token, err := PerformOIDCLogin(context.Background(), &configWithHint,
|
||||
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||
// Validate authURL has required parameters
|
||||
u, err := url.Parse(args.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid authURL: %w", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("response_type") != "code" {
|
||||
return nil, fmt.Errorf("missing response_type")
|
||||
if got := u.Query().Get("login_hint"); got != "user@example.com" {
|
||||
t.Errorf("login_hint = %q, want %q", got, "user@example.com")
|
||||
}
|
||||
if q.Get("state") == "" {
|
||||
return nil, fmt.Errorf("missing state")
|
||||
}
|
||||
|
||||
// Simulate successful user authentication
|
||||
return &auth.AuthorizationResult{
|
||||
Code: "mock-auth-code",
|
||||
State: q.Get("state"), // Return the expected state from URL
|
||||
}, nil
|
||||
return fetchAuthCode(ctx, args)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("PerformOIDCLogin failed: %v", err)
|
||||
t.Fatalf("PerformOIDCLogin() error = %v", err)
|
||||
}
|
||||
if err := verifyOIDCToken(token); err != nil {
|
||||
t.Errorf("invalid token returned: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
idToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || idToken == "" {
|
||||
t.Error("id_token is missing or empty")
|
||||
t.Run("without login_hint", func(t *testing.T) {
|
||||
token, err := PerformOIDCLogin(context.Background(), config,
|
||||
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||
u, err := url.Parse(args.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid authURL: %w", err)
|
||||
}
|
||||
if u.Query().Has("login_hint") {
|
||||
t.Errorf("login_hint should be absent, got %q", u.Query().Get("login_hint"))
|
||||
}
|
||||
return fetchAuthCode(ctx, args)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PerformOIDCLogin() error = %v", err)
|
||||
}
|
||||
if token.AccessToken == "" {
|
||||
t.Error("AccessToken is empty")
|
||||
if err := verifyOIDCToken(token); err != nil {
|
||||
t.Errorf("invalid token returned: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("state mismatch", func(t *testing.T) {
|
||||
_, err := PerformOIDCLogin(context.Background(), config,
|
||||
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||
// Return wrong state to simulate CSRF attack
|
||||
return &auth.AuthorizationResult{
|
||||
Code: "mock-auth-code",
|
||||
State: "wrong-state",
|
||||
|
|
@ -470,10 +154,10 @@ func TestPerformOIDCLogin(t *testing.T) {
|
|||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for state mismatch, got nil")
|
||||
t.Fatal("expected error for state mismatch, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "state mismatch") {
|
||||
t.Errorf("expected state mismatch error, got: %v", err)
|
||||
t.Errorf("error = %v, want error containing %q", err, "state mismatch")
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -484,20 +168,96 @@ func TestPerformOIDCLogin(t *testing.T) {
|
|||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "user cancelled") {
|
||||
t.Errorf("expected 'user cancelled' error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil fetcher", func(t *testing.T) {
|
||||
_, err := PerformOIDCLogin(context.Background(), config, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil fetcher, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "authCodeFetcher is required") {
|
||||
t.Errorf("expected 'authCodeFetcher is required' error, got: %v", err)
|
||||
t.Errorf("error = %v, want error containing %q", err, "user cancelled")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPerformOIDCLogin_ValidationErrors(t *testing.T) {
|
||||
const nonexistentIssuer = "https://idp.example.com"
|
||||
|
||||
noopFetcher := func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||
return nil, fmt.Errorf("should not be called")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *OIDCLoginConfig
|
||||
fetcher auth.AuthorizationCodeFetcher
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil config",
|
||||
config: nil,
|
||||
fetcher: noopFetcher,
|
||||
wantErr: "config is required",
|
||||
},
|
||||
{
|
||||
name: "nil fetcher",
|
||||
config: validOIDCLoginConfig(nonexistentIssuer),
|
||||
fetcher: nil,
|
||||
wantErr: "authCodeFetcher is required",
|
||||
},
|
||||
{
|
||||
name: "missing IssuerURL",
|
||||
config: validOIDCLoginConfig(""),
|
||||
fetcher: noopFetcher,
|
||||
wantErr: "IssuerURL is required",
|
||||
},
|
||||
{
|
||||
name: "missing ClientID",
|
||||
config: func() *OIDCLoginConfig {
|
||||
c := validOIDCLoginConfig(nonexistentIssuer)
|
||||
c.Credentials = &oauthex.ClientCredentials{}
|
||||
return c
|
||||
}(),
|
||||
fetcher: noopFetcher,
|
||||
wantErr: "ClientID is required",
|
||||
},
|
||||
{
|
||||
name: "missing RedirectURL",
|
||||
config: func() *OIDCLoginConfig {
|
||||
c := validOIDCLoginConfig(nonexistentIssuer)
|
||||
c.RedirectURL = ""
|
||||
return c
|
||||
}(),
|
||||
fetcher: noopFetcher,
|
||||
wantErr: "RedirectURL is required",
|
||||
},
|
||||
{
|
||||
name: "missing Scopes",
|
||||
config: func() *OIDCLoginConfig {
|
||||
c := validOIDCLoginConfig(nonexistentIssuer)
|
||||
c.Scopes = nil
|
||||
return c
|
||||
}(),
|
||||
fetcher: noopFetcher,
|
||||
wantErr: "at least one scope is required",
|
||||
},
|
||||
{
|
||||
name: "missing openid scope",
|
||||
config: func() *OIDCLoginConfig {
|
||||
c := validOIDCLoginConfig(nonexistentIssuer)
|
||||
c.Scopes = []string{"profile", "email"}
|
||||
return c
|
||||
}(),
|
||||
fetcher: noopFetcher,
|
||||
wantErr: "openid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := PerformOIDCLogin(context.Background(), tt.config, tt.fetcher)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error = %v, want error containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,14 @@ type RegistrationConfig struct {
|
|||
DynamicClientRegistrationEnabled bool
|
||||
}
|
||||
|
||||
// JWTBearerConfig configures support for the JWT Bearer grant type (RFC 7523)
|
||||
// on a [FakeAuthorizationServer].
|
||||
type JWTBearerConfig struct {
|
||||
// ValidAssertions is the set of assertion values that are accepted.
|
||||
// If empty, any non-empty assertion is accepted.
|
||||
ValidAssertions []string
|
||||
}
|
||||
|
||||
// Config holds configuration for FakeAuthorizationServer.
|
||||
type Config struct {
|
||||
// The optional path component of the issuer URL.
|
||||
|
|
@ -59,6 +67,9 @@ type Config struct {
|
|||
MetadataEndpointConfig *MetadataEndpointConfig
|
||||
// Configuration for client registration.
|
||||
RegistrationConfig *RegistrationConfig
|
||||
// JWTBearerConfig enables RFC 7523 JWT Bearer grant at the /token endpoint.
|
||||
// If non-nil, the server accepts grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer.
|
||||
JWTBearerConfig *JWTBearerConfig
|
||||
}
|
||||
|
||||
// FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing.
|
||||
|
|
@ -253,10 +264,19 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req
|
|||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Form.Get("grant_type") != "authorization_code" {
|
||||
http.Error(w, "invalid grant_type", http.StatusBadRequest)
|
||||
return
|
||||
|
||||
grantType := r.Form.Get("grant_type")
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
s.handleAuthorizationCodeGrant(w, r)
|
||||
case "urn:ietf:params:oauth:grant-type:jwt-bearer":
|
||||
s.handleJWTBearerGrant(w, r)
|
||||
default:
|
||||
http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.Form.Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
|
|
@ -287,6 +307,31 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req
|
|||
})
|
||||
}
|
||||
|
||||
func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r *http.Request) {
|
||||
if s.config.JWTBearerConfig == nil {
|
||||
http.Error(w, "JWT bearer grant not supported", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
assertion := r.Form.Get("assertion")
|
||||
if assertion == "" {
|
||||
http.Error(w, "missing assertion", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(s.config.JWTBearerConfig.ValidAssertions) > 0 {
|
||||
if !slices.Contains(s.config.JWTBearerConfig.ValidAssertions, assertion) {
|
||||
http.Error(w, "invalid assertion", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test_access_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *FakeAuthorizationServer) authenticateClient(r *http.Request) error {
|
||||
clientID, clientSecret, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
|
|
|
|||
258
internal/oauthtest/fake_idp_server.go
Normal file
258
internal/oauthtest/fake_idp_server.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package oauthtest
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
)
|
||||
|
||||
// TokenExchangeConfig configures RFC 8693 token exchange support on a [FakeIdPServer].
|
||||
type TokenExchangeConfig struct {
|
||||
// IDJAGToken is the ID-JAG value returned from token exchange.
|
||||
// Defaults to "test-id-jag-token" if empty.
|
||||
IDJAGToken string
|
||||
}
|
||||
|
||||
// IdPConfig holds configuration for [FakeIdPServer].
|
||||
type IdPConfig struct {
|
||||
// PreregisteredClients maps client IDs to their info.
|
||||
PreregisteredClients map[string]ClientInfo
|
||||
|
||||
// TokenExchangeConfig enables RFC 8693 token exchange at the /token endpoint.
|
||||
// If non-nil, the server accepts grant_type=urn:ietf:params:oauth:grant-type:token-exchange.
|
||||
TokenExchangeConfig *TokenExchangeConfig
|
||||
}
|
||||
|
||||
// FakeIdPServer is a fake OIDC Identity Provider for testing.
|
||||
// It supports:
|
||||
// - OIDC discovery (/.well-known/openid-configuration)
|
||||
// - Authorization Code Grant with PKCE
|
||||
// - ID Token issuance (fake JWTs)
|
||||
// - RFC 8693 Token Exchange (ID Token → ID-JAG), if configured
|
||||
type FakeIdPServer struct {
|
||||
server *httptest.Server
|
||||
mux *http.ServeMux
|
||||
config IdPConfig
|
||||
clients map[string]ClientInfo
|
||||
codes map[string]codeInfo
|
||||
}
|
||||
|
||||
// NewFakeIdPServer creates a new FakeIdPServer.
|
||||
func NewFakeIdPServer(config IdPConfig) *FakeIdPServer {
|
||||
s := &FakeIdPServer{
|
||||
mux: http.NewServeMux(),
|
||||
config: config,
|
||||
clients: make(map[string]ClientInfo),
|
||||
codes: make(map[string]codeInfo),
|
||||
}
|
||||
maps.Copy(s.clients, config.PreregisteredClients)
|
||||
|
||||
s.mux.HandleFunc("/.well-known/openid-configuration", s.handleMetadata)
|
||||
s.mux.HandleFunc("/authorize", s.handleAuthorize)
|
||||
s.mux.HandleFunc("/token", s.handleToken)
|
||||
s.server = httptest.NewUnstartedServer(s.mux)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Start starts the HTTP server and registers a cleanup function on t.
|
||||
func (s *FakeIdPServer) Start(t testing.TB) {
|
||||
s.server.Start()
|
||||
t.Cleanup(s.server.Close)
|
||||
}
|
||||
|
||||
// URL returns the base URL of the server (issuer).
|
||||
func (s *FakeIdPServer) URL() string {
|
||||
return s.server.URL
|
||||
}
|
||||
|
||||
func (s *FakeIdPServer) handleMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
grantTypes := []string{"authorization_code"}
|
||||
if s.config.TokenExchangeConfig != nil {
|
||||
grantTypes = append(grantTypes, oauthex.GrantTypeTokenExchange)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": s.URL(),
|
||||
"authorization_endpoint": s.URL() + "/authorize",
|
||||
"token_endpoint": s.URL() + "/token",
|
||||
"jwks_uri": s.URL() + "/.well-known/jwks.json",
|
||||
"response_types_supported": []string{"code"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"grant_types_supported": grantTypes,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *FakeIdPServer) handleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
clientID := r.URL.Query().Get("client_id")
|
||||
clientInfo, ok := s.clients[clientID]
|
||||
if !ok {
|
||||
http.Error(w, "unknown client_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
redirectURI := r.URL.Query().Get("redirect_uri")
|
||||
if redirectURI == "" {
|
||||
http.Error(w, "missing redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !slices.Contains(clientInfo.RedirectURIs, redirectURI) {
|
||||
http.Error(w, "invalid redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
codeChallenge := r.URL.Query().Get("code_challenge")
|
||||
if codeChallenge == "" {
|
||||
http.Error(w, "missing code_challenge", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code := rand.Text()
|
||||
s.codes[code] = codeInfo{CodeChallenge: codeChallenge}
|
||||
state := r.URL.Query().Get("state")
|
||||
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *FakeIdPServer) handleToken(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, "failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.authenticateClient(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
grantType := r.Form.Get("grant_type")
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
s.handleAuthorizationCodeGrant(w, r)
|
||||
case oauthex.GrantTypeTokenExchange:
|
||||
s.handleTokenExchangeGrant(w, r)
|
||||
default:
|
||||
http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FakeIdPServer) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.Form.Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ci, ok := s.codes[code]
|
||||
if !ok {
|
||||
http.Error(w, "unknown authorization code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
verifier := r.Form.Get("code_verifier")
|
||||
if verifier == "" {
|
||||
http.Error(w, "missing code_verifier", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sha := sha256.Sum256([]byte(verifier))
|
||||
expectedChallenge := base64.RawURLEncoding.EncodeToString(sha[:])
|
||||
if expectedChallenge != ci.CodeChallenge {
|
||||
http.Error(w, "PKCE verification failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := r.Form.Get("client_id")
|
||||
now := time.Now().Unix()
|
||||
idToken := fakeIDToken(s.URL(), clientID, now)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test_access_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "test_refresh_token",
|
||||
"id_token": idToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *FakeIdPServer) handleTokenExchangeGrant(w http.ResponseWriter, r *http.Request) {
|
||||
if s.config.TokenExchangeConfig == nil {
|
||||
http.Error(w, "token exchange not supported", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Form.Get("requested_token_type") != oauthex.TokenTypeIDJAG {
|
||||
http.Error(w, "invalid requested_token_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Form.Get("subject_token_type") != oauthex.TokenTypeIDToken {
|
||||
http.Error(w, "invalid subject_token_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Form.Get("subject_token") == "" {
|
||||
http.Error(w, "missing subject_token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
idJAG := s.config.TokenExchangeConfig.IDJAGToken
|
||||
if idJAG == "" {
|
||||
idJAG = "test-id-jag-token"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": idJAG,
|
||||
"issued_token_type": oauthex.TokenTypeIDJAG,
|
||||
"token_type": "N_A",
|
||||
"expires_in": 300,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *FakeIdPServer) authenticateClient(r *http.Request) error {
|
||||
clientID := r.Form.Get("client_id")
|
||||
clientSecret := r.Form.Get("client_secret")
|
||||
clientInfo, ok := s.clients[clientID]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown client")
|
||||
}
|
||||
if clientInfo.Secret != clientSecret {
|
||||
return fmt.Errorf("invalid client credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeIDToken creates a fake JWT ID token for testing.
|
||||
// The token has a valid structure but is not cryptographically signed.
|
||||
func fakeIDToken(issuer, audience string, now int64) string {
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||
claimsJSON, _ := json.Marshal(map[string]any{
|
||||
"iss": issuer,
|
||||
"sub": "test-user",
|
||||
"aud": audience,
|
||||
"exp": now + 3600,
|
||||
"iat": now,
|
||||
"email": "test@example.com",
|
||||
})
|
||||
claims := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
return header + "." + claims + ".mock-signature"
|
||||
}
|
||||
88
oauthex/client_test.go
Normal file
88
oauthex/client_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package oauthex
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientCredentials_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
creds ClientCredentials
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid public client",
|
||||
creds: ClientCredentials{ClientID: "my-client"},
|
||||
},
|
||||
{
|
||||
name: "valid confidential client",
|
||||
creds: ClientCredentials{
|
||||
ClientID: "my-client",
|
||||
ClientSecretAuth: &ClientSecretAuth{
|
||||
ClientSecret: "my-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty client ID",
|
||||
creds: ClientCredentials{},
|
||||
wantErr: "ClientID is required",
|
||||
},
|
||||
{
|
||||
name: "empty secret in ClientSecretAuth",
|
||||
creds: ClientCredentials{
|
||||
ClientID: "my-client",
|
||||
ClientSecretAuth: &ClientSecretAuth{},
|
||||
},
|
||||
wantErr: "ClientSecret is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.creds.Validate()
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("Validate() expected error containing %q, got nil", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want error containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientCredentials_ValidateCoversAllAuthFields uses reflection to detect
|
||||
// when new authentication method fields are added to ClientCredentials without
|
||||
// updating Validate. If this test fails, update Validate() to handle the new
|
||||
// field and increment knownAuthMethods.
|
||||
func TestClientCredentials_ValidateCoversAllAuthFields(t *testing.T) {
|
||||
const knownAuthMethods = 1 // ClientSecretAuth
|
||||
|
||||
typ := reflect.TypeOf(ClientCredentials{})
|
||||
var pointerFields int
|
||||
for i := range typ.NumField() {
|
||||
f := typ.Field(i)
|
||||
if f.Name == "ClientID" {
|
||||
continue
|
||||
}
|
||||
if f.Type.Kind() != reflect.Ptr {
|
||||
t.Errorf("field %q is %v, expected a pointer to an auth method struct", f.Name, f.Type.Kind())
|
||||
}
|
||||
pointerFields++
|
||||
}
|
||||
|
||||
if pointerFields != knownAuthMethods {
|
||||
t.Fatalf("ClientCredentials has %d auth method fields but Validate only knows about %d -- update Validate() and this test", pointerFields, knownAuthMethods)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue