diff --git a/internal/api/client.go b/internal/api/client.go index a04bd1a5..11aba245 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -342,6 +342,23 @@ func (c *Client) WhoAmI(ctx context.Context) (*Identity, error) { return &id, nil } +// RevokeToken revokes the presenting credential server-side via POST /auth/revoke +// (backend#887, shipped in backend#903): Bearer in, 204 out, idempotent. `logout` +// calls this so a copied/leaked token stops authenticating after sign-out — local +// clearing alone left it valid (RFC-0001 §7.5 / R2). Requires Token. A non-2xx is +// returned as an *APIError; callers treat the call as best-effort. +func (c *Client) RevokeToken(ctx context.Context) error { + url := c.BaseURL + "/auth/revoke" + status, raw, err := c.post(ctx, "/auth/revoke", nil) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return &APIError{StatusCode: status, Body: string(raw), URL: url} + } + return nil +} + // ── Client provisioning (Bearer-authed) — backend#836, /edge-device/ ── // ProvisionedClient is a tracebloc client (machine), as returned by the diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 08a00f19..621c92f7 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -353,3 +353,44 @@ func TestUpgradeRequired426_UnparseableBody(t *testing.T) { t.Errorf("MinVersion = %q, want empty", ue.MinVersion) } } + +// ── cli#112: logout server-side revoke (POST /auth/revoke, backend#887) ── + +// TestRevokeToken: a 204 from the endpoint → nil, with Bearer + POST on the wire. +func TestRevokeToken(t *testing.T) { + var sawAuth, sawMethod, sawPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawAuth, sawMethod, sawPath = r.Header.Get("Authorization"), r.Method, r.URL.Path + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + c.Token = "usertoken123" + if err := c.RevokeToken(context.Background()); err != nil { + t.Fatalf("revoke: %v", err) + } + if sawMethod != http.MethodPost || sawPath != "/auth/revoke" { + t.Errorf("revoke hit %s %s, want POST /auth/revoke", sawMethod, sawPath) + } + if sawAuth != "Bearer usertoken123" { + t.Errorf("revoke auth header = %q, want %q", sawAuth, "Bearer usertoken123") + } +} + +// TestRevokeTokenServerError: a non-2xx surfaces as *APIError so logout can log +// it (then clear local state regardless — see the cli-package logout tests). +func TestRevokeTokenServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"detail":"boom"}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + c.Token = "t" + var ae *APIError + if err := c.RevokeToken(context.Background()); !errors.As(err, &ae) || ae.StatusCode != http.StatusInternalServerError { + t.Errorf("want APIError 500, got %v", err) + } +} diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 9176586c..ce3aabca 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -136,21 +136,35 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error { } } -// newLogoutCmd implements `tracebloc logout` — clears the stored token. +// newLogoutCmd implements `tracebloc logout` — revokes the token server-side +// (so a copied/leaked credential stops working) and clears it locally. func newLogoutCmd() *cobra.Command { return &cobra.Command{ Use: "logout", - Short: "Sign out (clear the stored token)", + Short: "Sign out (revoke the token server-side and clear it locally)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + p := printerFor(cmd) cfg, err := config.Load() if err != nil { return &exitError{code: 1, err: err} } if !cfg.SignedIn() { - printerFor(cmd).Hintf("Already signed out.") + p.Hintf("Already signed out.") return nil } + + // Capture what the server-side revoke needs BEFORE clearing local + // state. Resolve the env the same way authedClient does (saved env, + // else $CLIENT_ENV, else prod) so revoke hits the host the token was + // issued for, not a hardcoded prod. + token := cfg.Token + env := sessionEnv(cfg) + + // Clear and persist local state FIRST — it's logout's primary job and + // the always-safe step. Saving before the network call means a failed + // Save can't leave a token that's already been revoked server-side + // sitting on disk as a broken "signed in" state. cfg.Token = "" cfg.Email = "" // Also drop the active-client pointer: it's account-scoped, so leaving @@ -161,7 +175,18 @@ func newLogoutCmd() *cobra.Command { if err := cfg.Save(); err != nil { return &exitError{code: 1, err: err} } - printerFor(cmd).Successf("Signed out.") + + // Then revoke the token server-side so a copied/leaked credential stops + // authenticating after sign-out (RFC-0001 §7.5 / R2, backend#887). + // Best-effort by contract: on failure (offline / already-revoked) the + // local session is already cleared — the user is logged out (cli#112). + client := newAPIClient(env) + client.Token = token + if rerr := client.RevokeToken(cmd.Context()); rerr != nil { + p.Hintf("Signed out locally, but couldn't revoke the token server-side (%v). Revoke from the dashboard if this was a shared machine.", rerr) + return nil + } + p.Successf("Signed out.") return nil }, } diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index d17ce6ee..7c5d0d61 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -167,7 +167,19 @@ func TestLogin_Denied(t *testing.T) { } func TestLogout(t *testing.T) { - t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + // logout now revokes server-side (cli#112) — route it at a stub, not prod. + var revoked bool + withTestBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/revoke" || r.Method != http.MethodPost { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + return + } + revoked = true + if got := r.Header.Get("Authorization"); got != "Bearer x" { + t.Errorf("revoke auth header = %q, want %q", got, "Bearer x") + } + w.WriteHeader(http.StatusNoContent) // 204, like the real endpoint + }) if err := (&config.Config{Token: "x", Email: "e@co", ActiveClientID: "7"}).Save(); err != nil { t.Fatal(err) } @@ -175,6 +187,9 @@ func TestLogout(t *testing.T) { if err != nil { t.Fatal(err) } + if !revoked { + t.Error("logout did not call POST /auth/revoke") + } cfg, _ := config.Load() if cfg.SignedIn() { t.Error("expected to be signed out") @@ -189,6 +204,60 @@ func TestLogout(t *testing.T) { } } +// TestLogout_RevokeFailureStillClearsLocal pins the cli#112 contract: when the +// server-side revoke fails (offline / already-revoked / 5xx), logout must still +// succeed and clear local state — never leave the user unable to log out locally. +func TestLogout_RevokeFailureStillClearsLocal(t *testing.T) { + withTestBackend(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) // revoke fails + }) + if err := (&config.Config{Token: "x", Email: "e@co", ActiveClientID: "7"}).Save(); err != nil { + t.Fatal(err) + } + out, err := runCmd(t, "logout") + if err != nil { + t.Fatalf("logout must succeed even when revoke fails: %v", err) + } + cfg, _ := config.Load() + if cfg.SignedIn() || cfg.ActiveClientID != "" { + t.Errorf("local state must be cleared even when revoke fails: %+v", cfg) + } + if !strings.Contains(out, "Signed out") { + t.Errorf("got:\n%s", out) + } +} + +// TestLogout_RevokesAgainstSessionEnv pins the cli#112 / Bugbot fix: with an +// empty cfg.Env (legacy config) the revoke must resolve the host like +// authedClient does (— $CLIENT_ENV, else prod —), not hardcode prod, or it +// hits the wrong backend and the real session token stays valid after logout. +func TestLogout_RevokesAgainstSessionEnv(t *testing.T) { + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + t.Setenv("CLIENT_ENV", "stg") + if err := (&config.Config{Token: "x"}).Save(); err != nil { // note: no Env set + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + + var gotEnv string + orig := newAPIClient + newAPIClient = func(env string) *api.Client { + gotEnv = env + return &api.Client{BaseURL: srv.URL, HTTP: srv.Client()} + } + t.Cleanup(func() { newAPIClient = orig }) + + if _, err := runCmd(t, "logout"); err != nil { + t.Fatal(err) + } + if gotEnv != "stg" { + t.Errorf("empty cfg.Env: revoked against %q, want $CLIENT_ENV %q (not hardcoded prod)", gotEnv, "stg") + } +} + func TestAuthStatus_SignedIn(t *testing.T) { t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) if err := (&config.Config{Env: "dev", Token: "x", Email: "ds@co"}).Save(); err != nil { diff --git a/internal/cli/client.go b/internal/cli/client.go index 328bbc32..10f52c77 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -104,6 +104,17 @@ func clientPrompter() prompter { return nil } +// sessionEnv resolves the backend env for the signed-in session: the env saved +// at login, falling back (legacy / empty config) to $CLIENT_ENV then prod. Shared +// by authedClient and logout so every authenticated call — including the revoke +// on sign-out — talks to the host the token was actually issued for. +func sessionEnv(cfg *config.Config) string { + if cfg.Env != "" { + return cfg.Env + } + return api.ResolveEnv("") +} + // authedClient loads the signed-in config and returns a token-bearing API // client, or an error telling the user to log in. func authedClient() (*api.Client, *config.Config, error) { @@ -114,11 +125,7 @@ func authedClient() (*api.Client, *config.Config, error) { if !cfg.SignedIn() { return nil, nil, errors.New("not signed in — run `tracebloc login` first") } - env := cfg.Env - if env == "" { - env = api.ResolveEnv("") - } - client := newAPIClient(env) + client := newAPIClient(sessionEnv(cfg)) client.Token = cfg.Token return client, cfg, nil }