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