Files
2026-08-08 10:12:58 +08:00

361 lines
11 KiB
Go

package mcpoauth
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/security"
)
func TestPKCEVerifierMatchesChallenge(t *testing.T) {
verifier, challenge, err := generatePKCE()
if err != nil {
t.Fatalf("generatePKCE() error: %v", err)
}
if verifier == "" || challenge == "" {
t.Fatal("expected non-empty verifier and challenge")
}
h := sha256.Sum256([]byte(verifier))
want := base64.RawURLEncoding.EncodeToString(h[:])
if challenge != want {
t.Errorf("challenge = %q, want %q", challenge, want)
}
}
func TestGenerateStateLength(t *testing.T) {
state, err := generateState()
if err != nil {
t.Fatalf("generateState() error: %v", err)
}
// 16 bytes base64url-encoded → at least 21 chars (no padding).
if len(state) < 20 {
t.Errorf("state too short: %q (len %d)", state, len(state))
}
}
func TestStartFlowReturnsPKCEAuthURL(t *testing.T) {
fm := NewFlowManager(http.DefaultClient)
disc := &DiscoveryResult{
AuthorizationEndpoint: "https://auth.example.com/authorize",
TokenEndpoint: "https://auth.example.com/token",
}
authURL, state, err := fm.StartFlow(context.Background(), StartFlowParams{
ServerID: uuid.New(),
TenantID: uuid.New(),
DiscoveryResult: disc,
ClientID: "test-client",
RedirectURI: "https://goclaw.example.com/v1/mcp/oauth/callback",
GrantType: "pkce",
})
if err != nil {
t.Fatalf("StartFlow() error: %v", err)
}
if authURL == "" {
t.Error("expected non-empty authURL")
}
if state == "" || len(state) < 10 {
t.Errorf("expected valid state token, got %q", state)
}
if !strings.Contains(authURL, "code_challenge=") {
t.Errorf("authURL missing code_challenge: %s", authURL)
}
if !strings.Contains(authURL, "code_challenge_method=S256") {
t.Errorf("authURL missing S256 method: %s", authURL)
}
if !strings.Contains(authURL, "state="+state) {
t.Errorf("authURL missing state param: %s", authURL)
}
if !strings.Contains(authURL, "client_id=test-client") {
t.Errorf("authURL missing client_id: %s", authURL)
}
}
func TestStartFlowIncludesExtraAuthParams(t *testing.T) {
fm := NewFlowManager(http.DefaultClient)
disc := &DiscoveryResult{
AuthorizationEndpoint: "https://auth.example.com/authorize",
TokenEndpoint: "https://auth.example.com/token",
}
authURL, _, err := fm.StartFlow(context.Background(), StartFlowParams{
ServerID: uuid.New(),
TenantID: uuid.New(),
DiscoveryResult: disc,
ClientID: "test-client",
RedirectURI: "https://goclaw.example.com/v1/mcp/oauth/callback",
GrantType: "pkce",
ExtraAuthParams: url.Values{"token_access_type": {"offline"}},
})
if err != nil {
t.Fatalf("StartFlow() error: %v", err)
}
if !strings.Contains(authURL, "token_access_type=offline") {
t.Errorf("authURL missing token_access_type=offline: %s", authURL)
}
}
func TestStartFlowDoesNotOverrideStandardParams(t *testing.T) {
fm := NewFlowManager(http.DefaultClient)
disc := &DiscoveryResult{
AuthorizationEndpoint: "https://auth.example.com/authorize",
TokenEndpoint: "https://auth.example.com/token",
}
authURL, _, err := fm.StartFlow(context.Background(), StartFlowParams{
ServerID: uuid.New(),
TenantID: uuid.New(),
DiscoveryResult: disc,
ClientID: "test-client",
RedirectURI: "https://goclaw.example.com/v1/mcp/oauth/callback",
GrantType: "pkce",
ExtraAuthParams: url.Values{"client_id": {"evil"}},
})
if err != nil {
t.Fatalf("StartFlow() error: %v", err)
}
if strings.Contains(authURL, "client_id=evil") {
t.Errorf("ExtraAuthParams must not override standard client_id: %s", authURL)
}
}
func TestStartFlowStoresInPending(t *testing.T) {
fm := NewFlowManager(http.DefaultClient)
disc := &DiscoveryResult{
AuthorizationEndpoint: "https://auth.example.com/authorize",
TokenEndpoint: "https://auth.example.com/token",
}
_, state, err := fm.StartFlow(context.Background(), StartFlowParams{
ServerID: uuid.New(), TenantID: uuid.New(),
DiscoveryResult: disc, ClientID: "c",
RedirectURI: "https://example.com/cb",
})
if err != nil {
t.Fatalf("StartFlow() error: %v", err)
}
fm.mu.Lock()
flow, ok := fm.pending[state]
fm.mu.Unlock()
if !ok {
t.Fatal("pending flow not found")
}
if flow.ClientID != "c" {
t.Errorf("ClientID = %q, want %q", flow.ClientID, "c")
}
if !flow.UsePKCE {
t.Error("expected UsePKCE = true for pkce grant type")
}
}
func TestExchangeCodeSuccess(t *testing.T) {
security.SetAllowLoopbackForTest(true)
defer security.SetAllowLoopbackForTest(false)
srv := httptest.NewServer(http.HandlerFunc(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, "bad form", http.StatusBadRequest)
return
}
if r.FormValue("grant_type") != "authorization_code" {
http.Error(w, "wrong grant_type: "+r.FormValue("grant_type"), http.StatusBadRequest)
return
}
if r.FormValue("code_verifier") == "" {
http.Error(w, "missing code_verifier", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(OAuthTokens{
AccessToken: "access-token-123",
RefreshToken: "refresh-token-456",
TokenType: "Bearer",
ExpiresIn: 3600,
})
}))
defer srv.Close()
client := security.NewSafeClient(5 * time.Second)
fm := NewFlowManager(client)
disc := &DiscoveryResult{
AuthorizationEndpoint: "https://auth.example.com/authorize",
TokenEndpoint: srv.URL,
}
_, state, err := fm.StartFlow(context.Background(), StartFlowParams{
ServerID: uuid.New(),
TenantID: uuid.New(),
DiscoveryResult: disc,
ClientID: "test-client",
RedirectURI: srv.URL + "/callback",
GrantType: "pkce",
})
if err != nil {
t.Fatalf("StartFlow() error: %v", err)
}
tokens, flow, err := fm.ExchangeCode(context.Background(), state, "auth-code-xyz")
if err != nil {
t.Fatalf("ExchangeCode() error: %v", err)
}
if tokens.AccessToken != "access-token-123" {
t.Errorf("access_token = %q, want %q", tokens.AccessToken, "access-token-123")
}
if tokens.RefreshToken != "refresh-token-456" {
t.Errorf("refresh_token = %q, want %q", tokens.RefreshToken, "refresh-token-456")
}
if flow == nil {
t.Fatal("expected non-nil PendingFlow")
}
}
func TestExchangeCodeInvalidState(t *testing.T) {
fm := NewFlowManager(http.DefaultClient)
_, _, err := fm.ExchangeCode(context.Background(), "no-such-state", "code")
if err == nil {
t.Fatal("expected error for unknown state, got nil")
}
}
func TestExchangeCodeExpiredFlow(t *testing.T) {
fm := NewFlowManager(http.DefaultClient)
fm.mu.Lock()
fm.pending["expired-state"] = &PendingFlow{
CreatedAt: time.Now().Add(-11 * time.Minute), // > flowTTL (10 min)
}
fm.mu.Unlock()
_, _, err := fm.ExchangeCode(context.Background(), "expired-state", "code")
if err == nil {
t.Fatal("expected error for expired flow, got nil")
}
}
func TestExchangeCodeReplayPrevented(t *testing.T) {
security.SetAllowLoopbackForTest(true)
defer security.SetAllowLoopbackForTest(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(OAuthTokens{AccessToken: "tok", TokenType: "Bearer"})
}))
defer srv.Close()
fm := NewFlowManager(security.NewSafeClient(5 * time.Second))
disc := &DiscoveryResult{
AuthorizationEndpoint: "https://auth.example.com/authorize",
TokenEndpoint: srv.URL,
}
_, state, err := fm.StartFlow(context.Background(), StartFlowParams{
ServerID: uuid.New(), TenantID: uuid.New(),
DiscoveryResult: disc, ClientID: "c", RedirectURI: srv.URL + "/cb",
})
if err != nil {
t.Fatalf("StartFlow() error: %v", err)
}
// First exchange: succeeds.
if _, _, err := fm.ExchangeCode(context.Background(), state, "code"); err != nil {
t.Fatalf("first ExchangeCode() failed: %v", err)
}
// Second exchange with same state: state was deleted → must fail.
if _, _, err := fm.ExchangeCode(context.Background(), state, "code"); err == nil {
t.Fatal("expected error on replay, got nil")
}
}
func TestExchangeCodeTokenEndpointError(t *testing.T) {
security.SetAllowLoopbackForTest(true)
defer security.SetAllowLoopbackForTest(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
}))
defer srv.Close()
fm := NewFlowManager(security.NewSafeClient(5 * time.Second))
disc := &DiscoveryResult{
AuthorizationEndpoint: "https://auth.example.com/authorize",
TokenEndpoint: srv.URL,
}
_, state, err := fm.StartFlow(context.Background(), StartFlowParams{
ServerID: uuid.New(), TenantID: uuid.New(),
DiscoveryResult: disc, ClientID: "c", RedirectURI: srv.URL + "/cb",
})
if err != nil {
t.Fatalf("StartFlow() error: %v", err)
}
_, _, err = fm.ExchangeCode(context.Background(), state, "bad-code")
if err == nil {
t.Fatal("expected error when token endpoint returns 400, got nil")
}
}
func TestClientCredentials(t *testing.T) {
security.SetAllowLoopbackForTest(true)
defer security.SetAllowLoopbackForTest(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
if r.FormValue("grant_type") != "client_credentials" {
http.Error(w, "wrong grant_type: "+r.FormValue("grant_type"), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(OAuthTokens{AccessToken: "cc-token", TokenType: "Bearer"})
}))
defer srv.Close()
fm := NewFlowManager(security.NewSafeClient(5 * time.Second))
tokens, err := fm.ClientCredentials(context.Background(), srv.URL, "client-id", "client-secret", "read write", "")
if err != nil {
t.Fatalf("ClientCredentials() error: %v", err)
}
if tokens.AccessToken != "cc-token" {
t.Errorf("access_token = %q, want %q", tokens.AccessToken, "cc-token")
}
}
func TestFlowCleanupPurgesExpired(t *testing.T) {
fm := NewFlowManager(http.DefaultClient)
fm.mu.Lock()
fm.pending["fresh"] = &PendingFlow{CreatedAt: time.Now()}
fm.pending["old"] = &PendingFlow{CreatedAt: time.Now().Add(-11 * time.Minute)}
fm.mu.Unlock()
// Simulate cleanup logic directly (same as cleanupLoop body).
cutoff := time.Now().Add(-flowTTL)
fm.mu.Lock()
for state, flow := range fm.pending {
if flow.CreatedAt.Before(cutoff) {
delete(fm.pending, state)
}
}
fm.mu.Unlock()
fm.mu.Lock()
_, freshOk := fm.pending["fresh"]
_, oldOk := fm.pending["old"]
fm.mu.Unlock()
if !freshOk {
t.Error("fresh flow should not be purged")
}
if oldOk {
t.Error("expired flow should be purged by cleanup")
}
}