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