fix(xcecli): call the logout API so the token is revoked (bug-1959)

xcecli logout only removed the token from the local config, so the token
stayed valid on the server until it expired. It now first sends
POST /xce/v1/auth/logout with the bearer token (APIClient.Logout), which
revokes it server side since bug-1946. Best effort: if the server is
unreachable or rejects the token, the local login info is still cleared
and a warning says the revocation could not be confirmed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-10 10:21:11 +00:00
parent c71049f28d
commit 0379823098
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
3 changed files with 139 additions and 1 deletions

View file

@ -16,7 +16,8 @@ func NewLogoutCmd(configManager *config.ConfigManager) *cobra.Command {
Use: "logout",
GroupID: "api",
Short: "Logout from API Server",
Long: `Logout from specified API Server, clear local saved token.
Long: `Logout from specified API Server: ask the server to revoke the token,
then clear the local saved token (also when the server cannot be reached).
Example:
xce logout --url http://tt1.test.com:8080
xce logout --url https://tt1.test.com:8443`,
@ -47,12 +48,14 @@ func runLogout(url string, cm *config.ConfigManager) error {
apiServerInfo := make([]config.APIServerInfo, 0)
// find server with specified url
found := false
token := ""
for _, server := range cfg.APIServerInfo {
switch server.URL {
case "":
continue
case url:
found = true
token = server.Token
default:
apiServerInfo = append(apiServerInfo, server)
}
@ -62,6 +65,14 @@ func runLogout(url string, cm *config.ConfigManager) error {
if !found {
return fmt.Errorf("server not found: %s", url)
}
// bug-1959: revoke the token on the server first (best effort): if the
// server is unreachable or rejects the token, still clear local state.
if token != "" {
if err := request.NewAPIClient(url).Logout(token); err != nil {
errMsg := request.ExtractErrorMessage(err)
fmt.Printf("warning: server-side token revocation could not be confirmed: [%s]\n", errMsg)
}
}
cfg.APIServerInfo = apiServerInfo
// save config
if err := cm.SaveConfig(cfg); err != nil {

View file

@ -1,6 +1,13 @@
package cmd
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"xcecli/config"
)
@ -51,3 +58,107 @@ func newTestConfig(t *testing.T, urls ...string) (*config.ConfigManager, *config
}
return cm, cfg
}
// logoutServer is an httptest API server that records every request's
// method, path and Authorization header and answers with the given status.
type logoutServer struct {
srv *httptest.Server
mu sync.Mutex
reqs []string // "METHOD PATH AUTH"
}
func newLogoutServer(t *testing.T, status int, body string) *logoutServer {
t.Helper()
ls := &logoutServer{}
ls.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ls.mu.Lock()
ls.reqs = append(ls.reqs, r.Method+" "+r.URL.Path+" "+r.Header.Get("Authorization"))
ls.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}))
t.Cleanup(ls.srv.Close)
return ls
}
func (ls *logoutServer) requests() []string {
ls.mu.Lock()
defer ls.mu.Unlock()
return append([]string(nil), ls.reqs...)
}
// captureStdout runs fn and returns what it printed to os.Stdout.
func captureStdout(t *testing.T, fn func() error) (string, error) {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
old := os.Stdout
os.Stdout = w
done := make(chan string)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
done <- buf.String()
}()
fnErr := fn()
os.Stdout = old
_ = w.Close()
return <-done, fnErr
}
// bug-1959: logout revokes the token on the server (POST
// /xce/v1/auth/logout with the bearer token) before clearing local state.
func TestRunLogout_RevokesTokenOnServer(t *testing.T) {
ls := newLogoutServer(t, http.StatusOK,
`{"code":200,"msg":"success","data":{"username":"u","message":"Logout successful"}}`)
cm, _ := newTestConfig(t, ls.srv.URL)
out, err := captureStdout(t, func() error { return runLogout("", cm) })
if err != nil {
t.Fatalf("runLogout: %v", err)
}
want := "POST /xce/v1/auth/logout Bearer tok"
if got := ls.requests(); len(got) != 1 || got[0] != want {
t.Fatalf("requests = %q, want [%q]", got, want)
}
cfg, _ := cm.GetConfig()
if len(cfg.APIServerInfo) != 0 {
t.Errorf("local login info not cleared: %+v", cfg.APIServerInfo)
}
if strings.Contains(out, "could not be confirmed") {
t.Errorf("successful revocation reported as unconfirmed: %q", out)
}
}
// bug-1959: when the server rejects the logout (token already invalid) or
// cannot be reached, local state is still cleared and the user is told the
// server-side revocation could not be confirmed.
func TestRunLogout_ServerFailureStillClearsLocalState(t *testing.T) {
rejecting := newLogoutServer(t, http.StatusInternalServerError,
`{"code":500,"msg":"token has been revoked"}`)
down := httptest.NewServer(http.NotFoundHandler())
downURL := down.URL
down.Close() // nothing listens there any more
for name, url := range map[string]string{"rejected": rejecting.srv.URL, "unreachable": downURL} {
t.Run(name, func(t *testing.T) {
cm, _ := newTestConfig(t, url, "http://other:8080")
out, err := captureStdout(t, func() error { return runLogout(url, cm) })
if err != nil {
t.Fatalf("runLogout: %v", err)
}
cfg, _ := cm.GetConfig()
if len(cfg.APIServerInfo) != 1 || cfg.APIServerInfo[0].URL != "http://other:8080" {
t.Errorf("local login info not cleared: %+v", cfg.APIServerInfo)
}
if !strings.Contains(out, "could not be confirmed") {
t.Errorf("output %q does not say the revocation could not be confirmed", out)
}
})
}
if got := rejecting.requests(); len(got) != 1 || !strings.HasSuffix(got[0], " Bearer tok") {
t.Errorf("rejecting server requests = %q, want one bearer-token logout", got)
}
}

View file

@ -75,6 +75,22 @@ func (c *APIClient) Logon(username, password string) (*models.LogonResponse, err
return &loginResp, nil
}
// Logout asks the API server to revoke the token (bug-1959): from then on
// the server rejects it, although it has not expired yet.
func (c *APIClient) Logout(token string) error {
resp, err := c.Client.R().
SetHeader("Authorization", "Bearer "+token).
Post(c.BaseURL + "/xce/v1/auth/logout")
if err != nil {
errMsg := ExtractErrorMessage(err)
return fmt.Errorf("logout failed: [%s]", errMsg)
}
if resp.StatusCode() != http.StatusOK {
return HandleErrorResponse(resp, "logout request")
}
return nil
}
func (c *APIClient) GetRequestFromAPIServer(token, pageToken, dst string, resp any, params map[string]string) error {
baseURL := c.BaseURL
if idx := strings.Index(baseURL, dst); idx != -1 {