Skip to content

Commit 5f0e8a4

Browse files
committed
fix(cli): accept rfc3339 credential expiry
1 parent 79eae79 commit 5f0e8a4

4 files changed

Lines changed: 131 additions & 16 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/openshell-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ tokio = { workspace = true }
3333
tonic = { workspace = true, features = ["tls", "tls-native-roots"] }
3434

3535
# CLI
36+
chrono = "0.4"
3637
clap = { workspace = true }
3738
clap_complete = { workspace = true }
3839
crossterm = { workspace = true }

crates/openshell-cli/src/main.rs

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -788,8 +788,8 @@ enum ProviderCommands {
788788
#[arg(long = "config", value_name = "KEY=VALUE")]
789789
config: Vec<String>,
790790

791-
/// Credential expiry (`KEY=TIMESTAMP_MS`). A zero timestamp clears expiry.
792-
#[arg(long = "credential-expires-at", value_name = "KEY=TIMESTAMP_MS")]
791+
/// Credential expiry (`KEY=TIMESTAMP`). Accepts epoch milliseconds or RFC3339. A zero timestamp clears expiry.
792+
#[arg(long = "credential-expires-at", value_name = "KEY=TIMESTAMP")]
793793
credential_expires_at: Vec<String>,
794794
},
795795

@@ -839,8 +839,12 @@ enum ProviderRefreshCommands {
839839
#[arg(long = "secret-material-key", value_name = "KEY")]
840840
secret_material_keys: Vec<String>,
841841

842-
/// Expiry for the current credential (`TIMESTAMP_MS`).
843-
#[arg(long = "credential-expires-at", value_name = "TIMESTAMP_MS")]
842+
/// Expiry for the current credential. Accepts epoch milliseconds or RFC3339.
843+
#[arg(
844+
long = "credential-expires-at",
845+
value_name = "TIMESTAMP",
846+
value_parser = run::parse_credential_expiry_cli_value
847+
)]
844848
credential_expires_at: Option<i64>,
845849
},
846850

@@ -3834,6 +3838,36 @@ mod tests {
38343838
));
38353839
}
38363840

3841+
#[test]
3842+
fn provider_refresh_config_accepts_rfc3339_credential_expiry() {
3843+
let cli = Cli::try_parse_from([
3844+
"openshell",
3845+
"provider",
3846+
"refresh",
3847+
"configure",
3848+
"my-graph",
3849+
"--credential-key",
3850+
"MS_GRAPH_ACCESS_TOKEN",
3851+
"--strategy",
3852+
"oauth2-client-credentials",
3853+
"--credential-expires-at",
3854+
"2026-01-01T00:00:00Z",
3855+
])
3856+
.expect("provider refresh configure should parse RFC3339 credential expiry");
3857+
3858+
assert!(matches!(
3859+
cli.command,
3860+
Some(Commands::Provider {
3861+
command: Some(ProviderCommands::Refresh(
3862+
ProviderRefreshCommands::Configure {
3863+
credential_expires_at: Some(1_767_225_600_000),
3864+
..
3865+
}
3866+
))
3867+
})
3868+
));
3869+
}
3870+
38373871
#[test]
38383872
fn settings_set_global_parses_yes_flag() {
38393873
let cli = Cli::try_parse_from([

crates/openshell-cli/src/run.rs

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::tls::{
99
grpc_inference_client, require_tls_materials,
1010
};
1111
use bytes::Bytes;
12+
use chrono::DateTime;
1213
use dialoguer::{Confirm, Select, theme::ColorfulTheme};
1314
use futures::StreamExt;
1415
use http_body_util::Full;
@@ -3758,13 +3759,57 @@ fn parse_credential_pairs(items: &[String]) -> Result<HashMap<String, String>> {
37583759
Ok(map)
37593760
}
37603761

3762+
pub fn parse_credential_expiry_cli_value(value: &str) -> std::result::Result<i64, String> {
3763+
parse_credential_expiry_value(value, None).map_err(|err| err.to_string())
3764+
}
3765+
3766+
fn credential_expiry_value_error(key: Option<&str>, detail: &str) -> miette::Report {
3767+
key.map_or_else(
3768+
|| miette::miette!("--credential-expires-at value {detail}"),
3769+
|key| miette::miette!("--credential-expires-at value for '{key}' {detail}"),
3770+
)
3771+
}
3772+
3773+
fn parse_credential_expiry_value(value: &str, key: Option<&str>) -> Result<i64> {
3774+
let value = value.trim();
3775+
if value.is_empty() {
3776+
return Err(credential_expiry_value_error(key, "cannot be empty"));
3777+
}
3778+
3779+
if let Ok(value_ms) = value.parse::<i64>() {
3780+
if value_ms < 0 {
3781+
return Err(credential_expiry_value_error(
3782+
key,
3783+
"must be greater than or equal to 0",
3784+
));
3785+
}
3786+
return Ok(value_ms);
3787+
}
3788+
3789+
let parsed = DateTime::parse_from_rfc3339(value).map_err(|_| {
3790+
credential_expiry_value_error(
3791+
key,
3792+
"must be a Unix epoch millisecond timestamp or RFC3339 timestamp",
3793+
)
3794+
})?;
3795+
let value_ms = parsed.timestamp_millis();
3796+
if value_ms < 0 {
3797+
return Err(credential_expiry_value_error(
3798+
key,
3799+
"must be greater than or equal to 0",
3800+
));
3801+
}
3802+
3803+
Ok(value_ms)
3804+
}
3805+
37613806
fn parse_credential_expiry_pairs(items: &[String]) -> Result<HashMap<String, i64>> {
37623807
let mut map = HashMap::new();
37633808

37643809
for item in items {
37653810
let Some((key, value)) = item.split_once('=') else {
37663811
return Err(miette::miette!(
3767-
"--credential-expires-at expects KEY=TIMESTAMP_MS, got '{item}'"
3812+
"--credential-expires-at expects KEY=TIMESTAMP, got '{item}'"
37683813
));
37693814
};
37703815
let key = key.trim();
@@ -3773,16 +3818,7 @@ fn parse_credential_expiry_pairs(items: &[String]) -> Result<HashMap<String, i64
37733818
"--credential-expires-at key cannot be empty"
37743819
));
37753820
}
3776-
let value = value.trim().parse::<i64>().map_err(|_| {
3777-
miette::miette!(
3778-
"--credential-expires-at value for '{key}' must be a Unix epoch millisecond timestamp"
3779-
)
3780-
})?;
3781-
if value < 0 {
3782-
return Err(miette::miette!(
3783-
"--credential-expires-at value for '{key}' must be greater than or equal to 0"
3784-
));
3785-
}
3821+
let value = parse_credential_expiry_value(value, Some(key))?;
37863822
map.insert(key.to_string(), value);
37873823
}
37883824

@@ -6674,7 +6710,8 @@ mod tests {
66746710
gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_type_label,
66756711
git_sync_files, http_health_check, image_requests_gpu, import_local_package_mtls_bundle,
66766712
inferred_provider_type, package_managed_tls_dirs, parse_cli_setting_value,
6677-
parse_credential_pairs, plaintext_gateway_is_remote, progress_step_from_metadata,
6713+
parse_credential_expiry_cli_value, parse_credential_expiry_pairs, parse_credential_pairs,
6714+
plaintext_gateway_is_remote, progress_step_from_metadata,
66786715
provider_profile_allows_refresh_bootstrap, provisioning_timeout_message,
66796716
ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from,
66806717
sandbox_should_persist, service_expose_status_error, service_url_for_gateway,
@@ -6802,6 +6839,48 @@ mod tests {
68026839
));
68036840
}
68046841

6842+
#[test]
6843+
fn parse_credential_expiry_pairs_accepts_epoch_millis_and_rfc3339() {
6844+
let parsed = parse_credential_expiry_pairs(&[
6845+
"API_TOKEN=1767225600000".to_string(),
6846+
"MS_GRAPH_ACCESS_TOKEN=2026-01-01T00:00:00Z".to_string(),
6847+
])
6848+
.expect("parse");
6849+
6850+
assert_eq!(parsed.get("API_TOKEN"), Some(&1_767_225_600_000));
6851+
assert_eq!(
6852+
parsed.get("MS_GRAPH_ACCESS_TOKEN"),
6853+
Some(&1_767_225_600_000)
6854+
);
6855+
}
6856+
6857+
#[test]
6858+
fn parse_credential_expiry_pairs_accepts_zero_to_clear_expiry() {
6859+
let parsed =
6860+
parse_credential_expiry_pairs(&["API_TOKEN=0".to_string()]).expect("parse zero");
6861+
6862+
assert_eq!(parsed.get("API_TOKEN"), Some(&0));
6863+
}
6864+
6865+
#[test]
6866+
fn parse_credential_expiry_rejects_invalid_timestamp() {
6867+
let err = parse_credential_expiry_pairs(&["API_TOKEN=next-week".to_string()])
6868+
.expect_err("invalid timestamp should error");
6869+
6870+
assert!(
6871+
err.to_string()
6872+
.contains("must be a Unix epoch millisecond timestamp or RFC3339 timestamp")
6873+
);
6874+
}
6875+
6876+
#[test]
6877+
fn parse_credential_expiry_cli_value_accepts_rfc3339_offsets() {
6878+
let parsed = parse_credential_expiry_cli_value("2026-01-01T01:00:00+01:00")
6879+
.expect("parse RFC3339 with offset");
6880+
6881+
assert_eq!(parsed, 1_767_225_600_000);
6882+
}
6883+
68056884
#[test]
68066885
fn provider_attachment_table_formats_provider_counts() {
68076886
let output = format_provider_attachment_table(

0 commit comments

Comments
 (0)