Skip to content

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

Merged
jeremy merged 7 commits into
mainfrom
authux-4
Sep 14, 2026
Merged

jeremy merged 7 commits into
mainfrom
authux-4

Conversation

@jeremy

@jeremy jeremy commented Sep 13, 2026

Copy link
Copy Markdown
Member

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.

Copilot AI balanced review requested due to automatic review settings September 13, 2026 01:25
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) skills Agent skills auth OAuth authentication labels Sep 13, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-13T02:39:51.922360Z 051210d New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Revocation can use the wrong profile security policy, storage failures are misreported, and failed-revocation messaging understates refresh-token exposure.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds server-side OAuth credential revocation to logout, profile deletion, and the new auth revoke command.

Changes:

  • Implements RFC 7009 revocation and issuer persistence.
  • Adds revoke-aware CLI output and failure handling.
  • Expands unit tests, command surface, skill docs, and smoke coverage.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
skills/basecamp/SKILL.md Documents auth revoke.
internal/commands/profile.go Revokes credentials during profile deletion.
internal/commands/profile_test.go Tests profile deletion revocation.
internal/commands/auth.go Adds revoke command and logout reporting.
internal/commands/auth_logout_test.go Tests logout and revoke output.
internal/auth/revoke.go Implements credential revocation workflows.
internal/auth/revoke_test.go Tests revocation behavior and failures.
internal/auth/keyring.go Persists authorization-server issuer.
internal/auth/device_test.go Extends mock authorization server.
internal/auth/auth.go Records issuer during login.
internal/auth/auth_test.go Updates logout tests.
e2e/smoke/smoke_lifecycle.bats Marks revoke outside smoke scope.
.surface Registers the new CLI surface.
Review details

Suppressed comments (1)

internal/auth/revoke.go:113

  • Store.Load can fail because credentials are malformed or because the backend failed, not only because the key is absent (keyring.go:137-147). Mapping every such error to ErrNoCredential makes auth revoke report successful “Not logged in” while a credential may remain and no revocation was attempted. Distinguish a genuinely missing key and propagate other load failures.
	creds, err := m.store.Load(credKey)
	if err != nil {
		return ErrNoCredential
  • Files reviewed: 13/13 changed files
  • Comments generated: 3
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/commands/profile.go Outdated
Comment thread internal/auth/revoke.go Outdated
Comment thread internal/commands/auth.go Outdated
@jeremy
jeremy added this pull request to stack #717 September 13, 2026 01:33
`auth logout` and `profile delete` only deleted the local copy, so the
refresh token and its access tokens stayed live on the server for
anyone who had captured them — the smoke test noted that logout never
revokes, even though the in-house authorization server advertises an
RFC 7009 revocation endpoint for the public basecamp-cli client.

Both now revoke first and delete regardless: the refresh token (which
revokes its whole family) and then the access token are POSTed to the
revocation endpoint named by the issuer's metadata, each request
bounded to ten seconds, and a failure is reported rather than fatal —
the access token expires within the hour on its own. Launchpad has no
revocation endpoint and an imported personal access token is the
operator's, not the CLI's, so those are forgotten locally and the
summary says so. Logging out with nothing stored is "Not logged in",
exit 0.

`auth revoke` is the explicit verb for "kill this token": it revokes
and then removes the credential, 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.

The SDK's discovery drops revocation_endpoint, so the metadata fetch is
a one-field GET here. Logins record the issuer on the credential;
earlier credentials derive it from the token endpoint's origin, which
Basecamp mounts under the issuer.
Review findings on the revoke-on-logout change:

The revocation for `profile delete` rode the BC5 lane of the ACTIVE
configuration, whose address policy derives from the active base URL —
not the deleted profile's. Deleting a loopback development profile
while production is active had the production lane refuse the loopback
issuer, after which the credential was discarded unrevoked. The lane is
now anchored on the deleted profile's own base URL (the operator's
configuration, never a server-named URL), falling back to the active
lane when they agree.

A stored blob that is not a credential was deleted best-effort on the
way to "Not logged in", hiding a failed delete. Store.Load now marks
that case with ErrInvalidCredentials; the logout clears it and reports
a failure to, and a missing key is reported without a delete.

"it expires within the hour" was true of the access token only: an
unrevoked refresh token stays valid until it is revoked, so the copy
says so.
Copilot AI review requested due to automatic review settings September 13, 2026 01:34

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5253b75b3d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/profile.go Outdated
Comment thread internal/auth/revoke.go Outdated
Comment thread internal/auth/revoke.go Outdated
Comment thread internal/commands/auth.go
Comment thread internal/commands/auth.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Credential-store failures are misreported as absent credentials, and the command catalog omits the new revoke action.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

internal/auth/revoke.go:122

  • This also maps every credential-store read failure to ErrNoCredential. For example, an unavailable keyring can make auth revoke claim Not logged in even though the credential remains and may still be valid; propagate non-missing load errors and reserve ErrNoCredential for an actual absent entry.
	creds, err := m.store.Load(credKey)
	if err != nil {
		return ErrNoCredential
  • Files reviewed: 13/13 changed files
  • Comments generated: 2
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

Comment thread internal/auth/revoke.go Outdated
Comment thread internal/commands/auth.go
Second review round on revoke-on-logout:

A store that could not be read — a locked keyring, an unreadable
credentials file — was reported as "Not logged in", leaving a
credential that may well be there live on both sides. Store.Load now
marks a genuine miss with ErrNoCredential (go-keyring's ErrNotFound, or
the file backend's one not-found phrase) and every other failure
propagates; logout and revoke fail loudly on it instead.

`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 failed revocation now says what it left usable — the whole family
when the refresh token was not accepted, only the access token after it
was — instead of a fixed claim about the refresh token, and `auth
revoke` keeps the failure's taxonomy so a 5xx or a transport failure
stays retryable while a refusal does not.

`revoke` joins the auth entry of the static command catalog.
Copilot AI review requested due to automatic review settings September 13, 2026 01:51
Copilot AI previously approved these changes Sep 13, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

The revocation flow is complete, security-conscious, and thoroughly covered by focused tests.

Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a48924ad1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/auth/revoke.go Outdated
A 429 from the revocation endpoint surfaced as a generic API error
(exit 7) rather than the CLI's rate-limit class (exit 5, Retry-After
honoured), and the blanket 5xx-is-retryable rule marked a 507 account
limit retryable when the rest of the CLI treats it as a verdict. The
revocation failures now follow the same table.
Copilot AI review requested due to automatic review settings September 13, 2026 02:04
Copilot AI dismissed their stale review, a newer Copilot review was requested September 13, 2026 02:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Local-store failures can orphan credentials, retry guidance conflicts with error taxonomy, and revocation metadata lacks issuer validation.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/commands/profile.go:381

  • err here is a local store read/delete failure, not a best-effort server revocation failure (those are returned inside result). Swallowing it and unregistering the profile can leave a live credential orphaned under profile:<name> with no profile available to select and retry cleanup. Preserve the previous invariant by aborting profile deletion when local credential removal fails.
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

Comment thread internal/auth/revoke.go
Comment thread internal/auth/revoke.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f72054a69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/profile.go
Comment thread internal/auth/revoke.go
Comment thread internal/auth/revoke.go
Comment thread internal/commands/auth.go
Third review round on revoke-on-logout:

The metadata fetch accepted any document the GET ended on. The lane
client follows GET redirects, so another server's perfectly valid
metadata could have named where both tokens were POSTed. The document's
issuer must now equal the issuer it was fetched for, as login discovery
already requires.

`auth revoke` advertised a retry for every failure, including refusals
its own classification marks final; the hint now offers a retry only
when the error is retryable, and logout otherwise.

`auth logout work` — a profile name where none is taken — was accepted
and revoked the selected credential; the command takes no arguments
now, like `auth revoke`. Deleting the active profile anchors its
revocation on the effective configuration, environment overrides
included, as at its login.
Copilot AI review requested due to automatic review settings September 13, 2026 02:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

RFC 8414 path-based issuer discovery and direct terminal-warning sanitization must be corrected.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/auth/revoke.go:305

  • This constructs the RFC 8414 metadata URL correctly only when the issuer has no path. Section 3 requires inserting /.well-known/oauth-authorization-server between the authority and an issuer path (for example, https://host/tenant maps to https://host/.well-known/oauth-authorization-server/tenant), whereas this requests https://host/tenant/.well-known/.... Resource discovery does not restrict res.Issuer to an origin, so a valid path-based BC5 issuer can log in but can never be revoked; construct the well-known URL by parsing the issuer and inserting the path per RFC 8414.
  • Files reviewed: 14/14 changed files
  • Comments generated: 1
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

Comment thread internal/commands/profile.go Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d576a10eeb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/auth/revoke.go
Comment thread internal/auth/revoke.go Outdated
Fourth review round on revoke-on-logout:

When a failed revocation leaves only the access token, the summary
promised it "expires within the hour" — Basecamp's lifetime, not
necessarily the one the server reported, which the CLI treats as
non-expiring when absent. The summary now states the stored expiry:
how long is left, that it has already passed, or that none was
reported.

The profile-delete warning writes the store's error text straight to
the terminal, so it is scrubbed of control sequences first.
Copilot AI review requested due to automatic review settings September 13, 2026 02:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Invalid credentials are reported as absent, and some revocation outcomes use an incorrect lane or remaining-token description.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

internal/auth/revoke.go:122

  • This branch has proved that a blob was stored but malformed. Returning ErrNoCredential after deleting it makes auth logout report Not logged in and exit successfully, contrary to the contract that only a genuine miss gets that result; propagate the original ErrInvalidCredentials after successful cleanup so the corruption is reported.
    internal/auth/revoke.go:257
  • When both token fields are empty, remaining is still RemainingAccess, so logout emits remaining: "access_token" and says that an access token remains even though this branch confirms none exists. A valid JSON blob such as {"oauth_type":"bc5"} reaches this path; represent no remaining token and avoid appending the outstanding-token suffix for that outcome.
  • Files reviewed: 14/14 changed files
  • Comments generated: 1
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

Comment thread internal/commands/profile.go Outdated
The previous round special-cased the active profile to anchor its
revocation on the effective configuration, so that a login made under a
base-URL override could reach the issuer it came from. That opened the
opposite hole: deleting an active loopback profile while a production
override is in force put the revocation on the production lane, which
refuses the loopback issuer, and the credential was then discarded
unrevoked.

One rule, written down: the anchor is the deleted profile's own saved
base URL, the operator's configuration for that profile. A credential
minted under a transient override is not recorded as such — the store
must not choose its own egress policy — so that case reaches the
summary as a failed revocation rather than a silent success.
Copilot AI review requested due to automatic review settings September 13, 2026 02:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Near-expiry access tokens can be incorrectly reported as already expired because expiry is rounded before comparison.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/auth/revoke.go:89

  • Rounding before testing expiry misclassifies any token with less than 30 seconds remaining as already expired (Duration.Round(time.Minute) returns 0s). Check the raw duration first, and round only when formatting a future expiry.
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

@jeremy

jeremy commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

Review threads: 7 resolved (4 fixed, 3 declined with the reasoning in each thread).

Declined:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth OAuth authentication commands CLI command implementations skills Agent skills tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants