package platform import ( "context" "crypto/sha256" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) func TestNewPKCE(t *testing.T) { v, c, err := newPKCE() if err != nil { t.Fatalf("newPKCE: %v", err) } if len(v) != 64 { t.Errorf("verifier length = %d, want 64", len(v)) } sum := sha256.Sum256([]byte(v)) want := base64.RawURLEncoding.EncodeToString(sum[:]) if c != want { t.Errorf("challenge = %q, want S256(verifier) = %q", c, want) } // two calls must differ v2, _, _ := newPKCE() if v == v2 { t.Error("two verifiers collided") } } // TestLogin_Success drives the whole loopback flow with a fake Casdoor token // endpoint and a fake browser, with no real network or prod dependency. func TestLogin_Success(t *testing.T) { var gotForm url.Values tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { t.Errorf("token endpoint ParseForm: %v", err) } gotForm = r.PostForm w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "at-123", "refresh_token": "rt-456", "expires_in": 3600, "token_type": "Bearer", }) })) defer tokenSrv.Close() cfg := OAuthConfig{ AuthorizeURL: "https://casdoor.example/login/oauth/authorize", TokenURL: tokenSrv.URL, ClientID: "cdrop-desktop", } // Fake browser: parse the authorize URL, assert it carries PKCE, then GET // the loopback redirect with a code + the same state (authorized). openURL := func(authURL string) { u, err := url.Parse(authURL) if err != nil { t.Errorf("parse authURL: %v", err) return } q := u.Query() if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" { t.Errorf("authorize request missing PKCE challenge: %v", q) } if q.Get("client_id") != "cdrop-desktop" { t.Errorf("authorize client_id = %q", q.Get("client_id")) } cb := q.Get("redirect_uri") + "?code=auth-code-xyz&state=" + url.QueryEscape(q.Get("state")) resp, err := http.Get(cb) if err != nil { t.Errorf("callback GET: %v", err) return } _ = resp.Body.Close() } tok, err := NewFlow(cfg, openURL).Login(context.Background()) if err != nil { t.Fatalf("Login: %v", err) } if tok.AccessToken != "at-123" { t.Errorf("access_token = %q, want at-123", tok.AccessToken) } if tok.RefreshToken != "rt-456" { t.Errorf("refresh_token = %q, want rt-456", tok.RefreshToken) } if tok.ExpiresIn != 3600 { t.Errorf("expires_in = %d, want 3600", tok.ExpiresIn) } // The exchange must carry the code + PKCE verifier and, as a public client, // no client_secret. if gotForm.Get("grant_type") != "authorization_code" { t.Errorf("grant_type = %q", gotForm.Get("grant_type")) } if gotForm.Get("code") != "auth-code-xyz" { t.Errorf("code = %q", gotForm.Get("code")) } if gotForm.Get("code_verifier") == "" { t.Error("token exchange missing code_verifier") } if gotForm.Get("client_secret") != "" { t.Error("public client must not send client_secret") } } func TestLogin_StateMismatch(t *testing.T) { cfg := OAuthConfig{ AuthorizeURL: "https://casdoor.example/login/oauth/authorize", TokenURL: "https://casdoor.example/token", ClientID: "cdrop-desktop", } openURL := func(authURL string) { u, _ := url.Parse(authURL) redirect := u.Query().Get("redirect_uri") resp, err := http.Get(redirect + "?code=c&state=WRONG-STATE") if err == nil { _ = resp.Body.Close() } } _, err := NewFlow(cfg, openURL).Login(context.Background()) if err == nil || !strings.Contains(err.Error(), "state mismatch") { t.Fatalf("want state mismatch error, got %v", err) } } func TestLogin_IncompleteConfig(t *testing.T) { _, err := NewFlow(OAuthConfig{ClientID: "only-id"}, func(string) {}).Login(context.Background()) if err == nil { t.Fatal("want error for incomplete config, got nil") } } func TestRefresh_Success(t *testing.T) { var gotForm url.Values tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() gotForm = r.PostForm w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "new-at", "refresh_token": "new-rt", "expires_in": 3600, }) })) defer tokenSrv.Close() cfg := OAuthConfig{ AuthorizeURL: "https://casdoor.example/login/oauth/authorize", TokenURL: tokenSrv.URL, ClientID: "cdrop-desktop", } tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rt") if err != nil { t.Fatalf("Refresh: %v", err) } if tok.AccessToken != "new-at" { t.Errorf("access_token = %q, want new-at", tok.AccessToken) } if gotForm.Get("grant_type") != "refresh_token" { t.Errorf("grant_type = %q", gotForm.Get("grant_type")) } if gotForm.Get("refresh_token") != "old-rt" { t.Errorf("refresh_token = %q", gotForm.Get("refresh_token")) } if gotForm.Get("client_secret") != "" { t.Error("public client must not send client_secret on refresh") } } func TestRefresh_EmptyToken(t *testing.T) { cfg := OAuthConfig{ AuthorizeURL: "https://x/authorize", TokenURL: "https://x/token", ClientID: "c", } if _, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), ""); err == nil { t.Fatal("want error for empty refresh token") } }