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
}