Revoke the credential with the server on logout, profile delete and auth revoke

Merged
JE
2mo ago
+1465-30

What

  • auth logout (and the logout alias) revokes the credential with its authorization server (RFC 7009) before removing it locally: the refresh token first, which revokes its whole family, then the access token, as the public basecamp-cli client. Local removal happens regardless of the server's answer.
  • profile delete <name> takes the same revoke-then-delete path for profile:<name> instead of calling the store directly, with the revocation's egress lane anchored on that profile's own base URL rather than the active configuration's.
  • New auth revoke [-P profile]: the explicit "kill this token" verb. It revokes and then removes the credential (a revoked refresh family leaves nothing usable), but unlike logout it keeps a credential it could not revoke so the revocation can be retried, and refuses Launchpad and imported tokens with the reason and a hint. Its Long spells out the difference from logout.
  • The human summary says what happened: Logged out (token revoked); Logged out locally; could not revoke the token server-side: <reason> — the refresh token stays valid until it is revoked (or — only the access token remains and it expires in 42m0s / has already expired / reports no expiry, from the stored expiry, when the failure struck after the refresh token was accepted or there was none); Logged out (Launchpad tokens cannot be revoked from the CLI); Logged out (forgot the imported token; it stays valid until revoked in Basecamp). Nothing stored is Not logged in, exit 0. JSON keeps status and gains revoked (plus reason and remaining when it is false).
  • Both auth logout and auth revoke refuse while BASECAMP_TOKEN is set: the stored credential is not the session in use, and revoking it would kill an unrelated refresh family for good.
  • A store that cannot be read (a locked keyring, an unreadable file) fails the logout with that error instead of reporting Not logged in; only a genuine miss does that, and an unreadable blob is cleared.
  • Revocation failures keep their taxonomy: a transport failure or 5xx stays retryable, a refusal does not, so auth revoke's "retry" hint and the JSON retryable flag agree.
  • New Manager.Revoke, Manager.Logout(ctx) returning a LogoutResult, Manager.LogoutCredential(ctx, key, baseURL), and Manager.RevokeStored(ctx); Store.Load marks an unreadable blob with ErrInvalidCredentials so logout can clear it (and report a failure to) rather than mistake it for nothing stored; Credentials gains issuer, recorded at login. Credentials stored before that derive the issuer from the token endpoint's origin, which Basecamp mounts under the issuer.
  • The SDK's discovery drops revocation_endpoint, so the metadata read is a small GET here: the document's issuer must equal the issuer it was fetched for (a GET may follow redirects; another server's valid metadata must not name where the tokens go), the endpoint is validated like every other server-named endpoint, and both ride the BC5 lane client so the egress policy applies. Each request is bounded to 10 s.
  • auth logout takes no arguments (auth logout work used to revoke the selected credential); auth revoke advertises a retry only when the failure is retryable.
  • .surface regenerated for the new command; one SKILL.md line under "Authentication errors"; auth revoke marked out of scope in the smoke suite alongside auth logout.

Why

The smoke tests flagged "CLI auth logout / profile delete never revokes the server-side token (in-house issuer)": logout only deleted the local copy while the server advertised a revocation endpoint the CLI never called, leaving the refresh token and its access tokens live for anyone who had captured them. The operator also asked for the CLI to be able to revoke its own token explicitly, hence auth revoke.

Launchpad has no revocation endpoint, and an imported personal access token is the operator's (the same token lives in their secret store), so those are forgotten locally and the summary says so rather than pretending.

The companion fix for a refused --expect-identity login leaving its freshly minted grant live is stacked on this branch as a separate PR.

Before / After

Before:

$ basecamp auth logout
Successfully logged out

After:

$ basecamp auth logout
Logged out (token revoked)

Status : logged_out
Revoked: yes

$ basecamp auth logout            # authorization server unreachable
Logged out locally; could not revoke the token server-side: fetching authorization server metadata: Get "https://3.basecamp.com/.well-known/oauth-authorization-server": dial tcp: lookup 3.basecamp.com: no such host — the access token expires within the hour, but the refresh token stays valid until it is revoked

Status : logged_out
Revoked: no
Reason : fetching authorization server metadata: Get "https://3.basecamp.com/.well-known/oauth-authorization-server": dial tcp: lookup 3.basecamp.com: no such host

$ basecamp auth logout            # nothing stored
Not logged in

$ basecamp auth logout --json
{
  "ok": true,
  "data": { "revoked": true, "status": "logged_out" },
  "summary": "Logged out (token revoked)"
}

$ basecamp auth revoke
Revoked the token with the server and removed the credential

$ basecamp auth revoke            # authorization server unreachable
Error: could not revoke the token server-side: fetching authorization server metadata: ... ; the credential is kept so you can retry
Retry: basecamp auth revoke — or forget it locally: basecamp auth logout

$ basecamp auth revoke            # imported personal access token
Error: An imported personal access token is not the CLI's to revoke; revoke it in Basecamp
Forget the credential locally instead: basecamp auth logout

Borrowed from Codex

Codex's logout_with_revoke revokes best-effort with a 10 s timeout and then always clears the local store, exiting 0 either way; auth logout does the same, and additionally sends both tokens (the hint is advisory) and tells the user which of the four outcomes they got. Codex has no explicit revoke verb.

Testing

  • internal/auth/revoke_test.go: the two POSTs and their form fields (no client secret), issuer derivation from the token endpoint, only-the-tokens-it-holds, metadata without an endpoint, unparseable/404 metadata, an insecure revocation endpoint refused before any POST, a 503 reported without the token in the message, the per-request timeout, logout deleting on success and on failure, Launchpad and imported tokens skipped, LogoutCredential clearing only the named key, a login recording its issuer, and RevokeStored deleting on success / keeping the credential on refusal / refusing Launchpad and imported tokens. startDeviceAS now serves /oauth/revocations and advertises it.
  • internal/commands/auth_logout_test.go: the JSON shape and every human summary for auth logout and auth revoke, including Not logged in and the kept-credential path. profile_test.go: deleting a profile revokes its credential; a profile that never logged in revokes nothing.
  • Gates: make fmt-check vet lint, make check-surface check-skill-drift check-smoke-coverage, and make test-e2e pass. Eleven pre-existing terminal-detection tests (appctx, cli, commands, stdinarg, tui/resolve) fail on this machine for a pristine origin/main checkout with the identical set — a pty quirk of the session, not the diff; the full suite was also run on a clean Linux host.