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