@@ -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+
656673impl 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 ) ]
781877enum 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