@@ -43,6 +43,11 @@ pub struct Vault {
4343 pub protected_user_key : String ,
4444 /// Account KDF parameters (for offline master-key derivation / re-persist).
4545 pub kdf : vault_core:: kdf:: KdfParams ,
46+ /// `OAuth2` refresh token (decrypted, in memory) when one is available — from
47+ /// the login token on an online unlock, or decrypted from the cache on an
48+ /// offline/PIN unlock. Lets a token-less session go online via
49+ /// [`Vault::ensure_online`] and is persisted (encrypted) by `persist_cache`.
50+ pub refresh_token : Option < Zeroizing < String > > ,
4651 /// Stable device id this session unlocked with (persisted to the cache).
4752 pub device_id : String ,
4853 /// Most recent sync time (ISO 8601 UTC), or `None` if never synced.
@@ -71,10 +76,59 @@ impl Vault {
7176 . map_err ( |e| IpcError :: Internal ( format ! ( "encrypt cache: {e}" ) ) ) ?;
7277 cache. protected_user_key = Some ( self . protected_user_key . clone ( ) ) ;
7378 cache. kdf = Some ( self . kdf ) ;
79+ // Persist the refresh token encrypted under the user key, so a later
80+ // cache/PIN unlock can recover it and go online without the password.
81+ if let Some ( rt) = self . refresh_token . as_ref ( ) {
82+ cache. refresh_token =
83+ Some ( EncString :: encrypt ( & self . user_enc , & self . user_mac , rt. as_bytes ( ) ) . serialize ( ) ) ;
84+ }
7485 vault_store:: save_to_dir ( & dir, & cache)
7586 . map_err ( |e| IpcError :: Internal ( format ! ( "write cache: {e}" ) ) ) ?;
7687 Ok ( ( ) )
7788 }
89+
90+ /// Ensure the vault has a live, authenticated client — establishing one
91+ /// from the held refresh token if the session was unlocked from cache
92+ /// (offline / PIN). Returns `Error::Offline` when there's no token and no
93+ /// way to get one (truly offline, or no refresh token persisted).
94+ ///
95+ /// # Errors
96+ ///
97+ /// [`IpcError::Offline`] if no client can be established;
98+ /// [`IpcError::Internal`] on a malformed server URL.
99+ pub async fn ensure_online ( & mut self ) -> Result < ( ) , IpcError > {
100+ if self . client . is_some ( ) {
101+ return Ok ( ( ) ) ;
102+ }
103+ let Some ( refresh) = self . refresh_token . as_ref ( ) else {
104+ return Err ( IpcError :: Offline ) ;
105+ } ;
106+ let urls = vault_api:: BaseUrls :: self_hosted ( & self . server )
107+ . map_err ( |e| IpcError :: Internal ( e. to_string ( ) ) ) ?;
108+ let device =
109+ uuid:: Uuid :: parse_str ( & self . device_id ) . unwrap_or_else ( |_| uuid:: Uuid :: new_v4 ( ) ) ;
110+ let mut client = vault_api:: BitwardenClient :: with_refresh_token (
111+ urls,
112+ device,
113+ "vault-agent" ,
114+ rt_string ( refresh) ,
115+ )
116+ . map_err ( |e| IpcError :: Internal ( e. to_string ( ) ) ) ?;
117+ // A failed refresh (network down, or token revoked) → stay offline.
118+ client. refresh ( ) . await . map_err ( |_| IpcError :: Offline ) ?;
119+ // Keep the (possibly rotated) refresh token in memory for re-persist.
120+ if let Some ( rt) = client. refresh_token ( ) {
121+ self . refresh_token = Some ( Zeroizing :: new ( rt. to_owned ( ) ) ) ;
122+ }
123+ self . client = Some ( client) ;
124+ Ok ( ( ) )
125+ }
126+ }
127+
128+ /// Clone a `Zeroizing<String>` refresh token into a plain `String` for the API
129+ /// call (the client re-wraps it in `Zeroizing`).
130+ fn rt_string ( rt : & Zeroizing < String > ) -> String {
131+ rt. as_str ( ) . to_owned ( )
78132}
79133
80134/// Wrong-PIN attempts allowed before the PIN is wiped and a master-password
@@ -335,6 +389,7 @@ impl AgentState {
335389 . decrypt_name ( & v. user_enc , & v. user_mac )
336390 . map_err ( |e| IpcError :: Decrypt ( e. to_string ( ) ) ) ?
337391 . unwrap_or_else ( || "<unnamed>" . to_owned ( ) ) ;
392+ v. ensure_online ( ) . await ?;
338393 v. client
339394 . as_mut ( )
340395 . ok_or ( IpcError :: Offline ) ?
@@ -389,6 +444,7 @@ impl AgentState {
389444 primary_uri : w. uri ,
390445 } ;
391446 let mut cipher = Cipher :: from_plain ( & plain, & v. user_enc , & v. user_mac ) ;
447+ v. ensure_online ( ) . await ?;
392448 let id = v
393449 . client
394450 . as_mut ( )
@@ -431,6 +487,7 @@ impl AgentState {
431487 apply_cipher_edits ( & mut cipher, & overlay, & v. user_enc , & v. user_mac ) ;
432488
433489 let id = cipher. id . clone ( ) ;
490+ v. ensure_online ( ) . await ?;
434491 v. client
435492 . as_mut ( )
436493 . ok_or ( IpcError :: Offline ) ?
@@ -455,6 +512,7 @@ impl AgentState {
455512 /// that surfaces as `IpcError::Network`.
456513 pub async fn resync ( & mut self ) -> Result < ( ) , IpcError > {
457514 let v = self . vault . as_mut ( ) . ok_or ( IpcError :: Locked ) ?;
515+ v. ensure_online ( ) . await ?;
458516 let sync = v
459517 . client
460518 . as_mut ( )
@@ -1120,6 +1178,7 @@ mod tests {
11201178 memory_kib : None ,
11211179 parallelism : None ,
11221180 } ,
1181+ refresh_token : None ,
11231182 device_id : "00000000-0000-0000-0000-000000000000" . into ( ) ,
11241183 last_sync : None ,
11251184 }
@@ -1147,6 +1206,22 @@ mod tests {
11471206 assert ! ( s. list_entries( ) . is_ok( ) ) ;
11481207 }
11491208
1209+ #[ test]
1210+ fn ensure_online_ok_with_client_offline_without ( ) {
1211+ let rt = tokio:: runtime:: Builder :: new_current_thread ( )
1212+ . build ( )
1213+ . expect ( "rt" ) ;
1214+ // A live client → Ok with no network call (short-circuits).
1215+ let mut v = stub_vault ( ) ;
1216+ assert ! ( rt. block_on( v. ensure_online( ) ) . is_ok( ) ) ;
1217+ // No client and no refresh token → Offline (can't establish a session).
1218+ let mut v = offline_vault ( ) ;
1219+ assert ! ( matches!(
1220+ rt. block_on( v. ensure_online( ) ) ,
1221+ Err ( IpcError :: Offline )
1222+ ) ) ;
1223+ }
1224+
11501225 #[ test]
11511226 fn account_dir_name_is_filesystem_safe_and_distinct ( ) {
11521227 let a = account_dir_name ( "https://vault.example.org" , "Me@Example.org" ) ;
0 commit comments