Skip to content

Hub: add token revocation surface (Store method + RPC + CLI); tokens currently valid forever #95

@v0lkan

Description

@v0lkan

Summary

The hub has no way to revoke a client token once issued. Tokens minted by Store.RegisterClient (internal/hub/store.go:147) live forever — there's no Revoke, DeleteClient, UnregisterClient, or equivalent on the Store, no corresponding RPC handler in grpc.go, and no CLI command surface. If a token leaks (shared credential file, accidental commit, compromised dev machine), the only mitigation is restarting the hub from a fresh persistence directory — which invalidates every other client's token at the same time.

This was tracked in TASKS.md as part of a broader "prevent duplicate client registration + add token revocation" entry (added 2026-04-08). The duplicate-registration half is already done; this issue covers only the revocation gap.

What's already in place

Store.RegisterClient rejects duplicate project names (good):

// internal/hub/store.go:147-164
func (s *Store) RegisterClient(client ClientInfo) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    // Reject duplicate project names.
    for i := range s.clients {
        if s.clients[i].ProjectName == client.ProjectName {
            return errHub.DuplicateProject(
                client.ProjectName,
            )
        }
    }
    ...
}

No corresponding regression test for the duplicate-rejection path, but that's a small follow-up (see "Out of scope" below).

What's missing

  • No Store.RevokeClient (or equivalent). Grep on Revoke|DeleteClient|UnregisterClient|RemoveClient in internal/hub/ returns zero hits.
  • No RPC method. internal/hub/grpc.go registers only Register, Publish, and Status handlers.
  • No CLI surface. ctx hub (or wherever the management commands live) has no revoke / client rm / similar.
  • No persistence story. When a token is revoked, s.clients and s.tokenIdx need a coordinated update, and clientsPath(s.dir) needs to be rewritten atomically.

Proposed Shape

Three layers, each small:

1. Store.RevokeClient(id string) error

// internal/hub/store.go
func (s *Store) RevokeClient(id string) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    for i := range s.clients {
        if s.clients[i].ID != id {
            continue
        }
        delete(s.tokenIdx, s.clients[i].Token)
        s.clients = append(s.clients[:i], s.clients[i+1:]...)
        // tokenIdx still points to indices ≥ i; rebuild to keep
        // them coherent. Cheap because the registry is small.
        s.tokenIdx = make(map[string]int, len(s.clients))
        for j := range s.clients {
            s.tokenIdx[s.clients[j].Token] = j
        }
        return saveJSON(clientsPath(s.dir), s.clients)
    }
    return errHub.UnknownClient(id)
}

Key by ClientInfo.ID (a stable identifier) rather than ProjectName (which a future operator might rename) or Token (which the operator presumably doesn't have to hand if they're trying to revoke a leaked one).

A sibling RevokeByProject(name string) error is also reasonable since operators usually remember "the customer project that just leaked" before "the client UUID 7f3a..."

2. RPC + handler

Add a Revoke RPC to the admin surface. Should be admin-token-gated (matches how Register handles admin auth in the current handler).

// internal/hub/handler.go
type RevokeRequest struct {
    AdminToken string
    ClientID   string  // or ProjectName, see above
}
type RevokeResponse struct{}

func (s *Server) handleRevoke(req *RevokeRequest) (*RevokeResponse, error) {
    if subtle.ConstantTimeCompare(
        []byte(req.AdminToken), []byte(s.adminToken),
    ) != 1 {
        return nil, status.Error(codes.Unauthenticated, "bad admin token")
    }
    if err := s.store.RevokeClient(req.ClientID); err != nil {
        return nil, err
    }
    return &RevokeResponse{}, nil
}

Wire via cfgHub.PathRevoke constant + makeRevokeHandler(s) registration in grpc.go.

3. Client wrapper + CLI

Client.Revoke(ctx, adminToken, clientID) error mirrors the existing Client.Register shape. Then a CLI command — ctx hub revoke <client-id> or ctx hub client rm <client-id> — that takes the admin token from $CTX_HUB_ADMIN_TOKEN or a flag.

Tests Required

  • TestStore_RevokeClient_RemovesByID: register two clients, revoke one by ID, assert the other still validates and the revoked one's token fails ValidateToken.
  • TestStore_RevokeClient_UnknownIDReturnsError: revoke a nonexistent ID, assert errHub.UnknownClient (or whatever typed error gets defined).
  • TestStore_RevokeClient_PersistsAcrossRestart: revoke, close+reopen the Store from the same directory, assert the revocation survived.
  • TestServer_Revoke_RequiresAdminToken: call the Revoke RPC with a non-admin bearer, assert Unauthenticated.
  • (Companion) TestStore_RegisterClient_RejectsDuplicateProject: regression-pin the already-implemented duplicate rejection. Out of scope per "Out of scope" below, but trivially small and worth landing in the same PR if convenient.

Out of Scope

  • The RegisterClient duplicate-rejection itself — already implemented. A test for it would be welcome but doesn't need to block this issue.
  • Token TTL / scheduled rotation (e.g., tokens expire after 90 days). Different concern; revocation is the prerequisite.
  • Audit log of revocations. Possibly worth doing alongside (the structured log surface at internal/log/event would be the natural target), but the minimum useful surface is revocation itself.
  • Self-service revocation (a client revoking its own token). Admin-only is the simpler v1.

Acceptance

  • Store.RevokeClient lands with tests for happy path, unknown ID, and persistence-across-restart.
  • Admin-gated Revoke RPC + handler.
  • CLI command exposing it.
  • Revoked tokens fail ValidateToken immediately (no in-memory staleness).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions