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:
parent
c71049f28d
commit
0379823098
3 changed files with 139 additions and 1 deletions
|
|
@ -16,7 +16,8 @@ func NewLogoutCmd(configManager *config.ConfigManager) *cobra.Command {
|
||||||
Use: "logout",
|
Use: "logout",
|
||||||
GroupID: "api",
|
GroupID: "api",
|
||||||
Short: "Logout from API Server",
|
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:
|
Example:
|
||||||
xce logout --url http://tt1.test.com:8080
|
xce logout --url http://tt1.test.com:8080
|
||||||
xce logout --url https://tt1.test.com:8443`,
|
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)
|
apiServerInfo := make([]config.APIServerInfo, 0)
|
||||||
// find server with specified url
|
// find server with specified url
|
||||||
found := false
|
found := false
|
||||||
|
token := ""
|
||||||
for _, server := range cfg.APIServerInfo {
|
for _, server := range cfg.APIServerInfo {
|
||||||
switch server.URL {
|
switch server.URL {
|
||||||
case "":
|
case "":
|
||||||
continue
|
continue
|
||||||
case url:
|
case url:
|
||||||
found = true
|
found = true
|
||||||
|
token = server.Token
|
||||||
default:
|
default:
|
||||||
apiServerInfo = append(apiServerInfo, server)
|
apiServerInfo = append(apiServerInfo, server)
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +65,14 @@ func runLogout(url string, cm *config.ConfigManager) error {
|
||||||
if !found {
|
if !found {
|
||||||
return fmt.Errorf("server not found: %s", url)
|
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
|
cfg.APIServerInfo = apiServerInfo
|
||||||
// save config
|
// save config
|
||||||
if err := cm.SaveConfig(cfg); err != nil {
|
if err := cm.SaveConfig(cfg); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"xcecli/config"
|
"xcecli/config"
|
||||||
)
|
)
|
||||||
|
|
@ -51,3 +58,107 @@ func newTestConfig(t *testing.T, urls ...string) (*config.ConfigManager, *config
|
||||||
}
|
}
|
||||||
return cm, cfg
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,22 @@ func (c *APIClient) Logon(username, password string) (*models.LogonResponse, err
|
||||||
return &loginResp, nil
|
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 {
|
func (c *APIClient) GetRequestFromAPIServer(token, pageToken, dst string, resp any, params map[string]string) error {
|
||||||
baseURL := c.BaseURL
|
baseURL := c.BaseURL
|
||||||
if idx := strings.Index(baseURL, dst); idx != -1 {
|
if idx := strings.Index(baseURL, dst); idx != -1 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue