car-server-core 0.14.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
Documentation
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)]
struct TokenResponse {
    access_token: String,
    expires_in: Option<u64>,
    refresh_token: Option<String>,
}

#[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);

    let token = if let Some(token) = access.filter(|_| expires_at > now_epoch_seconds() + 60) {
        token
    } else if let Some(refresh_token) = refresh {
        refresh_access_token(&store, &base, &refresh_token).await?
    } else {
        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> {
    let url = format!("{}/connect/token", base.trim_end_matches('/'));
    let body = format!(
        "grant_type=refresh_token&refresh_token={}",
        form_encode(refresh_token)
    );
    let response = reqwest::Client::new()
        .post(url)
        .header("content-type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .map_err(|e| format!("refresh Parslee token: {e}"))?;
    let status = response.status();
    let text = response
        .text()
        .await
        .map_err(|e| format!("read Parslee token response: {e}"))?;
    if !status.is_success() {
        return Err(format!("refresh Parslee token: HTTP {status}: {text}"));
    }
    let token: TokenResponse =
        serde_json::from_str(&text).map_err(|e| format!("parse Parslee token response: {e}"))?;
    store_secret(store, ACCESS_TOKEN_KEY, &token.access_token)?;
    if let Some(refresh) = token.refresh_token.as_deref() {
        store_secret(store, REFRESH_TOKEN_KEY, refresh)?;
    }
    if let Some(expires_in) = token.expires_in {
        store_secret(
            store,
            EXPIRES_AT_KEY,
            &(now_epoch_seconds() + expires_in).to_string(),
        )?;
    }
    Ok(token.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()
}

fn form_encode(input: &str) -> String {
    let mut out = String::new();
    for b in input.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
                out.push(b as char)
            }
            b' ' => out.push('+'),
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}