Skip to main content

volcengine_rust_sdk_auth/
lib.rs

1//! Credentials backed by profiles created by `ve configure`, `ve sso login`,
2//! and `ve login`.
3//!
4//! The implementation intentionally keeps the CLI cache read-only. Refreshes
5//! update only the in-process snapshot; `ve` remains the sole cache writer.
6
7use std::{
8    collections::{BTreeMap, HashMap},
9    env,
10    error::Error as StdError,
11    fmt, fs,
12    path::{Path, PathBuf},
13    sync::Mutex,
14    thread,
15    time::Duration,
16};
17
18use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
19use chrono::{DateTime, SecondsFormat, Utc};
20use hmac::{Hmac, Mac};
21use rand::Rng;
22use reqwest::{
23    StatusCode,
24    blocking::{Client, Response},
25    header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue},
26};
27use serde::{Deserialize, Deserializer, Serialize};
28use serde_json::Value as JsonValue;
29use sha1::{Digest as _, Sha1};
30use sha2::Sha256;
31use thiserror::Error;
32use uuid::Uuid;
33
34const PROVIDER_NAME: &str = "CliProvider";
35const DEFAULT_REGION: &str = "cn-beijing";
36const DEFAULT_STS_HOST: &str = "sts.volcengineapi.com";
37const DEFAULT_CONSOLE_LOGIN_ENDPOINT: &str = "https://signin.volcengine.com";
38const DEFAULT_IMDS_ENDPOINT: &str = "http://100.96.0.96";
39const EXPIRY_WINDOW: Duration = Duration::from_secs(60);
40const DEFAULT_RETRIES: usize = 3;
41
42pub type Result<T> = std::result::Result<T, CredentialError>;
43
44/// A resolved Volcengine credential triple.
45///
46/// `Debug` is intentionally redacted so accidental diagnostics do not expose
47/// secrets.
48#[derive(Clone, PartialEq, Eq)]
49pub struct CredentialValue {
50    pub access_key_id: String,
51    pub secret_access_key: String,
52    pub session_token: String,
53    pub provider_name: &'static str,
54}
55
56impl fmt::Debug for CredentialValue {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.debug_struct("CredentialValue")
59            .field("access_key_id", &"[REDACTED]")
60            .field("secret_access_key", &"[REDACTED]")
61            .field("session_token", &"[REDACTED]")
62            .field("provider_name", &self.provider_name)
63            .finish()
64    }
65}
66
67impl CredentialValue {
68    fn cli(access_key_id: String, secret_access_key: String, session_token: String) -> Self {
69        Self {
70            access_key_id,
71            secret_access_key,
72            session_token,
73            provider_name: PROVIDER_NAME,
74        }
75    }
76
77    fn named(
78        access_key_id: String,
79        secret_access_key: String,
80        session_token: String,
81        provider_name: &'static str,
82    ) -> Self {
83        Self {
84            access_key_id,
85            secret_access_key,
86            session_token,
87            provider_name,
88        }
89    }
90}
91
92/// Stable provider error with a Go-SDK-compatible error code.
93#[derive(Debug, Error)]
94#[error("{code}: {message}")]
95pub struct CredentialError {
96    code: &'static str,
97    message: String,
98    #[source]
99    source: Option<Box<dyn StdError + Send + Sync>>,
100}
101
102impl CredentialError {
103    fn new(code: &'static str, message: impl Into<String>) -> Self {
104        Self {
105            code,
106            message: message.into(),
107            source: None,
108        }
109    }
110
111    fn with_source(
112        code: &'static str,
113        message: impl Into<String>,
114        source: impl StdError + Send + Sync + 'static,
115    ) -> Self {
116        Self {
117            code,
118            message: message.into(),
119            source: Some(Box::new(source)),
120        }
121    }
122
123    pub fn code(&self) -> &'static str {
124        self.code
125    }
126}
127
128#[derive(Debug, Clone, Deserialize, Default)]
129struct CliConfig {
130    #[serde(default)]
131    current: String,
132    #[serde(default)]
133    profiles: HashMap<String, CliProfile>,
134    #[serde(
135        rename = "sso-session",
136        default,
137        deserialize_with = "deserialize_sso_sessions"
138    )]
139    sso_sessions: HashMap<String, SsoSession>,
140}
141
142fn deserialize_sso_sessions<'de, D>(
143    deserializer: D,
144) -> std::result::Result<HashMap<String, SsoSession>, D::Error>
145where
146    D: Deserializer<'de>,
147{
148    Ok(Option::deserialize(deserializer)?.unwrap_or_default())
149}
150
151#[derive(Debug, Clone, Deserialize, Default)]
152struct CliProfile {
153    #[serde(default)]
154    mode: String,
155    #[serde(rename = "access-key", default)]
156    access_key: String,
157    #[serde(rename = "secret-key", default)]
158    secret_key: String,
159    #[serde(rename = "session-token", default)]
160    session_token: String,
161    #[serde(rename = "sts-expiration", default)]
162    sts_expiration: i64,
163    #[serde(rename = "sso-session-name", default)]
164    sso_session_name: String,
165    #[serde(rename = "account-id", default)]
166    account_id: String,
167    #[serde(rename = "role-name", default)]
168    role_name: String,
169    #[serde(rename = "login-session", default)]
170    login_session: String,
171    #[serde(rename = "oidc-token-file", default)]
172    oidc_token_file: String,
173    #[serde(rename = "role-trn", default)]
174    role_trn: String,
175    #[serde(default)]
176    region: String,
177    #[serde(rename = "disable-ssl", default)]
178    disable_ssl: bool,
179}
180
181#[derive(Debug, Clone, Deserialize, Default)]
182struct SsoSession {
183    #[serde(rename = "start-url", default)]
184    start_url: String,
185    #[serde(default)]
186    region: String,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190enum ProfileMode {
191    AccessKey,
192    Sso,
193    IamRole,
194    Oidc,
195    EcsRole,
196    ConsoleLogin,
197}
198
199impl ProfileMode {
200    fn parse(raw: &str, profile_name: &str, config_path: &Path) -> Result<Self> {
201        match raw.trim().to_ascii_lowercase().as_str() {
202            "" | "ak" => Ok(Self::AccessKey),
203            "sso" => Ok(Self::Sso),
204            "iamrole" | "ramrolearn" => Ok(Self::IamRole),
205            "oidc" => Ok(Self::Oidc),
206            "ecsrole" => Ok(Self::EcsRole),
207            "console-login" => Ok(Self::ConsoleLogin),
208            _ => Err(CredentialError::new(
209                "CliConfigModeInvalid",
210                format!(
211                    "cli config profile {profile_name} in {} contained unsupported mode {raw:?}",
212                    config_path.display()
213                ),
214            )),
215        }
216    }
217}
218
219#[derive(Debug, Clone, Default)]
220struct EndpointOverrides {
221    sso_oauth: Option<String>,
222    sso_portal: Option<String>,
223    sts: Option<String>,
224    imds: Option<String>,
225}
226
227/// Thread-safe, lazily loaded credentials from the Volcengine CLI config.
228pub struct CliCredentials {
229    config_path: PathBuf,
230    requested_profile: Option<String>,
231    endpoints: EndpointOverrides,
232    state: Mutex<State>,
233}
234
235#[derive(Default)]
236struct State {
237    profile_name: Option<String>,
238    source: Option<CredentialSource>,
239}
240
241enum CredentialSource {
242    Static {
243        value: CredentialValue,
244        expires_at: Option<DateTime<Utc>>,
245    },
246    Dynamic(DynamicProvider),
247}
248
249enum DynamicProvider {
250    Sso(Box<SsoProvider>),
251    ConsoleLogin(Box<ConsoleLoginProvider>),
252    IamRole(Box<IamRoleProvider>),
253    Oidc(Box<OidcProvider>),
254    EcsRole(Box<EcsRoleProvider>),
255}
256
257impl CliCredentials {
258    /// Constructs a CLI credential source.
259    ///
260    /// An empty path follows `VOLCENGINE_CLI_CONFIG_FILE`, then defaults to
261    /// `~/.volcengine/config.json`. An empty profile follows
262    /// `VOLCENGINE_PROFILE`, `VOLCSTACK_PROFILE`, `config.current`, and finally
263    /// `default`.
264    pub fn new(config_path: Option<PathBuf>, profile: Option<String>) -> Self {
265        let config_path = config_path
266            .filter(|path| !path.as_os_str().is_empty())
267            .or_else(|| nonempty_env("VOLCENGINE_CLI_CONFIG_FILE").map(PathBuf::from))
268            .unwrap_or_else(default_cli_config_path);
269
270        Self {
271            config_path,
272            requested_profile: profile.filter(|value| !value.is_empty()),
273            endpoints: EndpointOverrides::default(),
274            state: Mutex::new(State::default()),
275        }
276    }
277
278    /// Constructs credentials using the default CLI config and profile.
279    pub fn from_cli() -> Self {
280        Self::new(None, None)
281    }
282
283    /// Returns a cached credential or refreshes it when its provider expires.
284    pub fn get(&self) -> Result<CredentialValue> {
285        let mut state = self.state.lock().map_err(|_| {
286            CredentialError::new(
287                "CliCredentialsPoisoned",
288                "credential state lock was poisoned",
289            )
290        })?;
291
292        let needs_reload = state
293            .source
294            .as_ref()
295            .is_none_or(CredentialSource::is_expired);
296        if needs_reload {
297            state.source = None;
298            let (config, profile_name, profile) = self.load_profile(&mut state)?;
299            state.source = Some(self.build_source(&config, &profile_name, profile)?);
300        }
301
302        state
303            .source
304            .as_mut()
305            .expect("credential source is initialized")
306            .get()
307    }
308
309    fn load_profile(&self, state: &mut State) -> Result<(CliConfig, String, CliProfile)> {
310        let bytes = fs::read(&self.config_path).map_err(|error| {
311            CredentialError::with_source(
312                "CliConfigLoad",
313                format!(
314                    "failed to load cli config file {}",
315                    self.config_path.display()
316                ),
317                error,
318            )
319        })?;
320
321        let config = if bytes.iter().all(u8::is_ascii_whitespace) {
322            CliConfig::default()
323        } else {
324            serde_json::from_slice(&bytes).map_err(|error| {
325                CredentialError::with_source(
326                    "CliConfigUnmarshal",
327                    format!(
328                        "failed to unmarshal cli config file {}",
329                        self.config_path.display()
330                    ),
331                    error,
332                )
333            })?
334        };
335
336        if config.profiles.is_empty() {
337            return Err(CredentialError::new(
338                "CliConfigNoProfiles",
339                format!(
340                    "cli config file {} did not contain any profiles",
341                    self.config_path.display()
342                ),
343            ));
344        }
345
346        let profile_name = state
347            .profile_name
348            .get_or_insert_with(|| self.resolve_profile_name(&config))
349            .clone();
350        let profile = config.profiles.get(&profile_name).cloned().ok_or_else(|| {
351            CredentialError::new(
352                "CliConfigProfileNotFound",
353                format!(
354                    "cli config file {} did not contain profile {profile_name}",
355                    self.config_path.display()
356                ),
357            )
358        })?;
359
360        Ok((config, profile_name, profile))
361    }
362
363    fn resolve_profile_name(&self, config: &CliConfig) -> String {
364        self.requested_profile
365            .clone()
366            .or_else(|| nonempty_env("VOLCENGINE_PROFILE"))
367            .or_else(|| nonempty_env("VOLCSTACK_PROFILE"))
368            .or_else(|| (!config.current.is_empty()).then(|| config.current.clone()))
369            .unwrap_or_else(|| "default".to_owned())
370    }
371
372    fn build_source(
373        &self,
374        config: &CliConfig,
375        profile_name: &str,
376        profile: CliProfile,
377    ) -> Result<CredentialSource> {
378        match ProfileMode::parse(&profile.mode, profile_name, &self.config_path)? {
379            ProfileMode::AccessKey => self.access_key_source(profile_name, &profile),
380            ProfileMode::Sso => self.sso_source(config, profile_name, profile),
381            ProfileMode::IamRole => self.iam_role_source(profile_name, profile),
382            ProfileMode::Oidc => self.oidc_source(profile_name, profile),
383            ProfileMode::EcsRole => self.ecs_role_source(profile_name, profile),
384            ProfileMode::ConsoleLogin => self.console_login_source(profile_name, profile),
385        }
386    }
387
388    fn access_key_source(
389        &self,
390        profile_name: &str,
391        profile: &CliProfile,
392    ) -> Result<CredentialSource> {
393        require_profile_field(
394            &profile.access_key,
395            "CliConfigAccessKey",
396            "access-key",
397            profile_name,
398            &self.config_path,
399        )?;
400        require_profile_field(
401            &profile.secret_key,
402            "CliConfigSecretKey",
403            "secret-key",
404            profile_name,
405            &self.config_path,
406        )?;
407
408        Ok(CredentialSource::Static {
409            value: CredentialValue::cli(
410                profile.access_key.clone(),
411                profile.secret_key.clone(),
412                profile.session_token.clone(),
413            ),
414            expires_at: None,
415        })
416    }
417
418    fn sso_source(
419        &self,
420        config: &CliConfig,
421        profile_name: &str,
422        profile: CliProfile,
423    ) -> Result<CredentialSource> {
424        if profile.sts_expiration < 0 {
425            return Err(CredentialError::new(
426                "CliConfigExpiration",
427                format!(
428                    "cli config profile {profile_name} in {} contained invalid sts-expiration",
429                    self.config_path.display()
430                ),
431            ));
432        }
433
434        if profile.sts_expiration > 0 {
435            let expiration = unix_timestamp_to_datetime(profile.sts_expiration)?;
436            if Utc::now() <= expiration {
437                require_profile_field(
438                    &profile.access_key,
439                    "CliConfigAccessKey",
440                    "access-key",
441                    profile_name,
442                    &self.config_path,
443                )?;
444                require_profile_field(
445                    &profile.secret_key,
446                    "CliConfigSecretKey",
447                    "secret-key",
448                    profile_name,
449                    &self.config_path,
450                )?;
451                return Ok(CredentialSource::Static {
452                    value: CredentialValue::cli(
453                        profile.access_key.clone(),
454                        profile.secret_key.clone(),
455                        profile.session_token.clone(),
456                    ),
457                    expires_at: expiration.checked_sub_signed(chrono::Duration::seconds(60)),
458                });
459            }
460        }
461
462        let session_name = require_trimmed_profile_field(
463            &profile.sso_session_name,
464            "CliConfigSsoSessionNameMissing",
465            "sso-session-name",
466            profile_name,
467            &self.config_path,
468        )?;
469        let session = config.sso_sessions.get(session_name).ok_or_else(|| {
470            CredentialError::new(
471                "CliConfigSsoSessionNotFound",
472                format!(
473                    "cli config file {} did not contain sso session {session_name}",
474                    self.config_path.display()
475                ),
476            )
477        })?;
478        let start_url = session.start_url.trim();
479        if start_url.is_empty() {
480            return Err(CredentialError::new(
481                "CliConfigSsoStartURLMissing",
482                format!(
483                    "sso session {session_name} in {} did not contain start-url",
484                    self.config_path.display()
485                ),
486            ));
487        }
488        let region = match session.region.trim() {
489            "" => match profile.region.trim() {
490                "" => DEFAULT_REGION,
491                value => value,
492            },
493            value => value,
494        }
495        .to_owned();
496        let cache_path = self
497            .config_path
498            .parent()
499            .unwrap_or_else(|| Path::new(""))
500            .join("sso/cache")
501            .join(sso_cache_filename(start_url, session_name)?);
502        if !cache_path.is_file() {
503            return Err(CredentialError::new(
504                "CliSsoTokenCacheMissing",
505                format!(
506                    "sso token cache file {} does not exist; please run 've sso login' to re-authenticate",
507                    cache_path.display()
508                ),
509            ));
510        }
511
512        Ok(CredentialSource::Dynamic(DynamicProvider::Sso(Box::new(
513            SsoProvider::new(
514                cache_path,
515                profile,
516                profile_name.to_owned(),
517                self.config_path.clone(),
518                region,
519                self.endpoints.clone(),
520            ),
521        ))))
522    }
523
524    fn iam_role_source(&self, profile_name: &str, profile: CliProfile) -> Result<CredentialSource> {
525        for (value, code, field) in [
526            (&profile.access_key, "CliConfigAccessKey", "access-key"),
527            (&profile.secret_key, "CliConfigSecretKey", "secret-key"),
528        ] {
529            require_profile_field(value, code, field, profile_name, &self.config_path)?;
530        }
531        for (value, code, field) in [
532            (&profile.role_name, "CliConfigRoleNameMissing", "role-name"),
533            (
534                &profile.account_id,
535                "CliConfigAccountIDMissing",
536                "account-id",
537            ),
538        ] {
539            require_trimmed_profile_field(value, code, field, profile_name, &self.config_path)?;
540        }
541
542        Ok(CredentialSource::Dynamic(DynamicProvider::IamRole(
543            Box::new(IamRoleProvider::new(profile, self.endpoints.clone())),
544        )))
545    }
546
547    fn oidc_source(&self, profile_name: &str, profile: CliProfile) -> Result<CredentialSource> {
548        require_trimmed_profile_field(
549            &profile.oidc_token_file,
550            "CliConfigOIDCTokenFileMissing",
551            "oidc-token-file",
552            profile_name,
553            &self.config_path,
554        )?;
555        require_trimmed_profile_field(
556            &profile.role_trn,
557            "CliConfigOIDCRoleTrnMissing",
558            "role-trn",
559            profile_name,
560            &self.config_path,
561        )?;
562
563        Ok(CredentialSource::Dynamic(DynamicProvider::Oidc(Box::new(
564            OidcProvider::new(profile, self.endpoints.clone()),
565        ))))
566    }
567
568    fn ecs_role_source(&self, profile_name: &str, profile: CliProfile) -> Result<CredentialSource> {
569        require_trimmed_profile_field(
570            &profile.role_name,
571            "CliConfigRoleNameMissing",
572            "role-name",
573            profile_name,
574            &self.config_path,
575        )?;
576
577        Ok(CredentialSource::Dynamic(DynamicProvider::EcsRole(
578            Box::new(EcsRoleProvider::new(
579                profile.role_name.trim().to_owned(),
580                self.endpoints.clone(),
581            )),
582        )))
583    }
584
585    fn console_login_source(
586        &self,
587        profile_name: &str,
588        profile: CliProfile,
589    ) -> Result<CredentialSource> {
590        let login_session = require_trimmed_profile_field(
591            &profile.login_session,
592            "CliConfigLoginSessionMissing",
593            "login-session",
594            profile_name,
595            &self.config_path,
596        )?;
597        let cache_dir = nonempty_env("VOLCENGINE_LOGIN_CACHE_DIRECTORY")
598            .map(PathBuf::from)
599            .unwrap_or_else(|| {
600                self.config_path
601                    .parent()
602                    .unwrap_or_else(|| Path::new(""))
603                    .join("login/cache")
604            });
605
606        Ok(CredentialSource::Dynamic(DynamicProvider::ConsoleLogin(
607            Box::new(ConsoleLoginProvider::new(
608                login_session.to_owned(),
609                cache_dir,
610            )),
611        )))
612    }
613}
614
615impl CredentialSource {
616    fn is_expired(&self) -> bool {
617        match self {
618            Self::Static {
619                expires_at: None, ..
620            } => false,
621            Self::Static {
622                expires_at: Some(expiration),
623                ..
624            } => Utc::now() > *expiration,
625            Self::Dynamic(provider) => provider.is_expired(),
626        }
627    }
628
629    fn get(&mut self) -> Result<CredentialValue> {
630        match self {
631            Self::Static { value, .. } => Ok(value.clone()),
632            Self::Dynamic(provider) => provider.get(),
633        }
634    }
635}
636
637impl DynamicProvider {
638    fn is_expired(&self) -> bool {
639        match self {
640            Self::Sso(provider) => provider.is_expired(),
641            Self::ConsoleLogin(provider) => provider.is_expired(),
642            Self::IamRole(provider) => provider.is_expired(),
643            Self::Oidc(provider) => provider.is_expired(),
644            Self::EcsRole(provider) => provider.is_expired(),
645        }
646    }
647
648    fn get(&mut self) -> Result<CredentialValue> {
649        match self {
650            Self::Sso(provider) => provider.get(),
651            Self::ConsoleLogin(provider) => provider.get(),
652            Self::IamRole(provider) => provider.get(),
653            Self::Oidc(provider) => provider.get(),
654            Self::EcsRole(provider) => provider.get(),
655        }
656    }
657}
658
659fn nonempty_env(name: &str) -> Option<String> {
660    env::var(name).ok().filter(|value| !value.is_empty())
661}
662
663fn default_cli_config_path() -> PathBuf {
664    dirs::home_dir()
665        .unwrap_or_default()
666        .join(".volcengine/config.json")
667}
668
669fn require_profile_field<'a>(
670    value: &'a str,
671    code: &'static str,
672    field: &str,
673    profile_name: &str,
674    config_path: &Path,
675) -> Result<&'a str> {
676    if value.is_empty() {
677        return Err(CredentialError::new(
678            code,
679            format!(
680                "cli config profile {profile_name} in {} did not contain {field}",
681                config_path.display()
682            ),
683        ));
684    }
685    Ok(value)
686}
687
688fn require_trimmed_profile_field<'a>(
689    value: &'a str,
690    code: &'static str,
691    field: &str,
692    profile_name: &str,
693    config_path: &Path,
694) -> Result<&'a str> {
695    let value = value.trim();
696    require_profile_field(value, code, field, profile_name, config_path)
697}
698
699#[derive(Debug, Clone, Deserialize)]
700struct SsoTokenCache {
701    #[serde(default)]
702    access_token: String,
703    #[serde(default)]
704    expires_at: String,
705    #[serde(default)]
706    client_id: String,
707    #[serde(default)]
708    client_secret: String,
709    #[serde(default)]
710    client_secret_expires_at: i64,
711    #[serde(default)]
712    refresh_token: String,
713}
714
715struct SsoProvider {
716    token_path: PathBuf,
717    profile: CliProfile,
718    profile_name: String,
719    config_path: PathBuf,
720    region: String,
721    endpoints: EndpointOverrides,
722    cached: Option<SsoTokenCache>,
723    credentials: Option<CredentialValue>,
724    expires_at: Option<DateTime<Utc>>,
725}
726
727impl SsoProvider {
728    fn new(
729        token_path: PathBuf,
730        profile: CliProfile,
731        profile_name: String,
732        config_path: PathBuf,
733        region: String,
734        endpoints: EndpointOverrides,
735    ) -> Self {
736        Self {
737            token_path,
738            profile,
739            profile_name,
740            config_path,
741            region,
742            endpoints,
743            cached: None,
744            credentials: None,
745            expires_at: None,
746        }
747    }
748
749    fn is_expired(&self) -> bool {
750        self.credentials.is_none()
751            || self
752                .expires_at
753                .is_none_or(|expiration| within_expiry_window(expiration, EXPIRY_WINDOW))
754    }
755
756    fn get(&mut self) -> Result<CredentialValue> {
757        if self.is_expired() {
758            self.refresh()?;
759        }
760        self.credentials.clone().ok_or_else(|| {
761            CredentialError::new(
762                "CliSsoPortalCredentialsEmpty",
763                "SSO provider did not produce credentials",
764            )
765        })
766    }
767
768    fn refresh(&mut self) -> Result<()> {
769        if self.cached.is_none() {
770            self.cached = Some(load_sso_cache(&self.token_path)?);
771        }
772
773        if !self.access_token_is_expired() {
774            return self.fetch_role_credentials();
775        }
776
777        match self.refresh_access_token() {
778            Ok(()) => self.fetch_role_credentials(),
779            Err(error) if !is_invalid_grant(&error) => Err(error),
780            Err(first_error) => {
781                let first_error_message = first_error.to_string();
782                let disk_cache = load_sso_cache(&self.token_path).map_err(|disk_error| {
783                    CredentialError::with_source(
784                        "CliSsoTokenRefreshFailed",
785                        format!(
786                            "SSO refresh token rejected ({first_error_message}); failed to reload cache from disk: {disk_error}; please run 've sso login'"
787                        ),
788                        disk_error,
789                    )
790                })?;
791                let current_refresh_token = self
792                    .cached
793                    .as_ref()
794                    .map(|cache| cache.refresh_token.as_str())
795                    .unwrap_or_default();
796                if disk_cache.refresh_token.trim().is_empty()
797                    || disk_cache.refresh_token == current_refresh_token
798                {
799                    return Err(CredentialError::with_source(
800                        "CliSsoTokenRefreshExpired",
801                        "SSO refresh token has been rejected by the authorization server; please run 've sso login' to re-authenticate",
802                        first_error,
803                    ));
804                }
805                self.cached = Some(disk_cache);
806                if !self.access_token_is_expired() {
807                    return self.fetch_role_credentials();
808                }
809                self.refresh_access_token().map_err(|error| {
810                    CredentialError::with_source(
811                        "CliSsoTokenRefreshExpired",
812                        "SSO refresh token rejected; reloaded cache from disk but new refresh token also failed; please run 've sso login'",
813                        error,
814                    )
815                })?;
816                self.fetch_role_credentials()
817            }
818        }
819    }
820
821    fn access_token_is_expired(&self) -> bool {
822        let Some(cache) = &self.cached else {
823            return true;
824        };
825        if cache.access_token.trim().is_empty() {
826            return true;
827        }
828        parse_rfc3339(&cache.expires_at)
829            .map(|expiration| within_expiry_window(expiration, EXPIRY_WINDOW))
830            .unwrap_or(true)
831    }
832
833    fn refresh_access_token(&mut self) -> Result<()> {
834        let cache = self.cached.as_ref().expect("SSO cache is loaded");
835        if cache.refresh_token.trim().is_empty() {
836            return Err(CredentialError::new(
837                "CliSsoTokenRefreshMissing",
838                format!(
839                    "SSO token cache {} did not contain refresh token; please run 've sso login' to re-authenticate",
840                    self.token_path.display()
841                ),
842            ));
843        }
844        if cache.client_secret_expires_at <= 0 {
845            return Err(CredentialError::new(
846                "CliSsoTokenRefreshExpiresParse",
847                format!(
848                    "failed to parse refresh token expiration in {}; please run 've sso login' to re-authenticate",
849                    self.token_path.display()
850                ),
851            ));
852        }
853        let refresh_expiration = unix_timestamp_to_datetime(cache.client_secret_expires_at)?;
854        if Utc::now() > refresh_expiration {
855            return Err(CredentialError::new(
856                "CliSsoTokenRefreshExpired",
857                format!(
858                    "SSO refresh token in {} has expired; please run 've sso login' to re-authenticate",
859                    self.token_path.display()
860                ),
861            ));
862        }
863        if cache.client_id.trim().is_empty() || cache.client_secret.trim().is_empty() {
864            return Err(CredentialError::new(
865                "CliSsoTokenClientMissing",
866                format!(
867                    "SSO token cache {} did not contain client id/secret; please run 've sso login' to re-authenticate",
868                    self.token_path.display()
869                ),
870            ));
871        }
872
873        let response = request_sso_token(
874            &self.region,
875            cache,
876            self.endpoints.sso_oauth.as_deref(),
877        )
878        .map_err(|failure| {
879            if failure.is_invalid_grant() {
880                CredentialError::with_source(
881                    "CliSsoTokenRefreshInvalidGrant",
882                    "SSO authorization server rejected the refresh token",
883                    failure,
884                )
885            } else {
886                CredentialError::with_source(
887                    "CliSsoTokenRefreshFailed",
888                    "failed to refresh SSO access token; please run 've sso login' to re-authenticate",
889                    failure,
890                )
891            }
892        })?;
893
894        if response.access_token.trim().is_empty() {
895            return Err(CredentialError::new(
896                "CliSsoTokenRefreshEmpty",
897                "SSO refresh token response did not include access token; please run 've sso login' to re-authenticate",
898            ));
899        }
900        if response.expires_in <= 0 {
901            return Err(CredentialError::new(
902                "CliSsoTokenRefreshExpiresIn",
903                "SSO refresh token response did not include expires_in; please run 've sso login' to re-authenticate",
904            ));
905        }
906
907        let cache = self.cached.as_mut().expect("SSO cache is loaded");
908        cache.access_token = response.access_token;
909        if !response.refresh_token.trim().is_empty() {
910            cache.refresh_token = response.refresh_token;
911        }
912        cache.expires_at = (Utc::now() + chrono::Duration::seconds(response.expires_in))
913            .to_rfc3339_opts(SecondsFormat::Secs, true);
914        Ok(())
915    }
916
917    fn fetch_role_credentials(&mut self) -> Result<()> {
918        let account_id = self.profile.account_id.trim();
919        if account_id.is_empty() {
920            return Err(CredentialError::new(
921                "CliConfigAccountIDMissing",
922                format!(
923                    "cli config profile {} in {} did not contain account-id",
924                    self.profile_name,
925                    self.config_path.display()
926                ),
927            ));
928        }
929        let role_name = self.profile.role_name.trim();
930        if role_name.is_empty() {
931            return Err(CredentialError::new(
932                "CliConfigRoleNameMissing",
933                format!(
934                    "cli config profile {} in {} did not contain role-name",
935                    self.profile_name,
936                    self.config_path.display()
937                ),
938            ));
939        }
940        let access_token = &self
941            .cached
942            .as_ref()
943            .expect("SSO cache is loaded")
944            .access_token;
945        let role_credentials = request_sso_role_credentials(
946            &self.region,
947            access_token,
948            account_id,
949            role_name,
950            self.endpoints.sso_portal.as_deref(),
951        )
952        .map_err(|failure| {
953            CredentialError::with_source(
954                "CliSsoPortalCredentials",
955                "failed to get SSO role credentials; please run 've sso login' to re-authenticate",
956                failure,
957            )
958        })?;
959
960        if role_credentials.access_key_id.trim().is_empty()
961            || role_credentials.secret_access_key.trim().is_empty()
962        {
963            return Err(CredentialError::new(
964                "CliSsoPortalCredentialsEmpty",
965                "SSO portal credentials response did not include access key or secret key",
966            ));
967        }
968
969        self.expires_at = (role_credentials.expiration > 0)
970            .then(|| unix_timestamp_to_datetime(role_credentials.expiration))
971            .transpose()?;
972        self.credentials = Some(CredentialValue::cli(
973            role_credentials.access_key_id,
974            role_credentials.secret_access_key,
975            role_credentials.session_token,
976        ));
977        Ok(())
978    }
979}
980
981fn load_sso_cache(path: &Path) -> Result<SsoTokenCache> {
982    let bytes = fs::read(path).map_err(|error| {
983        CredentialError::with_source(
984            "CliSsoTokenCacheLoad",
985            format!(
986                "failed to load sso token cache file {}; please run 've sso login' to re-authenticate",
987                path.display()
988            ),
989            error,
990        )
991    })?;
992    if bytes.iter().all(u8::is_ascii_whitespace) {
993        return Err(CredentialError::new(
994            "CliSsoTokenCacheEmpty",
995            format!(
996                "sso token cache file {} was empty; please run 've sso login' to re-authenticate",
997                path.display()
998            ),
999        ));
1000    }
1001    serde_json::from_slice(&bytes).map_err(|error| {
1002        CredentialError::with_source(
1003            "CliSsoTokenCacheUnmarshal",
1004            format!(
1005                "failed to unmarshal sso token cache file {}; please run 've sso login' to re-authenticate",
1006                path.display()
1007            ),
1008            error,
1009        )
1010    })
1011}
1012
1013#[derive(Debug, Serialize)]
1014struct SsoTokenRequest<'a> {
1015    grant_type: &'static str,
1016    client_id: &'a str,
1017    client_secret: &'a str,
1018    refresh_token: &'a str,
1019}
1020
1021#[derive(Debug, Deserialize)]
1022struct SsoTokenResponse {
1023    #[serde(default)]
1024    access_token: String,
1025    #[serde(default)]
1026    refresh_token: String,
1027    #[serde(default)]
1028    expires_in: i64,
1029}
1030
1031fn request_sso_token(
1032    region: &str,
1033    cache: &SsoTokenCache,
1034    endpoint_override: Option<&str>,
1035) -> std::result::Result<SsoTokenResponse, HttpFailure> {
1036    let base = endpoint_override
1037        .map(str::to_owned)
1038        .unwrap_or_else(|| format!("https://cloudidentity-oauth.{region}.volces.com"));
1039    let endpoint = format!("{}/token", base.trim_end_matches('/'));
1040    let client = build_http_client(Duration::from_secs(10))?;
1041    retry_http(3, || {
1042        let response = client
1043            .post(&endpoint)
1044            .json(&SsoTokenRequest {
1045                grant_type: "refresh_token",
1046                client_id: &cache.client_id,
1047                client_secret: &cache.client_secret,
1048                refresh_token: &cache.refresh_token,
1049            })
1050            .send()
1051            .map_err(HttpFailure::transport)?;
1052        parse_json_response(response, OAuthErrorShape::Standard)
1053    })
1054}
1055
1056#[derive(Debug, Deserialize)]
1057struct PortalRoleCredentials {
1058    #[serde(rename = "AccessKeyId", default)]
1059    access_key_id: String,
1060    #[serde(rename = "SecretAccessKey", default)]
1061    secret_access_key: String,
1062    #[serde(rename = "SessionToken", default)]
1063    session_token: String,
1064    #[serde(rename = "Expiration", default)]
1065    expiration: i64,
1066}
1067
1068fn request_sso_role_credentials(
1069    region: &str,
1070    access_token: &str,
1071    account_id: &str,
1072    role_name: &str,
1073    endpoint_override: Option<&str>,
1074) -> std::result::Result<PortalRoleCredentials, HttpFailure> {
1075    let base = endpoint_override
1076        .map(str::to_owned)
1077        .unwrap_or_else(|| format!("https://cloudidentity-portal.{region}.volces.com"));
1078    let endpoint = format!("{}/federation/credentials", base.trim_end_matches('/'));
1079    let client = build_http_client(Duration::from_secs(30))?;
1080    retry_http(3, || {
1081        let response = client
1082            .get(&endpoint)
1083            .query(&[("account_id", account_id), ("role_name", role_name)])
1084            .header(ACCEPT, "application/json")
1085            .header("x-bd-cloudidentity-bearer-token", access_token)
1086            .send()
1087            .map_err(HttpFailure::transport)?;
1088        let envelope: PortalEnvelope<PortalResult> =
1089            parse_json_response(response, OAuthErrorShape::Portal)?;
1090        if let Some(error) = envelope.response_metadata.error {
1091            return Err(HttpFailure::api(
1092                StatusCode::OK,
1093                Some(error.code),
1094                error.message,
1095                envelope.response_metadata.request_id,
1096            ));
1097        }
1098        envelope
1099            .result
1100            .map(|result| result.role_credentials)
1101            .ok_or_else(|| {
1102                HttpFailure::decode("GetRoleCredentials succeeded but response was empty")
1103            })
1104    })
1105}
1106
1107#[derive(Debug, Deserialize)]
1108struct PortalEnvelope<T> {
1109    #[serde(rename = "ResponseMetadata", default)]
1110    response_metadata: PortalMetadata,
1111    #[serde(rename = "Result")]
1112    result: Option<T>,
1113}
1114
1115#[derive(Debug, Deserialize, Default)]
1116struct PortalMetadata {
1117    #[serde(rename = "RequestId", default)]
1118    request_id: String,
1119    #[serde(rename = "Error", default)]
1120    error: Option<PortalError>,
1121}
1122
1123#[derive(Debug, Deserialize)]
1124struct PortalError {
1125    #[serde(rename = "Code", default)]
1126    code: String,
1127    #[serde(rename = "Message", default)]
1128    message: String,
1129}
1130
1131#[derive(Debug, Deserialize)]
1132struct PortalResult {
1133    #[serde(rename = "RoleCredentials")]
1134    role_credentials: PortalRoleCredentials,
1135}
1136
1137#[derive(Debug, Clone, Deserialize)]
1138struct LoginTokenCache {
1139    #[serde(default)]
1140    access_token: JsonValue,
1141    #[serde(default)]
1142    issued_at: String,
1143    #[serde(default)]
1144    expires_in: i64,
1145    #[serde(default)]
1146    token_type: String,
1147    #[serde(default)]
1148    refresh_token: String,
1149    #[serde(default)]
1150    client_id: String,
1151    #[serde(default)]
1152    scope: String,
1153    #[serde(default)]
1154    endpoint_url: String,
1155}
1156
1157struct ConsoleLoginProvider {
1158    login_session: String,
1159    cache_dir: PathBuf,
1160    cached: Option<LoginTokenCache>,
1161    credentials: Option<CredentialValue>,
1162    expires_at: Option<DateTime<Utc>>,
1163}
1164
1165impl ConsoleLoginProvider {
1166    fn new(login_session: String, cache_dir: PathBuf) -> Self {
1167        Self {
1168            login_session,
1169            cache_dir,
1170            cached: None,
1171            credentials: None,
1172            expires_at: None,
1173        }
1174    }
1175
1176    fn is_expired(&self) -> bool {
1177        self.credentials.is_none()
1178            || self
1179                .expires_at
1180                .is_none_or(|expiration| within_expiry_window(expiration, EXPIRY_WINDOW))
1181    }
1182
1183    fn get(&mut self) -> Result<CredentialValue> {
1184        if self.is_expired() {
1185            self.refresh()?;
1186        }
1187        self.credentials.clone().ok_or_else(|| {
1188            CredentialError::new(
1189                "CliConsoleLoginAccessTokenInvalid",
1190                "console-login provider did not produce credentials",
1191            )
1192        })
1193    }
1194
1195    fn refresh(&mut self) -> Result<()> {
1196        if self.cached.is_none() {
1197            self.cached = Some(self.load_cache()?);
1198        }
1199        if self.try_apply_cache()? {
1200            return Ok(());
1201        }
1202
1203        match self.refresh_access_token() {
1204            Ok(()) => Ok(()),
1205            Err(error) if !is_invalid_grant(&error) => Err(error),
1206            Err(first_error) => {
1207                let first_error_message = first_error.to_string();
1208                let disk_cache = self.load_cache().map_err(|disk_error| {
1209                    CredentialError::with_source(
1210                        "CliConsoleLoginRefreshTokenExpired",
1211                        format!(
1212                            "console-login refresh token rejected ({first_error_message}); failed to reload cache from disk: {disk_error}; please run 've login'"
1213                        ),
1214                        disk_error,
1215                    )
1216                })?;
1217                let current_refresh_token = self
1218                    .cached
1219                    .as_ref()
1220                    .map(|cache| cache.refresh_token.as_str())
1221                    .unwrap_or_default();
1222                if disk_cache.refresh_token.trim().is_empty()
1223                    || disk_cache.refresh_token == current_refresh_token
1224                {
1225                    return Err(CredentialError::with_source(
1226                        "CliConsoleLoginRefreshTokenExpired",
1227                        "console-login refresh token has been rejected by signin service; please run 've login' to re-authenticate",
1228                        first_error,
1229                    ));
1230                }
1231                self.cached = Some(disk_cache);
1232                if self.try_apply_cache()? {
1233                    return Ok(());
1234                }
1235                self.refresh_access_token().map_err(|error| {
1236                    CredentialError::with_source(
1237                        "CliConsoleLoginRefreshTokenExpired",
1238                        "console-login refresh token rejected; reloaded cache from disk but new refresh token also failed; please run 've login'",
1239                        error,
1240                    )
1241                })
1242            }
1243        }
1244    }
1245
1246    fn cache_path(&self) -> PathBuf {
1247        self.cache_dir
1248            .join(format!("{}.json", sha1_hex(self.login_session.as_bytes())))
1249    }
1250
1251    fn load_cache(&self) -> Result<LoginTokenCache> {
1252        let path = self.cache_path();
1253        let bytes = fs::read(&path).map_err(|error| {
1254            let code = if error.kind() == std::io::ErrorKind::NotFound {
1255                "CliConsoleLoginCacheMissing"
1256            } else {
1257                "CliConsoleLoginCacheLoad"
1258            };
1259            CredentialError::with_source(
1260                code,
1261                format!(
1262                    "failed to load console-login token cache file {}; please run 've login' first",
1263                    path.display()
1264                ),
1265                error,
1266            )
1267        })?;
1268        if bytes.iter().all(u8::is_ascii_whitespace) {
1269            return Err(CredentialError::new(
1270                "CliConsoleLoginCacheEmpty",
1271                format!(
1272                    "console-login token cache file {} was empty",
1273                    path.display()
1274                ),
1275            ));
1276        }
1277        serde_json::from_slice(&bytes).map_err(|error| {
1278            CredentialError::with_source(
1279                "CliConsoleLoginCacheUnmarshal",
1280                format!(
1281                    "failed to unmarshal console-login token cache file {}",
1282                    path.display()
1283                ),
1284                error,
1285            )
1286        })
1287    }
1288
1289    fn try_apply_cache(&mut self) -> Result<bool> {
1290        let Some(cache) = self.cached.as_ref() else {
1291            return Ok(false);
1292        };
1293        let Ok(expiration) = console_cache_expiration(cache) else {
1294            return Ok(false);
1295        };
1296        if within_expiry_window(expiration, EXPIRY_WINDOW) {
1297            return Ok(false);
1298        }
1299        let Ok(credentials) = parse_console_access_token(&cache.access_token) else {
1300            return Ok(false);
1301        };
1302        self.credentials = Some(credentials);
1303        self.expires_at = Some(expiration);
1304        Ok(true)
1305    }
1306
1307    fn refresh_access_token(&mut self) -> Result<()> {
1308        let cache = self.cached.as_ref().expect("console-login cache is loaded");
1309        if cache.refresh_token.trim().is_empty() {
1310            return Err(CredentialError::new(
1311                "CliConsoleLoginRefreshTokenMissing",
1312                format!(
1313                    "console-login cache for login_session {:?} does not contain refresh_token; please run 've login' first",
1314                    self.login_session
1315                ),
1316            ));
1317        }
1318        if cache.client_id.trim().is_empty() {
1319            return Err(CredentialError::new(
1320                "CliConsoleLoginClientIDMissing",
1321                format!(
1322                    "console-login cache for login_session {:?} does not contain client_id; please run 've login' to regenerate",
1323                    self.login_session
1324                ),
1325            ));
1326        }
1327
1328        let response = request_console_token(cache).map_err(|failure| {
1329            if failure.is_invalid_grant() {
1330                CredentialError::with_source(
1331                    "CliConsoleLoginInvalidGrant",
1332                    "console-login authorization server rejected the refresh token",
1333                    failure,
1334                )
1335            } else {
1336                CredentialError::with_source(
1337                    "CliConsoleLoginRefreshTokenFailed",
1338                    "failed to refresh console-login access token; please run 've login' to re-authenticate",
1339                    failure,
1340                )
1341            }
1342        })?;
1343
1344        if response.access_token.trim().is_empty() || response.expires_in <= 0 {
1345            return Err(CredentialError::new(
1346                "CliConsoleLoginAccessTokenInvalid",
1347                "console-login refresh response did not include a valid access_token and expires_in",
1348            ));
1349        }
1350        let access_token = serde_json::from_str(&response.access_token).map_err(|error| {
1351            CredentialError::with_source(
1352                "CliConsoleLoginAccessTokenInvalid",
1353                "console-login refresh succeeded but the new access_token could not be parsed into STS credentials",
1354                error,
1355            )
1356        })?;
1357
1358        let cache = self.cached.as_mut().expect("console-login cache is loaded");
1359        cache.access_token = access_token;
1360        if !response.refresh_token.trim().is_empty() {
1361            cache.refresh_token = response.refresh_token;
1362        }
1363        cache.issued_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
1364        cache.expires_in = response.expires_in;
1365        if !response.token_type.trim().is_empty() {
1366            cache.token_type = response.token_type;
1367        }
1368
1369        if !self.try_apply_cache()? {
1370            return Err(CredentialError::new(
1371                "CliConsoleLoginAccessTokenInvalid",
1372                "console-login refresh succeeded but the new access_token could not be parsed into STS credentials",
1373            ));
1374        }
1375        Ok(())
1376    }
1377}
1378
1379fn console_cache_expiration(cache: &LoginTokenCache) -> Result<DateTime<Utc>> {
1380    if cache.expires_in <= 0 {
1381        return Err(CredentialError::new(
1382            "CliConsoleLoginTokenExpiration",
1383            "expires_in is missing or invalid",
1384        ));
1385    }
1386    Ok(parse_rfc3339(&cache.issued_at)? + chrono::Duration::seconds(cache.expires_in))
1387}
1388
1389fn parse_console_access_token(value: &JsonValue) -> Result<CredentialValue> {
1390    let parsed = match value {
1391        JsonValue::String(encoded) => serde_json::from_str(encoded).map_err(|error| {
1392            CredentialError::with_source(
1393                "CliConsoleLoginAccessTokenParse",
1394                "failed to parse console-login access_token",
1395                error,
1396            )
1397        })?,
1398        value => value.clone(),
1399    };
1400    let object = parsed.as_object().ok_or_else(|| {
1401        CredentialError::new(
1402            "CliConsoleLoginAccessTokenParse",
1403            "console-login access_token is not an object",
1404        )
1405    })?;
1406    let field = |name: &str| {
1407        object
1408            .get(name)
1409            .and_then(JsonValue::as_str)
1410            .map(str::trim)
1411            .filter(|value| !value.is_empty())
1412            .map(str::to_owned)
1413            .ok_or_else(|| {
1414                CredentialError::new(
1415                    "CliConsoleLoginAccessTokenParse",
1416                    format!("parsed STS credentials missing {name}"),
1417                )
1418            })
1419    };
1420    Ok(CredentialValue::cli(
1421        field("access_key_id")?,
1422        field("secret_access_key")?,
1423        field("session_token")?,
1424    ))
1425}
1426
1427#[derive(Debug, Deserialize)]
1428struct ConsoleTokenResponse {
1429    #[serde(default)]
1430    access_token: String,
1431    #[serde(default)]
1432    token_type: String,
1433    #[serde(default)]
1434    expires_in: i64,
1435    #[serde(default)]
1436    refresh_token: String,
1437}
1438
1439fn request_console_token(
1440    cache: &LoginTokenCache,
1441) -> std::result::Result<ConsoleTokenResponse, HttpFailure> {
1442    let base = if cache.endpoint_url.trim().is_empty() {
1443        DEFAULT_CONSOLE_LOGIN_ENDPOINT
1444    } else {
1445        cache.endpoint_url.trim()
1446    };
1447    let endpoint = format!("{}/authorize/oauth/token", base.trim_end_matches('/'));
1448    let client = build_http_client(Duration::from_secs(30))?;
1449    retry_http(3, || {
1450        let mut form = vec![
1451            ("grant_type", "refresh_token"),
1452            ("client_id", cache.client_id.as_str()),
1453            ("refresh_token", cache.refresh_token.as_str()),
1454        ];
1455        if !cache.scope.trim().is_empty() {
1456            form.push(("scope", cache.scope.as_str()));
1457        }
1458        let response = client
1459            .post(&endpoint)
1460            .form(&form)
1461            .send()
1462            .map_err(HttpFailure::transport)?;
1463        parse_json_response(response, OAuthErrorShape::Standard)
1464    })
1465}
1466
1467struct IamRoleProvider {
1468    profile: CliProfile,
1469    endpoints: EndpointOverrides,
1470    credentials: Option<CredentialValue>,
1471    expires_at: Option<DateTime<Utc>>,
1472}
1473
1474impl IamRoleProvider {
1475    fn new(profile: CliProfile, endpoints: EndpointOverrides) -> Self {
1476        Self {
1477            profile,
1478            endpoints,
1479            credentials: None,
1480            expires_at: None,
1481        }
1482    }
1483
1484    fn is_expired(&self) -> bool {
1485        self.credentials.is_none()
1486            || self
1487                .expires_at
1488                .is_none_or(|expiration| Utc::now() > expiration)
1489    }
1490
1491    fn get(&mut self) -> Result<CredentialValue> {
1492        if self.is_expired() {
1493            self.refresh()?;
1494        }
1495        self.credentials.clone().ok_or_else(|| {
1496            CredentialError::new(
1497                "StsProviderEmpty",
1498                "STS AssumeRole returned empty credentials",
1499            )
1500        })
1501    }
1502
1503    fn refresh(&mut self) -> Result<()> {
1504        let duration_seconds = 3600_i64;
1505        let region = match self.profile.region.trim() {
1506            "" => DEFAULT_REGION,
1507            value => value,
1508        };
1509        let base = self.endpoints.sts.clone().unwrap_or_else(|| {
1510            format!(
1511                "{}://{DEFAULT_STS_HOST}",
1512                if self.profile.disable_ssl {
1513                    "http"
1514                } else {
1515                    "https"
1516                }
1517            )
1518        });
1519        let role_trn = format!(
1520            "trn:iam::{}:role/{}",
1521            self.profile.account_id, self.profile.role_name
1522        );
1523        let session_name = Uuid::new_v4().to_string();
1524
1525        let response = request_assume_role(
1526            &base,
1527            region,
1528            &self.profile.access_key,
1529            &self.profile.secret_key,
1530            &self.profile.session_token,
1531            &role_trn,
1532            &session_name,
1533            duration_seconds,
1534        )
1535        .map_err(|failure| {
1536            CredentialError::with_source(
1537                "StsProviderAssumeRole",
1538                "failed to retrieve credentials with STS AssumeRole",
1539                failure,
1540            )
1541        })?;
1542
1543        self.credentials = Some(CredentialValue::named(
1544            response.access_key_id,
1545            response.secret_access_key,
1546            response.session_token,
1547            "StsProvider",
1548        ));
1549        self.expires_at = Some(Utc::now() + chrono::Duration::seconds(duration_seconds - 60));
1550        Ok(())
1551    }
1552}
1553
1554struct OidcProvider {
1555    profile: CliProfile,
1556    endpoints: EndpointOverrides,
1557    credentials: Option<CredentialValue>,
1558    expires_at: Option<DateTime<Utc>>,
1559}
1560
1561impl OidcProvider {
1562    fn new(profile: CliProfile, endpoints: EndpointOverrides) -> Self {
1563        Self {
1564            profile,
1565            endpoints,
1566            credentials: None,
1567            expires_at: None,
1568        }
1569    }
1570
1571    fn is_expired(&self) -> bool {
1572        self.credentials.is_none()
1573            || self
1574                .expires_at
1575                .is_none_or(|expiration| Utc::now() > expiration)
1576    }
1577
1578    fn get(&mut self) -> Result<CredentialValue> {
1579        if self.is_expired() {
1580            self.refresh()?;
1581        }
1582        self.credentials.clone().ok_or_else(|| {
1583            CredentialError::new(
1584                "OIDCCredentialsEmpty",
1585                "OIDC provider returned empty credentials",
1586            )
1587        })
1588    }
1589
1590    fn refresh(&mut self) -> Result<()> {
1591        let oidc_token =
1592            fs::read_to_string(self.profile.oidc_token_file.trim()).map_err(|error| {
1593                CredentialError::with_source(
1594                    "OIDCTokenFileLoad",
1595                    format!(
1596                        "failed to read OIDC token file {}",
1597                        self.profile.oidc_token_file.trim()
1598                    ),
1599                    error,
1600                )
1601            })?;
1602        let base = self.endpoints.sts.clone().unwrap_or_else(|| {
1603            format!(
1604                "{}://{DEFAULT_STS_HOST}",
1605                if self.profile.disable_ssl {
1606                    "http"
1607                } else {
1608                    "https"
1609                }
1610            )
1611        });
1612        let response = request_assume_role_with_oidc(
1613            &base,
1614            self.profile.role_trn.trim(),
1615            &oidc_token,
1616            "volcengine-go-sdk-oidc-session",
1617            3660,
1618        )
1619        .map_err(|failure| {
1620            CredentialError::with_source(
1621                "OIDCAssumeRole",
1622                "failed to retrieve credentials with STS AssumeRoleWithOIDC",
1623                failure,
1624            )
1625        })?;
1626        let expiration = parse_rfc3339(&response.expiration)?;
1627        self.credentials = Some(CredentialValue::named(
1628            response.access_key_id,
1629            response.secret_access_key,
1630            response.session_token,
1631            "OIDC",
1632        ));
1633        self.expires_at = expiration.checked_sub_signed(chrono::Duration::seconds(60));
1634        Ok(())
1635    }
1636}
1637
1638struct EcsRoleProvider {
1639    role_name: String,
1640    endpoints: EndpointOverrides,
1641    credentials: Option<CredentialValue>,
1642    expires_at: Option<DateTime<Utc>>,
1643}
1644
1645impl EcsRoleProvider {
1646    fn new(role_name: String, endpoints: EndpointOverrides) -> Self {
1647        Self {
1648            role_name,
1649            endpoints,
1650            credentials: None,
1651            expires_at: None,
1652        }
1653    }
1654
1655    fn is_expired(&self) -> bool {
1656        self.credentials.is_none()
1657            || self.expires_at.is_none_or(|expiration| {
1658                within_expiry_window(expiration, Duration::from_secs(5 * 60))
1659            })
1660    }
1661
1662    fn get(&mut self) -> Result<CredentialValue> {
1663        if self.is_expired() {
1664            self.refresh()?;
1665        }
1666        self.credentials.clone().ok_or_else(|| {
1667            CredentialError::new(
1668                "EcsRoleCredentialsEmpty",
1669                "ECS role provider returned empty credentials",
1670            )
1671        })
1672    }
1673
1674    fn refresh(&mut self) -> Result<()> {
1675        if nonempty_env("VOLCENGINE_ECS_METADATA_DISABLED")
1676            .is_some_and(|value| value.eq_ignore_ascii_case("true"))
1677        {
1678            return Err(CredentialError::new(
1679                "EcsRoleDisabled",
1680                "IMDS credential retrieval is disabled via VOLCENGINE_ECS_METADATA_DISABLED=true",
1681            ));
1682        }
1683
1684        let base = self
1685            .endpoints
1686            .imds
1687            .as_deref()
1688            .unwrap_or(DEFAULT_IMDS_ENDPOINT)
1689            .trim_end_matches('/');
1690        let client = build_http_client(Duration::from_secs(2)).map_err(|failure| {
1691            CredentialError::with_source(
1692                "EcsRoleClient",
1693                "failed to construct IMDS client",
1694                failure,
1695            )
1696        })?;
1697        let token_endpoint = format!("{base}/latest/api/token");
1698        let token = retry_imds(|| {
1699            let response = client
1700                .put(&token_endpoint)
1701                .header("X-volc-ecs-metadata-token-ttl-seconds", "21600")
1702                .send()
1703                .map_err(HttpFailure::transport)?;
1704            parse_text_response(response)
1705        })
1706        .map_err(|failure| {
1707            CredentialError::with_source(
1708                "EcsRoleTokenFailed",
1709                "failed to get IMDSv2 token",
1710                failure,
1711            )
1712        })?;
1713        let token = token.trim();
1714        if token.is_empty() {
1715            return Err(CredentialError::new(
1716                "EcsRoleTokenEmpty",
1717                "IMDSv2 token endpoint returned empty response",
1718            ));
1719        }
1720
1721        let role_name = if !self.role_name.is_empty() {
1722            self.role_name.clone()
1723        } else if let Some(role_name) = nonempty_env("VOLCENGINE_ECS_METADATA") {
1724            role_name
1725        } else {
1726            let endpoint =
1727                format!("{base}/volcstack/latest/iam/security_credentials?type=user&format=json");
1728            let body = retry_imds(|| {
1729                let response = client
1730                    .get(&endpoint)
1731                    .header("X-volc-ecs-metadata-token", token)
1732                    .send()
1733                    .map_err(HttpFailure::transport)?;
1734                parse_text_response(response)
1735            })
1736            .map_err(|failure| {
1737                CredentialError::with_source(
1738                    "EcsRoleDetectFailed",
1739                    "failed to auto-detect role name from IMDS",
1740                    failure,
1741                )
1742            })?;
1743            parse_imds_role_names(&body)?
1744                .into_iter()
1745                .next()
1746                .ok_or_else(|| {
1747                    CredentialError::new("EcsRoleNotFound", "no IAM roles found via IMDS")
1748                })?
1749        };
1750
1751        let endpoint = format!(
1752            "{base}/volcstack/latest/iam/security_credentials/{}",
1753            percent_encode(&role_name, false)
1754        );
1755        let response: ImdsCredentialResponse = retry_imds(|| {
1756            let response = client
1757                .get(&endpoint)
1758                .header("X-volc-ecs-metadata-token", token)
1759                .send()
1760                .map_err(HttpFailure::transport)?;
1761            parse_json_response(response, OAuthErrorShape::None)
1762        })
1763        .map_err(|failure| {
1764            CredentialError::with_source(
1765                "EcsRoleCredentialsFailed",
1766                format!("failed to get credentials for role {role_name} from IMDS"),
1767                failure,
1768            )
1769        })?;
1770        if response.access_key_id.is_empty() || response.secret_access_key.is_empty() {
1771            return Err(CredentialError::new(
1772                "EcsRoleCredentialsEmpty",
1773                "IMDS returned empty credentials",
1774            ));
1775        }
1776
1777        self.expires_at = Some(parse_rfc3339(&response.expiration).map_err(|error| {
1778            CredentialError::with_source(
1779                "EcsRoleExpirationParse",
1780                format!(
1781                    "failed to parse credential expiration: {}",
1782                    response.expiration
1783                ),
1784                error,
1785            )
1786        })?);
1787        self.credentials = Some(CredentialValue::named(
1788            response.access_key_id,
1789            response.secret_access_key,
1790            response.session_token,
1791            "EcsRoleProvider",
1792        ));
1793        Ok(())
1794    }
1795}
1796
1797#[derive(Debug, Deserialize)]
1798struct ImdsCredentialResponse {
1799    #[serde(rename = "AccessKeyId", default)]
1800    access_key_id: String,
1801    #[serde(rename = "SecretAccessKey", default)]
1802    secret_access_key: String,
1803    #[serde(rename = "SessionToken", default)]
1804    session_token: String,
1805    #[serde(rename = "ExpiredTime", default)]
1806    expiration: String,
1807}
1808
1809fn parse_imds_role_names(body: &str) -> Result<Vec<String>> {
1810    if let Ok(roles) = serde_json::from_str::<Vec<String>>(body) {
1811        return Ok(roles
1812            .into_iter()
1813            .map(|role| role.trim().to_owned())
1814            .filter(|role| !role.is_empty())
1815            .collect());
1816    }
1817    Ok(body
1818        .lines()
1819        .map(str::trim)
1820        .filter(|role| !role.is_empty())
1821        .map(str::to_owned)
1822        .collect())
1823}
1824
1825#[derive(Debug, Deserialize)]
1826struct StsEnvelope {
1827    #[serde(rename = "ResponseMetadata", default)]
1828    response_metadata: StsMetadata,
1829    #[serde(rename = "Result", default)]
1830    result: Option<StsResult>,
1831}
1832
1833#[derive(Debug, Deserialize, Default)]
1834struct StsMetadata {
1835    #[serde(rename = "RequestId", default)]
1836    request_id: String,
1837    #[serde(rename = "Error", default)]
1838    error: Option<StsError>,
1839}
1840
1841#[derive(Debug, Deserialize)]
1842struct StsError {
1843    #[serde(rename = "Code", default)]
1844    code: String,
1845    #[serde(rename = "Message", default)]
1846    message: String,
1847}
1848
1849#[derive(Debug, Deserialize)]
1850struct StsResult {
1851    #[serde(rename = "Credentials")]
1852    credentials: StsCredentials,
1853}
1854
1855#[derive(Debug, Deserialize)]
1856struct StsCredentials {
1857    #[serde(rename = "AccessKeyId", default)]
1858    access_key_id: String,
1859    #[serde(rename = "SecretAccessKey", default)]
1860    secret_access_key: String,
1861    #[serde(rename = "SessionToken", default)]
1862    session_token: String,
1863    #[serde(rename = "Expiration", alias = "ExpiredTime", default)]
1864    expiration: String,
1865}
1866
1867#[allow(clippy::too_many_arguments)]
1868fn request_assume_role(
1869    base: &str,
1870    region: &str,
1871    access_key: &str,
1872    secret_key: &str,
1873    session_token: &str,
1874    role_trn: &str,
1875    role_session_name: &str,
1876    duration_seconds: i64,
1877) -> std::result::Result<StsCredentials, HttpFailure> {
1878    let query = BTreeMap::from([
1879        ("Action", "AssumeRole".to_owned()),
1880        ("DurationSeconds", duration_seconds.to_string()),
1881        ("RoleSessionName", role_session_name.to_owned()),
1882        ("RoleTrn", role_trn.to_owned()),
1883        ("Version", "2018-01-01".to_owned()),
1884    ]);
1885    let client = build_http_client(Duration::from_secs(5))?;
1886    retry_sts(|| {
1887        let signed = sign_query_request(
1888            base,
1889            &query,
1890            access_key,
1891            secret_key,
1892            session_token,
1893            region,
1894            "sts",
1895        );
1896        let response = client
1897            .get(&signed.url)
1898            .headers(signed.headers)
1899            .send()
1900            .map_err(HttpFailure::transport)?;
1901        parse_sts_response(response)
1902    })
1903}
1904
1905fn request_assume_role_with_oidc(
1906    base: &str,
1907    role_trn: &str,
1908    oidc_token: &str,
1909    role_session_name: &str,
1910    duration_seconds: i64,
1911) -> std::result::Result<StsCredentials, HttpFailure> {
1912    let endpoint = format!(
1913        "{}/?Action=AssumeRoleWithOIDC&Version=2018-01-01",
1914        base.trim_end_matches('/')
1915    );
1916    let form = BTreeMap::from([
1917        ("DurationSeconds", duration_seconds.to_string()),
1918        ("OIDCToken", oidc_token.to_owned()),
1919        ("RoleSessionName", role_session_name.to_owned()),
1920        ("RoleTrn", role_trn.to_owned()),
1921    ]);
1922    let client = build_http_client(Duration::from_secs(10))?;
1923    retry_sts(|| {
1924        let response = client
1925            .post(&endpoint)
1926            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
1927            .body(form_encode(&form, true))
1928            .send()
1929            .map_err(HttpFailure::transport)?;
1930        parse_sts_response(response)
1931    })
1932}
1933
1934fn parse_sts_response(response: Response) -> std::result::Result<StsCredentials, HttpFailure> {
1935    let status = response.status();
1936    let request_id = response
1937        .headers()
1938        .get("x-tt-logid")
1939        .and_then(|value| value.to_str().ok())
1940        .unwrap_or_default()
1941        .to_owned();
1942    let body = response.bytes().map_err(HttpFailure::transport)?;
1943    let envelope = serde_json::from_slice::<StsEnvelope>(&body).map_err(|error| {
1944        if status.is_success() {
1945            HttpFailure::decode(format!("failed to decode STS response: {error}"))
1946        } else {
1947            HttpFailure::api(
1948                status,
1949                None,
1950                String::from_utf8_lossy(&body),
1951                request_id.clone(),
1952            )
1953        }
1954    })?;
1955    if !status.is_success() {
1956        let (code, message) = envelope
1957            .response_metadata
1958            .error
1959            .map(|error| (Some(error.code), error.message))
1960            .unwrap_or_else(|| (None, String::from_utf8_lossy(&body).into_owned()));
1961        return Err(HttpFailure::api(
1962            status,
1963            code,
1964            message,
1965            envelope.response_metadata.request_id,
1966        ));
1967    }
1968    if let Some(error) = envelope.response_metadata.error {
1969        return Err(HttpFailure::api(
1970            status,
1971            Some(error.code),
1972            error.message,
1973            envelope.response_metadata.request_id,
1974        ));
1975    }
1976    envelope
1977        .result
1978        .map(|result| result.credentials)
1979        .ok_or_else(|| HttpFailure::decode("STS response did not contain credentials"))
1980}
1981
1982struct SignedRequest {
1983    url: String,
1984    headers: HeaderMap,
1985}
1986
1987fn sign_query_request(
1988    base: &str,
1989    query: &BTreeMap<&str, String>,
1990    access_key: &str,
1991    secret_key: &str,
1992    session_token: &str,
1993    region: &str,
1994    service: &str,
1995) -> SignedRequest {
1996    sign_query_request_at(
1997        base,
1998        query,
1999        access_key,
2000        secret_key,
2001        session_token,
2002        region,
2003        service,
2004        Utc::now(),
2005    )
2006}
2007
2008#[allow(clippy::too_many_arguments)]
2009fn sign_query_request_at(
2010    base: &str,
2011    query: &BTreeMap<&str, String>,
2012    access_key: &str,
2013    secret_key: &str,
2014    session_token: &str,
2015    region: &str,
2016    service: &str,
2017    now: DateTime<Utc>,
2018) -> SignedRequest {
2019    let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
2020    let date = &timestamp[..8];
2021    let body_hash = sha256_hex(b"");
2022    let host = base
2023        .split_once("://")
2024        .map(|(_, host)| host.trim_end_matches('/'))
2025        .unwrap_or(base)
2026        .to_owned();
2027    let normalized_host = normalize_host(&host);
2028
2029    let mut signed_headers = BTreeMap::from([
2030        (
2031            "content-type",
2032            "application/x-www-form-urlencoded; charset=utf-8".to_owned(),
2033        ),
2034        ("host", normalized_host),
2035        ("x-content-sha256", body_hash.clone()),
2036        ("x-date", timestamp.clone()),
2037    ]);
2038    if !session_token.is_empty() {
2039        signed_headers.insert("x-security-token", session_token.to_owned());
2040    }
2041    let canonical_headers = signed_headers
2042        .iter()
2043        .map(|(name, value)| format!("{name}:{}\n", value.trim()))
2044        .collect::<String>();
2045    let signed_header_names = signed_headers.keys().copied().collect::<Vec<_>>().join(";");
2046    let canonical_query = form_encode(query, false);
2047    let canonical_request = format!(
2048        "GET\n/\n{canonical_query}\n{canonical_headers}\n{signed_header_names}\n{body_hash}"
2049    );
2050    let credential_scope = format!("{date}/{region}/{service}/request");
2051    let string_to_sign = format!(
2052        "HMAC-SHA256\n{timestamp}\n{credential_scope}\n{}",
2053        sha256_hex(canonical_request.as_bytes())
2054    );
2055    let signing_key = signing_key(secret_key, date, region, service);
2056    let signature = hmac_sha256_hex(&signing_key, string_to_sign.as_bytes());
2057    let authorization = format!(
2058        "HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders={signed_header_names}, Signature={signature}"
2059    );
2060
2061    let mut headers = HeaderMap::new();
2062    headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
2063    headers.insert(
2064        CONTENT_TYPE,
2065        HeaderValue::from_static("application/x-www-form-urlencoded; charset=utf-8"),
2066    );
2067    headers.insert(
2068        HeaderName::from_static("x-date"),
2069        HeaderValue::from_str(&timestamp).expect("timestamp is a valid header"),
2070    );
2071    headers.insert(
2072        HeaderName::from_static("x-content-sha256"),
2073        HeaderValue::from_str(&body_hash).expect("hash is a valid header"),
2074    );
2075    if !session_token.is_empty() {
2076        headers.insert(
2077            HeaderName::from_static("x-security-token"),
2078            HeaderValue::from_str(session_token).expect("session token is a valid header"),
2079        );
2080    }
2081    headers.insert(
2082        AUTHORIZATION,
2083        HeaderValue::from_str(&authorization).expect("authorization is a valid header"),
2084    );
2085
2086    SignedRequest {
2087        url: format!(
2088            "{}/?{}",
2089            base.trim_end_matches('/'),
2090            form_encode(query, true)
2091        ),
2092        headers,
2093    }
2094}
2095
2096fn normalize_host(host: &str) -> String {
2097    let Some((name, port)) = host.rsplit_once(':') else {
2098        return host.to_owned();
2099    };
2100    if port == "80" || port == "443" {
2101        name.to_owned()
2102    } else {
2103        host.to_owned()
2104    }
2105}
2106
2107fn signing_key(secret_key: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
2108    let date_key = hmac_sha256(secret_key.as_bytes(), date.as_bytes());
2109    let region_key = hmac_sha256(&date_key, region.as_bytes());
2110    let service_key = hmac_sha256(&region_key, service.as_bytes());
2111    hmac_sha256(&service_key, b"request")
2112}
2113
2114fn hmac_sha256(key: &[u8], content: &[u8]) -> Vec<u8> {
2115    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts keys of any size");
2116    mac.update(content);
2117    mac.finalize().into_bytes().to_vec()
2118}
2119
2120fn hmac_sha256_hex(key: &[u8], content: &[u8]) -> String {
2121    hex::encode(hmac_sha256(key, content))
2122}
2123
2124fn sha256_hex(content: &[u8]) -> String {
2125    hex::encode(Sha256::digest(content))
2126}
2127
2128#[derive(Debug, Clone, Copy)]
2129enum OAuthErrorShape {
2130    None,
2131    Standard,
2132    Portal,
2133}
2134
2135#[derive(Debug)]
2136struct HttpFailure {
2137    status: Option<StatusCode>,
2138    error_code: Option<String>,
2139    message: String,
2140    request_id: String,
2141    transport: Option<reqwest::Error>,
2142}
2143
2144impl HttpFailure {
2145    fn transport(error: reqwest::Error) -> Self {
2146        Self {
2147            status: error.status(),
2148            error_code: None,
2149            message: error.to_string(),
2150            request_id: String::new(),
2151            transport: Some(error),
2152        }
2153    }
2154
2155    fn api(
2156        status: StatusCode,
2157        error_code: Option<String>,
2158        message: impl Into<String>,
2159        request_id: impl Into<String>,
2160    ) -> Self {
2161        Self {
2162            status: Some(status),
2163            error_code,
2164            message: message.into(),
2165            request_id: request_id.into(),
2166            transport: None,
2167        }
2168    }
2169
2170    fn decode(message: impl Into<String>) -> Self {
2171        Self {
2172            status: None,
2173            error_code: None,
2174            message: message.into(),
2175            request_id: String::new(),
2176            transport: None,
2177        }
2178    }
2179
2180    fn is_invalid_grant(&self) -> bool {
2181        self.status == Some(StatusCode::BAD_REQUEST)
2182            && self
2183                .error_code
2184                .as_deref()
2185                .is_some_and(|code| code.eq_ignore_ascii_case("invalid_grant"))
2186    }
2187
2188    fn is_general_retryable(&self) -> bool {
2189        if let Some(error) = &self.transport {
2190            return !error.is_timeout();
2191        }
2192        self.status.is_some_and(|status| {
2193            status == StatusCode::TOO_MANY_REQUESTS
2194                || status == StatusCode::REQUEST_TIMEOUT
2195                || status.is_server_error()
2196        })
2197    }
2198
2199    fn is_sts_retryable(&self) -> bool {
2200        if self.transport.is_some() {
2201            return true;
2202        }
2203        if self.status.is_some_and(|status| {
2204            matches!(
2205                status,
2206                StatusCode::TOO_MANY_REQUESTS
2207                    | StatusCode::BAD_GATEWAY
2208                    | StatusCode::SERVICE_UNAVAILABLE
2209                    | StatusCode::GATEWAY_TIMEOUT
2210            )
2211        }) {
2212            return true;
2213        }
2214        self.error_code.as_deref().is_some_and(|code| {
2215            matches!(
2216                code,
2217                "ProvisionedThroughputExceededException"
2218                    | "Throttling"
2219                    | "ThrottlingException"
2220                    | "RequestLimitExceeded"
2221                    | "RequestThrottled"
2222                    | "RequestThrottledException"
2223                    | "TooManyRequestsException"
2224                    | "PriorRequestNotComplete"
2225                    | "TransactionInProgressException"
2226                    | "RequestError"
2227                    | "RequestTimeout"
2228                    | "ResponseTimeout"
2229                    | "RequestTimeoutException"
2230            )
2231        })
2232    }
2233}
2234
2235impl fmt::Display for HttpFailure {
2236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2237        write!(f, "HTTP request failed")?;
2238        if let Some(status) = self.status {
2239            write!(f, " with status {}", status.as_u16())?;
2240        }
2241        if let Some(code) = &self.error_code {
2242            write!(f, ": {code}")?;
2243        }
2244        if !self.message.is_empty() {
2245            write!(f, ": {}", self.message)?;
2246        }
2247        if !self.request_id.is_empty() {
2248            write!(f, " (requestId: {})", self.request_id)?;
2249        }
2250        Ok(())
2251    }
2252}
2253
2254impl StdError for HttpFailure {
2255    fn source(&self) -> Option<&(dyn StdError + 'static)> {
2256        self.transport
2257            .as_ref()
2258            .map(|error| error as &(dyn StdError + 'static))
2259    }
2260}
2261
2262fn build_http_client(timeout: Duration) -> std::result::Result<Client, HttpFailure> {
2263    Client::builder()
2264        .timeout(timeout)
2265        .build()
2266        .map_err(HttpFailure::transport)
2267}
2268
2269fn retry_http<T>(
2270    attempts: usize,
2271    mut operation: impl FnMut() -> std::result::Result<T, HttpFailure>,
2272) -> std::result::Result<T, HttpFailure> {
2273    let attempts = attempts.max(1);
2274    for attempt in 0..attempts {
2275        match operation() {
2276            Ok(value) => return Ok(value),
2277            Err(error) if attempt + 1 == attempts || !error.is_general_retryable() => {
2278                return Err(error);
2279            }
2280            Err(_) => {
2281                let base_ms = (200_u64.saturating_mul(1_u64 << attempt)).min(2_000);
2282                let jitter_ms = rand::rng().random_range(0..100_u64);
2283                thread::sleep(Duration::from_millis(base_ms + jitter_ms));
2284            }
2285        }
2286    }
2287    unreachable!("retry loop always returns")
2288}
2289
2290fn retry_sts<T>(
2291    mut operation: impl FnMut() -> std::result::Result<T, HttpFailure>,
2292) -> std::result::Result<T, HttpFailure> {
2293    for attempt in 0..=DEFAULT_RETRIES {
2294        match operation() {
2295            Ok(value) => return Ok(value),
2296            Err(error) if attempt == DEFAULT_RETRIES || !error.is_sts_retryable() => {
2297                return Err(error);
2298            }
2299            Err(_) => thread::sleep(Duration::from_secs(1)),
2300        }
2301    }
2302    unreachable!("retry loop always returns")
2303}
2304
2305fn retry_imds<T>(
2306    mut operation: impl FnMut() -> std::result::Result<T, HttpFailure>,
2307) -> std::result::Result<T, HttpFailure> {
2308    for attempt in 0..=DEFAULT_RETRIES {
2309        match operation() {
2310            Ok(value) => return Ok(value),
2311            Err(error) if attempt == DEFAULT_RETRIES => return Err(error),
2312            Err(_) => thread::sleep(Duration::from_secs(1)),
2313        }
2314    }
2315    unreachable!("retry loop always returns")
2316}
2317
2318fn parse_json_response<T: for<'de> Deserialize<'de>>(
2319    response: Response,
2320    error_shape: OAuthErrorShape,
2321) -> std::result::Result<T, HttpFailure> {
2322    let status = response.status();
2323    let request_id = response
2324        .headers()
2325        .get("x-tt-logid")
2326        .and_then(|value| value.to_str().ok())
2327        .unwrap_or_default()
2328        .to_owned();
2329    let body = response.bytes().map_err(HttpFailure::transport)?;
2330
2331    if !status.is_success() {
2332        let (error_code, message, metadata_request_id) = match error_shape {
2333            OAuthErrorShape::Standard => {
2334                #[derive(Deserialize, Default)]
2335                struct StandardError {
2336                    #[serde(default)]
2337                    error: String,
2338                    #[serde(default)]
2339                    error_description: String,
2340                }
2341                serde_json::from_slice::<StandardError>(&body)
2342                    .map(|error| {
2343                        (
2344                            (!error.error.is_empty()).then_some(error.error),
2345                            error.error_description,
2346                            String::new(),
2347                        )
2348                    })
2349                    .unwrap_or_default()
2350            }
2351            OAuthErrorShape::Portal => serde_json::from_slice::<PortalEnvelope<JsonValue>>(&body)
2352                .ok()
2353                .and_then(|envelope| {
2354                    envelope.response_metadata.error.map(|error| {
2355                        (
2356                            (!error.code.is_empty()).then_some(error.code),
2357                            error.message,
2358                            envelope.response_metadata.request_id,
2359                        )
2360                    })
2361                })
2362                .unwrap_or_default(),
2363            OAuthErrorShape::None => (None, String::new(), String::new()),
2364        };
2365        return Err(HttpFailure::api(
2366            status,
2367            error_code,
2368            if message.is_empty() {
2369                String::from_utf8_lossy(&body).into_owned()
2370            } else {
2371                message
2372            },
2373            if metadata_request_id.is_empty() {
2374                request_id
2375            } else {
2376                metadata_request_id
2377            },
2378        ));
2379    }
2380
2381    serde_json::from_slice(&body)
2382        .map_err(|error| HttpFailure::decode(format!("failed to decode response: {error}")))
2383}
2384
2385fn parse_text_response(response: Response) -> std::result::Result<String, HttpFailure> {
2386    let status = response.status();
2387    let body = response.text().map_err(HttpFailure::transport)?;
2388    if status != StatusCode::OK {
2389        return Err(HttpFailure::api(status, None, body, ""));
2390    }
2391    Ok(body)
2392}
2393
2394fn is_invalid_grant(error: &CredentialError) -> bool {
2395    let mut current: Option<&(dyn StdError + 'static)> = Some(error);
2396    while let Some(item) = current {
2397        if let Some(failure) = item.downcast_ref::<HttpFailure>() {
2398            return failure.is_invalid_grant();
2399        }
2400        current = item.source();
2401    }
2402    false
2403}
2404
2405fn parse_rfc3339(value: &str) -> Result<DateTime<Utc>> {
2406    let value = value.trim();
2407    if value.is_empty() {
2408        return Err(CredentialError::new(
2409            "CliTokenExpirationParse",
2410            "expires_at is empty",
2411        ));
2412    }
2413    DateTime::parse_from_rfc3339(value)
2414        .map(|value| value.with_timezone(&Utc))
2415        .map_err(|error| {
2416            CredentialError::with_source(
2417                "CliTokenExpirationParse",
2418                format!("expires_at is invalid: {value}"),
2419                error,
2420            )
2421        })
2422}
2423
2424fn within_expiry_window(expiration: DateTime<Utc>, window: Duration) -> bool {
2425    Utc::now()
2426        >= expiration
2427            .checked_sub_signed(
2428                chrono::Duration::from_std(window).expect("credential expiry window fits chrono"),
2429            )
2430            .unwrap_or(expiration)
2431}
2432
2433fn unix_timestamp_to_datetime(timestamp: i64) -> Result<DateTime<Utc>> {
2434    let (seconds, nanoseconds) = match timestamp {
2435        value if value >= 1_000_000_000_000_000_000 => {
2436            (value / 1_000_000_000, (value % 1_000_000_000) as u32)
2437        }
2438        value if value >= 1_000_000_000_000_000 => {
2439            (value / 1_000_000, ((value % 1_000_000) * 1_000) as u32)
2440        }
2441        value if value >= 1_000_000_000_000 => {
2442            (value / 1_000, ((value % 1_000) * 1_000_000) as u32)
2443        }
2444        value => (value, 0),
2445    };
2446    DateTime::from_timestamp(seconds, nanoseconds).ok_or_else(|| {
2447        CredentialError::new(
2448            "CliConfigExpiration",
2449            format!("invalid Unix timestamp: {timestamp}"),
2450        )
2451    })
2452}
2453
2454fn sso_cache_filename(start_url: &str, session_name: &str) -> Result<String> {
2455    #[derive(Serialize)]
2456    struct CacheKey<'a> {
2457        start_url: &'a str,
2458        session_name: &'a str,
2459    }
2460    let payload = serde_json::to_vec(&CacheKey {
2461        start_url,
2462        session_name,
2463    })
2464    .map_err(|error| {
2465        CredentialError::with_source("CliSsoCacheKey", "failed to serialize SSO cache key", error)
2466    })?;
2467    Ok(format!("{}.json", sha1_hex(&payload)))
2468}
2469
2470fn sha1_hex(content: &[u8]) -> String {
2471    hex::encode(Sha1::digest(content))
2472}
2473
2474fn form_encode<K: AsRef<str> + Ord>(values: &BTreeMap<K, String>, spaces_as_plus: bool) -> String {
2475    values
2476        .iter()
2477        .map(|(key, value)| {
2478            format!(
2479                "{}={}",
2480                percent_encode(key.as_ref(), spaces_as_plus),
2481                percent_encode(value, spaces_as_plus)
2482            )
2483        })
2484        .collect::<Vec<_>>()
2485        .join("&")
2486}
2487
2488fn percent_encode(value: &str, spaces_as_plus: bool) -> String {
2489    let mut encoded = String::with_capacity(value.len());
2490    for byte in value.bytes() {
2491        match byte {
2492            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2493                encoded.push(char::from(byte));
2494            }
2495            b' ' if spaces_as_plus => encoded.push('+'),
2496            value => {
2497                const HEX: &[u8; 16] = b"0123456789ABCDEF";
2498                encoded.push('%');
2499                encoded.push(char::from(HEX[(value >> 4) as usize]));
2500                encoded.push(char::from(HEX[(value & 0x0f) as usize]));
2501            }
2502        }
2503    }
2504    encoded
2505}
2506
2507/// Extracts protobuf field 3 (`ExpiredTime`) from a Volcengine session token.
2508pub fn extract_expired_time(session_token: &str) -> Result<u64> {
2509    let mut segments = session_token.split('.');
2510    let _header = segments.next();
2511    let payload = segments
2512        .next()
2513        .ok_or_else(|| CredentialError::new("SessionTokenInvalid", "invalid session token"))?;
2514    if segments.next().is_none() || segments.next().is_some() {
2515        return Err(CredentialError::new(
2516            "SessionTokenInvalid",
2517            "invalid session token",
2518        ));
2519    }
2520    let payload = URL_SAFE_NO_PAD.decode(payload).map_err(|error| {
2521        CredentialError::with_source(
2522            "SessionTokenDecode",
2523            "failed to decode session token payload",
2524            error,
2525        )
2526    })?;
2527    parse_expired_time_field(&payload)
2528}
2529
2530fn parse_expired_time_field(mut payload: &[u8]) -> Result<u64> {
2531    while !payload.is_empty() {
2532        let (tag, tag_len) = decode_varint(payload)?;
2533        payload = &payload[tag_len..];
2534        let field_number = tag >> 3;
2535        let wire_type = tag & 7;
2536        match wire_type {
2537            0 => {
2538                let (value, value_len) = decode_varint(payload)?;
2539                if field_number == 3 {
2540                    return Ok(value);
2541                }
2542                payload = &payload[value_len..];
2543            }
2544            1 => {
2545                payload = payload.get(8..).ok_or_else(|| {
2546                    CredentialError::new(
2547                        "SessionTokenInvalid",
2548                        format!("invalid fixed64 field {field_number}"),
2549                    )
2550                })?
2551            }
2552            2 => {
2553                let (length, length_len) = decode_varint(payload)?;
2554                let length = usize::try_from(length).map_err(|_| {
2555                    CredentialError::new(
2556                        "SessionTokenInvalid",
2557                        format!("invalid bytes field {field_number}"),
2558                    )
2559                })?;
2560                payload = payload
2561                    .get(length_len..)
2562                    .and_then(|rest| rest.get(length..))
2563                    .ok_or_else(|| {
2564                        CredentialError::new(
2565                            "SessionTokenInvalid",
2566                            format!("invalid bytes field {field_number}"),
2567                        )
2568                    })?;
2569            }
2570            5 => {
2571                payload = payload.get(4..).ok_or_else(|| {
2572                    CredentialError::new(
2573                        "SessionTokenInvalid",
2574                        format!("invalid fixed32 field {field_number}"),
2575                    )
2576                })?
2577            }
2578            _ => {
2579                return Err(CredentialError::new(
2580                    "SessionTokenInvalid",
2581                    format!("unsupported wire type {wire_type}"),
2582                ));
2583            }
2584        }
2585    }
2586    Err(CredentialError::new(
2587        "SessionTokenExpiredTimeMissing",
2588        "ExpiredTime not found",
2589    ))
2590}
2591
2592fn decode_varint(input: &[u8]) -> Result<(u64, usize)> {
2593    let mut value = 0_u64;
2594    for (index, byte) in input.iter().copied().take(10).enumerate() {
2595        if index == 9 && byte > 1 {
2596            break;
2597        }
2598        value |= u64::from(byte & 0x7f) << (index * 7);
2599        if byte & 0x80 == 0 {
2600            return Ok((value, index + 1));
2601        }
2602    }
2603    Err(CredentialError::new(
2604        "SessionTokenInvalid",
2605        "invalid protobuf varint",
2606    ))
2607}
2608
2609#[cfg(test)]
2610mod tests {
2611    use std::{
2612        io::{Read, Write},
2613        net::TcpListener,
2614        panic,
2615        sync::Mutex as StdMutex,
2616        thread::JoinHandle,
2617    };
2618
2619    use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2620    use chrono::{SecondsFormat, Utc};
2621    use serde_json::json;
2622    use tempfile::TempDir;
2623
2624    use super::*;
2625
2626    static ENV_LOCK: StdMutex<()> = StdMutex::new(());
2627
2628    #[test]
2629    fn cli_config_normalizes_empty_sso_session_representations() {
2630        for config in [
2631            json!({"profiles": {}}),
2632            json!({"profiles": {}, "sso-session": null}),
2633            json!({"profiles": {}, "sso-session": {}}),
2634        ] {
2635            let config: CliConfig = serde_json::from_value(config).unwrap();
2636            assert!(config.sso_sessions.is_empty());
2637        }
2638    }
2639
2640    #[test]
2641    fn cli_config_preserves_configured_sso_sessions() {
2642        let config: CliConfig = serde_json::from_value(json!({
2643            "profiles": {},
2644            "sso-session": {
2645                "work": {
2646                    "start-url": "https://example.com/start",
2647                    "region": "cn-beijing"
2648                }
2649            }
2650        }))
2651        .unwrap();
2652
2653        let session = config.sso_sessions.get("work").unwrap();
2654        assert_eq!(session.start_url, "https://example.com/start");
2655        assert_eq!(session.region, "cn-beijing");
2656    }
2657
2658    #[test]
2659    fn access_key_mode_accepts_null_sso_sessions_from_cli_config() {
2660        let directory = TempDir::new().unwrap();
2661        let config_path = write_config(
2662            &directory,
2663            json!({
2664                "current": "default",
2665                "profiles": {
2666                    "default": {
2667                        "mode": "ak",
2668                        "access-key": "test-ak",
2669                        "secret-key": "test-sk"
2670                    }
2671                },
2672                "sso-session": null
2673            }),
2674        );
2675
2676        let value = CliCredentials::new(Some(config_path), None).get().unwrap();
2677
2678        assert_eq!(value.access_key_id, "test-ak");
2679        assert_eq!(value.secret_access_key, "test-sk");
2680    }
2681
2682    #[test]
2683    fn iam_role_and_legacy_mode_names_share_the_same_profile_mode() {
2684        for mode in ["iamrole", "IAMRole", "ramrolearn", "RamRoleArn"] {
2685            assert_eq!(
2686                ProfileMode::parse(mode, "test", Path::new("config.json")).unwrap(),
2687                ProfileMode::IamRole,
2688                "mode {mode} should use the IAM role provider"
2689            );
2690        }
2691    }
2692
2693    #[test]
2694    fn access_key_mode_uses_explicit_profile_and_redacts_debug_output() {
2695        let directory = TempDir::new().unwrap();
2696        let config_path = write_config(
2697            &directory,
2698            json!({
2699                "current": "default",
2700                "profiles": {
2701                    "default": {"mode": "ak", "access-key": "default-ak", "secret-key": "default-sk"},
2702                    "selected": {
2703                        "mode": "ak",
2704                        "access-key": "selected-ak",
2705                        "secret-key": "selected-sk",
2706                        "session-token": "selected-token"
2707                    }
2708                }
2709            }),
2710        );
2711        let credentials = CliCredentials::new(Some(config_path), Some("selected".to_owned()));
2712
2713        let value = credentials.get().unwrap();
2714
2715        assert_eq!(value.access_key_id, "selected-ak");
2716        assert_eq!(value.secret_access_key, "selected-sk");
2717        assert_eq!(value.session_token, "selected-token");
2718        let debug = format!("{value:?}");
2719        assert!(!debug.contains("selected-ak"));
2720        assert!(!debug.contains("selected-sk"));
2721        assert!(!debug.contains("selected-token"));
2722    }
2723
2724    #[test]
2725    fn profile_environment_priority_matches_go_provider() {
2726        let _lock = ENV_LOCK.lock().unwrap();
2727        let directory = TempDir::new().unwrap();
2728        let config_path = write_config(
2729            &directory,
2730            json!({
2731                "current": "current",
2732                "profiles": {
2733                    "current": {"mode": "ak", "access-key": "current-ak", "secret-key": "sk"},
2734                    "volcstack": {"mode": "ak", "access-key": "volcstack-ak", "secret-key": "sk"},
2735                    "volcengine": {"mode": "ak", "access-key": "volcengine-ak", "secret-key": "sk"}
2736                }
2737            }),
2738        );
2739        let _config = EnvGuard::set(
2740            "VOLCENGINE_CLI_CONFIG_FILE",
2741            config_path.to_string_lossy().as_ref(),
2742        );
2743        let _fallback = EnvGuard::set("VOLCSTACK_PROFILE", "volcstack");
2744        let primary = EnvGuard::set("VOLCENGINE_PROFILE", "volcengine");
2745
2746        assert_eq!(
2747            CliCredentials::from_cli().get().unwrap().access_key_id,
2748            "volcengine-ak"
2749        );
2750        drop(primary);
2751        assert_eq!(
2752            CliCredentials::from_cli().get().unwrap().access_key_id,
2753            "volcstack-ak"
2754        );
2755        drop(_fallback);
2756        assert_eq!(
2757            CliCredentials::from_cli().get().unwrap().access_key_id,
2758            "current-ak"
2759        );
2760    }
2761
2762    #[test]
2763    fn sso_mode_refreshes_access_token_and_exchanges_it_for_sts() {
2764        let directory = TempDir::new().unwrap();
2765        let server = MockServer::start(vec![
2766            ExpectedRequest::json(
2767                "POST",
2768                "/token",
2769                &[
2770                    "\"grant_type\":\"refresh_token\"",
2771                    "\"refresh_token\":\"sso-rt\"",
2772                ],
2773                json!({
2774                    "access_token": "new-sso-access-token",
2775                    "token_type": "Bearer",
2776                    "refresh_token": "rotated-sso-rt",
2777                    "expires_in": 3600
2778                }),
2779            ),
2780            ExpectedRequest::json(
2781                "GET",
2782                "/federation/credentials?account_id=2100000000&role_name=Developer",
2783                &["x-bd-cloudidentity-bearer-token: new-sso-access-token"],
2784                json!({
2785                    "ResponseMetadata": {"RequestId": "request-id"},
2786                    "Result": {"RoleCredentials": {
2787                        "AccessKeyId": "sso-ak",
2788                        "SecretAccessKey": "sso-sk",
2789                        "SessionToken": "sso-st",
2790                        "Expiration": (Utc::now().timestamp() + 3600)
2791                    }}
2792                }),
2793            ),
2794        ]);
2795        let session_name = "work";
2796        let start_url = "https://example.com/start";
2797        let config_path = write_config(
2798            &directory,
2799            json!({
2800                "current": "sso",
2801                "profiles": {"sso": {
2802                    "mode": "sso",
2803                    "sso-session-name": session_name,
2804                    "account-id": "2100000000",
2805                    "role-name": "Developer"
2806                }},
2807                "sso-session": {session_name: {
2808                    "start-url": start_url,
2809                    "region": "cn-beijing"
2810                }}
2811            }),
2812        );
2813        let cache_dir = directory.path().join("sso/cache");
2814        fs::create_dir_all(&cache_dir).unwrap();
2815        fs::write(
2816            cache_dir.join(sso_cache_filename(start_url, session_name).unwrap()),
2817            serde_json::to_vec(&json!({
2818                "access_token": "expired-access-token",
2819                "expires_at": (Utc::now() - chrono::Duration::hours(1)).to_rfc3339(),
2820                "client_id": "client-id",
2821                "client_secret": "client-secret",
2822                "client_secret_expires_at": Utc::now().timestamp() + 3600,
2823                "refresh_token": "sso-rt"
2824            }))
2825            .unwrap(),
2826        )
2827        .unwrap();
2828        let mut credentials = CliCredentials::new(Some(config_path), None);
2829        credentials.endpoints.sso_oauth = Some(server.base_url().to_owned());
2830        credentials.endpoints.sso_portal = Some(server.base_url().to_owned());
2831
2832        let value = credentials.get().unwrap();
2833
2834        assert_eq!(value.access_key_id, "sso-ak");
2835        assert_eq!(value.secret_access_key, "sso-sk");
2836        assert_eq!(value.session_token, "sso-st");
2837        server.finish();
2838    }
2839
2840    #[test]
2841    fn console_login_mode_refreshes_rt_into_sts_without_writing_cache() {
2842        let _lock = ENV_LOCK.lock().unwrap();
2843        let _cache_env = EnvGuard::remove("VOLCENGINE_LOGIN_CACHE_DIRECTORY");
2844        let directory = TempDir::new().unwrap();
2845        let refreshed_sts = json!({
2846            "access_key_id": "login-ak",
2847            "secret_access_key": "login-sk",
2848            "session_token": "login-st"
2849        });
2850        let server = MockServer::start(vec![ExpectedRequest::json(
2851            "POST",
2852            "/authorize/oauth/token",
2853            &[
2854                "grant_type=refresh_token",
2855                "client_id=login-client",
2856                "refresh_token=login-rt",
2857                "scope=openid",
2858            ],
2859            json!({
2860                "access_token": serde_json::to_string(&refreshed_sts).unwrap(),
2861                "token_type": "Bearer",
2862                "expires_in": 900,
2863                "refresh_token": "rotated-login-rt"
2864            }),
2865        )]);
2866        let login_session = "login-session-id";
2867        let config_path = write_config(
2868            &directory,
2869            json!({
2870                "current": "default",
2871                "profiles": {"default": {
2872                    "mode": "console-login",
2873                    "login-session": login_session
2874                }}
2875            }),
2876        );
2877        let cache_dir = directory.path().join("login/cache");
2878        fs::create_dir_all(&cache_dir).unwrap();
2879        let cache_path = cache_dir.join(format!("{}.json", sha1_hex(login_session.as_bytes())));
2880        let original_cache = serde_json::to_vec(&json!({
2881            "login_session": login_session,
2882            "access_token": {
2883                "access_key_id": "expired-ak",
2884                "secret_access_key": "expired-sk",
2885                "session_token": "expired-st"
2886            },
2887            "issued_at": (Utc::now() - chrono::Duration::hours(1)).to_rfc3339(),
2888            "expires_in": 900,
2889            "refresh_token": "login-rt",
2890            "client_id": "login-client",
2891            "scope": "openid",
2892            "endpoint_url": server.base_url()
2893        }))
2894        .unwrap();
2895        fs::write(&cache_path, &original_cache).unwrap();
2896
2897        let value = CliCredentials::new(Some(config_path), None).get().unwrap();
2898
2899        assert_eq!(value.access_key_id, "login-ak");
2900        assert_eq!(value.secret_access_key, "login-sk");
2901        assert_eq!(value.session_token, "login-st");
2902        assert_eq!(fs::read(cache_path).unwrap(), original_cache);
2903        server.finish();
2904    }
2905
2906    #[test]
2907    fn console_login_invalid_grant_is_not_retried_and_requires_new_login() {
2908        let _lock = ENV_LOCK.lock().unwrap();
2909        let _cache_env = EnvGuard::remove("VOLCENGINE_LOGIN_CACHE_DIRECTORY");
2910        let directory = TempDir::new().unwrap();
2911        let server = MockServer::start(vec![ExpectedRequest::json_status(
2912            "POST",
2913            "/authorize/oauth/token",
2914            &["refresh_token=rejected-rt"],
2915            StatusCode::BAD_REQUEST,
2916            json!({"error": "invalid_grant", "error_description": "revoked"}),
2917        )]);
2918        let login_session = "rejected-login-session";
2919        let config_path = write_config(
2920            &directory,
2921            json!({
2922                "profiles": {"default": {
2923                    "mode": "console-login",
2924                    "login-session": login_session
2925                }}
2926            }),
2927        );
2928        let cache_dir = directory.path().join("login/cache");
2929        fs::create_dir_all(&cache_dir).unwrap();
2930        fs::write(
2931            cache_dir.join(format!("{}.json", sha1_hex(login_session.as_bytes()))),
2932            serde_json::to_vec(&json!({
2933                "access_token": {
2934                    "access_key_id": "expired-ak",
2935                    "secret_access_key": "expired-sk",
2936                    "session_token": "expired-st"
2937                },
2938                "issued_at": (Utc::now() - chrono::Duration::hours(1)).to_rfc3339(),
2939                "expires_in": 900,
2940                "refresh_token": "rejected-rt",
2941                "client_id": "login-client",
2942                "endpoint_url": server.base_url()
2943            }))
2944            .unwrap(),
2945        )
2946        .unwrap();
2947
2948        let error = CliCredentials::new(Some(config_path), None)
2949            .get()
2950            .unwrap_err();
2951
2952        assert_eq!(error.code(), "CliConsoleLoginRefreshTokenExpired");
2953        assert!(error.to_string().contains("ve login"));
2954        server.finish();
2955    }
2956
2957    #[test]
2958    fn console_login_honors_custom_cache_directory_and_string_access_token() {
2959        let _lock = ENV_LOCK.lock().unwrap();
2960        let directory = TempDir::new().unwrap();
2961        let custom_cache = directory.path().join("custom-cache");
2962        fs::create_dir_all(&custom_cache).unwrap();
2963        let _cache_env = EnvGuard::set(
2964            "VOLCENGINE_LOGIN_CACHE_DIRECTORY",
2965            custom_cache.to_string_lossy().as_ref(),
2966        );
2967        let login_session = "custom-cache-session";
2968        let config_path = write_config(
2969            &directory,
2970            json!({
2971                "profiles": {"default": {
2972                    "mode": "console-login",
2973                    "login-session": login_session
2974                }}
2975            }),
2976        );
2977        fs::write(
2978            custom_cache.join(format!("{}.json", sha1_hex(login_session.as_bytes()))),
2979            serde_json::to_vec(&json!({
2980                "access_token": serde_json::to_string(&json!({
2981                    "access_key_id": "custom-ak",
2982                    "secret_access_key": "custom-sk",
2983                    "session_token": "custom-st"
2984                })).unwrap(),
2985                "issued_at": Utc::now().to_rfc3339(),
2986                "expires_in": 900
2987            }))
2988            .unwrap(),
2989        )
2990        .unwrap();
2991
2992        let value = CliCredentials::new(Some(config_path), None).get().unwrap();
2993
2994        assert_eq!(value.access_key_id, "custom-ak");
2995        assert_eq!(value.session_token, "custom-st");
2996    }
2997
2998    #[test]
2999    fn iam_role_mode_signs_assume_role_request() {
3000        let directory = TempDir::new().unwrap();
3001        let server = MockServer::start(vec![ExpectedRequest::json(
3002            "GET",
3003            "/?Action=AssumeRole",
3004            &[
3005                "authorization: HMAC-SHA256 Credential=source-ak/",
3006                "x-security-token: source-st",
3007            ],
3008            sts_response("role-ak", "role-sk", "role-st", "ExpiredTime"),
3009        )]);
3010        let config_path = write_config(
3011            &directory,
3012            json!({
3013                "current": "role",
3014                "profiles": {"role": {
3015                    "mode": "iamrole",
3016                    "access-key": "source-ak",
3017                    "secret-key": "source-sk",
3018                    "session-token": "source-st",
3019                    "account-id": "2100000000",
3020                    "role-name": "Developer",
3021                    "region": "cn-beijing"
3022                }}
3023            }),
3024        );
3025        let mut credentials = CliCredentials::new(Some(config_path), None);
3026        credentials.endpoints.sts = Some(server.base_url().to_owned());
3027
3028        let value = credentials.get().unwrap();
3029
3030        assert_eq!(value.access_key_id, "role-ak");
3031        assert_eq!(value.provider_name, "StsProvider");
3032        server.finish();
3033    }
3034
3035    #[test]
3036    fn sts_signature_matches_python_sdk_signer_v4() {
3037        let query = BTreeMap::from([
3038            ("Action", "AssumeRole".to_owned()),
3039            ("DurationSeconds", "3600".to_owned()),
3040            ("Policy", String::new()),
3041            (
3042                "RoleSessionName",
3043                "volcengine-go-sdk-12345678-1234-1234-1234-123456789abc".to_owned(),
3044            ),
3045            ("RoleTrn", "trn:iam::2100000000:role/Developer".to_owned()),
3046            ("Version", "2018-01-01".to_owned()),
3047        ]);
3048        let now = DateTime::parse_from_rfc3339("2026-07-21T12:34:56Z")
3049            .unwrap()
3050            .with_timezone(&Utc);
3051
3052        let signed = sign_query_request_at(
3053            "https://sts.volcengineapi.com",
3054            &query,
3055            "AKIDEXAMPLE",
3056            "secret",
3057            "session-token",
3058            "cn-beijing",
3059            "sts",
3060            now,
3061        );
3062
3063        assert_eq!(
3064            signed.headers.get(AUTHORIZATION).unwrap().to_str().unwrap(),
3065            "HMAC-SHA256 Credential=AKIDEXAMPLE/20260721/cn-beijing/sts/request, SignedHeaders=content-type;host;x-content-sha256;x-date;x-security-token, Signature=10f40e5c592af3fc8d85c552a6b39d549824760075857bb9cabf41f1b044ecf4"
3066        );
3067        assert_eq!(signed.headers.get("x-date").unwrap(), "20260721T123456Z");
3068    }
3069
3070    #[test]
3071    fn oidc_mode_reads_token_file_and_calls_unsigned_sts_endpoint() {
3072        let directory = TempDir::new().unwrap();
3073        let server = MockServer::start(vec![ExpectedRequest::json(
3074            "POST",
3075            "/?Action=AssumeRoleWithOIDC&Version=2018-01-01",
3076            &[
3077                "DurationSeconds=3660",
3078                "OIDCToken=oidc-token%0A",
3079                "RoleSessionName=volcengine-go-sdk-oidc-session",
3080                "RoleTrn=trn%3Aiam%3A%3A2100000000%3Arole%2FWorkload",
3081            ],
3082            sts_response("oidc-ak", "oidc-sk", "oidc-st", "Expiration"),
3083        )]);
3084        let token_path = directory.path().join("oidc-token");
3085        fs::write(&token_path, "oidc-token\n").unwrap();
3086        let config_path = write_config(
3087            &directory,
3088            json!({
3089                "current": "oidc",
3090                "profiles": {"oidc": {
3091                    "mode": "oidc",
3092                    "oidc-token-file": token_path,
3093                    "role-trn": "trn:iam::2100000000:role/Workload"
3094                }}
3095            }),
3096        );
3097        let mut credentials = CliCredentials::new(Some(config_path), None);
3098        credentials.endpoints.sts = Some(server.base_url().to_owned());
3099
3100        let value = credentials.get().unwrap();
3101
3102        assert_eq!(value.access_key_id, "oidc-ak");
3103        assert_eq!(value.provider_name, "OIDC");
3104        server.finish();
3105    }
3106
3107    #[test]
3108    fn ecs_role_mode_uses_imdsv2_token_then_fetches_credentials() {
3109        let _lock = ENV_LOCK.lock().unwrap();
3110        let _disabled = EnvGuard::remove("VOLCENGINE_ECS_METADATA_DISABLED");
3111        let directory = TempDir::new().unwrap();
3112        let server = MockServer::start(vec![
3113            ExpectedRequest::text(
3114                "PUT",
3115                "/latest/api/token",
3116                &["x-volc-ecs-metadata-token-ttl-seconds: 21600"],
3117                "imds-token",
3118            ),
3119            ExpectedRequest::json(
3120                "GET",
3121                "/volcstack/latest/iam/security_credentials/MyRole",
3122                &["x-volc-ecs-metadata-token: imds-token"],
3123                json!({
3124                    "AccessKeyId": "ecs-ak",
3125                    "SecretAccessKey": "ecs-sk",
3126                    "SessionToken": "ecs-st",
3127                    "ExpiredTime": (Utc::now() + chrono::Duration::hours(1))
3128                        .to_rfc3339_opts(SecondsFormat::Secs, true)
3129                }),
3130            ),
3131        ]);
3132        let config_path = write_config(
3133            &directory,
3134            json!({
3135                "current": "ecs",
3136                "profiles": {"ecs": {"mode": "ecsrole", "role-name": "MyRole"}}
3137            }),
3138        );
3139        let mut credentials = CliCredentials::new(Some(config_path), None);
3140        credentials.endpoints.imds = Some(server.base_url().to_owned());
3141
3142        let value = credentials.get().unwrap();
3143
3144        assert_eq!(value.access_key_id, "ecs-ak");
3145        assert_eq!(value.provider_name, "EcsRoleProvider");
3146        server.finish();
3147    }
3148
3149    #[test]
3150    fn cached_sso_sts_avoids_network_until_expiry_window() {
3151        let directory = TempDir::new().unwrap();
3152        let config_path = write_config(
3153            &directory,
3154            json!({
3155                "current": "sso",
3156                "profiles": {"sso": {
3157                    "mode": "sso",
3158                    "access-key": "cached-ak",
3159                    "secret-key": "cached-sk",
3160                    "session-token": "cached-st",
3161                    "sts-expiration": (Utc::now().timestamp() + 3600)
3162                }}
3163            }),
3164        );
3165
3166        let value = CliCredentials::new(Some(config_path), None).get().unwrap();
3167
3168        assert_eq!(value.access_key_id, "cached-ak");
3169        assert_eq!(value.session_token, "cached-st");
3170    }
3171
3172    #[test]
3173    fn unix_timestamps_support_seconds_milliseconds_microseconds_and_nanoseconds() {
3174        let seconds = 1_784_635_239_i64;
3175        for timestamp in [
3176            seconds,
3177            seconds * 1_000,
3178            seconds * 1_000_000,
3179            seconds * 1_000_000_000,
3180        ] {
3181            assert_eq!(
3182                unix_timestamp_to_datetime(timestamp).unwrap().timestamp(),
3183                seconds
3184            );
3185        }
3186    }
3187
3188    #[test]
3189    fn sso_cache_name_matches_go_json_and_sha1_convention() {
3190        assert_eq!(
3191            sso_cache_filename("https://example.com/start", "work").unwrap(),
3192            "ef1434a855843424932974030909e655cc244a40.json"
3193        );
3194    }
3195
3196    #[test]
3197    fn extracts_expired_time_from_protobuf_session_token() {
3198        let expiration = 1_784_635_239_u64;
3199        let mut payload = vec![0x0a, 0x03, b'a', b'b', b'c', 0x18];
3200        encode_varint(expiration, &mut payload);
3201        let token = format!("header.{}.signature", URL_SAFE_NO_PAD.encode(payload));
3202
3203        assert_eq!(extract_expired_time(&token).unwrap(), expiration);
3204    }
3205
3206    #[test]
3207    fn rejects_unsupported_profile_mode() {
3208        let directory = TempDir::new().unwrap();
3209        let config_path = write_config(
3210            &directory,
3211            json!({"profiles": {"default": {"mode": "unknown"}}}),
3212        );
3213
3214        let error = CliCredentials::new(Some(config_path), None)
3215            .get()
3216            .unwrap_err();
3217
3218        assert_eq!(error.code(), "CliConfigModeInvalid");
3219    }
3220
3221    fn write_config(directory: &TempDir, config: JsonValue) -> PathBuf {
3222        let path = directory.path().join("config.json");
3223        fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap();
3224        path
3225    }
3226
3227    fn sts_response(ak: &str, sk: &str, token: &str, expiration_name: &str) -> JsonValue {
3228        let mut credentials = serde_json::Map::from_iter([
3229            ("AccessKeyId".to_owned(), json!(ak)),
3230            ("SecretAccessKey".to_owned(), json!(sk)),
3231            ("SessionToken".to_owned(), json!(token)),
3232        ]);
3233        credentials.insert(
3234            expiration_name.to_owned(),
3235            json!(
3236                (Utc::now() + chrono::Duration::hours(1))
3237                    .to_rfc3339_opts(SecondsFormat::Secs, true)
3238            ),
3239        );
3240        json!({
3241            "ResponseMetadata": {"RequestId": "request-id"},
3242            "Result": {"Credentials": credentials}
3243        })
3244    }
3245
3246    fn encode_varint(mut value: u64, output: &mut Vec<u8>) {
3247        while value >= 0x80 {
3248            output.push((value as u8) | 0x80);
3249            value >>= 7;
3250        }
3251        output.push(value as u8);
3252    }
3253
3254    struct EnvGuard {
3255        name: &'static str,
3256        previous: Option<std::ffi::OsString>,
3257    }
3258
3259    impl EnvGuard {
3260        fn set(name: &'static str, value: &str) -> Self {
3261            let previous = env::var_os(name);
3262            // SAFETY: tests that mutate process environment hold ENV_LOCK.
3263            unsafe { env::set_var(name, value) };
3264            Self { name, previous }
3265        }
3266
3267        fn remove(name: &'static str) -> Self {
3268            let previous = env::var_os(name);
3269            // SAFETY: tests that mutate process environment hold ENV_LOCK.
3270            unsafe { env::remove_var(name) };
3271            Self { name, previous }
3272        }
3273    }
3274
3275    impl Drop for EnvGuard {
3276        fn drop(&mut self) {
3277            // SAFETY: tests that mutate process environment hold ENV_LOCK.
3278            unsafe {
3279                if let Some(previous) = &self.previous {
3280                    env::set_var(self.name, previous);
3281                } else {
3282                    env::remove_var(self.name);
3283                }
3284            }
3285        }
3286    }
3287
3288    struct ExpectedRequest {
3289        method: &'static str,
3290        target: &'static str,
3291        fragments: Vec<String>,
3292        status: StatusCode,
3293        content_type: &'static str,
3294        response_body: String,
3295    }
3296
3297    impl ExpectedRequest {
3298        fn json(
3299            method: &'static str,
3300            target: &'static str,
3301            fragments: &[&str],
3302            response: JsonValue,
3303        ) -> Self {
3304            Self {
3305                method,
3306                target,
3307                fragments: fragments.iter().map(|value| (*value).to_owned()).collect(),
3308                status: StatusCode::OK,
3309                content_type: "application/json",
3310                response_body: serde_json::to_string(&response).unwrap(),
3311            }
3312        }
3313
3314        fn json_status(
3315            method: &'static str,
3316            target: &'static str,
3317            fragments: &[&str],
3318            status: StatusCode,
3319            response: JsonValue,
3320        ) -> Self {
3321            Self {
3322                method,
3323                target,
3324                fragments: fragments.iter().map(|value| (*value).to_owned()).collect(),
3325                status,
3326                content_type: "application/json",
3327                response_body: serde_json::to_string(&response).unwrap(),
3328            }
3329        }
3330
3331        fn text(
3332            method: &'static str,
3333            target: &'static str,
3334            fragments: &[&str],
3335            response: &str,
3336        ) -> Self {
3337            Self {
3338                method,
3339                target,
3340                fragments: fragments.iter().map(|value| (*value).to_owned()).collect(),
3341                status: StatusCode::OK,
3342                content_type: "text/plain",
3343                response_body: response.to_owned(),
3344            }
3345        }
3346    }
3347
3348    struct MockServer {
3349        base_url: String,
3350        handle: JoinHandle<()>,
3351    }
3352
3353    impl MockServer {
3354        fn start(expectations: Vec<ExpectedRequest>) -> Self {
3355            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3356            let address = listener.local_addr().unwrap();
3357            let handle = std::thread::spawn(move || {
3358                for expected in expectations {
3359                    let (mut stream, _) = listener.accept().unwrap();
3360                    let request = read_http_request(&mut stream);
3361                    let lower_request = request.to_ascii_lowercase();
3362                    let request_line = request.lines().next().unwrap_or_default();
3363                    assert!(
3364                        request_line.starts_with(&format!("{} ", expected.method)),
3365                        "unexpected request line: {request_line}"
3366                    );
3367                    assert!(
3368                        request_line.contains(expected.target),
3369                        "request target {request_line:?} did not contain {:?}",
3370                        expected.target
3371                    );
3372                    for fragment in expected.fragments {
3373                        assert!(
3374                            request.contains(&fragment)
3375                                || lower_request.contains(&fragment.to_ascii_lowercase()),
3376                            "request did not contain {fragment:?}:\n{request}"
3377                        );
3378                    }
3379                    let response = format!(
3380                        "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3381                        expected.status.as_u16(),
3382                        expected.status.canonical_reason().unwrap_or("Unknown"),
3383                        expected.content_type,
3384                        expected.response_body.len(),
3385                        expected.response_body
3386                    );
3387                    stream.write_all(response.as_bytes()).unwrap();
3388                }
3389            });
3390            Self {
3391                base_url: format!("http://{address}"),
3392                handle,
3393            }
3394        }
3395
3396        fn base_url(&self) -> &str {
3397            &self.base_url
3398        }
3399
3400        fn finish(self) {
3401            if let Err(payload) = self.handle.join() {
3402                panic::resume_unwind(payload);
3403            }
3404        }
3405    }
3406
3407    fn read_http_request(stream: &mut std::net::TcpStream) -> String {
3408        let mut bytes = Vec::new();
3409        let mut buffer = [0_u8; 4096];
3410        let header_end = loop {
3411            let read = stream.read(&mut buffer).unwrap();
3412            assert!(read > 0, "connection closed before request headers");
3413            bytes.extend_from_slice(&buffer[..read]);
3414            if let Some(position) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
3415                break position + 4;
3416            }
3417        };
3418        let headers = String::from_utf8_lossy(&bytes[..header_end]);
3419        let content_length = headers
3420            .lines()
3421            .find_map(|line| {
3422                line.split_once(':').and_then(|(name, value)| {
3423                    name.eq_ignore_ascii_case("content-length")
3424                        .then(|| value.trim().parse::<usize>().unwrap())
3425                })
3426            })
3427            .unwrap_or_default();
3428        while bytes.len() < header_end + content_length {
3429            let read = stream.read(&mut buffer).unwrap();
3430            assert!(read > 0, "connection closed before request body");
3431            bytes.extend_from_slice(&buffer[..read]);
3432        }
3433        String::from_utf8(bytes).unwrap()
3434    }
3435}