diff --git a/src/frontend/xcecli/cmd/auth/logout.go b/src/frontend/xcecli/cmd/auth/logout.go index 1d62ca94..f6f5e9f2 100755 --- a/src/frontend/xcecli/cmd/auth/logout.go +++ b/src/frontend/xcecli/cmd/auth/logout.go @@ -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 { diff --git a/src/frontend/xcecli/cmd/auth/logout_test.go b/src/frontend/xcecli/cmd/auth/logout_test.go index 27c00b1b..7d61343f 100644 --- a/src/frontend/xcecli/cmd/auth/logout_test.go +++ b/src/frontend/xcecli/cmd/auth/logout_test.go @@ -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) + } +} diff --git a/src/frontend/xcecli/common/request/request.go b/src/frontend/xcecli/common/request/request.go index f321eef0..c965a28b 100755 --- a/src/frontend/xcecli/common/request/request.go +++ b/src/frontend/xcecli/common/request/request.go @@ -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 {