Skip to content

Commit d255cdd

Browse files
authored
feat(providers): add credential refresh foundation (#1349)
* feat(providers): add credential refresh foundation * feat(providers): mint oauth refresh token credentials * fix(providers): allow delegated refresh bootstrap * fix(providers): guard refresh credential modes * fix(providers): refine refresh lifecycle UX * fix(cli): restore provider test gateway response import * fix(server): clean auth endpoint test qualifications * fix(providers): tighten refresh authorization and collisions * chore(providers): trim bundled v2 profiles * fix(providers): resolve refresh rebase fallout * fix(cli): accept rfc3339 credential expiry * test(providers): update inferred claude provider type * test(providers): avoid removed outlook default profile * test(providers): isolate attach limit fixtures
1 parent f9435b4 commit d255cdd

48 files changed

Lines changed: 5411 additions & 490 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,23 @@ enum OutputFormat {
653653
Json,
654654
}
655655

656+
#[derive(Clone, Debug, ValueEnum)]
657+
enum CliProviderRefreshStrategy {
658+
Oauth2RefreshToken,
659+
Oauth2ClientCredentials,
660+
GoogleServiceAccountJwt,
661+
}
662+
663+
impl CliProviderRefreshStrategy {
664+
fn as_str(&self) -> &'static str {
665+
match self {
666+
Self::Oauth2RefreshToken => "oauth2_refresh_token",
667+
Self::Oauth2ClientCredentials => "oauth2_client_credentials",
668+
Self::GoogleServiceAccountJwt => "google_service_account_jwt",
669+
}
670+
}
671+
}
672+
656673
impl OutputFormat {
657674
fn as_str(&self) -> &'static str {
658675
match self {
@@ -708,6 +725,10 @@ enum ProviderCommands {
708725
config: Vec<String>,
709726
},
710727

728+
/// Manage provider credential refresh.
729+
#[command(subcommand, help_template = SUBCOMMAND_HELP_TEMPLATE)]
730+
Refresh(ProviderRefreshCommands),
731+
711732
/// Fetch a provider by name.
712733
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
713734
Get {
@@ -766,6 +787,10 @@ enum ProviderCommands {
766787
/// Provider config key/value pair.
767788
#[arg(long = "config", value_name = "KEY=VALUE")]
768789
config: Vec<String>,
790+
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")]
793+
credential_expires_at: Vec<String>,
769794
},
770795

771796
/// Delete providers by name.
@@ -777,6 +802,77 @@ enum ProviderCommands {
777802
},
778803
}
779804

805+
#[derive(Subcommand, Debug)]
806+
enum ProviderRefreshCommands {
807+
/// Show provider credential refresh status.
808+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
809+
Status {
810+
/// Provider name.
811+
#[arg(add = ArgValueCompleter::new(completers::complete_provider_names))]
812+
name: String,
813+
814+
/// Optional credential key to filter by.
815+
#[arg(long = "credential-key")]
816+
credential_key: Option<String>,
817+
},
818+
819+
/// Configure refresh metadata for a provider credential.
820+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
821+
Configure {
822+
/// Provider name.
823+
#[arg(add = ArgValueCompleter::new(completers::complete_provider_names))]
824+
name: String,
825+
826+
/// Injectable credential key, for example `MS_GRAPH_ACCESS_TOKEN`.
827+
#[arg(long = "credential-key")]
828+
credential_key: String,
829+
830+
/// Refresh strategy.
831+
#[arg(long, value_enum)]
832+
strategy: CliProviderRefreshStrategy,
833+
834+
/// Non-injectable refresh material (`KEY=VALUE`).
835+
#[arg(long = "material", value_name = "KEY=VALUE")]
836+
material: Vec<String>,
837+
838+
/// Material keys that are secret and must not be exposed.
839+
#[arg(long = "secret-material-key", value_name = "KEY")]
840+
secret_material_keys: Vec<String>,
841+
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+
)]
848+
credential_expires_at: Option<i64>,
849+
},
850+
851+
/// Record a gateway-owned credential rotation request.
852+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
853+
Rotate {
854+
/// Provider name.
855+
#[arg(add = ArgValueCompleter::new(completers::complete_provider_names))]
856+
name: String,
857+
858+
/// Injectable credential key, for example `MS_GRAPH_ACCESS_TOKEN`.
859+
#[arg(long = "credential-key")]
860+
credential_key: String,
861+
},
862+
863+
/// Delete refresh metadata for a provider credential.
864+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
865+
Delete {
866+
/// Provider name.
867+
#[arg(add = ArgValueCompleter::new(completers::complete_provider_names))]
868+
name: String,
869+
870+
/// Injectable credential key, for example `MS_GRAPH_ACCESS_TOKEN`.
871+
#[arg(long = "credential-key")]
872+
credential_key: String,
873+
},
874+
}
875+
780876
#[derive(Subcommand, Debug)]
781877
enum ProviderProfileCommands {
782878
/// Export a provider profile.
@@ -2641,6 +2737,55 @@ async fn main() -> Result<()> {
26412737
)
26422738
.await?;
26432739
}
2740+
ProviderCommands::Refresh(command) => match command {
2741+
ProviderRefreshCommands::Status {
2742+
name,
2743+
credential_key,
2744+
} => {
2745+
run::provider_refresh_status(
2746+
endpoint,
2747+
&name,
2748+
credential_key.as_deref(),
2749+
&tls,
2750+
)
2751+
.await?;
2752+
}
2753+
ProviderRefreshCommands::Configure {
2754+
name,
2755+
credential_key,
2756+
strategy,
2757+
material,
2758+
secret_material_keys,
2759+
credential_expires_at,
2760+
} => {
2761+
run::provider_refresh_config(
2762+
endpoint,
2763+
run::ProviderRefreshConfigInput {
2764+
name: &name,
2765+
credential_key: &credential_key,
2766+
strategy: strategy.as_str(),
2767+
material: &material,
2768+
secret_material_keys: &secret_material_keys,
2769+
credential_expires_at_ms: credential_expires_at,
2770+
},
2771+
&tls,
2772+
)
2773+
.await?;
2774+
}
2775+
ProviderRefreshCommands::Rotate {
2776+
name,
2777+
credential_key,
2778+
} => {
2779+
run::provider_rotate(endpoint, &name, &credential_key, &tls).await?;
2780+
}
2781+
ProviderRefreshCommands::Delete {
2782+
name,
2783+
credential_key,
2784+
} => {
2785+
run::provider_refresh_delete(endpoint, &name, &credential_key, &tls)
2786+
.await?;
2787+
}
2788+
},
26442789
ProviderCommands::Get { name } => {
26452790
run::provider_get(endpoint, &name, &tls).await?;
26462791
}
@@ -2685,13 +2830,15 @@ async fn main() -> Result<()> {
26852830
from_existing,
26862831
credentials,
26872832
config,
2833+
credential_expires_at,
26882834
} => {
26892835
run::provider_update(
26902836
endpoint,
26912837
&name,
26922838
from_existing,
26932839
&credentials,
26942840
&config,
2841+
&credential_expires_at,
26952842
&tls,
26962843
)
26972844
.await?;
@@ -3572,6 +3719,155 @@ mod tests {
35723719
}
35733720
}
35743721

3722+
#[test]
3723+
fn provider_refresh_commands_parse() {
3724+
let status = Cli::try_parse_from([
3725+
"openshell",
3726+
"provider",
3727+
"refresh",
3728+
"status",
3729+
"my-graph",
3730+
"--credential-key",
3731+
"MS_GRAPH_ACCESS_TOKEN",
3732+
])
3733+
.expect("provider refresh status should parse");
3734+
assert!(matches!(
3735+
status.command,
3736+
Some(Commands::Provider {
3737+
command: Some(ProviderCommands::Refresh(ProviderRefreshCommands::Status {
3738+
name,
3739+
credential_key: Some(key)
3740+
}))
3741+
}) if name == "my-graph" && key == "MS_GRAPH_ACCESS_TOKEN"
3742+
));
3743+
3744+
let config = Cli::try_parse_from([
3745+
"openshell",
3746+
"provider",
3747+
"refresh",
3748+
"configure",
3749+
"my-graph",
3750+
"--credential-key",
3751+
"MS_GRAPH_ACCESS_TOKEN",
3752+
"--strategy",
3753+
"oauth2-client-credentials",
3754+
"--material",
3755+
"tenant_id=abc",
3756+
"--secret-material-key",
3757+
"client_secret",
3758+
"--credential-expires-at",
3759+
"1767225600000",
3760+
])
3761+
.expect("provider refresh configure should parse");
3762+
assert!(matches!(
3763+
config.command,
3764+
Some(Commands::Provider {
3765+
command: Some(ProviderCommands::Refresh(
3766+
ProviderRefreshCommands::Configure {
3767+
strategy: CliProviderRefreshStrategy::Oauth2ClientCredentials,
3768+
credential_expires_at: Some(1_767_225_600_000),
3769+
..
3770+
}
3771+
))
3772+
})
3773+
));
3774+
3775+
let rotate = Cli::try_parse_from([
3776+
"openshell",
3777+
"provider",
3778+
"refresh",
3779+
"rotate",
3780+
"my-graph",
3781+
"--credential-key",
3782+
"MS_GRAPH_ACCESS_TOKEN",
3783+
])
3784+
.expect("provider refresh rotate should parse");
3785+
assert!(matches!(
3786+
rotate.command,
3787+
Some(Commands::Provider {
3788+
command: Some(ProviderCommands::Refresh(ProviderRefreshCommands::Rotate {
3789+
name,
3790+
credential_key
3791+
}))
3792+
}) if name == "my-graph" && credential_key == "MS_GRAPH_ACCESS_TOKEN"
3793+
));
3794+
3795+
let delete = Cli::try_parse_from([
3796+
"openshell",
3797+
"provider",
3798+
"refresh",
3799+
"delete",
3800+
"my-graph",
3801+
"--credential-key",
3802+
"MS_GRAPH_ACCESS_TOKEN",
3803+
])
3804+
.expect("provider refresh delete should parse");
3805+
assert!(matches!(
3806+
delete.command,
3807+
Some(Commands::Provider {
3808+
command: Some(ProviderCommands::Refresh(ProviderRefreshCommands::Delete {
3809+
name,
3810+
credential_key
3811+
}))
3812+
}) if name == "my-graph" && credential_key == "MS_GRAPH_ACCESS_TOKEN"
3813+
));
3814+
}
3815+
3816+
#[test]
3817+
fn provider_update_accepts_credential_expiry() {
3818+
let cli = Cli::try_parse_from([
3819+
"openshell",
3820+
"provider",
3821+
"update",
3822+
"my-graph",
3823+
"--credential",
3824+
"MS_GRAPH_ACCESS_TOKEN=abc",
3825+
"--credential-expires-at",
3826+
"MS_GRAPH_ACCESS_TOKEN=1767225600000",
3827+
])
3828+
.expect("provider update should parse credential expiry");
3829+
3830+
assert!(matches!(
3831+
cli.command,
3832+
Some(Commands::Provider {
3833+
command: Some(ProviderCommands::Update {
3834+
credential_expires_at,
3835+
..
3836+
})
3837+
}) if credential_expires_at == vec!["MS_GRAPH_ACCESS_TOKEN=1767225600000"]
3838+
));
3839+
}
3840+
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+
35753871
#[test]
35763872
fn settings_set_global_parses_yes_flag() {
35773873
let cli = Cli::try_parse_from([

0 commit comments

Comments
 (0)