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);
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),
},
},
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> {
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()
}