返回 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 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
577 lines RUST