mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
361 lines
11 KiB
Go
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")
|
|
}
|
|
}
|