1use 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
17pub const DEFAULT_ACCOUNT_API_BASE: &str = "https://api.codewhale.net";
19pub const ACCOUNT_API_BASE_ENV: &str = "CODEWHALE_CLOUD_API_BASE";
21pub const ACCOUNT_ALLOW_FILE_SESSION_STORE_ENV: &str = "CODEWHALE_CLOUD_ALLOW_FILE_SESSION_STORE";
23pub const ACCOUNT_KEYRING_SERVICE: &str = "codewhale-cloud";
25pub 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#[derive(Clone, Deserialize, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct AccountAuthBundle {
39 pub token_type: String,
41 pub access_token: String,
43 pub refresh_token: String,
45 #[serde(default)]
47 pub session: Option<AccountSession>,
48 #[serde(default)]
50 pub user: Option<AccountUser>,
51}
52
53#[derive(Clone, Default, Deserialize, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct AccountSession {
57 pub id: String,
59 #[serde(default)]
61 pub provider: String,
62 #[serde(default)]
64 pub providers: Vec<String>,
65 #[serde(default)]
67 pub scopes: Vec<String>,
68 #[serde(default)]
70 pub expires_at: String,
71 #[serde(default)]
73 pub refresh_expires_at: String,
74 #[serde(default)]
76 pub status: String,
77 #[serde(default)]
79 pub revoked_at: String,
80}
81
82#[derive(Clone, Default, Deserialize, Serialize)]
84#[serde(rename_all = "camelCase")]
85pub struct AccountUser {
86 #[serde(default)]
88 pub id: String,
89 #[serde(default)]
91 pub display_name: String,
92 #[serde(default)]
94 pub email: String,
95 #[serde(default)]
97 pub region: String,
98 #[serde(default)]
100 pub plan: String,
101 #[serde(default)]
103 pub model_keys: BTreeMap<String, AccountModelKeyState>,
104}
105
106#[derive(Clone, Default, Deserialize, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct AccountModelKeyState {
110 #[serde(default)]
112 pub configured: bool,
113}
114
115#[derive(Clone, Deserialize, Serialize)]
120#[serde(rename_all = "camelCase")]
121pub struct StoredAccountAuth {
122 pub schema_version: u8,
124 pub api_base: String,
126 pub bundle: AccountAuthBundle,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
132#[serde(rename_all = "snake_case")]
133pub enum AccountSessionState {
134 SignedOut,
136 Authenticated,
138 OfflineCached,
140 Expired,
142 Revoked,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
148pub struct RuntimeAccountInfo {
149 pub schema_version: u8,
151 pub state: AccountSessionState,
153 pub api_base: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub account_id: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub session_id: Option<String>,
161 pub scopes: Vec<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub expires_at: Option<String>,
166}
167
168impl RuntimeAccountInfo {
169 #[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#[derive(Debug, Error)]
186pub enum AccountSessionError {
187 #[error(transparent)]
189 Secrets(#[from] SecretsError),
190 #[error("the local Codewhale account session is unreadable")]
192 UnreadableRecord(#[source] serde_json::Error),
193 #[error("the Codewhale account session contains invalid credentials")]
195 InvalidCredentials,
196 #[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#[derive(Clone)]
205pub struct AccountSessionStore {
206 secrets: Secrets,
207 auth_slot: String,
208 api_base: String,
209}
210
211impl AccountSessionStore {
212 #[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 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 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 pub fn clear(&self) -> Result<(), AccountSessionError> {
255 self.secrets.delete(&self.auth_slot)?;
256 Ok(())
257 }
258
259 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
271pub 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#[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#[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#[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#[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
324pub 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}