@@ -7,7 +7,7 @@ use clap::{Args, Subcommand};
77use docspring:: Api ;
88use 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 } ;
1111use crate :: errors:: CliError ;
1212use crate :: oauth;
1313use 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