| 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 | use std::sync::Arc; |
| 9 | |
| 10 | use chrono::{DateTime, Utc}; |
| 11 | use serde::{Deserialize, Serialize}; |
| 12 | use sha2::{Digest, Sha256}; |
| 13 | use thiserror::Error; |
| 14 | |
| 15 | use crate::{DefaultKeyringStore, Secrets, SecretsError}; |
| 16 | |
| 17 | /// Production account API origin used when no override is configured. |
| 18 | pub const DEFAULT_ACCOUNT_API_BASE: &str = "https://api.codewhale.net"; |
| 19 | /// Environment variable that selects the account API origin. |
| 20 | pub const ACCOUNT_API_BASE_ENV: &str = "CODEWHALE_CLOUD_API_BASE"; |
| 21 | /// Explicit opt-in for storing account sessions in the private local file. |
| 22 | pub const ACCOUNT_ALLOW_FILE_SESSION_STORE_ENV: &str = "CODEWHALE_CLOUD_ALLOW_FILE_SESSION_STORE"; |
| 23 | /// OS credential-manager service shared by CLI, TUI, and Runtime API. |
| 24 | pub const ACCOUNT_KEYRING_SERVICE: &str = "codewhale-cloud"; |
| 25 | /// Current serialized account-session record version. |
| 26 | pub const ACCOUNT_SESSION_SCHEMA_VERSION: u8 = 1; |
| 27 | |
| 28 | const MAX_TOKEN_BYTES: usize = 64 * 1024; |
| 29 | const MAX_SCOPES: usize = 64; |
| 30 | const MAX_SCOPE_BYTES: usize = 128; |
| 31 | |
| 32 | /// A short-lived access credential and its durable refresh/session metadata. |
| 33 | /// |
| 34 | /// This type intentionally does not implement `Debug`, preventing accidental |
| 35 | /// token disclosure through ordinary diagnostic formatting. |
| 36 | #[derive(Clone, Deserialize, Serialize)] |
| 37 | #[serde(rename_all = "camelCase")] |
| 38 | pub struct AccountAuthBundle { |
| 39 | /// Authentication scheme returned by the account service. |
| 40 | pub token_type: String, |
| 41 | /// Short-lived bearer credential. Never serialize this outside secure storage. |
| 42 | pub access_token: String, |
| 43 | /// Refresh credential. Never serialize this outside secure storage. |
| 44 | pub refresh_token: String, |
| 45 | /// Durable server session metadata, when returned by the service. |
| 46 | #[serde(default)] |
| 47 | pub session: Option<AccountSession>, |
| 48 | /// Cached non-secret account record, when returned by the service. |
| 49 | #[serde(default)] |
| 50 | pub user: Option<AccountUser>, |
| 51 | } |
| 52 | |
| 53 | /// Durable account-session metadata supplied by the account service. |
| 54 | #[derive(Clone, Default, Deserialize, Serialize)] |
| 55 | #[serde(rename_all = "camelCase")] |
| 56 | pub struct AccountSession { |
| 57 | /// Durable session identifier shared across Codewhale surfaces. |
| 58 | pub id: String, |
| 59 | /// Authorization provider used to establish the session. |
| 60 | #[serde(default)] |
| 61 | pub provider: String, |
| 62 | /// Linked authorization providers recorded by the account service. |
| 63 | #[serde(default)] |
| 64 | pub providers: Vec<String>, |
| 65 | /// Explicit bounded scopes granted to this session. |
| 66 | #[serde(default)] |
| 67 | pub scopes: Vec<String>, |
| 68 | /// Access-credential expiration in RFC 3339 format. |
| 69 | #[serde(default)] |
| 70 | pub expires_at: String, |
| 71 | /// Refresh/session expiration in RFC 3339 format. |
| 72 | #[serde(default)] |
| 73 | pub refresh_expires_at: String, |
| 74 | /// Explicit server session state when supplied. |
| 75 | #[serde(default)] |
| 76 | pub status: String, |
| 77 | /// Explicit revocation timestamp when supplied. |
| 78 | #[serde(default)] |
| 79 | pub revoked_at: String, |
| 80 | } |
| 81 | |
| 82 | /// Cached account identity and non-secret presentation fields. |
| 83 | #[derive(Clone, Default, Deserialize, Serialize)] |
| 84 | #[serde(rename_all = "camelCase")] |
| 85 | pub struct AccountUser { |
| 86 | /// Stable Codewhale account identifier. |
| 87 | #[serde(default)] |
| 88 | pub id: String, |
| 89 | /// User-facing account name. |
| 90 | #[serde(default)] |
| 91 | pub display_name: String, |
| 92 | /// Account email returned by the service. Runtime metadata never exposes it. |
| 93 | #[serde(default)] |
| 94 | pub email: String, |
| 95 | /// Account residency region returned by the service. |
| 96 | #[serde(default)] |
| 97 | pub region: String, |
| 98 | /// Account plan returned by the service. |
| 99 | #[serde(default)] |
| 100 | pub plan: String, |
| 101 | /// Provider-key presence metadata; values never contain provider credentials. |
| 102 | #[serde(default)] |
| 103 | pub model_keys: BTreeMap<String, AccountModelKeyState>, |
| 104 | } |
| 105 | |
| 106 | /// Non-secret provider-key presence metadata returned by the account service. |
| 107 | #[derive(Clone, Default, Deserialize, Serialize)] |
| 108 | #[serde(rename_all = "camelCase")] |
| 109 | pub struct AccountModelKeyState { |
| 110 | /// Whether the account service reports a credential for this provider. |
| 111 | #[serde(default)] |
| 112 | pub configured: bool, |
| 113 | } |
| 114 | |
| 115 | /// Versioned secure-storage envelope shared by every local Codewhale surface. |
| 116 | /// |
| 117 | /// This type intentionally does not implement `Debug` because `bundle` |
| 118 | /// contains access and refresh credentials. |
| 119 | #[derive(Clone, Deserialize, Serialize)] |
| 120 | #[serde(rename_all = "camelCase")] |
| 121 | pub struct StoredAccountAuth { |
| 122 | /// Serialized record version. |
| 123 | pub schema_version: u8, |
| 124 | /// Exact canonical API origin that owns this session. |
| 125 | pub api_base: String, |
| 126 | /// Secret account bundle stored inside the credential manager. |
| 127 | pub bundle: AccountAuthBundle, |
| 128 | } |
| 129 | |
| 130 | /// Normalized account states exposed by the token-free Runtime API contract. |
| 131 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] |
| 132 | #[serde(rename_all = "snake_case")] |
| 133 | pub enum AccountSessionState { |
| 134 | /// No valid secure-store session was found for the selected profile/origin. |
| 135 | SignedOut, |
| 136 | /// The cached session and access credential are within their recorded lifetime. |
| 137 | Authenticated, |
| 138 | /// Durable identity remains cached, but the access credential has expired. |
| 139 | OfflineCached, |
| 140 | /// The durable refresh/session lifetime has ended. |
| 141 | Expired, |
| 142 | /// The stored session carries an explicit revocation receipt. |
| 143 | Revoked, |
| 144 | } |
| 145 | |
| 146 | /// Token-free account receipt returned by `GET /v1/runtime/info`. |
| 147 | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] |
| 148 | pub struct RuntimeAccountInfo { |
| 149 | /// Runtime account receipt schema version. |
| 150 | pub schema_version: u8, |
| 151 | /// Current account-session state. |
| 152 | pub state: AccountSessionState, |
| 153 | /// Exact account API origin used to locate the secure session. |
| 154 | pub api_base: String, |
| 155 | /// Stable account identifier, present only when read from secure storage. |
| 156 | #[serde(skip_serializing_if = "Option::is_none")] |
| 157 | pub account_id: Option<String>, |
| 158 | /// Durable session identifier, present only when read from secure storage. |
| 159 | #[serde(skip_serializing_if = "Option::is_none")] |
| 160 | pub session_id: Option<String>, |
| 161 | /// Explicit session scopes from secure storage; never inferred from identity. |
| 162 | pub scopes: Vec<String>, |
| 163 | /// Access-credential expiration, when the stored value is valid RFC 3339. |
| 164 | #[serde(skip_serializing_if = "Option::is_none")] |
| 165 | pub expires_at: Option<String>, |
| 166 | } |
| 167 | |
| 168 | impl RuntimeAccountInfo { |
| 169 | /// Build the fail-closed signed-out receipt for an API origin. |
| 170 | #[must_use] |
| 171 | pub fn signed_out(api_base: impl Into<String>) -> Self { |
| 172 | Self { |
| 173 | schema_version: ACCOUNT_SESSION_SCHEMA_VERSION, |
| 174 | state: AccountSessionState::SignedOut, |
| 175 | api_base: api_base.into(), |
| 176 | account_id: None, |
| 177 | session_id: None, |
| 178 | scopes: Vec::new(), |
| 179 | expires_at: None, |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | /// Failures while selecting, decoding, or validating account session storage. |
| 185 | #[derive(Debug, Error)] |
| 186 | pub enum AccountSessionError { |
| 187 | /// Underlying credential-store failure. |
| 188 | #[error(transparent)] |
| 189 | Secrets(#[from] SecretsError), |
| 190 | /// Stored session JSON could not be decoded. |
| 191 | #[error("the local Codewhale account session is unreadable")] |
| 192 | UnreadableRecord(#[source] serde_json::Error), |
| 193 | /// Stored or newly returned authentication credentials are malformed. |
| 194 | #[error("the Codewhale account session contains invalid credentials")] |
| 195 | InvalidCredentials, |
| 196 | /// No approved secure session backend is available. |
| 197 | #[error( |
| 198 | "Codewhale account sessions require an OS credential manager; set {ACCOUNT_ALLOW_FILE_SESSION_STORE_ENV}=1 only to explicitly opt into the private local file" |
| 199 | )] |
| 200 | SecureStoreUnavailable, |
| 201 | } |
| 202 | |
| 203 | /// Profile- and origin-scoped view of the shared account credential record. |
| 204 | #[derive(Clone)] |
| 205 | pub struct AccountSessionStore { |
| 206 | secrets: Secrets, |
| 207 | auth_slot: String, |
| 208 | api_base: String, |
| 209 | } |
| 210 | |
| 211 | impl AccountSessionStore { |
| 212 | /// Create a store view for one local profile and one validated API origin. |
| 213 | #[must_use] |
| 214 | pub fn new(secrets: Secrets, profile: Option<&str>, api_base: &str) -> Self { |
| 215 | let profile = normalize_account_profile(profile); |
| 216 | let api_base = api_base.trim().trim_end_matches('/').to_string(); |
| 217 | Self { |
| 218 | auth_slot: account_auth_slot(&profile, &api_base), |
| 219 | secrets, |
| 220 | api_base, |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /// Load and validate the selected account session from secure storage. |
| 225 | pub fn load(&self) -> Result<Option<StoredAccountAuth>, AccountSessionError> { |
| 226 | let Some(raw) = self.secrets.get(&self.auth_slot)? else { |
| 227 | return Ok(None); |
| 228 | }; |
| 229 | let stored: StoredAccountAuth = |
| 230 | serde_json::from_str(&raw).map_err(AccountSessionError::UnreadableRecord)?; |
| 231 | if stored.schema_version != ACCOUNT_SESSION_SCHEMA_VERSION |
| 232 | || stored.api_base != self.api_base |
| 233 | { |
| 234 | return Ok(None); |
| 235 | } |
| 236 | validate_account_auth_bundle(&stored.bundle)?; |
| 237 | Ok(Some(stored)) |
| 238 | } |
| 239 | |
| 240 | /// Validate and save an account bundle in the selected secure-store slot. |
| 241 | pub fn save(&self, bundle: AccountAuthBundle) -> Result<(), AccountSessionError> { |
| 242 | validate_account_auth_bundle(&bundle)?; |
| 243 | let stored = StoredAccountAuth { |
| 244 | schema_version: ACCOUNT_SESSION_SCHEMA_VERSION, |
| 245 | api_base: self.api_base.clone(), |
| 246 | bundle, |
| 247 | }; |
| 248 | let raw = serde_json::to_string(&stored).map_err(AccountSessionError::UnreadableRecord)?; |
| 249 | self.secrets.set(&self.auth_slot, &raw)?; |
| 250 | Ok(()) |
| 251 | } |
| 252 | |
| 253 | /// Remove only the selected profile/origin account session. |
| 254 | pub fn clear(&self) -> Result<(), AccountSessionError> { |
| 255 | self.secrets.delete(&self.auth_slot)?; |
| 256 | Ok(()) |
| 257 | } |
| 258 | |
| 259 | /// Read a token-free runtime receipt at a caller-supplied clock instant. |
| 260 | pub fn runtime_info_at( |
| 261 | &self, |
| 262 | now: DateTime<Utc>, |
| 263 | ) -> Result<RuntimeAccountInfo, AccountSessionError> { |
| 264 | let Some(stored) = self.load()? else { |
| 265 | return Ok(RuntimeAccountInfo::signed_out(self.api_base.clone())); |
| 266 | }; |
| 267 | Ok(runtime_account_info_from_stored(stored, now)) |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | /// Select the approved account-session backend shared by CLI, TUI, and Runtime. |
| 272 | /// |
| 273 | /// The native credential manager is required unless the user explicitly opts |
| 274 | /// into the private `0600` file store for a headless environment. |
| 275 | pub fn secure_account_session_secrets() -> Result<Secrets, AccountSessionError> { |
| 276 | let keyring = DefaultKeyringStore::new(ACCOUNT_KEYRING_SERVICE); |
| 277 | match keyring.probe() { |
| 278 | Ok(()) => Ok(Secrets::new(Arc::new(keyring))), |
| 279 | Err(_) if account_file_session_store_opted_in() => Ok(Secrets::file_backed()), |
| 280 | Err(_) => Err(AccountSessionError::SecureStoreUnavailable), |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | /// Normalize an optional CLI/TUI profile to the durable account slot label. |
| 285 | #[must_use] |
| 286 | pub fn normalize_account_profile(profile: Option<&str>) -> String { |
| 287 | profile |
| 288 | .map(str::trim) |
| 289 | .filter(|value| !value.is_empty()) |
| 290 | .unwrap_or("default") |
| 291 | .to_string() |
| 292 | } |
| 293 | |
| 294 | /// Derive the opaque secure-store slot for a profile and account API origin. |
| 295 | #[must_use] |
| 296 | pub fn account_auth_slot(profile: &str, api_base: &str) -> String { |
| 297 | let mut digest = Sha256::new(); |
| 298 | digest.update(profile.as_bytes()); |
| 299 | digest.update([0]); |
| 300 | digest.update(api_base.as_bytes()); |
| 301 | let digest = digest.finalize(); |
| 302 | const HEX: &[u8; 16] = b"0123456789abcdef"; |
| 303 | let mut encoded = String::with_capacity(digest.len() * 2); |
| 304 | for byte in digest { |
| 305 | encoded.push(HEX[(byte >> 4) as usize] as char); |
| 306 | encoded.push(HEX[(byte & 0x0f) as usize] as char); |
| 307 | } |
| 308 | format!("codewhale-cloud-auth-v1-{encoded}") |
| 309 | } |
| 310 | |
| 311 | /// Return whether the private file session store was explicitly enabled. |
| 312 | #[must_use] |
| 313 | pub fn account_file_session_store_opted_in() -> bool { |
| 314 | let value = std::env::var(ACCOUNT_ALLOW_FILE_SESSION_STORE_ENV).ok(); |
| 315 | account_file_session_store_opted_in_value(value.as_deref()) |
| 316 | } |
| 317 | |
| 318 | /// Parse the explicit file-session-store opt-in value. |
| 319 | #[must_use] |
| 320 | pub fn account_file_session_store_opted_in_value(value: Option<&str>) -> bool { |
| 321 | value.is_some_and(|value| value.trim() == "1") |
| 322 | } |
| 323 | |
| 324 | /// Validate the credential-bearing portion of an account response or record. |
| 325 | pub fn validate_account_auth_bundle(bundle: &AccountAuthBundle) -> Result<(), AccountSessionError> { |
| 326 | if !bundle.token_type.eq_ignore_ascii_case("bearer") |
| 327 | || bundle.access_token.trim().is_empty() |
| 328 | || bundle.refresh_token.trim().is_empty() |
| 329 | || bundle.access_token.len() > MAX_TOKEN_BYTES |
| 330 | || bundle.refresh_token.len() > MAX_TOKEN_BYTES |
| 331 | || bundle |
| 332 | .access_token |
| 333 | .chars() |
| 334 | .any(|character| character.is_control() || character.is_whitespace()) |
| 335 | || bundle |
| 336 | .refresh_token |
| 337 | .chars() |
| 338 | .any(|character| character.is_control() || character.is_whitespace()) |
| 339 | { |
| 340 | return Err(AccountSessionError::InvalidCredentials); |
| 341 | } |
| 342 | Ok(()) |
| 343 | } |
| 344 | |
| 345 | fn runtime_account_info_from_stored( |
| 346 | stored: StoredAccountAuth, |
| 347 | now: DateTime<Utc>, |
| 348 | ) -> RuntimeAccountInfo { |
| 349 | let account_id = stored |
| 350 | .bundle |
| 351 | .user |
| 352 | .as_ref() |
| 353 | .map(|user| user.id.trim()) |
| 354 | .filter(|value| !value.is_empty()) |
| 355 | .map(str::to_string); |
| 356 | let session = stored.bundle.session.as_ref(); |
| 357 | let session_id = session |
| 358 | .map(|session| session.id.trim()) |
| 359 | .filter(|value| !value.is_empty()) |
| 360 | .map(str::to_string); |
| 361 | let expires_at = session |
| 362 | .map(|session| session.expires_at.trim()) |
| 363 | .filter(|value| parse_rfc3339(value).is_some()) |
| 364 | .map(str::to_string); |
| 365 | let scopes = normalized_scopes(session.map_or(&[], |session| &session.scopes)); |
| 366 | let state = session.map_or(AccountSessionState::OfflineCached, |session| { |
| 367 | classify_session_state(session, now) |
| 368 | }); |
| 369 | RuntimeAccountInfo { |
| 370 | schema_version: ACCOUNT_SESSION_SCHEMA_VERSION, |
| 371 | state, |
| 372 | api_base: stored.api_base, |
| 373 | account_id, |
| 374 | session_id, |
| 375 | scopes, |
| 376 | expires_at, |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | fn classify_session_state(session: &AccountSession, now: DateTime<Utc>) -> AccountSessionState { |
| 381 | let explicit = session.status.trim().to_ascii_lowercase(); |
| 382 | if explicit == "revoked" || !session.revoked_at.trim().is_empty() { |
| 383 | return AccountSessionState::Revoked; |
| 384 | } |
| 385 | if explicit == "expired" |
| 386 | || parse_rfc3339(&session.refresh_expires_at).is_some_and(|expiry| expiry <= now) |
| 387 | { |
| 388 | return AccountSessionState::Expired; |
| 389 | } |
| 390 | if explicit == "offline_cached" |
| 391 | || parse_rfc3339(&session.expires_at).is_some_and(|expiry| expiry <= now) |
| 392 | { |
| 393 | return AccountSessionState::OfflineCached; |
| 394 | } |
| 395 | AccountSessionState::Authenticated |
| 396 | } |
| 397 | |
| 398 | fn parse_rfc3339(value: &str) -> Option<DateTime<Utc>> { |
| 399 | DateTime::parse_from_rfc3339(value.trim()) |
| 400 | .ok() |
| 401 | .map(|value| value.with_timezone(&Utc)) |
| 402 | } |
| 403 | |
| 404 | fn normalized_scopes(scopes: &[String]) -> Vec<String> { |
| 405 | let mut scopes = scopes |
| 406 | .iter() |
| 407 | .map(|scope| scope.trim()) |
| 408 | .filter(|scope| { |
| 409 | !scope.is_empty() |
| 410 | && scope.len() <= MAX_SCOPE_BYTES |
| 411 | && scope.bytes().all(|byte| { |
| 412 | byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'_' | b'-' | b'/') |
| 413 | }) |
| 414 | }) |
| 415 | .map(str::to_string) |
| 416 | .collect::<Vec<_>>(); |
| 417 | scopes.sort(); |
| 418 | scopes.dedup(); |
| 419 | scopes.truncate(MAX_SCOPES); |
| 420 | scopes |
| 421 | } |
| 422 | |
| 423 | #[cfg(test)] |
| 424 | mod tests { |
| 425 | use super::*; |
| 426 | use crate::InMemoryKeyringStore; |
| 427 | |
| 428 | fn auth( |
| 429 | account_id: &str, |
| 430 | session_id: &str, |
| 431 | expires_at: &str, |
| 432 | refresh_expires_at: &str, |
| 433 | ) -> AccountAuthBundle { |
| 434 | AccountAuthBundle { |
| 435 | token_type: "Bearer".to_string(), |
| 436 | access_token: "access-never-serialize".to_string(), |
| 437 | refresh_token: "refresh-never-serialize".to_string(), |
| 438 | session: Some(AccountSession { |
| 439 | id: session_id.to_string(), |
| 440 | scopes: vec!["identity:read".to_string(), "session:sync".to_string()], |
| 441 | expires_at: expires_at.to_string(), |
| 442 | refresh_expires_at: refresh_expires_at.to_string(), |
| 443 | ..AccountSession::default() |
| 444 | }), |
| 445 | user: Some(AccountUser { |
| 446 | id: account_id.to_string(), |
| 447 | email: "private@example.test".to_string(), |
| 448 | ..AccountUser::default() |
| 449 | }), |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | fn test_store() -> (Secrets, Arc<InMemoryKeyringStore>) { |
| 454 | let store = Arc::new(InMemoryKeyringStore::new()); |
| 455 | (Secrets::new(store.clone()), store) |
| 456 | } |
| 457 | |
| 458 | #[test] |
| 459 | fn runtime_receipt_is_token_free_and_preserves_only_explicit_scopes() { |
| 460 | let (secrets, _) = test_store(); |
| 461 | let store = AccountSessionStore::new(secrets, Some("work"), "https://api.codewhale.net"); |
| 462 | store |
| 463 | .save(auth( |
| 464 | "acct-1", |
| 465 | "session-1", |
| 466 | "2030-01-01T00:00:00Z", |
| 467 | "2031-01-01T00:00:00Z", |
| 468 | )) |
| 469 | .unwrap(); |
| 470 | |
| 471 | let info = store.runtime_info_at(Utc::now()).unwrap(); |
| 472 | assert_eq!(info.state, AccountSessionState::Authenticated); |
| 473 | assert_eq!(info.account_id.as_deref(), Some("acct-1")); |
| 474 | assert_eq!(info.session_id.as_deref(), Some("session-1")); |
| 475 | assert_eq!(info.scopes, ["identity:read", "session:sync"]); |
| 476 | let json = serde_json::to_string(&info).unwrap(); |
| 477 | for secret in [ |
| 478 | "access-never-serialize", |
| 479 | "refresh-never-serialize", |
| 480 | "private@example.test", |
| 481 | ] { |
| 482 | assert!(!json.contains(secret)); |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | #[test] |
| 487 | fn profiles_and_origins_preserve_same_account_and_cross_account_isolation() { |
| 488 | let (secrets, _) = test_store(); |
| 489 | let default = AccountSessionStore::new(secrets.clone(), None, "https://api.codewhale.net"); |
| 490 | let work = |
| 491 | AccountSessionStore::new(secrets.clone(), Some("work"), "https://api.codewhale.net"); |
| 492 | let local = AccountSessionStore::new(secrets, None, "http://127.0.0.1:8787"); |
| 493 | default.save(auth("acct-a", "session-a", "", "")).unwrap(); |
| 494 | work.save(auth("acct-a", "session-b", "", "")).unwrap(); |
| 495 | local.save(auth("acct-b", "session-c", "", "")).unwrap(); |
| 496 | |
| 497 | let now = Utc::now(); |
| 498 | assert_eq!( |
| 499 | default.runtime_info_at(now).unwrap().account_id.as_deref(), |
| 500 | Some("acct-a") |
| 501 | ); |
| 502 | assert_eq!( |
| 503 | work.runtime_info_at(now).unwrap().account_id.as_deref(), |
| 504 | Some("acct-a") |
| 505 | ); |
| 506 | assert_eq!( |
| 507 | local.runtime_info_at(now).unwrap().account_id.as_deref(), |
| 508 | Some("acct-b") |
| 509 | ); |
| 510 | default.clear().unwrap(); |
| 511 | assert_eq!( |
| 512 | default.runtime_info_at(now).unwrap().state, |
| 513 | AccountSessionState::SignedOut |
| 514 | ); |
| 515 | assert_eq!( |
| 516 | work.runtime_info_at(now).unwrap().state, |
| 517 | AccountSessionState::Authenticated |
| 518 | ); |
| 519 | } |
| 520 | |
| 521 | #[test] |
| 522 | fn signed_out_expired_offline_and_revoked_states_are_distinct() { |
| 523 | let (secrets, _) = test_store(); |
| 524 | let store = AccountSessionStore::new(secrets, None, DEFAULT_ACCOUNT_API_BASE); |
| 525 | let now = DateTime::parse_from_rfc3339("2029-01-01T00:00:00Z") |
| 526 | .unwrap() |
| 527 | .with_timezone(&Utc); |
| 528 | assert_eq!( |
| 529 | store.runtime_info_at(now).unwrap().state, |
| 530 | AccountSessionState::SignedOut |
| 531 | ); |
| 532 | |
| 533 | store |
| 534 | .save(auth( |
| 535 | "acct", |
| 536 | "offline", |
| 537 | "2028-12-31T23:59:59Z", |
| 538 | "2029-12-31T23:59:59Z", |
| 539 | )) |
| 540 | .unwrap(); |
| 541 | assert_eq!( |
| 542 | store.runtime_info_at(now).unwrap().state, |
| 543 | AccountSessionState::OfflineCached |
| 544 | ); |
| 545 | |
| 546 | store |
| 547 | .save(auth( |
| 548 | "acct", |
| 549 | "expired", |
| 550 | "2028-12-31T23:59:59Z", |
| 551 | "2028-12-31T23:59:59Z", |
| 552 | )) |
| 553 | .unwrap(); |
| 554 | assert_eq!( |
| 555 | store.runtime_info_at(now).unwrap().state, |
| 556 | AccountSessionState::Expired |
| 557 | ); |
| 558 | |
| 559 | let mut revoked = auth("acct", "revoked", "2030-01-01T00:00:00Z", ""); |
| 560 | revoked.session.as_mut().unwrap().status = "revoked".to_string(); |
| 561 | store.save(revoked).unwrap(); |
| 562 | assert_eq!( |
| 563 | store.runtime_info_at(now).unwrap().state, |
| 564 | AccountSessionState::Revoked |
| 565 | ); |
| 566 | } |
| 567 | |
| 568 | #[test] |
| 569 | fn file_store_requires_the_exact_explicit_opt_in() { |
| 570 | assert!(!account_file_session_store_opted_in_value(None)); |
| 571 | assert!(!account_file_session_store_opted_in_value(Some(""))); |
| 572 | assert!(!account_file_session_store_opted_in_value(Some("true"))); |
| 573 | assert!(account_file_session_store_opted_in_value(Some("1"))); |
| 574 | assert!(account_file_session_store_opted_in_value(Some(" 1 "))); |
| 575 | } |
| 576 | } |
| 577 |