Revoke the credential with the server on logout, profile delete and auth revoke
What
auth logout(and thelogoutalias) 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 publicbasecamp-cliclient. Local removal happens regardless of the server's answer.profile delete <name>takes the same revoke-then-delete path forprofile:<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. ItsLongspells 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 isNot logged in, exit 0. JSON keepsstatusand gainsrevoked(plusreasonandremainingwhen it is false). - Both
auth logoutandauth revokerefuse whileBASECAMP_TOKENis 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, soauth revoke's "retry" hint and the JSONretryableflag agree. - New
Manager.Revoke,Manager.Logout(ctx)returning aLogoutResult,Manager.LogoutCredential(ctx, key, baseURL), andManager.RevokeStored(ctx);Store.Loadmarks an unreadable blob withErrInvalidCredentialsso logout can clear it (and report a failure to) rather than mistake it for nothing stored;Credentialsgainsissuer, 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'sissuermust 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 logouttakes no arguments (auth logout workused to revoke the selected credential);auth revokeadvertises a retry only when the failure is retryable..surfaceregenerated for the new command; one SKILL.md line under "Authentication errors";auth revokemarked out of scope in the smoke suite alongsideauth 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,LogoutCredentialclearing only the named key, a login recording its issuer, andRevokeStoreddeleting on success / keeping the credential on refusal / refusing Launchpad and imported tokens.startDeviceASnow serves/oauth/revocationsand advertises it.internal/commands/auth_logout_test.go: the JSON shape and every human summary forauth logoutandauth revoke, includingNot logged inand 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, andmake test-e2epass. Eleven pre-existing terminal-detection tests (appctx,cli,commands,stdinarg,tui/resolve) fail on this machine for a pristineorigin/maincheckout with the identical set — a pty quirk of the session, not the diff; the full suite was also run on a clean Linux host.