Skip to main content

codewhale_secrets/
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
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use thiserror::Error;
14
15use crate::{DefaultKeyringStore, Secrets, SecretsError};
16
17/// Production account API origin used when no override is configured.
18pub const DEFAULT_ACCOUNT_API_BASE: &str = "https://api.codewhale.net";
19/// Environment variable that selects the account API origin.
20pub const ACCOUNT_API_BASE_ENV: &str = "CODEWHALE_CLOUD_API_BASE";
21/// Explicit opt-in for storing account sessions in the private local file.
22pub 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.
24pub const ACCOUNT_KEYRING_SERVICE: &str = "codewhale-cloud";
25/// Current serialized account-session record version.
26pub const ACCOUNT_SESSION_SCHEMA_VERSION: u8 = 1;
27
28const MAX_TOKEN_BYTES: usize = 64 * 1024;
29const MAX_SCOPES: usize = 64;
30const 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")]
38pub 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")]
56pub 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")]
85pub 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")]
109pub 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")]
121pub 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")]
133pub 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)]
148pub 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
168impl 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)]
186pub 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)]
205pub struct AccountSessionStore {
206    secrets: Secrets,
207    auth_slot: String,
208    api_base: String,
209}
210
211impl 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.
275pub 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]
286pub 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]
296pub 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]
313pub 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]
320pub 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.
325pub 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
345fn 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
380fn 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
398fn 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
404fn 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)]
424mod 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}