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",
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue