返回 CodeWhale
account.rs
根目录 / crates / secrets / src / account.rs
1 //! Shared secure-storage contract for Codewhale account sessions.
2 //!
3 //! The CLI owns device authorization and refresh traffic. This module owns the
4 //! durable record written by that flow so the TUI and Runtime API can recognize
5 //! the same account without copying tokens or inventing another login protocol.
6
7 use std::collections::BTreeMap;
8
9 use chrono::{DateTime, Utc};
10 use serde::{Deserialize, Serialize};
11 use sha2::{Digest, Sha256};
12 use thiserror::Error;
13
14 use crate::{Secrets, SecretsError};
15
16 /// Production account API origin used when no override is configured.
17 pub const DEFAULT_ACCOUNT_API_BASE: &str = "https://api.codewhale.net";
18 /// Environment variable that selects the account API origin.
19 pub const ACCOUNT_API_BASE_ENV: &str = "CODEWHALE_CLOUD_API_BASE";
20 /// OS credential-manager service shared by CLI, TUI, and Runtime API.
21 pub const ACCOUNT_KEYRING_SERVICE: &str = "codewhale-cloud";
22 /// Current serialized account-session record version.
23 pub const ACCOUNT_SESSION_SCHEMA_VERSION: u8 = 1;
24
25 const MAX_TOKEN_BYTES: usize = 64 * 1024;
26 const MAX_SCOPES: usize = 64;
27 const MAX_SCOPE_BYTES: usize = 128;
28
29 /// A short-lived access credential and its durable refresh/session metadata.
30 ///
31 /// This type intentionally does not implement `Debug`, preventing accidental
32 /// token disclosure through ordinary diagnostic formatting.
33 #[derive(Clone, Deserialize, Serialize)]
34 #[serde(rename_all = "camelCase")]
35 pub struct AccountAuthBundle {
36 /// Authentication scheme returned by the account service.
37 pub token_type: String,
38 /// Short-lived bearer credential. Never serialize this outside secure storage.
39 pub access_token: String,
40 /// Refresh credential. Never serialize this outside secure storage.
41 pub refresh_token: String,
42 /// Durable server session metadata, when returned by the service.
43 #[serde(default)]
44 pub session: Option<AccountSession>,
45 /// Cached non-secret account record, when returned by the service.
46 #[serde(default)]
47 pub user: Option<AccountUser>,
48 }
49
50 /// Durable account-session metadata supplied by the account service.
51 #[derive(Clone, Default, Deserialize, Serialize)]
52 #[serde(rename_all = "camelCase")]
53 pub struct AccountSession {
54 /// Durable session identifier shared across Codewhale surfaces.
55 pub id: String,
56 /// Authorization provider used to establish the session.
57 #[serde(default)]
58 pub provider: String,
59 /// Linked authorization providers recorded by the account service.
60 #[serde(default)]
61 pub providers: Vec<String>,
62 /// Explicit bounded scopes granted to this session.
63 #[serde(default)]
64 pub scopes: Vec<String>,
65 /// Access-credential expiration in RFC 3339 format.
66 #[serde(default)]
67 pub expires_at: String,
68 /// Refresh/session expiration in RFC 3339 format.
69 #[serde(default)]
70 pub refresh_expires_at: String,
71 /// Explicit server session state when supplied.
72 #[serde(default)]
73 pub status: String,
74 /// Explicit revocation timestamp when supplied.
75 #[serde(default)]
76 pub revoked_at: String,
77 }
78
79 /// Cached account identity and non-secret presentation fields.
80 #[derive(Clone, Default, Deserialize, Serialize)]
81 #[serde(rename_all = "camelCase")]
82 pub struct AccountUser {
83 /// Stable Codewhale account identifier.
84 #[serde(default)]
85 pub id: String,
86 /// User-facing account name.
87 #[serde(default)]
88 pub display_name: String,
89 /// Account email returned by the service. Runtime metadata never exposes it.
90 #[serde(default)]
91 pub email: String,
92 /// Account residency region returned by the service.
93 #[serde(default)]
94 pub region: String,
95 /// Account plan returned by the service.
96 #[serde(default)]
97 pub plan: String,
98 /// Provider-key presence metadata; values never contain provider credentials.
99 #[serde(default)]
100 pub model_keys: BTreeMap<String, AccountModelKeyState>,
101 }
102
103 /// Non-secret provider-key presence metadata returned by the account service.
104 #[derive(Clone, Default, Deserialize, Serialize)]
105 #[serde(rename_all = "camelCase")]
106 pub struct AccountModelKeyState {
107 /// Whether the account service reports a credential for this provider.
108 #[serde(default)]
109 pub configured: bool,
110 /// Service-reported credential state (for example `active`, `invalid`).
111 ///
112 /// Non-secret presentation metadata: it says whether a stored key is
113 /// usable without ever revealing the key. Absent when the service did not
114 /// state one.
115 #[serde(default)]
116 pub state: Option<String>,
117 /// Non-secret label the account holder gave the stored credential.
118 #[serde(default)]
119 pub label: Option<String>,
120 }
121
122 /// Versioned secure-storage envelope shared by every local Codewhale surface.
123 ///
124 /// This type intentionally does not implement `Debug` because `bundle`
125 /// contains access and refresh credentials.
126 #[derive(Clone, Deserialize, Serialize)]
127 #[serde(rename_all = "camelCase")]
128 pub struct StoredAccountAuth {
129 /// Serialized record version.
130 pub schema_version: u8,
131 /// Exact canonical API origin that owns this session.
132 pub api_base: String,
133 /// Secret account bundle stored inside the credential manager.
134 pub bundle: AccountAuthBundle,
135 }
136
137 /// Normalized account states exposed by the token-free Runtime API contract.
138 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
139 #[serde(rename_all = "snake_case")]
140 pub enum AccountSessionState {
141 /// No valid secure-store session was found for the selected profile/origin.
142 SignedOut,
143 /// The cached session and access credential are within their recorded lifetime.
144 Authenticated,
145 /// Durable identity remains cached, but the access credential has expired.
146 OfflineCached,
147 /// The durable refresh/session lifetime has ended.
148 Expired,
149 /// The stored session carries an explicit revocation receipt.
150 Revoked,
151 }
152
153 /// Token-free account receipt returned by `GET /v1/runtime/info`.
154 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155 pub struct RuntimeAccountInfo {
156 /// Runtime account receipt schema version.
157 pub schema_version: u8,
158 /// Current account-session state.
159 pub state: AccountSessionState,
160 /// Exact account API origin used to locate the secure session.
161 pub api_base: String,
162 /// Stable account identifier, present only when read from secure storage.
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub account_id: Option<String>,
165 /// Durable session identifier, present only when read from secure storage.
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub session_id: Option<String>,
168 /// Explicit session scopes from secure storage; never inferred from identity.
169 pub scopes: Vec<String>,
170 /// Access-credential expiration, when the stored value is valid RFC 3339.
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub expires_at: Option<String>,
173 }
174
175 impl RuntimeAccountInfo {
176 /// Build the fail-closed signed-out receipt for an API origin.
177 #[must_use]
178 pub fn signed_out(api_base: impl Into<String>) -> Self {
179 Self {
180 schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
181 state: AccountSessionState::SignedOut,
182 api_base: api_base.into(),
183 account_id: None,
184 session_id: None,
185 scopes: Vec::new(),
186 expires_at: None,
187 }
188 }
189 }
190
191 /// Failures while selecting, decoding, or validating account session storage.
192 #[derive(Debug, Error)]
193 pub enum AccountSessionError {
194 /// Underlying credential-store failure.
195 #[error(transparent)]
196 Secrets(#[from] SecretsError),
197 /// Stored session JSON could not be decoded.
198 #[error("the local Codewhale account session is unreadable")]
199 UnreadableRecord(#[source] serde_json::Error),
200 /// Stored or newly returned authentication credentials are malformed.
201 #[error("the Codewhale account session contains invalid credentials")]
202 InvalidCredentials,
203 /// No approved secure session backend is available.
204 #[error(
205 "Codewhale account sessions could not open a secret store; check that HOME is writable or set CODEWHALE_HOME to an absolute path"
206 )]
207 SecureStoreUnavailable,
208 }
209
210 /// Profile- and origin-scoped view of the shared account credential record.
211 #[derive(Clone)]
212 pub struct AccountSessionStore {
213 secrets: Secrets,
214 auth_slot: String,
215 api_base: String,
216 }
217
218 /// Opaque account-store revision. Contains credentials; deliberately no Debug.
219 #[derive(Clone)]
220 pub struct AccountSessionSnapshot {
221 raw: Option<String>,
222 slot: String,
223 api_base: String,
224 }
225
226 impl AccountSessionSnapshot {
227 /// Decode and validate the captured record without re-reading storage.
228 pub fn load(&self) -> Result<Option<StoredAccountAuth>, AccountSessionError> {
229 let Some(raw) = &self.raw else {
230 return Ok(None);
231 };
232 let stored: StoredAccountAuth =
233 serde_json::from_str(raw).map_err(AccountSessionError::UnreadableRecord)?;
234 if stored.schema_version != ACCOUNT_SESSION_SCHEMA_VERSION
235 || stored.api_base != self.api_base
236 {
237 return Ok(None);
238 }
239 validate_account_auth_bundle(&stored.bundle)?;
240 Ok(Some(stored))
241 }
242 }
243
244 /// Locked account entry. Never reacquire its store lock from a callback.
245 pub struct AccountSessionTransaction<'a> {
246 raw: &'a mut Option<String>,
247 slot: &'a str,
248 api_base: &'a str,
249 }
250 impl AccountSessionTransaction<'_> {
251 /// Capture the exact current revision without unlocking.
252 pub fn snapshot(&self) -> AccountSessionSnapshot {
253 AccountSessionSnapshot {
254 raw: self.raw.clone(),
255 slot: self.slot.into(),
256 api_base: self.api_base.into(),
257 }
258 }
259 /// Decode the current record; errors may be recovered by explicit logout.
260 pub fn load(&self) -> Result<Option<StoredAccountAuth>, AccountSessionError> {
261 self.snapshot().load()
262 }
263 /// Compare an admitted snapshot without reacquiring the lock.
264 pub fn matches(&self, snapshot: &AccountSessionSnapshot) -> bool {
265 self.slot == snapshot.slot
266 && self.api_base == snapshot.api_base
267 && *self.raw == snapshot.raw
268 }
269 /// Replace this entry on successful transaction completion.
270 pub fn replace(&mut self, bundle: AccountAuthBundle) -> Result<(), AccountSessionError> {
271 validate_account_auth_bundle(&bundle)?;
272 *self.raw = Some(
273 serde_json::to_string(&StoredAccountAuth {
274 schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
275 api_base: self.api_base.into(),
276 bundle,
277 })
278 .map_err(AccountSessionError::UnreadableRecord)?,
279 );
280 Ok(())
281 }
282 /// Remove this entry on successful transaction completion.
283 pub fn clear(&mut self) {
284 *self.raw = None;
285 }
286 }
287
288 impl AccountSessionStore {
289 /// Create a store view for one local profile and one validated API origin.
290 #[must_use]
291 pub fn new(secrets: Secrets, profile: Option<&str>, api_base: &str) -> Self {
292 let profile = normalize_account_profile(profile);
293 let api_base = api_base.trim().trim_end_matches('/').to_string();
294 Self {
295 auth_slot: account_auth_slot(&profile, &api_base),
296 secrets,
297 api_base,
298 }
299 }
300
301 /// Load and validate the selected account session from secure storage.
302 pub fn load(&self) -> Result<Option<StoredAccountAuth>, AccountSessionError> {
303 self.snapshot()?.load()
304 }
305
306 /// Capture exact account-store bytes, including malformed/obsolete records
307 /// so logout can clear only the revision it inspected.
308 pub fn snapshot(&self) -> Result<AccountSessionSnapshot, AccountSessionError> {
309 Ok(AccountSessionSnapshot {
310 raw: self.secrets.get(&self.auth_slot)?,
311 slot: self.auth_slot.clone(),
312 api_base: self.api_base.clone(),
313 })
314 }
315
316 /// Save a renewed bundle only if the captured session has not changed.
317 /// Returns the exact committed revision, or None on a concurrent change.
318 pub fn save_if_unchanged(
319 &self,
320 expected: &AccountSessionSnapshot,
321 bundle: AccountAuthBundle,
322 ) -> Result<Option<AccountSessionSnapshot>, AccountSessionError> {
323 validate_account_auth_bundle(&bundle)?;
324 if expected.slot != self.auth_slot || expected.api_base != self.api_base {
325 return Err(AccountSessionError::InvalidCredentials);
326 }
327 let raw = serde_json::to_string(&StoredAccountAuth {
328 schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
329 api_base: self.api_base.clone(),
330 bundle,
331 })
332 .map_err(AccountSessionError::UnreadableRecord)?;
333 if !self
334 .secrets
335 .compare_exchange(&self.auth_slot, expected.raw.as_deref(), Some(&raw))?
336 {
337 return Ok(None);
338 }
339 Ok(Some(AccountSessionSnapshot {
340 raw: Some(raw),
341 slot: self.auth_slot.clone(),
342 api_base: self.api_base.clone(),
343 }))
344 }
345
346 /// Remove exactly the captured revision; never erase a newer sign-in.
347 pub fn clear_if_unchanged(
348 &self,
349 expected: &AccountSessionSnapshot,
350 ) -> Result<bool, AccountSessionError> {
351 if expected.slot != self.auth_slot || expected.api_base != self.api_base {
352 return Err(AccountSessionError::InvalidCredentials);
353 }
354 Ok(self
355 .secrets
356 .compare_exchange(&self.auth_slot, expected.raw.as_deref(), None)?)
357 }
358
359 /// Serialize an account lifecycle operation with every backend writer.
360 /// Return errors roll back local changes; no nested store operations allowed.
361 pub fn with_transaction<T, E>(
362 &self,
363 operation: impl FnOnce(&mut AccountSessionTransaction<'_>) -> Result<T, E>,
364 ) -> Result<T, E>
365 where
366 E: From<AccountSessionError>,
367 {
368 let mut outcome = None;
369 let result = self.secrets.with_entry_transaction(&self.auth_slot, |raw| {
370 let mut transaction = AccountSessionTransaction {
371 raw,
372 slot: &self.auth_slot,
373 api_base: &self.api_base,
374 };
375 match operation(&mut transaction) {
376 Ok(value) => {
377 outcome = Some(Ok(value));
378 Ok(())
379 }
380 Err(error) => {
381 outcome = Some(Err(error));
382 Err(SecretsError::Keyring("Account transaction aborted".into()))
383 }
384 }
385 });
386 match outcome {
387 Some(Err(error)) => Err(error),
388 Some(Ok(value)) => {
389 result.map_err(AccountSessionError::from).map_err(E::from)?;
390 Ok(value)
391 }
392 None => {
393 result.map_err(AccountSessionError::from).map_err(E::from)?;
394 Err(E::from(AccountSessionError::InvalidCredentials))
395 }
396 }
397 }
398
399 /// Validate and save an account bundle in the selected secure-store slot.
400 pub fn save(&self, bundle: AccountAuthBundle) -> Result<(), AccountSessionError> {
401 validate_account_auth_bundle(&bundle)?;
402 let stored = StoredAccountAuth {
403 schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
404 api_base: self.api_base.clone(),
405 bundle,
406 };
407 let raw = serde_json::to_string(&stored).map_err(AccountSessionError::UnreadableRecord)?;
408 self.secrets.set(&self.auth_slot, &raw)?;
409 Ok(())
410 }
411
412 /// Remove only the selected profile/origin account session.
413 pub fn clear(&self) -> Result<(), AccountSessionError> {
414 self.secrets.delete(&self.auth_slot)?;
415 Ok(())
416 }
417
418 /// Read a token-free runtime receipt at a caller-supplied clock instant.
419 pub fn runtime_info_at(
420 &self,
421 now: DateTime<Utc>,
422 ) -> Result<RuntimeAccountInfo, AccountSessionError> {
423 let Some(stored) = self.load()? else {
424 return Ok(RuntimeAccountInfo::signed_out(self.api_base.clone()));
425 };
426 Ok(runtime_account_info_from_stored(stored, now))
427 }
428 }
429
430 /// Select the approved account-session backend shared by CLI, TUI, and Runtime.
431 ///
432 /// Account sessions live in the private `0600` Codewhale secrets file. The OS
433 /// keyring is not used: on macOS every unsigned or rebuilt `codewhale` binary
434 /// is a new Keychain ACL principal, so reading `codewhale-cloud-auth-v1-*`
435 /// under the legacy `deepseek` service pops a password dialog on every start.
436 pub fn secure_account_session_secrets() -> Result<Secrets, AccountSessionError> {
437 Ok(Secrets::file_backed())
438 }
439
440 /// Normalize an optional CLI/TUI profile to the durable account slot label.
441 #[must_use]
442 pub fn normalize_account_profile(profile: Option<&str>) -> String {
443 profile
444 .map(str::trim)
445 .filter(|value| !value.is_empty())
446 .unwrap_or("default")
447 .to_string()
448 }
449
450 /// Derive the opaque secure-store slot for a profile and account API origin.
451 #[must_use]
452 pub fn account_auth_slot(profile: &str, api_base: &str) -> String {
453 let mut digest = Sha256::new();
454 digest.update(profile.as_bytes());
455 digest.update([0]);
456 digest.update(api_base.as_bytes());
457 let digest = digest.finalize();
458 const HEX: &[u8; 16] = b"0123456789abcdef";
459 let mut encoded = String::with_capacity(digest.len() * 2);
460 for byte in digest {
461 encoded.push(HEX[(byte >> 4) as usize] as char);
462 encoded.push(HEX[(byte & 0x0f) as usize] as char);
463 }
464 format!("codewhale-cloud-auth-v1-{encoded}")
465 }
466
467 /// Validate the credential-bearing portion of an account response or record.
468 pub fn validate_account_auth_bundle(bundle: &AccountAuthBundle) -> Result<(), AccountSessionError> {
469 if !bundle.token_type.eq_ignore_ascii_case("bearer")
470 || bundle.access_token.trim().is_empty()
471 || bundle.refresh_token.trim().is_empty()
472 || bundle.access_token.len() > MAX_TOKEN_BYTES
473 || bundle.refresh_token.len() > MAX_TOKEN_BYTES
474 || bundle
475 .access_token
476 .chars()
477 .any(|character| character.is_control() || character.is_whitespace())
478 || bundle
479 .refresh_token
480 .chars()
481 .any(|character| character.is_control() || character.is_whitespace())
482 {
483 return Err(AccountSessionError::InvalidCredentials);
484 }
485 Ok(())
486 }
487
488 fn runtime_account_info_from_stored(
489 stored: StoredAccountAuth,
490 now: DateTime<Utc>,
491 ) -> RuntimeAccountInfo {
492 let account_id = stored
493 .bundle
494 .user
495 .as_ref()
496 .map(|user| user.id.trim())
497 .filter(|value| !value.is_empty())
498 .map(str::to_string);
499 let session = stored.bundle.session.as_ref();
500 let session_id = session
501 .map(|session| session.id.trim())
502 .filter(|value| !value.is_empty())
503 .map(str::to_string);
504 let expires_at = session
505 .map(|session| session.expires_at.trim())
506 .filter(|value| parse_rfc3339(value).is_some())
507 .map(str::to_string);
508 let scopes = normalized_scopes(session.map_or(&[], |session| &session.scopes));
509 let state = session.map_or(AccountSessionState::OfflineCached, |session| {
510 classify_session_state(session, now)
511 });
512 RuntimeAccountInfo {
513 schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
514 state,
515 api_base: stored.api_base,
516 account_id,
517 session_id,
518 scopes,
519 expires_at,
520 }
521 }
522
523 fn classify_session_state(session: &AccountSession, now: DateTime<Utc>) -> AccountSessionState {
524 let explicit = session.status.trim().to_ascii_lowercase();
525 if explicit == "revoked" || !session.revoked_at.trim().is_empty() {
526 return AccountSessionState::Revoked;
527 }
528 if explicit == "expired"
529 || parse_rfc3339(&session.refresh_expires_at).is_some_and(|expiry| expiry <= now)
530 {
531 return AccountSessionState::Expired;
532 }
533 if explicit == "offline_cached"
534 || parse_rfc3339(&session.expires_at).is_some_and(|expiry| expiry <= now)
535 {
536 return AccountSessionState::OfflineCached;
537 }
538 AccountSessionState::Authenticated
539 }
540
541 fn parse_rfc3339(value: &str) -> Option<DateTime<Utc>> {
542 DateTime::parse_from_rfc3339(value.trim())
543 .ok()
544 .map(|value| value.with_timezone(&Utc))
545 }
546
547 fn normalized_scopes(scopes: &[String]) -> Vec<String> {
548 let mut scopes = scopes
549 .iter()
550 .map(|scope| scope.trim())
551 .filter(|scope| {
552 !scope.is_empty()
553 && scope.len() <= MAX_SCOPE_BYTES
554 && scope.bytes().all(|byte| {
555 byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'_' | b'-' | b'/')
556 })
557 })
558 .map(str::to_string)
559 .collect::<Vec<_>>();
560 scopes.sort();
561 scopes.dedup();
562 scopes.truncate(MAX_SCOPES);
563 scopes
564 }
565
566 #[cfg(test)]
567 mod tests {
568 use super::*;
569 use std::sync::Arc;
570
571 use crate::InMemoryKeyringStore;
572
573 /// Account sessions must never touch the OS keyring. Unsigned rebuilds
574 /// on macOS otherwise prompt on every `codewhale web` / TUI start.
575 #[test]
576 fn session_secrets_use_the_file_store_not_keychain() {
577 let _lock = crate::tests::env_lock();
578 let dir = tempfile::TempDir::new().expect("tempdir");
579 let home = dir.path().join("codewhale-home");
580 std::fs::create_dir_all(&home).expect("home");
581 unsafe { std::env::set_var("CODEWHALE_HOME", &home) };
582 unsafe { std::env::remove_var("CODEWHALE_SECRET_BACKEND") };
583 unsafe { std::env::remove_var("DEEPSEEK_SECRET_BACKEND") };
584 let secrets =
585 secure_account_session_secrets().expect("session secrets must resolve without opt-in");
586 let name = secrets.backend_name();
587 assert!(
588 name.to_lowercase().contains("file"),
589 "account sessions must not use Keychain: {name}"
590 );
591 assert!(!name.to_lowercase().contains("keychain"));
592 assert!(!name.to_lowercase().contains("keyring"));
593 }
594
595 fn auth(
596 account_id: &str,
597 session_id: &str,
598 expires_at: &str,
599 refresh_expires_at: &str,
600 ) -> AccountAuthBundle {
601 AccountAuthBundle {
602 token_type: "Bearer".to_string(),
603 access_token: "access-never-serialize".to_string(),
604 refresh_token: "refresh-never-serialize".to_string(),
605 session: Some(AccountSession {
606 id: session_id.to_string(),
607 scopes: vec!["identity:read".to_string(), "session:sync".to_string()],
608 expires_at: expires_at.to_string(),
609 refresh_expires_at: refresh_expires_at.to_string(),
610 ..AccountSession::default()
611 }),
612 user: Some(AccountUser {
613 id: account_id.to_string(),
614 email: "private@example.test".to_string(),
615 ..AccountUser::default()
616 }),
617 }
618 }
619
620 fn test_store() -> (Secrets, Arc<InMemoryKeyringStore>) {
621 let store = Arc::new(InMemoryKeyringStore::new());
622 (Secrets::new(store.clone()), store)
623 }
624
625 #[test]
626 fn runtime_receipt_is_token_free_and_preserves_only_explicit_scopes() {
627 let (secrets, _) = test_store();
628 let store = AccountSessionStore::new(secrets, Some("work"), "https://api.codewhale.net");
629 store
630 .save(auth(
631 "acct-1",
632 "session-1",
633 "2030-01-01T00:00:00Z",
634 "2031-01-01T00:00:00Z",
635 ))
636 .unwrap();
637
638 let info = store.runtime_info_at(Utc::now()).unwrap();
639 assert_eq!(info.state, AccountSessionState::Authenticated);
640 assert_eq!(info.account_id.as_deref(), Some("acct-1"));
641 assert_eq!(info.session_id.as_deref(), Some("session-1"));
642 assert_eq!(info.scopes, ["identity:read", "session:sync"]);
643 let json = serde_json::to_string(&info).unwrap();
644 for secret in [
645 "access-never-serialize",
646 "refresh-never-serialize",
647 "private@example.test",
648 ] {
649 assert!(!json.contains(secret));
650 }
651 }
652
653 #[test]
654 fn profiles_and_origins_preserve_same_account_and_cross_account_isolation() {
655 let (secrets, _) = test_store();
656 let default = AccountSessionStore::new(secrets.clone(), None, "https://api.codewhale.net");
657 let work =
658 AccountSessionStore::new(secrets.clone(), Some("work"), "https://api.codewhale.net");
659 let local = AccountSessionStore::new(secrets, None, "http://127.0.0.1:8787");
660 default.save(auth("acct-a", "session-a", "", "")).unwrap();
661 work.save(auth("acct-a", "session-b", "", "")).unwrap();
662 local.save(auth("acct-b", "session-c", "", "")).unwrap();
663
664 let now = Utc::now();
665 assert_eq!(
666 default.runtime_info_at(now).unwrap().account_id.as_deref(),
667 Some("acct-a")
668 );
669 assert_eq!(
670 work.runtime_info_at(now).unwrap().account_id.as_deref(),
671 Some("acct-a")
672 );
673 assert_eq!(
674 local.runtime_info_at(now).unwrap().account_id.as_deref(),
675 Some("acct-b")
676 );
677 default.clear().unwrap();
678 assert_eq!(
679 default.runtime_info_at(now).unwrap().state,
680 AccountSessionState::SignedOut
681 );
682 assert_eq!(
683 work.runtime_info_at(now).unwrap().state,
684 AccountSessionState::Authenticated
685 );
686 }
687
688 #[test]
689 fn signed_out_expired_offline_and_revoked_states_are_distinct() {
690 let (secrets, _) = test_store();
691 let store = AccountSessionStore::new(secrets, None, DEFAULT_ACCOUNT_API_BASE);
692 let now = DateTime::parse_from_rfc3339("2029-01-01T00:00:00Z")
693 .unwrap()
694 .with_timezone(&Utc);
695 assert_eq!(
696 store.runtime_info_at(now).unwrap().state,
697 AccountSessionState::SignedOut
698 );
699
700 store
701 .save(auth(
702 "acct",
703 "offline",
704 "2028-12-31T23:59:59Z",
705 "2029-12-31T23:59:59Z",
706 ))
707 .unwrap();
708 assert_eq!(
709 store.runtime_info_at(now).unwrap().state,
710 AccountSessionState::OfflineCached
711 );
712
713 store
714 .save(auth(
715 "acct",
716 "expired",
717 "2028-12-31T23:59:59Z",
718 "2028-12-31T23:59:59Z",
719 ))
720 .unwrap();
721 assert_eq!(
722 store.runtime_info_at(now).unwrap().state,
723 AccountSessionState::Expired
724 );
725
726 let mut revoked = auth("acct", "revoked", "2030-01-01T00:00:00Z", "");
727 revoked.session.as_mut().unwrap().status = "revoked".to_string();
728 store.save(revoked).unwrap();
729 assert_eq!(
730 store.runtime_info_at(now).unwrap().state,
731 AccountSessionState::Revoked
732 );
733 }
734 }
735
736 /// Keep device custody tied to the account session, inside the file store's
737 /// existing transaction. Refreshing access tokens within that owner preserves it.
738 pub(crate) fn invalidate_device_companion(
739 entries: &mut std::collections::HashMap<String, String>,
740 slot: &str,
741 replacement: Option<&str>,
742 ) {
743 let Some(suffix) = slot.strip_prefix("codewhale-cloud-auth-v1-") else {
744 return;
745 };
746 if suffix.len() != 64 || !suffix.bytes().all(|c| c.is_ascii_hexdigit()) {
747 return;
748 }
749 fn identity(raw: &str) -> Option<(String, String, String)> {
750 let stored: StoredAccountAuth = serde_json::from_str(raw).ok()?;
751 if stored.schema_version != ACCOUNT_SESSION_SCHEMA_VERSION {
752 return None;
753 }
754 let account = stored.bundle.user?.id;
755 let session = stored.bundle.session?.id;
756 if stored.api_base.is_empty() || account.is_empty() || session.is_empty() {
757 return None;
758 }
759 Some((stored.api_base, account, session))
760 }
761 let old = entries.get(slot).and_then(|raw| identity(raw));
762 let next = replacement.and_then(identity);
763 if old.is_none() || old != next {
764 entries.remove(&format!("codewhale-cloud-device-v1-{suffix}"));
765 }
766 }
767
768 #[cfg(test)]
769 mod snapshot_tests {
770 use super::*;
771 use std::sync::Arc;
772 fn bundle(id: &str) -> AccountAuthBundle {
773 AccountAuthBundle {
774 token_type: "Bearer".into(),
775 access_token: format!("access-{id}"),
776 refresh_token: format!("refresh-{id}"),
777 session: Some(AccountSession {
778 id: id.into(),
779 ..Default::default()
780 }),
781 user: Some(AccountUser {
782 id: id.into(),
783 ..Default::default()
784 }),
785 }
786 }
787 #[test]
788 fn exact_revision_cas_rejects_late_replace_and_clear_on_file_and_memory() {
789 let dir = tempfile::tempdir().unwrap();
790 for secrets in [
791 Secrets::new(Arc::new(crate::InMemoryKeyringStore::new())),
792 Secrets::new(Arc::new(crate::FileKeyringStore::new(
793 dir.path().join("secrets.json"),
794 ))),
795 ] {
796 let store = AccountSessionStore::new(secrets, None, DEFAULT_ACCOUNT_API_BASE);
797 store.save(bundle("old")).unwrap();
798 let old = store.snapshot().unwrap();
799 let other = store.clone();
800 std::thread::spawn(move || other.save(bundle("new")))
801 .join()
802 .unwrap()
803 .unwrap();
804 assert!(
805 store
806 .save_if_unchanged(&old, bundle("stale-refresh"))
807 .unwrap()
808 .is_none()
809 );
810 assert!(!store.clear_if_unchanged(&old).unwrap());
811 assert_eq!(
812 store.load().unwrap().unwrap().bundle.user.unwrap().id,
813 "new"
814 );
815 let latest = store.snapshot().unwrap();
816 assert!(store.clear_if_unchanged(&latest).unwrap());
817 assert!(store.load().unwrap().is_none());
818 }
819 }
820 #[test]
821 fn snapshot_compares_original_unknown_fields_and_scopes_store_identity() {
822 let secrets = Secrets::new(Arc::new(crate::InMemoryKeyringStore::new()));
823 let store = AccountSessionStore::new(secrets.clone(), None, DEFAULT_ACCOUNT_API_BASE);
824 store.save(bundle("account")).unwrap();
825 let slot = account_auth_slot("default", DEFAULT_ACCOUNT_API_BASE);
826 let mut value: serde_json::Value =
827 serde_json::from_str(&secrets.get(&slot).unwrap().unwrap()).unwrap();
828 value["futureField"] = true.into();
829 secrets.set(&slot, &value.to_string()).unwrap();
830 let snapshot = store.snapshot().unwrap();
831 let other =
832 AccountSessionStore::new(secrets.clone(), Some("other"), DEFAULT_ACCOUNT_API_BASE);
833 assert!(other.clear_if_unchanged(&snapshot).is_err());
834 assert!(
835 store
836 .save_if_unchanged(&snapshot, bundle("account"))
837 .unwrap()
838 .is_some()
839 );
840 secrets.set(&slot, "malformed").unwrap();
841 let corrupt = store.snapshot().unwrap();
842 assert!(corrupt.load().is_err());
843 assert!(store.clear_if_unchanged(&corrupt).unwrap());
844 }
845 }
846
846 lines RUST