car-server-core 0.26.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
use car_proto::ParsleeIdentity;
use car_secrets::{SecretError, SecretRef, SecretStore, DEFAULT_SERVICE};
use serde::Deserialize;
use std::time::{SystemTime, UNIX_EPOCH};

pub const ACCESS_TOKEN_KEY: &str = "PARSLEE_ACCESS_TOKEN";
pub const REFRESH_TOKEN_KEY: &str = "PARSLEE_REFRESH_TOKEN";
pub const EXPIRES_AT_KEY: &str = "PARSLEE_ACCESS_TOKEN_EXPIRES_AT";
pub const API_BASE_KEY: &str = "PARSLEE_API_BASE";
pub const DEFAULT_API_BASE: &str = "https://api.parslee.ai";

#[derive(Debug, Clone)]
pub struct ParsleeSession {
    pub access_token: String,
    pub identity: ParsleeIdentity,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct SessionInfoPascal {
    authenticated: bool,
    account: Option<SessionAccountPascal>,
    active_organization: Option<String>,
    organization_name: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SessionInfoCamel {
    authenticated: bool,
    account: Option<SessionAccountCamel>,
    active_organization: Option<String>,
    organization_name: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct SessionAccountPascal {
    id: Option<String>,
    account_id: Option<String>,
    email: Option<String>,
    display_name: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SessionAccountCamel {
    id: Option<String>,
    account_id: Option<String>,
    email: Option<String>,
    display_name: Option<String>,
}

impl SessionInfoPascal {
    fn into_identity(self) -> Option<ParsleeIdentity> {
        if !self.authenticated {
            return None;
        }
        let account = self.account?;
        Some(ParsleeIdentity {
            account_id: account.account_id.or(account.id)?,
            email: account.email,
            display_name: account.display_name,
            active_organization: self.active_organization,
            organization_name: self.organization_name,
        })
    }
}

impl SessionInfoCamel {
    fn into_identity(self) -> Option<ParsleeIdentity> {
        if !self.authenticated {
            return None;
        }
        let account = self.account?;
        Some(ParsleeIdentity {
            account_id: account.account_id.or(account.id)?,
            email: account.email,
            display_name: account.display_name,
            active_organization: self.active_organization,
            organization_name: self.organization_name,
        })
    }
}

pub async fn load_or_refresh() -> Result<Option<ParsleeSession>, String> {
    let store = SecretStore::new();
    if !store.is_available() {
        return Ok(None);
    }

    let base =
        secret_optional(&store, API_BASE_KEY)?.unwrap_or_else(|| DEFAULT_API_BASE.to_string());
    let access = secret_optional(&store, ACCESS_TOKEN_KEY)?;
    let refresh = secret_optional(&store, REFRESH_TOKEN_KEY)?;
    let expires_at = secret_optional(&store, EXPIRES_AT_KEY)?
        .and_then(|raw| raw.parse::<u64>().ok())
        .unwrap_or(0);

    // Prefer a non-expired access token. When the local clock says it's
    // expired, try to refresh — but a refresh failure (commonly an expired
    // refresh token) must NOT discard a still-usable access token: the server's
    // `/connect/session` check in fetch_identity below is the real validator,
    // and a stale stored `expires_at` shouldn't nuke a working session. Only
    // give up when there's no credential left to try.
    let token = match access
        .clone()
        .filter(|_| expires_at > now_epoch_seconds() + car_auth::REFRESH_SKEW_SECS)
    {
        Some(token) => token,
        None => match refresh {
            Some(refresh_token) => match refresh_access_token(&store, &base, &refresh_token).await {
                Ok(token) => token,
                Err(e) => match access {
                    Some(token) => {
                        tracing::warn!(
                            error = %e,
                            "Parslee token refresh failed; falling back to stored access token"
                        );
                        token
                    }
                    None => return Err(e),
                },
            },
            // No refresh token. If an access token exists, let the server decide
            // (expires_at can be stale); otherwise we're signed out.
            None => match access {
                Some(token) => token,
                None => return Ok(None),
            },
        },
    };

    match fetch_identity(&base, &token).await {
        Ok(identity) => Ok(Some(ParsleeSession {
            access_token: token,
            identity,
        })),
        Err(e) => {
            tracing::warn!(error = %e, "stored Parslee token could not be validated");
            Ok(None)
        }
    }
}

async fn refresh_access_token(
    store: &SecretStore,
    base: &str,
    refresh_token: &str,
) -> Result<String, String> {
    // Delegate the wire grant to car-auth so there is ONE definition of the
    // Parslee refresh contract (#320) — the inference path already uses it.
    // Persistence stays here because this path writes through an explicit
    // `SecretStore` handle.
    let tokens = car_auth::refresh_grant(base, refresh_token).await?;
    store_secret(store, ACCESS_TOKEN_KEY, &tokens.access_token)?;
    if let Some(refresh) = tokens.refresh_token.as_deref() {
        store_secret(store, REFRESH_TOKEN_KEY, refresh)?;
    }
    if let Some(expires_in) = tokens.expires_in {
        store_secret(
            store,
            EXPIRES_AT_KEY,
            &(now_epoch_seconds() + expires_in).to_string(),
        )?;
    }
    Ok(tokens.access_token)
}

async fn fetch_identity(base: &str, access_token: &str) -> Result<ParsleeIdentity, String> {
    let url = format!("{}/connect/session", base.trim_end_matches('/'));
    let response = reqwest::Client::new()
        .get(url)
        .bearer_auth(access_token)
        .send()
        .await
        .map_err(|e| format!("fetch Parslee session: {e}"))?;
    let status = response.status();
    let text = response
        .text()
        .await
        .map_err(|e| format!("read Parslee session response: {e}"))?;
    if !status.is_success() {
        return Err(format!("fetch Parslee session: HTTP {status}: {text}"));
    }
    if let Ok(session) = serde_json::from_str::<SessionInfoCamel>(&text) {
        if let Some(identity) = session.into_identity() {
            return Ok(identity);
        }
    }
    if let Ok(session) = serde_json::from_str::<SessionInfoPascal>(&text) {
        if let Some(identity) = session.into_identity() {
            return Ok(identity);
        }
    }
    Err("Parslee session response did not contain an authenticated account".to_string())
}

fn secret_optional(store: &SecretStore, key: &str) -> Result<Option<String>, String> {
    match store.get(&SecretRef::new(DEFAULT_SERVICE, key)) {
        Ok(value) if !value.is_empty() => Ok(Some(value)),
        Ok(_) | Err(SecretError::NotFound { .. }) => Ok(None),
        Err(e) => Err(e.to_string()),
    }
}

fn store_secret(store: &SecretStore, key: &str, value: &str) -> Result<(), String> {
    store
        .put(&SecretRef::new(DEFAULT_SERVICE, key), value)
        .map_err(|e| e.to_string())
}

fn now_epoch_seconds() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}