Skip to content

feat(auth): report login_expired as the credential problem - #72

Merged
thebrandonlucas merged 1 commit into
fix/rustsec-webpkifrom
fix/cli-login-expired-problem
Oct 21, 2026
Merged

thebrandonlucas merged 1 commit into
fix/rustsec-webpkifrom
fix/cli-login-expired-problem

Conversation

@thebrandonlucas

@thebrandonlucas thebrandonlucas commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

When a saved login's refresh token is dead, the auth service answers its refresh or the organization token exchange with invalid_grant. The CLI already names the fix in the message and hint (#24), but --json output gave agents no stable code for it, so an agent could only parse the message.

Change

The invalid_grant error now carries a typed detail:

{"error": {"message": "The saved login for NAME has expired or was revoked",
           "exit_code": 3,
           "hint": "Remove it with `voltage logout --local --account NAME`, then ...",
           "detail": {"credential_problem": "login_expired"}}}
  • ErrorDetail::Credential { credential_problem: CredentialRejection } in src/error.rs, set only by expired_login in src/auth.rs.
  • CredentialRejection is separate from config::CredentialProblem on purpose. voltage context reports CredentialProblem and can never produce login_expired, so the types enforce that boundary.
  • Human output adds one details: line, credential_problem login_expired, under the existing message and hint.

voltage context is unchanged (decision)

src/context.rs documents that context never reads a saved secret or makes a network request. The access-token expiry (expires_at) is stored in the secret, and only the auth service knows the refresh token is dead. An expired access token is also not a dead login, because the next command refreshes it. So context cannot detect login_expired. The troubleshooting and login guides now say so and point to voltage auth status and to the next command's error. Augustus approved this approach, so the context item in #49's "Left" list is closed by decision. The in-place replacement item is done by #67.

Closes #49

Overlap with #66 and #67

#66 and #67 also change expired_login and ErrorDetail. This PR is now stacked on #67, and the conflicts are resolved: expired_login carries the login_expired detail and keeps #67's hint, voltage login --account NAME, and the troubleshooting entry combines both. Gate and CRAP pass on the stack tip (118 unit, 103 integration; 0 of 555 functions at or above 20).

Stack

One stack, merged bottom up:

  1. feat(cli): name the next step after an accepted receive or quote #62: next step after an accepted receive or quote (base master)
  2. feat(cli): name VOLTAGE_API_KEY and the organization source on a 403 #66: credential source on a 403
  3. feat(cli): replace a saved login when the same user signs in again #67: replace a saved login of the same user
  4. fix(deps): reject the distrusted emSign intermediates #71: certificate advisories RUSTSEC-2026-0382: Certificate verification may accept distrusted intermediates #68/RUSTSEC-2026-0383: Bundled root certificates allow trust in distrusted emSign intermediates #69
  5. This PR: login_expired credential problem, Hint to log in again when a saved login returns invalid_grant #49

Checks

  • Verification gate in nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked, python3 scripts/check-coverage.py. All pass.
  • Tests for the new path: a_revoked_login_names_the_command_that_renews_it asserts the JSON detail for both the refresh and the token-exchange rejections. expired_logins_name_commands_that_can_be_pasted checks the typed detail. details_keep_their_documented_shapes pins the serialized shape. troubleshooting_covers_every_exit_code_and_credential_problem requires a login_expired entry in the troubleshooting guide.
  • Self-review with voltage-pr-review and its Rust style checklist (errors topic: a stable code, not parsed text).
  • CRAP (scripts/crap-report.sh on this head): 0 violations. Highest: 19.60, wait_for_payment (src/api.rs:1113), which this PR does not change. No function is at or above 20.

When the auth service answers a saved login's refresh or organization
token exchange with invalid_grant, the error's --json detail now carries
"credential_problem": "login_expired", so agents can detect a dead login
without parsing the message.

voltage context stays unchanged. It reads no saved secret and makes no
network request, so it cannot see that the auth service rejects a login,
and an expired access token alone is refreshed by the next command. The
troubleshooting and login guides say so.

Closes #49

@tee8z tee8z 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 b0e4d1b against fix/rustsec-webpki (#71 f6a608d).

expired_login is the only source of ErrorDetail::Credential { credential_problem: login_expired }. Both invalid_grant paths, the refresh and the token exchange, reach it, and the integration test asserts the JSON detail for each. Keeping CredentialRejection separate from config::CredentialProblem is the right boundary, because voltage context can never produce it. The detail has no data, so redact correctly skips it. The rebase onto #67 keeps #67's hint, voltage login --account NAME. No link to the old #the-saved-login-has-expired-or-was-revoked anchor remains.

Nonblocking:

  • In the description, the example JSON hint still reads "Remove it with voltage logout --local --account NAME, then ...". On this stack, the hint is Sign in again with voltage login --account NAME.
  • No test pins the human details: line, credential_problem login_expired, for this error. The JSON form is pinned.

Validation on this head, in nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (118 unit, 103 integration) and python3 scripts/check-coverage.py passed. CRAP: 555 measured functions, none at or above 20, highest wait_for_payment at 19.60; 152 functions were unmeasured. CI is green.

@thebrandonlucas
thebrandonlucas merged commit b4294dd into fix/rustsec-webpki Oct 21, 2026
8 checks passed
@thebrandonlucas
thebrandonlucas deleted the fix/cli-login-expired-problem branch October 21, 2026 16:16
thebrandonlucas added a commit that referenced this pull request Oct 21, 2026
When the auth service answers a saved login's refresh or organization
token exchange with invalid_grant, the error's --json detail now carries
"credential_problem": "login_expired", so agents can detect a dead login
without parsing the message.

voltage context stays unchanged. It reads no saved secret and makes no
network request, so it cannot see that the auth service rejects a login,
and an expired access token alone is refreshed by the next command. The
troubleshooting and login guides say so.

Closes #49
thebrandonlucas added a commit that referenced this pull request Oct 21, 2026
* fix(deps): reject the distrusted emSign intermediates

Update rustls-webpki to 0.103.16 and webpki-root-certs to 1.0.10.
Both reject certificate paths through the emSign intermediates whose
private keys are now public (RUSTSEC-2026-0382, RUSTSEC-2026-0383).

Closes #68
Closes #69

* feat(auth): report login_expired as the credential problem (#72)

When the auth service answers a saved login's refresh or organization
token exchange with invalid_grant, the error's --json detail now carries
"credential_problem": "login_expired", so agents can detect a dead login
without parsing the message.

voltage context stays unchanged. It reads no saved secret and makes no
network request, so it cannot see that the auth service rejects a login,
and an expired access token alone is refreshed by the next command. The
troubleshooting and login guides say so.

Closes #49
thebrandonlucas added a commit that referenced this pull request Oct 21, 2026
…66)

* feat(cli): name VOLTAGE_API_KEY and the organization source on a 403

VOLTAGE_API_KEY takes precedence over a saved login without saying so,
so a key from another organization failed with a bare 403. A 403 to
that key now names it and where the organization came from (--org or
VOLTAGE_ORGANIZATION_ID), and suggests --account NAME or unsetting the
variable. The JSON error reports both as detail.credential_sources.

Closes #47

* feat(cli): replace a saved login when the same user signs in again (#67)

`voltage login` under a saved name now replaces that login when the new
session is for the same user at the same auth URL, under the credential
lock, and revokes the replaced refresh token. A different user, or a
saved login without a user ID, is still refused, and the new session is
revoked. With `--account NAME`, a name saved for an API key, a login at
another auth URL, or a login whose secret cannot be read is refused
before the browser step. An unreadable secret is refused as a name in
use with a `voltage logout --local --account NAME` hint, and
`logout --local` removes an account whose secret is already gone, or
warns when the secret cannot be removed. The expired-login hint is now
only `voltage login --account NAME`.

Without `--credential-store`, a replaced login keeps its store and a new
login uses the keychain. Any failed secret or configuration write puts
the replaced login back, including a write that already renamed its
file, and the error says when the replaced login could not be restored.
A Ctrl-C while the replaced session is revoked says the new login is
saved. `shell_word` moves to `config`, which no longer imports `auth`.

Closes #48

* fix(deps): reject the distrusted emSign intermediates (#71)

* fix(deps): reject the distrusted emSign intermediates

Update rustls-webpki to 0.103.16 and webpki-root-certs to 1.0.10.
Both reject certificate paths through the emSign intermediates whose
private keys are now public (RUSTSEC-2026-0382, RUSTSEC-2026-0383).

Closes #68
Closes #69

* feat(auth): report login_expired as the credential problem (#72)

When the auth service answers a saved login's refresh or organization
token exchange with invalid_grant, the error's --json detail now carries
"credential_problem": "login_expired", so agents can detect a dead login
without parsing the message.

voltage context stays unchanged. It reads no saved secret and makes no
network request, so it cannot see that the auth service rejects a login,
and an expired access token alone is refreshed by the next command. The
troubleshooting and login guides say so.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants