Skip to main content

car_server_core/
parslee_auth.rs

1use car_proto::ParsleeIdentity;
2use car_secrets::{SecretError, SecretRef, SecretStore, DEFAULT_SERVICE};
3use serde::Deserialize;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6pub const ACCESS_TOKEN_KEY: &str = "PARSLEE_ACCESS_TOKEN";
7pub const REFRESH_TOKEN_KEY: &str = "PARSLEE_REFRESH_TOKEN";
8pub const EXPIRES_AT_KEY: &str = "PARSLEE_ACCESS_TOKEN_EXPIRES_AT";
9pub const API_BASE_KEY: &str = "PARSLEE_API_BASE";
10pub const DEFAULT_API_BASE: &str = "https://api.parslee.ai";
11
12#[derive(Debug, Clone)]
13pub struct ParsleeSession {
14    pub access_token: String,
15    pub identity: ParsleeIdentity,
16}
17
18#[derive(Debug, Deserialize)]
19struct TokenResponse {
20    access_token: String,
21    expires_in: Option<u64>,
22    refresh_token: Option<String>,
23}
24
25#[derive(Debug, Deserialize)]
26#[serde(rename_all = "PascalCase")]
27struct SessionInfoPascal {
28    authenticated: bool,
29    account: Option<SessionAccountPascal>,
30    active_organization: Option<String>,
31    organization_name: Option<String>,
32}
33
34#[derive(Debug, Deserialize)]
35#[serde(rename_all = "camelCase")]
36struct SessionInfoCamel {
37    authenticated: bool,
38    account: Option<SessionAccountCamel>,
39    active_organization: Option<String>,
40    organization_name: Option<String>,
41}
42
43#[derive(Debug, Deserialize)]
44#[serde(rename_all = "PascalCase")]
45struct SessionAccountPascal {
46    id: Option<String>,
47    account_id: Option<String>,
48    email: Option<String>,
49    display_name: Option<String>,
50}
51
52#[derive(Debug, Deserialize)]
53#[serde(rename_all = "camelCase")]
54struct SessionAccountCamel {
55    id: Option<String>,
56    account_id: Option<String>,
57    email: Option<String>,
58    display_name: Option<String>,
59}
60
61impl SessionInfoPascal {
62    fn into_identity(self) -> Option<ParsleeIdentity> {
63        if !self.authenticated {
64            return None;
65        }
66        let account = self.account?;
67        Some(ParsleeIdentity {
68            account_id: account.account_id.or(account.id)?,
69            email: account.email,
70            display_name: account.display_name,
71            active_organization: self.active_organization,
72            organization_name: self.organization_name,
73        })
74    }
75}
76
77impl SessionInfoCamel {
78    fn into_identity(self) -> Option<ParsleeIdentity> {
79        if !self.authenticated {
80            return None;
81        }
82        let account = self.account?;
83        Some(ParsleeIdentity {
84            account_id: account.account_id.or(account.id)?,
85            email: account.email,
86            display_name: account.display_name,
87            active_organization: self.active_organization,
88            organization_name: self.organization_name,
89        })
90    }
91}
92
93pub async fn load_or_refresh() -> Result<Option<ParsleeSession>, String> {
94    let store = SecretStore::new();
95    if !store.is_available() {
96        return Ok(None);
97    }
98
99    let base =
100        secret_optional(&store, API_BASE_KEY)?.unwrap_or_else(|| DEFAULT_API_BASE.to_string());
101    let access = secret_optional(&store, ACCESS_TOKEN_KEY)?;
102    let refresh = secret_optional(&store, REFRESH_TOKEN_KEY)?;
103    let expires_at = secret_optional(&store, EXPIRES_AT_KEY)?
104        .and_then(|raw| raw.parse::<u64>().ok())
105        .unwrap_or(0);
106
107    let token = if let Some(token) = access.filter(|_| expires_at > now_epoch_seconds() + 60) {
108        token
109    } else if let Some(refresh_token) = refresh {
110        refresh_access_token(&store, &base, &refresh_token).await?
111    } else {
112        return Ok(None);
113    };
114
115    match fetch_identity(&base, &token).await {
116        Ok(identity) => Ok(Some(ParsleeSession {
117            access_token: token,
118            identity,
119        })),
120        Err(e) => {
121            tracing::warn!(error = %e, "stored Parslee token could not be validated");
122            Ok(None)
123        }
124    }
125}
126
127async fn refresh_access_token(
128    store: &SecretStore,
129    base: &str,
130    refresh_token: &str,
131) -> Result<String, String> {
132    let url = format!("{}/connect/token", base.trim_end_matches('/'));
133    let body = format!(
134        "grant_type=refresh_token&refresh_token={}",
135        form_encode(refresh_token)
136    );
137    let response = reqwest::Client::new()
138        .post(url)
139        .header("content-type", "application/x-www-form-urlencoded")
140        .body(body)
141        .send()
142        .await
143        .map_err(|e| format!("refresh Parslee token: {e}"))?;
144    let status = response.status();
145    let text = response
146        .text()
147        .await
148        .map_err(|e| format!("read Parslee token response: {e}"))?;
149    if !status.is_success() {
150        return Err(format!("refresh Parslee token: HTTP {status}: {text}"));
151    }
152    let token: TokenResponse =
153        serde_json::from_str(&text).map_err(|e| format!("parse Parslee token response: {e}"))?;
154    store_secret(store, ACCESS_TOKEN_KEY, &token.access_token)?;
155    if let Some(refresh) = token.refresh_token.as_deref() {
156        store_secret(store, REFRESH_TOKEN_KEY, refresh)?;
157    }
158    if let Some(expires_in) = token.expires_in {
159        store_secret(
160            store,
161            EXPIRES_AT_KEY,
162            &(now_epoch_seconds() + expires_in).to_string(),
163        )?;
164    }
165    Ok(token.access_token)
166}
167
168async fn fetch_identity(base: &str, access_token: &str) -> Result<ParsleeIdentity, String> {
169    let url = format!("{}/connect/session", base.trim_end_matches('/'));
170    let response = reqwest::Client::new()
171        .get(url)
172        .bearer_auth(access_token)
173        .send()
174        .await
175        .map_err(|e| format!("fetch Parslee session: {e}"))?;
176    let status = response.status();
177    let text = response
178        .text()
179        .await
180        .map_err(|e| format!("read Parslee session response: {e}"))?;
181    if !status.is_success() {
182        return Err(format!("fetch Parslee session: HTTP {status}: {text}"));
183    }
184    if let Ok(session) = serde_json::from_str::<SessionInfoCamel>(&text) {
185        if let Some(identity) = session.into_identity() {
186            return Ok(identity);
187        }
188    }
189    if let Ok(session) = serde_json::from_str::<SessionInfoPascal>(&text) {
190        if let Some(identity) = session.into_identity() {
191            return Ok(identity);
192        }
193    }
194    Err("Parslee session response did not contain an authenticated account".to_string())
195}
196
197fn secret_optional(store: &SecretStore, key: &str) -> Result<Option<String>, String> {
198    match store.get(&SecretRef::new(DEFAULT_SERVICE, key)) {
199        Ok(value) if !value.is_empty() => Ok(Some(value)),
200        Ok(_) | Err(SecretError::NotFound { .. }) => Ok(None),
201        Err(e) => Err(e.to_string()),
202    }
203}
204
205fn store_secret(store: &SecretStore, key: &str, value: &str) -> Result<(), String> {
206    store
207        .put(&SecretRef::new(DEFAULT_SERVICE, key), value)
208        .map_err(|e| e.to_string())
209}
210
211fn now_epoch_seconds() -> u64 {
212    SystemTime::now()
213        .duration_since(UNIX_EPOCH)
214        .unwrap_or_default()
215        .as_secs()
216}
217
218fn form_encode(input: &str) -> String {
219    let mut out = String::new();
220    for b in input.bytes() {
221        match b {
222            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
223                out.push(b as char)
224            }
225            b' ' => out.push('+'),
226            _ => out.push_str(&format!("%{b:02X}")),
227        }
228    }
229    out
230}