Skip to content

Commit 075c35c

Browse files
Merge pull request #12 from Spacecraft-Software/token-refresh
feat(vault): token persistence + refresh
2 parents 602c082 + f9d9eef commit 075c35c

7 files changed

Lines changed: 263 additions & 91 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ range may break in any release.
1010

1111
### Added
1212

13+
- **Token persistence + refresh.** The OAuth2 refresh token is now kept and
14+
reused, so a cache/PIN/offline session can become fully capable and a
15+
long-lived session survives access-token expiry.
16+
- `vault-api`: `BitwardenClient` keeps the `refresh_token` from
17+
`login_password`, exposes it (`refresh_token()`), and can be seeded with one
18+
(`with_refresh_token`). New `refresh()` runs `grant_type=refresh_token`. The
19+
four server ops (`sync`/`create`/`update`/`delete`) route through one
20+
`send_with_auth` helper that, on a `401` with a refresh token held,
21+
refreshes once and retries.
22+
- The refresh token is persisted **encrypted under the user key** in the cache
23+
(`refresh_token` envelope field) on online unlock + every `sync`, and
24+
recovered into the in-memory session on a cache/PIN unlock.
25+
- New `Vault::ensure_online`: a token-less session (offline / PIN) **lazily
26+
upgrades** to online on its first `sync`/`add`/`edit`/`remove` by building a
27+
client from the stored refresh token and refreshing — so PIN unlock stays
28+
fast/offline but server ops "just work" when the network is up. A genuinely
29+
offline box (or no stored refresh token) still returns `Error::Offline`.
30+
- Tests: store refresh-token round-trip; `ensure_online` returns `Offline`
31+
without a client/token and `Ok` with a live client; refresh-token recovery
32+
from the cache on offline unlock. No new dependencies.
33+
1334
- **PIN unlock.** Unlock the vault with a short PIN instead of the master
1435
password (like the Bitwarden extension/desktop), built on the encrypted cache.
1536
- `vault pin set` (requires an unlocked agent) encrypts the user key under a

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ vault pin status # enrolled? attempts remaining?
7272
vault pin disable # forget the PIN
7373
```
7474

75-
A PIN session is **read-only** (browse / `get` / copy from the encrypted cache);
76-
`sync` and edits need a master-password unlock. Five wrong PINs disable the PIN
77-
and require a master-password unlock — bounding brute-force of a short secret.
75+
A PIN session reads from the encrypted cache, and — once the network is
76+
reachable — transparently goes online for `sync` and edits by refreshing a
77+
stored token (no master password needed); only a genuinely offline box stays
78+
read-only. Five wrong PINs disable the PIN and require a master-password unlock
79+
— bounding brute-force of a short secret.
7880

7981
## Configuration
8082

crates/vault-agent/src/state.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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");

crates/vault-agent/src/unlock.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ async fn online_unlock(
8787
let sync = client.sync().await.map_err(api_err)?;
8888
let (ciphers, folders) = ciphers_and_folders(&sync, &user_enc, &user_mac);
8989

90+
// Capture the refresh token before the client is moved into the vault, so
91+
// it can be persisted (encrypted) and reused after a cache/PIN unlock.
92+
let refresh_token = client.refresh_token().map(|s| Zeroizing::new(s.to_owned()));
93+
9094
// `client` holds the access token internally (`login_password` stashed it).
9195
// Hand it to the vault so Sync / Remove / Edit / Add reuse the session.
9296
let vault = Vault {
@@ -99,6 +103,7 @@ async fn online_unlock(
99103
client: Some(client),
100104
protected_user_key: encrypted_user_key.to_owned(),
101105
kdf: params,
106+
refresh_token,
102107
device_id: device.to_string(),
103108
last_sync: now_iso(),
104109
};
@@ -194,6 +199,13 @@ pub fn vault_from_user_key(
194199
let kdf = cache
195200
.kdf
196201
.ok_or_else(|| IpcError::Internal("cached account has no KDF params".to_owned()))?;
202+
// Recover the refresh token (encrypted under the user key) so this offline /
203+
// PIN session can lazily go online. A decode failure just leaves it absent.
204+
let refresh_token = cache.refresh_token.as_deref().and_then(|enc| {
205+
let parsed = vault_core::EncString::parse(enc).ok()?;
206+
let bytes = parsed.decrypt(&user_enc, &user_mac).ok()?;
207+
String::from_utf8(bytes).ok().map(Zeroizing::new)
208+
});
197209
Ok(Vault {
198210
server: server.to_owned(),
199211
email: email.trim().to_lowercase(),
@@ -204,6 +216,7 @@ pub fn vault_from_user_key(
204216
client: None,
205217
protected_user_key: cache.protected_user_key.clone().unwrap_or_default(),
206218
kdf,
219+
refresh_token,
207220
device_id: cache.device_id.clone(),
208221
last_sync: cache.last_sync.clone(),
209222
})
@@ -525,9 +538,19 @@ mod tests {
525538
.unwrap();
526539
cache.protected_user_key = Some(protected);
527540
cache.kdf = Some(kdf);
541+
// A refresh token, encrypted under the user key, must round-trip back
542+
// into the recovered vault so an offline session can go online.
543+
cache.refresh_token = Some(
544+
vault_core::EncString::encrypt(&user_enc, &user_mac, b"my-refresh-token").serialize(),
545+
);
528546

529547
let vault = unlock_from_cache(&cache, "https://x", email, password).unwrap();
530548
assert!(vault.client.is_none(), "offline session has no token");
549+
assert_eq!(
550+
vault.refresh_token.as_deref().map(String::as_str),
551+
Some("my-refresh-token"),
552+
"refresh token recovered from cache"
553+
);
531554
assert_eq!(vault.ciphers.len(), 1);
532555
assert_eq!(
533556
vault.ciphers[0]

0 commit comments

Comments
 (0)