Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- `voltage login` under a saved name replaces that login when it is the same user at the same auth URL, and revokes the replaced session. A different user is still refused. With `--account NAME`, a name saved for an API key or for another auth URL is refused before the browser step. An expired or revoked login's hint is now only `voltage login --account NAME`. A saved login whose secret cannot be read is refused, also before the browser step, with a hint that names `voltage logout --local --account NAME`, and `logout --local` removes an account whose secret is already gone or cannot be removed, with a warning for the latter. Without `--credential-store`, a replaced login keeps its credential store, and a new login uses the keychain. `voltage login --json` reports `replaced: true` when the login replaced a saved one. When a replacement cannot be saved, the replaced login stays saved, and the error says so if it could not be restored.
- Human tables fit the terminal width, and all API text is scrubbed of terminal control characters.
- Each item of a string list gets its own line in human output.
- A saved login the auth service no longer accepts reports `credential_problem: login_expired` in `--json` `error.detail`. `voltage context` cannot detect it, because it makes no network request.
- Errors name the next command: missing scope names the command that lists IDs, credential and profile errors list the saved names, HTTP 401 and 403 point to `voltage auth status` and `voltage context`, and `quote_required` points to `voltage quotes create`.
- An API rejection's error leads with the API's reason, and human output shows the rest of the API error as field/value lines instead of JSON.
- `-h` shows usage and options; `--help` adds notes, examples, and the guide link.
Expand All @@ -32,6 +33,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes

### Fixed

- TLS certificate checks reject the distrusted emSign intermediates: `rustls-webpki` 0.103.16 and `webpki-root-certs` 1.0.10 (RUSTSEC-2026-0382, RUSTSEC-2026-0383).
- Help and README examples for changes include `--execute`, so copying one sends it.
- `voltage auth import-key` removes the saved key again when it cannot save the configuration, as `voltage login` does, and reports a name in use with the same message.

Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ voltage profiles delete staging

## See the effective scope

`voltage context` shows the effective profile, account, organization, environments, wallet, API and auth URLs, and configuration directory, and where each came from (`flag`, `profile`, `environment`, `default`, or `unset`). It reports why commands would reject the credential selection as `credential_problem` (`empty_api_key`, `no_credential`, `multiple_credentials`, `unknown_credential`, `bound_elsewhere` for an API key used outside its organization or environment, or `auth_url_mismatch` for a browser login used with a different `--auth-url`), using the same selection commands use. It also lists `VOLTAGE_*` variables that are set but ignored because a flag or the selected profile takes precedence. It reads no saved secret, makes no network request, and creates no files:
`voltage context` shows the effective profile, account, organization, environments, wallet, API and auth URLs, and configuration directory, and where each came from (`flag`, `profile`, `environment`, `default`, or `unset`). It reports why commands would reject the credential selection as `credential_problem` (`empty_api_key`, `no_credential`, `multiple_credentials`, `unknown_credential`, `bound_elsewhere` for an API key used outside its organization or environment, or `auth_url_mismatch` for a browser login used with a different `--auth-url`), using the same selection commands use. It cannot report `login_expired`, a login the auth service no longer accepts: that needs a network request, so the next command that uses the login reports it in `error.detail.credential_problem`. It also lists `VOLTAGE_*` variables that are set but ignored because a flag or the selected profile takes precedence. It reads no saved secret, makes no network request, and creates no files:

```sh
voltage context --profile staging
Expand Down
8 changes: 5 additions & 3 deletions docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,25 @@ An imported API key belongs to one organization and environment, and the command

`error: Saved credentials cannot be refreshed at a different auth URL`. A browser login refreshes only at the auth URL it was saved with. Omit `--auth-url` to use that URL, which `voltage context` shows. To move the login to the new URL, run `voltage logout --account NAME`, then `voltage login --account NAME --auth-url URL`.

### The saved login has expired or was revoked
### `login_expired`

Sign in again with the command in the hint:
`error: The saved login for NAME has expired or was revoked`. The auth service rejected the login's refresh or token exchange with `invalid_grant`. `--json` reports `"credential_problem": "login_expired"` in `error.detail`. Sign in again with the command in the hint:

```sh
voltage login --account NAME
```

When you sign in as the same user, the new login replaces the saved one, and the CLI revokes the replaced session. Keep the same `NAME` so profiles that select it keep working. If you sign in as a different user, `voltage login` refuses the name; run `voltage logout --local --account NAME` first, or choose another name. It refuses the name the same way when the saved login's secret cannot be read, for example from a locked OS credential store or a damaged credential file. `voltage logout --local --account NAME` removes that account even when its secret cannot be removed, and warns you to remove the secret yourself.

`voltage context` cannot report this problem. It reads no saved secret and makes no network request, so it cannot see that the auth service rejects the login. An expired access token alone is not this problem, because the next command refreshes it. `voltage auth status` shows whether the access token has expired, and the next command that uses the login reports `login_expired`.

### needs a browser login

`organizations list` and `environments list` need a browser login; an API key cannot list them. Pass `--account NAME` for a saved login, or unset `VOLTAGE_API_KEY`.

### HTTP 401

The API did not accept the credential. `voltage auth status` shows the selected one and whether it has expired. For a saved login, replace it as described in [The saved login has expired or was revoked](#the-saved-login-has-expired-or-was-revoked).
The API did not accept the credential. `voltage auth status` shows the selected one and whether it has expired. For a saved login, replace it as described in [`login_expired`](#login_expired).

A checkout command presents its session or stream token, not the account credential. Check the token in `--token-file`, `VOLTAGE_CHECKOUT_TOKEN`, or `VOLTAGE_STREAM_TOKEN`.

Expand Down
12 changes: 11 additions & 1 deletion src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::{
InputSource, LOCK_TIMEOUT, Login, Scope, ScopeSources, Settings, Source, name_in_use,
read_secret, shell_word, unreadable_name,
},
error::CredentialSources,
error::{CredentialRejection, CredentialSources, ErrorDetail},
secret::Secret,
terminal::{self, Terminal},
};
Expand Down Expand Up @@ -804,6 +804,9 @@ fn expired_login(account: &str, url: &str) -> Error {
Error::auth(format!(
"The saved login for {account} has expired or was revoked"
))
.with_detail(ErrorDetail::Credential {
credential_problem: CredentialRejection::LoginExpired,
})
// A login of the same user replaces the stale one, so nothing has to be removed first.
.with_hint(format!(
"Sign in again with `voltage login --account {account}{auth_url}`."
Expand Down Expand Up @@ -1136,6 +1139,13 @@ mod tests {
fn expired_logins_name_commands_that_can_be_pasted() {
let hint = |account, url| expired_login(account, url).hint.unwrap();
let default = base_url(AUTH_URL).unwrap();
// Agents detect a dead login by its problem, not by parsing the message.
assert!(matches!(
expired_login("me", &default).detail,
Some(ErrorDetail::Credential {
credential_problem: CredentialRejection::LoginExpired
})
));
assert_eq!(
hint("me@example.com", &default),
"Sign in again with `voltage login --account me@example.com`."
Expand Down
11 changes: 10 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1248,7 +1248,7 @@ fn leaf(matches: &ArgMatches) -> (Vec<String>, &ArgMatches) {
mod tests {
use super::*;
use crate::config::CredentialProblem as Problem;
use crate::error::ErrorKind as Kind;
use crate::error::{CredentialRejection, ErrorKind as Kind};
use std::path::Path;

const ENV: &str = "22222222-2222-4222-8222-222222222222";
Expand Down Expand Up @@ -1883,6 +1883,15 @@ mod tests {
assert_eq!(serde_json::to_value(problem).unwrap(), name);
assert!(text.contains(&format!("\n### `{name}`\n")), "{name}");
}
let rejected = |rejection: CredentialRejection| match rejection {
CredentialRejection::LoginExpired => "login_expired",
};
let name = rejected(CredentialRejection::LoginExpired);
assert_eq!(
serde_json::to_value(CredentialRejection::LoginExpired).unwrap(),
name
);
assert!(text.contains(&format!("\n### `{name}`\n")), "{name}");
}

/// Every command below `cmd`, by path, with its rendered long help.
Expand Down
23 changes: 22 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ pub enum ErrorDetail {
},
/// A payment's terminal state, as the API reported it.
Payment(Value),
/// The auth service rejected a saved credential.
Credential {
credential_problem: CredentialRejection,
},
}

/// Why the auth service rejected a saved credential. `voltage context` reads no secret and
/// makes no request, so it cannot report these; they appear only on a command's error.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CredentialRejection {
/// The auth service answered a saved login's refresh or token exchange with
/// `invalid_grant`: the login expired, was revoked, or its grant was already spent.
LoginExpired,
}

/// Where the credential and the organization of a request came from. A 403 reports it for
Expand Down Expand Up @@ -119,7 +133,7 @@ impl ErrorDetail {
pub fn redact(&mut self, secrets: &[&str]) {
match self {
Self::Http { data, .. } | Self::Payment(data) => redact(data, secrets),
Self::UncertainSubmission { .. } | Self::Pending { .. } => {}
Self::UncertainSubmission { .. } | Self::Pending { .. } | Self::Credential { .. } => {}
}
}
}
Expand Down Expand Up @@ -292,6 +306,13 @@ mod tests {
"action": "Query the original resource ID before deciding whether to resubmit"
})
);
assert_eq!(
serde_json::to_value(ErrorDetail::Credential {
credential_problem: CredentialRejection::LoginExpired
})
.unwrap(),
json!({"credential_problem": "login_expired"})
);
let http = Error::api("failed").with_detail(ErrorDetail::Http {
http_status: 404,
data: json!({"api_key": "secret"}),
Expand Down
5 changes: 5 additions & 0 deletions tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3873,6 +3873,11 @@ async fn a_revoked_login_names_the_command_that_renews_it() {
),
"expiring={expiring}"
);
assert_eq!(
error["error"]["detail"],
json!({"credential_problem": "login_expired"}),
"expiring={expiring}"
);
}
}

Expand Down
Loading