Skip to content

Commit 277e5d4

Browse files
committed
Update to version 1.0.1 on 2026-11-03
- A profile's credentials only go to the server it was saved for. A command that uses a profile with `--region` or `--host` for another server now fails instead of sending them there. - A command that runs while you log in to the same profile again, on another server, uses the profile's old server and old login, or both new ones. Before, it could send the new login's token to the old server. - `docspring login` without `--region` or `--host` uses `DOCSPRING_REGION` or `DOCSPRING_HOST` for a new profile, as commands do with an API token in the environment. - Logging in again to a `--host` profile uses the `--issuer` its last login was given. - A profile saved by a newer version of the CLI no longer stops your other profiles from working, and saving another profile keeps it. - `docspring submit` and `docspring submissions download` check each file's size as well as its SHA-256 checksum, so a file the API lists without a checksum (an audit trail made before September 2024) is checked too.
1 parent 61bc2a7 commit 277e5d4

10 files changed

Lines changed: 655 additions & 78 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
### 1.0.1 [November 3, 2026]
2+
3+
- A profile's credentials only go to the server it was saved for. A command that uses a profile with `--region` or
4+
`--host` for another server now fails instead of sending them there.
5+
- A command that runs while you log in to the same profile again, on another server, uses the profile's old server and
6+
old login, or both new ones. Before, it could send the new login's token to the old server.
7+
- `docspring login` without `--region` or `--host` uses `DOCSPRING_REGION` or `DOCSPRING_HOST` for a new profile, as
8+
commands do with an API token in the environment.
9+
- Logging in again to a `--host` profile uses the `--issuer` its last login was given.
10+
- A profile saved by a newer version of the CLI no longer stops your other profiles from working, and saving another
11+
profile keeps it.
12+
- `docspring submit` and `docspring submissions download` check each file's size as well as its SHA-256 checksum, so a
13+
file the API lists without a checksum (an audit trail made before September 2024) is checked too.
14+
115
### 1.0.0 [November 3, 2026]
216

317
- The first release of the DocSpring CLI: a `docspring` command for every operation in DocSpring's API v2, such as

‎Cargo.lock‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "docspring-cli"
3-
version = "1.0.0"
3+
version = "1.0.1"
44
edition = "2021"
55
rust-version = "1.88"
66
# Its own tests (cargo test in this directory) resolve dependencies that build with rust-version

‎action.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ inputs:
1717
The version to install, such as 1.2.0, or latest. Default this action's own release, so
1818
DocSpring/docspring-cli@v1 installs the newest 1.x.
1919
required: false
20-
default: "1.0.0"
20+
default: "1.0.1"
2121
outputs:
2222
version:
2323
description: The version that was installed

‎src/auth.rs‎

Lines changed: 167 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use clap::{Args, Subcommand};
77
use docspring::Api;
88
use serde_json::json;
99

10-
use crate::config::{check_profile_name, config_dir, Config, CredentialKind, Profile, DEFAULT_PROFILE};
10+
use crate::config::{check_profile_name, config_dir, saved_by_newer_cli, Config, CredentialKind, Profile, DEFAULT_PROFILE};
1111
use crate::errors::CliError;
1212
use crate::oauth;
1313
use crate::output;
@@ -89,25 +89,21 @@ pub async fn login(runner: &Runner, args: LoginArgs) -> CommandResult {
8989
check_profile_name(&name)?;
9090
let dir = config_dir()?;
9191
let config = Config::load(&dir)?;
92-
let existing = config.profiles.get(&name);
93-
// The same server as the profile's last login, unless a flag names another
94-
let (region, host) = match (&runner.global.region, &runner.global.host) {
95-
(None, None) => existing
96-
.map(|profile| (profile.region.clone(), profile.host.clone()))
97-
.unwrap_or((Some("US".to_owned()), None)),
98-
(region, host) => (region.as_ref().map(|region| region.to_uppercase()), host.clone()),
99-
};
100-
if args.issuer.is_some() && host.is_none() {
101-
return Err(CliError::usage("--issuer is for logging in to a --host"));
102-
}
92+
let LoginServer { region, host, issuer } = login_server(
93+
&runner.global,
94+
args.issuer.as_deref(),
95+
config.profiles.get(&name),
96+
session::env_var("DOCSPRING_HOST"),
97+
session::env_var("DOCSPRING_REGION"),
98+
)?;
10399
let store = SecretStore::new(&dir)?;
104100
let (profile, secret) = if args.with_token {
105101
token_login(runner, region, host).await?
106102
} else {
107-
browser_login(runner, &args, region, host).await?
103+
browser_login(runner, &args, issuer, region, host).await?
108104
};
109105
let location = profile.location();
110-
let kind = profile.credential;
106+
let kind = profile.credential.clone();
111107
// The config and credentials as they are now, under the lock: another command (a login in another terminal,
112108
// `profiles use`) may have changed them while this one waited for the browser
113109
let (saved, config, replaced) = {
@@ -160,9 +156,58 @@ pub async fn login(runner: &Runner, args: LoginArgs) -> CommandResult {
160156
Ok(())
161157
}
162158

163-
async fn browser_login(runner: &Runner, args: &LoginArgs, region: Option<String>, host: Option<String>) -> Result<(Profile, Secret), CliError> {
159+
/// The server a login goes to, and the OAuth server a browser login uses
160+
#[derive(Debug, PartialEq)]
161+
struct LoginServer {
162+
region: Option<String>,
163+
host: Option<String>,
164+
issuer: Option<String>,
165+
}
166+
167+
/// The server a flag names, or the profile's last login's, or DOCSPRING_HOST's or DOCSPRING_REGION's (the variables
168+
/// that go with an API token in the environment), or the US. Logging in to a host again uses the --issuer its last login
169+
/// was given.
170+
fn login_server(
171+
global: &crate::GlobalArgs,
172+
issuer: Option<&str>,
173+
existing: Option<&Profile>,
174+
env_host: Option<String>,
175+
env_region: Option<String>,
176+
) -> Result<LoginServer, CliError> {
177+
let (region, host) = match (&global.region, &global.host) {
178+
(None, None) => match (existing, env_host, env_region) {
179+
(Some(profile), _, _) => (profile.region.clone(), profile.host.clone()),
180+
(None, Some(host), _) => (None, Some(host)),
181+
(None, None, Some(region)) => match region.to_uppercase().as_str() {
182+
region @ ("US" | "EU") => (Some(region.to_owned()), None),
183+
_ => return Err(CliError::usage(format!("DOCSPRING_REGION is {region:?}: it can be US or EU"))),
184+
},
185+
(None, None, None) => (Some("US".to_owned()), None),
186+
},
187+
(region, host) => (region.as_ref().map(|region| region.to_uppercase()), host.clone()),
188+
};
189+
if issuer.is_some() && host.is_none() {
190+
return Err(CliError::usage("--issuer is for logging in to a --host"));
191+
}
192+
let same_host = |profile: &&Profile| match (&profile.host, &host) {
193+
(Some(saved), Some(host)) => session::api_base(saved).eq_ignore_ascii_case(&session::api_base(host)),
194+
_ => false,
195+
};
196+
let issuer = issuer
197+
.map(str::to_owned)
198+
.or_else(|| existing.filter(same_host).and_then(|profile| profile.issuer.clone()));
199+
Ok(LoginServer { region, host, issuer })
200+
}
201+
202+
async fn browser_login(
203+
runner: &Runner,
204+
args: &LoginArgs,
205+
issuer: Option<String>,
206+
region: Option<String>,
207+
host: Option<String>,
208+
) -> Result<(Profile, Secret), CliError> {
164209
let http = session::http_client()?;
165-
let issuer = match (&args.issuer, &host) {
210+
let issuer = match (&issuer, &host) {
166211
(Some(issuer), _) => session::origin(issuer),
167212
(None, Some(host)) => session::origin(host),
168213
(None, None) => session::region_issuer(region.as_deref()).to_owned(),
@@ -199,6 +244,7 @@ async fn browser_login(runner: &Runner, args: &LoginArgs, region: Option<String>
199244
issuer: Some(metadata.issuer.clone()),
200245
scope: tokens.scope.clone(),
201246
token_id: None,
247+
unknown: Default::default(),
202248
};
203249
let secret = Secret::Login {
204250
access_token: tokens.access_token,
@@ -241,6 +287,7 @@ async fn token_login(runner: &Runner, region: Option<String>, host: Option<Strin
241287
issuer: None,
242288
scope: None,
243289
token_id: Some(token_id),
290+
unknown: Default::default(),
244291
};
245292
Ok((profile, Secret::ApiToken { token_secret }))
246293
}
@@ -291,6 +338,10 @@ async fn remove_profile(runner: &Runner, name: &str) -> CommandResult {
291338
let _lock = secrets::lock(&dir).await?;
292339
let mut config = Config::load(&dir)?;
293340
let profile = config.profile(name)?.clone();
341+
// This version can't revoke the newer one's credentials, and deleting them would leave them working
342+
if let CredentialKind::Other(kind) = &profile.credential {
343+
return Err(saved_by_newer_cli(name, kind));
344+
}
294345
{
295346
// A keychain that can't be read or changed fails the command, which leaves the profile as it was: the login's
296347
// tokens would otherwise stay saved, and valid
@@ -445,6 +496,7 @@ fn list_profiles(runner: &Runner, with_source: bool) -> CommandResult {
445496
id
446497
}
447498
}
499+
CredentialKind::Other(_) => "saved by a newer version of the CLI".to_owned(),
448500
};
449501
json!({
450502
"profile": format!("{name}{default}"),
@@ -463,3 +515,102 @@ fn list_profiles(runner: &Runner, with_source: bool) -> CommandResult {
463515
);
464516
Ok(())
465517
}
518+
519+
#[cfg(test)]
520+
mod tests {
521+
use super::*;
522+
523+
fn global(region: Option<&str>, host: Option<&str>) -> crate::GlobalArgs {
524+
crate::GlobalArgs {
525+
region: region.map(str::to_owned),
526+
host: host.map(str::to_owned),
527+
..crate::GlobalArgs::default()
528+
}
529+
}
530+
531+
fn server(region: Option<&str>, host: Option<&str>, issuer: Option<&str>) -> LoginServer {
532+
LoginServer {
533+
region: region.map(str::to_owned),
534+
host: host.map(str::to_owned),
535+
issuer: issuer.map(str::to_owned),
536+
}
537+
}
538+
539+
fn profile(region: Option<&str>, host: Option<&str>, issuer: Option<&str>) -> Profile {
540+
Profile {
541+
credential: CredentialKind::Login,
542+
region: region.map(str::to_owned),
543+
host: host.map(str::to_owned),
544+
issuer: issuer.map(str::to_owned),
545+
scope: None,
546+
token_id: None,
547+
unknown: Default::default(),
548+
}
549+
}
550+
551+
#[test]
552+
fn a_new_login_uses_the_environments_region_or_host_when_no_flag_names_one() {
553+
let env = |name: &str| Some(name.to_owned());
554+
assert_eq!(
555+
login_server(&global(None, None), None, None, None, None).unwrap(),
556+
server(Some("US"), None, None)
557+
);
558+
assert_eq!(
559+
login_server(&global(None, None), None, None, None, env("eu")).unwrap(),
560+
server(Some("EU"), None, None)
561+
);
562+
assert_eq!(
563+
login_server(&global(None, None), None, None, env("docspring.example.com"), env("EU")).unwrap(),
564+
server(None, Some("docspring.example.com"), None)
565+
);
566+
assert_eq!(
567+
login_server(&global(Some("us"), None), None, None, None, env("EU")).unwrap(),
568+
server(Some("US"), None, None)
569+
);
570+
let error = login_server(&global(None, None), None, None, None, env("AU")).unwrap_err();
571+
assert_eq!(error.message, "DOCSPRING_REGION is \"AU\": it can be US or EU");
572+
573+
// Logging in to a profile again goes to its server
574+
let eu = profile(Some("EU"), None, Some("https://app-eu.docspring.com"));
575+
assert_eq!(
576+
login_server(&global(None, None), None, Some(&eu), None, env("US")).unwrap(),
577+
server(Some("EU"), None, None)
578+
);
579+
}
580+
581+
#[test]
582+
fn logging_in_to_a_host_again_uses_its_last_issuer() {
583+
let enterprise = profile(None, Some("api.docspring.example.com"), Some("https://docspring.example.com"));
584+
let issuer = Some("https://docspring.example.com");
585+
assert_eq!(
586+
login_server(&global(None, None), None, Some(&enterprise), None, None).unwrap(),
587+
server(None, Some("api.docspring.example.com"), issuer)
588+
);
589+
assert_eq!(
590+
login_server(
591+
&global(None, Some("https://API.docspring.example.com/")),
592+
None,
593+
Some(&enterprise),
594+
None,
595+
None
596+
)
597+
.unwrap(),
598+
server(None, Some("https://API.docspring.example.com/"), issuer)
599+
);
600+
// --issuer replaces it, and another host doesn't use it
601+
assert_eq!(
602+
login_server(&global(None, None), Some("https://sso.example.com"), Some(&enterprise), None, None).unwrap(),
603+
server(None, Some("api.docspring.example.com"), Some("https://sso.example.com"))
604+
);
605+
assert_eq!(
606+
login_server(&global(None, Some("other.example.com")), None, Some(&enterprise), None, None).unwrap(),
607+
server(None, Some("other.example.com"), None)
608+
);
609+
assert_eq!(
610+
login_server(&global(Some("EU"), None), None, Some(&enterprise), None, None).unwrap(),
611+
server(Some("EU"), None, None)
612+
);
613+
let error = login_server(&global(Some("EU"), None), Some("https://sso.example.com"), None, None, None).unwrap_err();
614+
assert_eq!(error.message, "--issuer is for logging in to a --host");
615+
}
616+
}

0 commit comments

Comments
 (0)