car_server_core/
parslee_auth.rs1use 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}