@@ -185,8 +185,7 @@ pub fn new_refresh_state(
185185) -> Result < StoredProviderCredentialRefreshState , Status > {
186186 let provider_id = provider. object_id ( ) . to_string ( ) ;
187187 let provider_name = provider. object_name ( ) . to_string ( ) ;
188- let now_ms =
189- current_time_ms ( ) . map_err ( |e| Status :: internal ( format ! ( "timestamp error: {e}" ) ) ) ?;
188+ let now_ms = current_time_ms ( ) ;
190189 let next_refresh_at_ms = next_refresh_at_ms (
191190 config. expires_at_ms ,
192191 config. refresh_before_seconds ,
@@ -224,12 +223,14 @@ use openshell_core::{ObjectId, ObjectName};
224223struct MintedCredential {
225224 access_token : String ,
226225 expires_at_ms : i64 ,
226+ refresh_token : Option < String > ,
227227}
228228
229229#[ derive( Debug , Deserialize ) ]
230230struct TokenResponse {
231231 access_token : String ,
232232 expires_in : Option < i64 > ,
233+ refresh_token : Option < String > ,
233234}
234235
235236#[ derive( Debug , Serialize ) ]
@@ -307,9 +308,20 @@ pub async fn refresh_provider_credential(
307308
308309 match mint_credential ( & state) . await {
309310 Ok ( minted) => {
310- let now_ms =
311- current_time_ms ( ) . map_err ( |e| Status :: internal ( format ! ( "timestamp error: {e}" ) ) ) ?;
311+ let now_ms = current_time_ms ( ) ;
312312 apply_minted_credential ( store, & provider, credential_key, & minted) . await ?;
313+ if let Some ( refresh_token) = minted. refresh_token {
314+ state
315+ . material
316+ . insert ( "refresh_token" . to_string ( ) , refresh_token) ;
317+ if !state
318+ . secret_material_keys
319+ . iter ( )
320+ . any ( |key| key == "refresh_token" )
321+ {
322+ state. secret_material_keys . push ( "refresh_token" . to_string ( ) ) ;
323+ }
324+ }
313325 state. expires_at_ms = minted. expires_at_ms ;
314326 state. next_refresh_at_ms = next_refresh_at_ms (
315327 minted. expires_at_ms ,
@@ -334,8 +346,7 @@ pub async fn refresh_provider_credential(
334346 Ok ( state)
335347 }
336348 Err ( err) => {
337- let now_ms =
338- current_time_ms ( ) . map_err ( |e| Status :: internal ( format ! ( "timestamp error: {e}" ) ) ) ?;
349+ let now_ms = current_time_ms ( ) ;
339350 state. status = "error" . to_string ( ) ;
340351 state. last_error = err. message ( ) . to_string ( ) ;
341352 state. next_refresh_at_ms =
@@ -385,6 +396,9 @@ async fn mint_credential(
385396 let strategy = ProviderCredentialRefreshStrategy :: try_from ( state. strategy )
386397 . unwrap_or ( ProviderCredentialRefreshStrategy :: Unspecified ) ;
387398 match strategy {
399+ ProviderCredentialRefreshStrategy :: Oauth2RefreshToken => {
400+ mint_oauth2_refresh_token ( state) . await
401+ }
388402 ProviderCredentialRefreshStrategy :: Oauth2ClientCredentials => {
389403 mint_oauth2_client_credentials ( state) . await
390404 }
@@ -393,13 +407,34 @@ async fn mint_credential(
393407 }
394408 ProviderCredentialRefreshStrategy :: External
395409 | ProviderCredentialRefreshStrategy :: Static
396- | ProviderCredentialRefreshStrategy :: Oauth2RefreshToken
397410 | ProviderCredentialRefreshStrategy :: Unspecified => Err ( Status :: failed_precondition (
398411 format ! ( "refresh strategy '{strategy:?}' cannot be minted by the gateway yet" ) ,
399412 ) ) ,
400413 }
401414}
402415
416+ async fn mint_oauth2_refresh_token (
417+ state : & StoredProviderCredentialRefreshState ,
418+ ) -> Result < MintedCredential , Status > {
419+ let token_url = oauth2_token_url ( state) ?;
420+ let client_id = required_material ( & state. material , "client_id" ) ?;
421+ let refresh_token = required_material ( & state. material , "refresh_token" ) ?;
422+ let mut form = vec ! [
423+ ( "grant_type" . to_string( ) , "refresh_token" . to_string( ) ) ,
424+ ( "client_id" . to_string( ) , client_id) ,
425+ ( "refresh_token" . to_string( ) , refresh_token) ,
426+ ] ;
427+ if let Some ( client_secret) = material_value ( & state. material , & [ "client_secret" ] ) {
428+ form. push ( ( "client_secret" . to_string ( ) , client_secret) ) ;
429+ }
430+ let scope = refresh_scopes ( state) . join ( " " ) ;
431+ if !scope. is_empty ( ) {
432+ form. push ( ( "scope" . to_string ( ) , scope) ) ;
433+ }
434+
435+ request_token ( & token_url, & form, state. max_lifetime_seconds ) . await
436+ }
437+
403438async fn mint_oauth2_client_credentials (
404439 state : & StoredProviderCredentialRefreshState ,
405440) -> Result < MintedCredential , Status > {
@@ -431,8 +466,7 @@ async fn mint_google_service_account_jwt(
431466 "google_service_account_jwt requires at least one scope" ,
432467 ) ) ;
433468 }
434- let now_ms =
435- current_time_ms ( ) . map_err ( |e| Status :: internal ( format ! ( "timestamp error: {e}" ) ) ) ?;
469+ let now_ms = current_time_ms ( ) ;
436470 let now_secs = now_ms / 1000 ;
437471 let lifetime_secs = if state. max_lifetime_seconds > 0 {
438472 state. max_lifetime_seconds . min ( DEFAULT_MAX_LIFETIME_SECONDS )
@@ -508,8 +542,7 @@ async fn request_token(
508542 "token endpoint returned empty access_token" ,
509543 ) ) ;
510544 }
511- let now_ms =
512- current_time_ms ( ) . map_err ( |e| Status :: internal ( format ! ( "timestamp error: {e}" ) ) ) ?;
545+ let now_ms = current_time_ms ( ) ;
513546 let lifetime_cap_seconds = if max_lifetime_seconds > 0 {
514547 max_lifetime_seconds
515548 } else {
@@ -523,6 +556,9 @@ async fn request_token(
523556 Ok ( MintedCredential {
524557 access_token : token. access_token ,
525558 expires_at_ms : now_ms. saturating_add ( lifetime_seconds. saturating_mul ( 1000 ) ) ,
559+ refresh_token : token
560+ . refresh_token
561+ . filter ( |refresh_token| !refresh_token. trim ( ) . is_empty ( ) ) ,
526562 } )
527563}
528564
@@ -618,8 +654,7 @@ pub fn spawn_refresh_worker(state: std::sync::Arc<crate::ServerState>, interval:
618654}
619655
620656async fn run_refresh_worker_tick ( store : & Store ) -> Result < ( ) , Status > {
621- let now_ms =
622- current_time_ms ( ) . map_err ( |e| Status :: internal ( format ! ( "timestamp error: {e}" ) ) ) ?;
657+ let now_ms = current_time_ms ( ) ;
623658 let states = list_all_refresh_states ( store) . await ?;
624659 let watched_count = states. len ( ) ;
625660 let due_count = states
@@ -681,10 +716,11 @@ async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> {
681716#[ cfg( test) ]
682717mod tests {
683718 use super :: {
684- NewRefreshStateConfig , new_refresh_state , put_refresh_state , refresh_provider_credential ,
685- refresh_state_name, refresh_strategy_name, seconds_until_ms,
719+ NewRefreshStateConfig , get_refresh_state , new_refresh_state , put_refresh_state ,
720+ refresh_provider_credential , refresh_state_name, refresh_strategy_name, seconds_until_ms,
686721 } ;
687722 use crate :: persistence:: Store ;
723+ use openshell_core:: ObjectId ;
688724 use openshell_core:: proto:: datamodel:: v1:: ObjectMeta ;
689725 use openshell_core:: proto:: { Provider , ProviderCredentialRefreshStrategy } ;
690726 use std:: collections:: HashMap ;
@@ -714,6 +750,10 @@ mod tests {
714750 assert_eq ! ( seconds_until_ms( 1_000 , 61_000 ) , 60 ) ;
715751 assert_eq ! ( seconds_until_ms( 61_000 , 1_000 ) , 0 ) ;
716752 assert_eq ! ( seconds_until_ms( 1_000 , 0 ) , 0 ) ;
753+ assert_eq ! (
754+ refresh_strategy_name( ProviderCredentialRefreshStrategy :: Oauth2RefreshToken as i32 ) ,
755+ "oauth2_refresh_token"
756+ ) ;
717757 assert_eq ! (
718758 refresh_strategy_name(
719759 ProviderCredentialRefreshStrategy :: Oauth2ClientCredentials as i32
@@ -752,7 +792,7 @@ mod tests {
752792 . unwrap ( ) ;
753793 let provider = provider ( "my-graph" , "outlook" ) ;
754794 store. put_message ( & provider) . await . unwrap ( ) ;
755- let before_refresh_ms = crate :: persistence:: current_time_ms ( ) . unwrap ( ) ;
795+ let before_refresh_ms = crate :: persistence:: current_time_ms ( ) ;
756796 let state = new_refresh_state (
757797 & provider,
758798 "MS_GRAPH_ACCESS_TOKEN" ,
@@ -797,6 +837,90 @@ mod tests {
797837 ) ;
798838 }
799839
840+ #[ tokio:: test]
841+ async fn oauth2_refresh_token_refresh_mints_access_token_and_persists_rotated_refresh_token ( ) {
842+ let mock_server = MockServer :: start ( ) . await ;
843+ Mock :: given ( method ( "POST" ) )
844+ . and ( path ( "/token" ) )
845+ . and ( body_string_contains ( "grant_type=refresh_token" ) )
846+ . and ( body_string_contains ( "client_id=client-id" ) )
847+ . and ( body_string_contains ( "refresh_token=old-refresh-token" ) )
848+ . and ( body_string_contains (
849+ "scope=https%3A%2F%2Fgraph.microsoft.com%2F.default" ,
850+ ) )
851+ . respond_with ( ResponseTemplate :: new ( 200 ) . set_body_json ( serde_json:: json!( {
852+ "access_token" : "delegated-graph-token" ,
853+ "refresh_token" : "rotated-refresh-token" ,
854+ "expires_in" : 3600 ,
855+ "token_type" : "Bearer"
856+ } ) ) )
857+ . mount ( & mock_server)
858+ . await ;
859+
860+ let store = Store :: connect ( "sqlite::memory:?cache=shared" )
861+ . await
862+ . unwrap ( ) ;
863+ let provider = provider ( "my-delegated-graph" , "outlook" ) ;
864+ store. put_message ( & provider) . await . unwrap ( ) ;
865+ let state = new_refresh_state (
866+ & provider,
867+ "MS_GRAPH_ACCESS_TOKEN" ,
868+ NewRefreshStateConfig {
869+ strategy : ProviderCredentialRefreshStrategy :: Oauth2RefreshToken ,
870+ material : HashMap :: from ( [
871+ ( "client_id" . to_string ( ) , "client-id" . to_string ( ) ) ,
872+ ( "refresh_token" . to_string ( ) , "old-refresh-token" . to_string ( ) ) ,
873+ ] ) ,
874+ secret_material_keys : vec ! [ "refresh_token" . to_string( ) ] ,
875+ expires_at_ms : 0 ,
876+ token_url : format ! ( "{}/token" , mock_server. uri( ) ) ,
877+ scopes : vec ! [ "https://graph.microsoft.com/.default" . to_string( ) ] ,
878+ refresh_before_seconds : 30 ,
879+ max_lifetime_seconds : 60 ,
880+ } ,
881+ )
882+ . unwrap ( ) ;
883+ put_refresh_state ( & store, & state) . await . unwrap ( ) ;
884+
885+ let refreshed =
886+ refresh_provider_credential ( & store, "my-delegated-graph" , "MS_GRAPH_ACCESS_TOKEN" )
887+ . await
888+ . unwrap ( ) ;
889+ assert_eq ! ( refreshed. status, "refreshed" ) ;
890+ assert ! ( refreshed. expires_at_ms > 0 ) ;
891+
892+ let stored_provider = store
893+ . get_message_by_name :: < Provider > ( "my-delegated-graph" )
894+ . await
895+ . unwrap ( )
896+ . unwrap ( ) ;
897+ assert_eq ! (
898+ stored_provider. credentials. get( "MS_GRAPH_ACCESS_TOKEN" ) ,
899+ Some ( & "delegated-graph-token" . to_string( ) )
900+ ) ;
901+ assert_eq ! (
902+ stored_provider
903+ . credential_expires_at_ms
904+ . get( "MS_GRAPH_ACCESS_TOKEN" ) ,
905+ Some ( & refreshed. expires_at_ms)
906+ ) ;
907+
908+ let stored_state = get_refresh_state ( & store, provider. object_id ( ) , "MS_GRAPH_ACCESS_TOKEN" )
909+ . await
910+ . unwrap ( )
911+ . unwrap ( ) ;
912+ assert_eq ! (
913+ stored_state. material. get( "refresh_token" ) ,
914+ Some ( & "rotated-refresh-token" . to_string( ) )
915+ ) ;
916+ assert ! (
917+ stored_state
918+ . secret_material_keys
919+ . iter( )
920+ . any( |key| key == "refresh_token" )
921+ ) ;
922+ }
923+
800924 #[ tokio:: test]
801925 async fn google_service_account_refresh_mints_and_persists_access_token ( ) {
802926 let mock_server = MockServer :: start ( ) . await ;
0 commit comments