Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions internal/api/client.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
41 changes: 41 additions & 0 deletions internal/api/client_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
}
}
33 changes: 29 additions & 4 deletions internal/cli/auth.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
},
}
Expand Down
71 changes: 70 additions & 1 deletion internal/cli/auth_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -167,14 +167,29 @@ 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)
}
out, err := runCmd(t, "logout")
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")
Expand All@@ -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 {
Expand Down
17 changes: 12 additions & 5 deletions internal/cli/client.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand All@@ -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
}
Expand Down
Loading