use std::collections::BTreeMap;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::{DefaultKeyringStore, Secrets, SecretsError};
pub const DEFAULT_ACCOUNT_API_BASE: &str = "https://api.codewhale.net";
pub const ACCOUNT_API_BASE_ENV: &str = "CODEWHALE_CLOUD_API_BASE";
pub const ACCOUNT_ALLOW_FILE_SESSION_STORE_ENV: &str = "CODEWHALE_CLOUD_ALLOW_FILE_SESSION_STORE";
pub const ACCOUNT_KEYRING_SERVICE: &str = "codewhale-cloud";
pub const ACCOUNT_SESSION_SCHEMA_VERSION: u8 = 1;
const MAX_TOKEN_BYTES: usize = 64 * 1024;
const MAX_SCOPES: usize = 64;
const MAX_SCOPE_BYTES: usize = 128;
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountAuthBundle {
pub token_type: String,
pub access_token: String,
pub refresh_token: String,
#[serde(default)]
pub session: Option<AccountSession>,
#[serde(default)]
pub user: Option<AccountUser>,
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountSession {
pub id: String,
#[serde(default)]
pub provider: String,
#[serde(default)]
pub providers: Vec<String>,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub expires_at: String,
#[serde(default)]
pub refresh_expires_at: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub revoked_at: String,
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUser {
#[serde(default)]
pub id: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub region: String,
#[serde(default)]
pub plan: String,
#[serde(default)]
pub model_keys: BTreeMap<String, AccountModelKeyState>,
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountModelKeyState {
#[serde(default)]
pub configured: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredAccountAuth {
pub schema_version: u8,
pub api_base: String,
pub bundle: AccountAuthBundle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountSessionState {
SignedOut,
Authenticated,
OfflineCached,
Expired,
Revoked,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RuntimeAccountInfo {
pub schema_version: u8,
pub state: AccountSessionState,
pub api_base: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub scopes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
impl RuntimeAccountInfo {
#[must_use]
pub fn signed_out(api_base: impl Into<String>) -> Self {
Self {
schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
state: AccountSessionState::SignedOut,
api_base: api_base.into(),
account_id: None,
session_id: None,
scopes: Vec::new(),
expires_at: None,
}
}
}
#[derive(Debug, Error)]
pub enum AccountSessionError {
#[error(transparent)]
Secrets(#[from] SecretsError),
#[error("the local Codewhale account session is unreadable")]
UnreadableRecord(#[source] serde_json::Error),
#[error("the Codewhale account session contains invalid credentials")]
InvalidCredentials,
#[error(
"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"
)]
SecureStoreUnavailable,
}
#[derive(Clone)]
pub struct AccountSessionStore {
secrets: Secrets,
auth_slot: String,
api_base: String,
}
impl AccountSessionStore {
#[must_use]
pub fn new(secrets: Secrets, profile: Option<&str>, api_base: &str) -> Self {
let profile = normalize_account_profile(profile);
let api_base = api_base.trim().trim_end_matches('/').to_string();
Self {
auth_slot: account_auth_slot(&profile, &api_base),
secrets,
api_base,
}
}
pub fn load(&self) -> Result<Option<StoredAccountAuth>, AccountSessionError> {
let Some(raw) = self.secrets.get(&self.auth_slot)? else {
return Ok(None);
};
let stored: StoredAccountAuth =
serde_json::from_str(&raw).map_err(AccountSessionError::UnreadableRecord)?;
if stored.schema_version != ACCOUNT_SESSION_SCHEMA_VERSION
|| stored.api_base != self.api_base
{
return Ok(None);
}
validate_account_auth_bundle(&stored.bundle)?;
Ok(Some(stored))
}
pub fn save(&self, bundle: AccountAuthBundle) -> Result<(), AccountSessionError> {
validate_account_auth_bundle(&bundle)?;
let stored = StoredAccountAuth {
schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
api_base: self.api_base.clone(),
bundle,
};
let raw = serde_json::to_string(&stored).map_err(AccountSessionError::UnreadableRecord)?;
self.secrets.set(&self.auth_slot, &raw)?;
Ok(())
}
pub fn clear(&self) -> Result<(), AccountSessionError> {
self.secrets.delete(&self.auth_slot)?;
Ok(())
}
pub fn runtime_info_at(
&self,
now: DateTime<Utc>,
) -> Result<RuntimeAccountInfo, AccountSessionError> {
let Some(stored) = self.load()? else {
return Ok(RuntimeAccountInfo::signed_out(self.api_base.clone()));
};
Ok(runtime_account_info_from_stored(stored, now))
}
}
pub fn secure_account_session_secrets() -> Result<Secrets, AccountSessionError> {
let keyring = DefaultKeyringStore::new(ACCOUNT_KEYRING_SERVICE);
match keyring.probe() {
Ok(()) => Ok(Secrets::new(Arc::new(keyring))),
Err(_) if account_file_session_store_opted_in() => Ok(Secrets::file_backed()),
Err(_) => Err(AccountSessionError::SecureStoreUnavailable),
}
}
#[must_use]
pub fn normalize_account_profile(profile: Option<&str>) -> String {
profile
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default")
.to_string()
}
#[must_use]
pub fn account_auth_slot(profile: &str, api_base: &str) -> String {
let mut digest = Sha256::new();
digest.update(profile.as_bytes());
digest.update([0]);
digest.update(api_base.as_bytes());
let digest = digest.finalize();
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
format!("codewhale-cloud-auth-v1-{encoded}")
}
#[must_use]
pub fn account_file_session_store_opted_in() -> bool {
let value = std::env::var(ACCOUNT_ALLOW_FILE_SESSION_STORE_ENV).ok();
account_file_session_store_opted_in_value(value.as_deref())
}
#[must_use]
pub fn account_file_session_store_opted_in_value(value: Option<&str>) -> bool {
value.is_some_and(|value| value.trim() == "1")
}
pub fn validate_account_auth_bundle(bundle: &AccountAuthBundle) -> Result<(), AccountSessionError> {
if !bundle.token_type.eq_ignore_ascii_case("bearer")
|| bundle.access_token.trim().is_empty()
|| bundle.refresh_token.trim().is_empty()
|| bundle.access_token.len() > MAX_TOKEN_BYTES
|| bundle.refresh_token.len() > MAX_TOKEN_BYTES
|| bundle
.access_token
.chars()
.any(|character| character.is_control() || character.is_whitespace())
|| bundle
.refresh_token
.chars()
.any(|character| character.is_control() || character.is_whitespace())
{
return Err(AccountSessionError::InvalidCredentials);
}
Ok(())
}
fn runtime_account_info_from_stored(
stored: StoredAccountAuth,
now: DateTime<Utc>,
) -> RuntimeAccountInfo {
let account_id = stored
.bundle
.user
.as_ref()
.map(|user| user.id.trim())
.filter(|value| !value.is_empty())
.map(str::to_string);
let session = stored.bundle.session.as_ref();
let session_id = session
.map(|session| session.id.trim())
.filter(|value| !value.is_empty())
.map(str::to_string);
let expires_at = session
.map(|session| session.expires_at.trim())
.filter(|value| parse_rfc3339(value).is_some())
.map(str::to_string);
let scopes = normalized_scopes(session.map_or(&[], |session| &session.scopes));
let state = session.map_or(AccountSessionState::OfflineCached, |session| {
classify_session_state(session, now)
});
RuntimeAccountInfo {
schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
state,
api_base: stored.api_base,
account_id,
session_id,
scopes,
expires_at,
}
}
fn classify_session_state(session: &AccountSession, now: DateTime<Utc>) -> AccountSessionState {
let explicit = session.status.trim().to_ascii_lowercase();
if explicit == "revoked" || !session.revoked_at.trim().is_empty() {
return AccountSessionState::Revoked;
}
if explicit == "expired"
|| parse_rfc3339(&session.refresh_expires_at).is_some_and(|expiry| expiry <= now)
{
return AccountSessionState::Expired;
}
if explicit == "offline_cached"
|| parse_rfc3339(&session.expires_at).is_some_and(|expiry| expiry <= now)
{
return AccountSessionState::OfflineCached;
}
AccountSessionState::Authenticated
}
fn parse_rfc3339(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value.trim())
.ok()
.map(|value| value.with_timezone(&Utc))
}
fn normalized_scopes(scopes: &[String]) -> Vec<String> {
let mut scopes = scopes
.iter()
.map(|scope| scope.trim())
.filter(|scope| {
!scope.is_empty()
&& scope.len() <= MAX_SCOPE_BYTES
&& scope.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'_' | b'-' | b'/')
})
})
.map(str::to_string)
.collect::<Vec<_>>();
scopes.sort();
scopes.dedup();
scopes.truncate(MAX_SCOPES);
scopes
}
#[cfg(test)]
mod tests {
use super::*;
use crate::InMemoryKeyringStore;
fn auth(
account_id: &str,
session_id: &str,
expires_at: &str,
refresh_expires_at: &str,
) -> AccountAuthBundle {
AccountAuthBundle {
token_type: "Bearer".to_string(),
access_token: "access-never-serialize".to_string(),
refresh_token: "refresh-never-serialize".to_string(),
session: Some(AccountSession {
id: session_id.to_string(),
scopes: vec!["identity:read".to_string(), "session:sync".to_string()],
expires_at: expires_at.to_string(),
refresh_expires_at: refresh_expires_at.to_string(),
..AccountSession::default()
}),
user: Some(AccountUser {
id: account_id.to_string(),
email: "private@example.test".to_string(),
..AccountUser::default()
}),
}
}
fn test_store() -> (Secrets, Arc<InMemoryKeyringStore>) {
let store = Arc::new(InMemoryKeyringStore::new());
(Secrets::new(store.clone()), store)
}
#[test]
fn runtime_receipt_is_token_free_and_preserves_only_explicit_scopes() {
let (secrets, _) = test_store();
let store = AccountSessionStore::new(secrets, Some("work"), "https://api.codewhale.net");
store
.save(auth(
"acct-1",
"session-1",
"2030-01-01T00:00:00Z",
"2031-01-01T00:00:00Z",
))
.unwrap();
let info = store.runtime_info_at(Utc::now()).unwrap();
assert_eq!(info.state, AccountSessionState::Authenticated);
assert_eq!(info.account_id.as_deref(), Some("acct-1"));
assert_eq!(info.session_id.as_deref(), Some("session-1"));
assert_eq!(info.scopes, ["identity:read", "session:sync"]);
let json = serde_json::to_string(&info).unwrap();
for secret in [
"access-never-serialize",
"refresh-never-serialize",
"private@example.test",
] {
assert!(!json.contains(secret));
}
}
#[test]
fn profiles_and_origins_preserve_same_account_and_cross_account_isolation() {
let (secrets, _) = test_store();
let default = AccountSessionStore::new(secrets.clone(), None, "https://api.codewhale.net");
let work =
AccountSessionStore::new(secrets.clone(), Some("work"), "https://api.codewhale.net");
let local = AccountSessionStore::new(secrets, None, "http://127.0.0.1:8787");
default.save(auth("acct-a", "session-a", "", "")).unwrap();
work.save(auth("acct-a", "session-b", "", "")).unwrap();
local.save(auth("acct-b", "session-c", "", "")).unwrap();
let now = Utc::now();
assert_eq!(
default.runtime_info_at(now).unwrap().account_id.as_deref(),
Some("acct-a")
);
assert_eq!(
work.runtime_info_at(now).unwrap().account_id.as_deref(),
Some("acct-a")
);
assert_eq!(
local.runtime_info_at(now).unwrap().account_id.as_deref(),
Some("acct-b")
);
default.clear().unwrap();
assert_eq!(
default.runtime_info_at(now).unwrap().state,
AccountSessionState::SignedOut
);
assert_eq!(
work.runtime_info_at(now).unwrap().state,
AccountSessionState::Authenticated
);
}
#[test]
fn signed_out_expired_offline_and_revoked_states_are_distinct() {
let (secrets, _) = test_store();
let store = AccountSessionStore::new(secrets, None, DEFAULT_ACCOUNT_API_BASE);
let now = DateTime::parse_from_rfc3339("2029-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(
store.runtime_info_at(now).unwrap().state,
AccountSessionState::SignedOut
);
store
.save(auth(
"acct",
"offline",
"2028-12-31T23:59:59Z",
"2029-12-31T23:59:59Z",
))
.unwrap();
assert_eq!(
store.runtime_info_at(now).unwrap().state,
AccountSessionState::OfflineCached
);
store
.save(auth(
"acct",
"expired",
"2028-12-31T23:59:59Z",
"2028-12-31T23:59:59Z",
))
.unwrap();
assert_eq!(
store.runtime_info_at(now).unwrap().state,
AccountSessionState::Expired
);
let mut revoked = auth("acct", "revoked", "2030-01-01T00:00:00Z", "");
revoked.session.as_mut().unwrap().status = "revoked".to_string();
store.save(revoked).unwrap();
assert_eq!(
store.runtime_info_at(now).unwrap().state,
AccountSessionState::Revoked
);
}
#[test]
fn file_store_requires_the_exact_explicit_opt_in() {
assert!(!account_file_session_store_opted_in_value(None));
assert!(!account_file_session_store_opted_in_value(Some("")));
assert!(!account_file_session_store_opted_in_value(Some("true")));
assert!(account_file_session_store_opted_in_value(Some("1")));
assert!(account_file_session_store_opted_in_value(Some(" 1 ")));
}
}