@@ -604,12 +604,15 @@ impl TokenDispatcher {
604604 match Secrets :: is_read_only ( secrets_path) . await {
605605 Ok ( true ) => Ok ( ( ) ) ,
606606 Ok ( false ) => {
607- // If the payload is a PkceFlow, write the fresh refresh token if available.
607+ // Persist the fresh refresh token if the grant carries one, so that a rotated
608+ // refresh token isn't lost on the next load. Both the PKCE and refresh-token
609+ // grants can hold a refresh token that the auth server may have rotated.
608610 let refresh_token = match & oauth_session. payload {
609611 OAuthGrant :: PkceFlow ( payload) => {
610612 payload. refresh_token . as_ref ( ) . map ( |rt| & rt. refresh_token )
611613 }
612- _ => None ,
614+ OAuthGrant :: RefreshToken ( payload) => Some ( & payload. refresh_token ) ,
615+ OAuthGrant :: ExternallyManaged ( _) | OAuthGrant :: ClientCredentials ( _) => None ,
613616 } ;
614617
615618 let now = OffsetDateTime :: now_utc ( ) ;
@@ -1217,6 +1220,102 @@ updated_at = "2024-01-01T00:00:00Z"
12171220 } ) ;
12181221 }
12191222
1223+ /// When the auth server rotates the refresh token, a [`OAuthGrant::RefreshToken`] grant should
1224+ /// persist the new refresh token to the secrets file (not just the access token).
1225+ #[ test]
1226+ fn test_refresh_token_grant_persists_rotated_refresh_token ( ) {
1227+ let initial_refresh_token = "initial_refresh_token" ;
1228+ let rotated_refresh_token = "rotated_refresh_token" ;
1229+ let new_access_token = "new_access_token" ;
1230+
1231+ figment:: Jail :: expect_with ( |jail| {
1232+ jail. clear_env ( ) ;
1233+
1234+ let secrets_path = "secrets.toml" ;
1235+ let initial_secrets_file_contents = format ! (
1236+ r#"
1237+ [credentials]
1238+ [credentials.test]
1239+ [credentials.test.token_payload]
1240+ access_token = "initial_access_token"
1241+ refresh_token = "{initial_refresh_token}"
1242+ updated_at = "2024-01-01T00:00:00Z"
1243+ "#
1244+ ) ;
1245+ jail. create_file ( secrets_path, & initial_secrets_file_contents)
1246+ . expect ( "should create test secrets.toml" ) ;
1247+
1248+ let rt = tokio:: runtime:: Runtime :: new ( ) . unwrap ( ) ;
1249+ rt. block_on ( async {
1250+ let mock_server = MockServer :: start_async ( ) . await ;
1251+ let oidc_mock = mock_server
1252+ . mock_async ( |when, then| {
1253+ when. method ( GET ) . path ( "/.well-known/openid-configuration" ) ;
1254+ then. status ( 200 )
1255+ . json_body_obj ( & oidc:: Discovery :: new_for_test (
1256+ mock_server. base_url ( ) . parse ( ) . unwrap ( ) ,
1257+ ) ) ;
1258+ } )
1259+ . await ;
1260+ let issuer_mock = mock_server
1261+ . mock_async ( |when, then| {
1262+ when. method ( POST ) . path ( "/v1/token" ) ;
1263+ then. status ( 200 ) . json_body_obj ( & RefreshTokenResponse {
1264+ access_token : SecretAccessToken :: from ( new_access_token) ,
1265+ refresh_token : Some ( SecretRefreshToken :: from ( rotated_refresh_token) ) ,
1266+ } ) ;
1267+ } )
1268+ . await ;
1269+
1270+ let dispatcher: TokenDispatcher = OAuthSession :: from_refresh_token (
1271+ RefreshToken :: new ( SecretRefreshToken :: from ( initial_refresh_token) ) ,
1272+ AuthServer {
1273+ client_id : "client_id" . to_string ( ) ,
1274+ issuer : mock_server. base_url ( ) ,
1275+ scopes : None ,
1276+ } ,
1277+ Some ( SecretAccessToken :: from ( "initial_access_token" ) ) ,
1278+ )
1279+ . into ( ) ;
1280+
1281+ dispatcher
1282+ . refresh (
1283+ & ConfigSource :: File {
1284+ settings_path : "" . into ( ) ,
1285+ secrets_path : secrets_path. into ( ) ,
1286+ } ,
1287+ "test" ,
1288+ )
1289+ . await
1290+ . expect ( "refresh should succeed" ) ;
1291+
1292+ oidc_mock. assert_async ( ) . await ;
1293+ issuer_mock. assert_async ( ) . await ;
1294+ } ) ;
1295+
1296+ // The rotated refresh token (and the new access token) should be persisted.
1297+ let payload = Secrets :: load_from_path ( & secrets_path. into ( ) )
1298+ . expect ( "should load secrets" )
1299+ . credentials
1300+ . remove ( "test" )
1301+ . expect ( "should have test credentials" )
1302+ . token_payload
1303+ . expect ( "should have token payload" ) ;
1304+ assert_eq ! (
1305+ payload. refresh_token. unwrap( ) ,
1306+ SecretRefreshToken :: from( rotated_refresh_token) ,
1307+ "rotated refresh token should be persisted to the secrets file"
1308+ ) ;
1309+ assert_eq ! (
1310+ payload. access_token. unwrap( ) ,
1311+ SecretAccessToken :: from( new_access_token) ,
1312+ "new access token should be persisted to the secrets file"
1313+ ) ;
1314+
1315+ Ok ( ( ) )
1316+ } ) ;
1317+ }
1318+
12201319 #[ test]
12211320 fn test_auth_session_debug_fmt ( ) {
12221321 let session = OAuthSession {
0 commit comments