Skip to main content

car_auth/
lib.rs

1//! Shared Parslee OAuth2 PKCE + token/keychain logic.
2//!
3//! Used by `car-cli` (`car auth login parslee`, loopback flow) and by
4//! `car-server` (the `auth.*` JSON-RPC surface that CAR Host.app's
5//! signup GUI drives). The keychain keys + default service exactly
6//! match what `car-inference` reads at request time
7//! (`PARSLEE_ACCESS_TOKEN`, default `"car"` service) — see
8//! `car-inference` `remote.rs::lease_key`.
9
10use base64::Engine;
11use serde::Deserialize;
12use sha2::{Digest, Sha256};
13
14use car_secrets::{SecretRef, SecretStore};
15
16pub const PARSLEE_ACCESS_TOKEN_KEY: &str = "PARSLEE_ACCESS_TOKEN";
17pub const PARSLEE_REFRESH_TOKEN_KEY: &str = "PARSLEE_REFRESH_TOKEN";
18pub const PARSLEE_EXPIRES_AT_KEY: &str = "PARSLEE_ACCESS_TOKEN_EXPIRES_AT";
19pub const PARSLEE_API_BASE_KEY: &str = "PARSLEE_API_BASE";
20pub const DEFAULT_API_BASE: &str = "https://api.parslee.ai";
21
22/// `/connect/token` success body.
23#[derive(Debug, Clone, Deserialize)]
24pub struct TokenSet {
25    pub access_token: String,
26    pub refresh_token: String,
27    pub expires_in: u64,
28    pub token_type: String,
29}
30
31fn epoch_seconds() -> u64 {
32    std::time::SystemTime::now()
33        .duration_since(std::time::UNIX_EPOCH)
34        .map(|d| d.as_secs())
35        .unwrap_or(0)
36}
37
38/// PKCE code verifier (URL-safe, no padding).
39pub fn pkce_verifier() -> String {
40    let raw = format!(
41        "{}{}",
42        uuid::Uuid::new_v4().simple(),
43        uuid::Uuid::new_v4().simple()
44    );
45    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
46}
47
48/// Opaque OAuth `state` value (CSRF guard).
49pub fn new_state() -> String {
50    uuid::Uuid::new_v4().simple().to_string()
51}
52
53/// PKCE S256 challenge for a verifier.
54pub fn pkce_challenge(verifier: &str) -> String {
55    let digest = Sha256::digest(verifier.as_bytes());
56    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
57}
58
59/// Build the `/connect/authorize` URL the user opens in a browser.
60pub fn authorize_url(
61    api_base: &str,
62    client_id: &str,
63    redirect_uri: &str,
64    state: &str,
65    challenge: &str,
66    provider: Option<&str>,
67    prompt: Option<&str>,
68) -> Result<String, String> {
69    let mut url = reqwest::Url::parse(&format!(
70        "{}/connect/authorize",
71        api_base.trim_end_matches('/')
72    ))
73    .map_err(|e| format!("build authorize URL: {e}"))?;
74    url.query_pairs_mut()
75        .append_pair("client_id", client_id)
76        .append_pair("redirect_uri", redirect_uri)
77        .append_pair("response_type", "code")
78        .append_pair("scope", "openid profile email")
79        .append_pair("state", state)
80        .append_pair("code_challenge", challenge)
81        .append_pair("code_challenge_method", "S256");
82    if let Some(provider) = provider {
83        url.query_pairs_mut().append_pair("provider", provider);
84    }
85    // `prompt=select_account` forces a fresh account chooser (add-account),
86    // bypassing the existing SSO cookie so a second login can be added.
87    if let Some(prompt) = prompt {
88        url.query_pairs_mut().append_pair("prompt", prompt);
89    }
90    Ok(url.to_string())
91}
92
93fn form_body(pairs: &[(&str, &str)]) -> String {
94    let mut s = String::new();
95    for (i, (k, v)) in pairs.iter().enumerate() {
96        if i > 0 {
97            s.push('&');
98        }
99        s.push_str(&urlencode(k));
100        s.push('=');
101        s.push_str(&urlencode(v));
102    }
103    s
104}
105
106fn urlencode(s: &str) -> String {
107    let mut out = String::with_capacity(s.len());
108    for b in s.bytes() {
109        match b {
110            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
111                out.push(b as char)
112            }
113            _ => out.push_str(&format!("%{b:02X}")),
114        }
115    }
116    out
117}
118
119/// Exchange an authorization code + PKCE verifier for tokens.
120pub async fn exchange_code(
121    api_base: &str,
122    client_id: &str,
123    redirect_uri: &str,
124    code: &str,
125    verifier: &str,
126) -> Result<TokenSet, String> {
127    let body = form_body(&[
128        ("grant_type", "authorization_code"),
129        ("client_id", client_id),
130        ("redirect_uri", redirect_uri),
131        ("code", code),
132        ("code_verifier", verifier),
133    ]);
134    let token_url = format!("{}/connect/token", api_base.trim_end_matches('/'));
135    let response = reqwest::Client::new()
136        .post(token_url)
137        .header("content-type", "application/x-www-form-urlencoded")
138        .body(body)
139        .send()
140        .await
141        .map_err(|e| format!("exchange Parslee authorization code: {e}"))?;
142    let status = response.status();
143    let text = response
144        .text()
145        .await
146        .map_err(|e| format!("read token response: {e}"))?;
147    if !status.is_success() {
148        return Err(format!(
149            "Parslee token exchange failed: HTTP {status}: {text}"
150        ));
151    }
152    let token: TokenSet =
153        serde_json::from_str(&text).map_err(|e| format!("parse token response: {e}"))?;
154    if !token.token_type.eq_ignore_ascii_case("bearer") {
155        return Err(format!(
156            "unexpected Parslee token_type `{}`",
157            token.token_type
158        ));
159    }
160    Ok(token)
161}
162
163fn put(key: &str, value: &str) -> Result<(), String> {
164    SecretStore::new()
165        .put(&SecretRef::with_default_service(key), value)
166        .map_err(|e| format!("store {key}: {e}"))
167}
168
169/// Persist a token set + the API base into the OS keychain (default
170/// `"car"` service — the same place `car-inference` reads from).
171pub fn store_tokens(api_base: &str, token: &TokenSet) -> Result<(), String> {
172    put(PARSLEE_ACCESS_TOKEN_KEY, &token.access_token)?;
173    put(PARSLEE_REFRESH_TOKEN_KEY, &token.refresh_token)?;
174    put(PARSLEE_API_BASE_KEY, api_base.trim_end_matches('/'))?;
175    put(
176        PARSLEE_EXPIRES_AT_KEY,
177        &(epoch_seconds() + token.expires_in).to_string(),
178    )?;
179    Ok(())
180}
181
182/// Remove all stored Parslee credentials. Idempotent.
183pub fn clear_tokens() -> Result<(), String> {
184    let store = SecretStore::new();
185    for key in [
186        PARSLEE_ACCESS_TOKEN_KEY,
187        PARSLEE_REFRESH_TOKEN_KEY,
188        PARSLEE_EXPIRES_AT_KEY,
189        PARSLEE_API_BASE_KEY,
190    ] {
191        let _ = store.delete(&SecretRef::with_default_service(key));
192    }
193    Ok(())
194}
195
196/// Current access token (env override first, then keychain).
197pub fn access_token() -> Option<String> {
198    car_secrets::resolve_env_or_keychain(PARSLEE_ACCESS_TOKEN_KEY)
199}
200
201/// Seconds before the stored expiry at which [`access_token_refreshing`]
202/// proactively refreshes — absorbs clock skew plus a slow request. Public so
203/// the daemon's `load_or_refresh` shares the same threshold (#320).
204pub const REFRESH_SKEW_SECS: u64 = 120;
205
206/// Result of a [`refresh_grant`]. The gateway may omit a rotated refresh
207/// token (reuse the prior one) and/or an expiry, so both are optional.
208#[derive(Debug, Clone)]
209pub struct RefreshedTokens {
210    pub access_token: String,
211    pub refresh_token: Option<String>,
212    pub expires_in: Option<u64>,
213}
214
215/// `refresh_token` grant against `/connect/token`. Network-only — the
216/// caller persists. Mirrors the Parslee gateway contract used by the
217/// daemon's own refresh path (`car-server-core::parslee_auth`): the
218/// gateway treats this as a public-client grant, so no `client_id` is
219/// sent. This lives in `car-auth` (not `car-server-core`) so the
220/// request-time inference path — which cannot depend on `car-server-core`
221/// — shares one definition of "mint a fresh Parslee bearer" (#313).
222pub async fn refresh_grant(api_base: &str, refresh_token: &str) -> Result<RefreshedTokens, String> {
223    #[derive(Deserialize)]
224    struct Resp {
225        access_token: String,
226        #[serde(default)]
227        refresh_token: Option<String>,
228        #[serde(default)]
229        expires_in: Option<u64>,
230    }
231    let body = form_body(&[
232        ("grant_type", "refresh_token"),
233        ("refresh_token", refresh_token),
234    ]);
235    let token_url = format!("{}/connect/token", api_base.trim_end_matches('/'));
236    let response = reqwest::Client::new()
237        .post(token_url)
238        .header("content-type", "application/x-www-form-urlencoded")
239        .body(body)
240        .send()
241        .await
242        .map_err(|e| format!("refresh Parslee token: {e}"))?;
243    let status = response.status();
244    let text = response
245        .text()
246        .await
247        .map_err(|e| format!("read Parslee token response: {e}"))?;
248    if !status.is_success() {
249        return Err(format!("refresh Parslee token: HTTP {status}: {text}"));
250    }
251    let r: Resp =
252        serde_json::from_str(&text).map_err(|e| format!("parse Parslee token response: {e}"))?;
253    Ok(RefreshedTokens {
254        access_token: r.access_token,
255        refresh_token: r.refresh_token,
256        expires_in: r.expires_in,
257    })
258}
259
260/// Persist refreshed credentials to the keychain (the same keys
261/// `car-inference` reads). Best-effort: a keychain write failure must not
262/// fail the in-flight request — the returned access token still works.
263fn persist_refreshed(api_base: &str, t: &RefreshedTokens) {
264    let _ = put(PARSLEE_ACCESS_TOKEN_KEY, &t.access_token);
265    if let Some(refresh) = &t.refresh_token {
266        let _ = put(PARSLEE_REFRESH_TOKEN_KEY, refresh);
267    }
268    if let Some(expires_in) = t.expires_in {
269        let _ = put(
270            PARSLEE_EXPIRES_AT_KEY,
271            &(epoch_seconds() + expires_in).to_string(),
272        );
273    }
274    let _ = put(PARSLEE_API_BASE_KEY, api_base.trim_end_matches('/'));
275}
276
277/// Current access token, **proactively refreshed** when the stored token
278/// is within [`REFRESH_SKEW_SECS`] of expiry (or already expired) and a
279/// refresh token is available. The `PARSLEE_ACCESS_TOKEN` env override
280/// always wins and is never refreshed — it's a deliberate injection for
281/// tests/CI. Returns `None` only when no token is available at all.
282///
283/// Request-time consumers (notably `car-inference`) should call this
284/// instead of [`access_token`]: it's the difference between a lapsed
285/// token producing a 401 burst that poisons 30-day model health and a
286/// transparent refresh-and-proceed (#313).
287pub async fn access_token_refreshing() -> Option<String> {
288    // Env override wins and is never refreshed.
289    if let Ok(tok) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
290        if !tok.is_empty() {
291            return Some(tok);
292        }
293    }
294    let current = car_secrets::resolve_env_or_keychain(PARSLEE_ACCESS_TOKEN_KEY)?;
295    // Refresh only when we can *see* the token is (nearly) expired and we
296    // have a refresh token. An unknown/missing expiry means "don't churn".
297    let expiring = car_secrets::resolve_env_or_keychain(PARSLEE_EXPIRES_AT_KEY)
298        .and_then(|s| s.trim().parse::<u64>().ok())
299        .map(|exp| epoch_seconds() + REFRESH_SKEW_SECS >= exp)
300        .unwrap_or(false);
301    if !expiring {
302        return Some(current);
303    }
304    let Some(refresh) = car_secrets::resolve_env_or_keychain(PARSLEE_REFRESH_TOKEN_KEY) else {
305        return Some(current);
306    };
307    let base = api_base(None);
308    match refresh_grant(&base, &refresh).await {
309        Ok(tokens) => {
310            let access = tokens.access_token.clone();
311            persist_refreshed(&base, &tokens);
312            Some(access)
313        }
314        // Refresh failed (expired refresh token / offline): fall back to the
315        // stored access token and let the server decide. No worse than today
316        // — a still-valid access token keeps working — but WARN so a lapsed
317        // session that then 401s downstream is diagnosable, rather than the
318        // refresh failing silently ("why didn't it refresh?").
319        Err(e) => {
320            eprintln!("car-auth: proactive Parslee token refresh failed; using stored token (it may 401 — re-run `car auth login`) ({e})");
321            Some(current)
322        }
323    }
324}
325
326/// Unconditionally refresh the Parslee bearer, for the **reactive 401**
327/// path. [`access_token_refreshing`] only refreshes inside a proactive
328/// window keyed on the stored expiry — but a token can be revoked or
329/// invalidated server-side *before* its advertised expiry, and a token
330/// stored without an expiry never enters that window at all. When a live
331/// request is rejected with 401/403, the caller invokes this to mint a
332/// fresh bearer and retry once, instead of letting the failure poison
333/// 30-day model health (#313).
334///
335/// Returns the new access token, or `None` when there is no refresh token
336/// to use or the refresh itself fails. The `PARSLEE_ACCESS_TOKEN` env
337/// override is authoritative and never refreshed (returns `None` so the
338/// caller keeps using the injected token).
339pub async fn force_refresh() -> Option<String> {
340    if let Ok(tok) = std::env::var(PARSLEE_ACCESS_TOKEN_KEY) {
341        if !tok.is_empty() {
342            return None;
343        }
344    }
345    let Some(refresh) = car_secrets::resolve_env_or_keychain(PARSLEE_REFRESH_TOKEN_KEY) else {
346        eprintln!("car-auth: reactive Parslee refresh: no refresh token in keychain — run `car auth login`");
347        return None;
348    };
349    let base = api_base(None);
350    match refresh_grant(&base, &refresh).await {
351        Ok(tokens) => {
352            let access = tokens.access_token.clone();
353            persist_refreshed(&base, &tokens);
354            Some(access)
355        }
356        Err(e) => {
357            eprintln!("car-auth: reactive Parslee token refresh failed (401 will surface) — re-run `car auth login` ({e})");
358            None
359        }
360    }
361}
362
363/// Resolve the API base: explicit override → stored → default.
364pub fn api_base(override_: Option<&str>) -> String {
365    override_
366        .map(|s| s.trim_end_matches('/').to_string())
367        .or_else(|| car_secrets::resolve_env_or_keychain(PARSLEE_API_BASE_KEY))
368        .unwrap_or_else(|| DEFAULT_API_BASE.to_string())
369}
370
371/// Fetch the Parslee session JSON for the stored token. Returns the
372/// raw response body (the caller renders it). `Ok(None)` = not signed in.
373pub async fn fetch_status(api_base_override: Option<&str>) -> Result<Option<String>, String> {
374    let Some(access) = access_token() else {
375        return Ok(None);
376    };
377    let base = api_base(api_base_override);
378    let response = reqwest::Client::new()
379        .get(format!("{}/connect/session", base.trim_end_matches('/')))
380        .bearer_auth(access)
381        .send()
382        .await
383        .map_err(|e| format!("fetch Parslee session: {e}"))?;
384    let status = response.status();
385    let text = response
386        .text()
387        .await
388        .map_err(|e| format!("read Parslee session response: {e}"))?;
389    if !status.is_success() {
390        return Err(format!(
391            "Parslee session check failed: HTTP {status}: {text}"
392        ));
393    }
394    Ok(Some(text))
395}
396
397/// Set the account's active organization (bearer `PUT /accounts/me/active-org`).
398///
399/// This changes the account-level `active_org_id` PREFERENCE server-side and
400/// validates membership. It does NOT re-scope the currently-stored access
401/// token — the token's `active_org` claim (what inference reads) is fixed at
402/// mint time, so a caller who wants the switch to take effect for inference
403/// must re-authorize afterward to mint a token bound to the new org. Returns
404/// the raw `AccountResponse` body on success.
405pub async fn set_active_org(
406    api_base_override: Option<&str>,
407    organization_id: &str,
408) -> Result<String, String> {
409    let Some(access) = access_token_refreshing().await else {
410        return Err("not signed in".to_string());
411    };
412    let base = api_base(api_base_override);
413    // reqwest is built without the `json` feature, so serialize by hand.
414    let body = serde_json::json!({ "organizationId": organization_id }).to_string();
415    let response = reqwest::Client::new()
416        .put(format!(
417            "{}/api/v1/accounts/me/active-org",
418            base.trim_end_matches('/')
419        ))
420        .bearer_auth(access)
421        .header("content-type", "application/json")
422        .body(body)
423        .send()
424        .await
425        .map_err(|e| format!("set active org: {e}"))?;
426    let status = response.status();
427    let text = response
428        .text()
429        .await
430        .map_err(|e| format!("read set-active-org response: {e}"))?;
431    if !status.is_success() {
432        return Err(format!("set active org failed: HTTP {status}: {text}"));
433    }
434    Ok(text)
435}
436
437/// Switch the active organization **silently** by minting a fresh token
438/// scoped to `org_id` via the refresh grant's `organization_id` override
439/// (`/connect/token`, `grant_type=refresh_token`). The backend validates
440/// membership and stamps `active_org=org_id` on the new access token — which
441/// is what inference reads — so the switch takes effect without a browser
442/// re-authorization. Rotated tokens are persisted to the keychain. Also
443/// best-effort updates the account's default org so a future fresh sign-in
444/// lands in the same place.
445pub async fn switch_org(api_base_override: Option<&str>, org_id: &str) -> Result<(), String> {
446    #[derive(Deserialize)]
447    struct Resp {
448        access_token: String,
449        #[serde(default)]
450        refresh_token: Option<String>,
451        #[serde(default)]
452        expires_in: Option<u64>,
453    }
454    let Some(refresh) = car_secrets::resolve_env_or_keychain(PARSLEE_REFRESH_TOKEN_KEY) else {
455        return Err("not signed in".to_string());
456    };
457    let base = api_base(api_base_override);
458    let body = form_body(&[
459        ("grant_type", "refresh_token"),
460        ("refresh_token", &refresh),
461        ("organization_id", org_id),
462    ]);
463    let response = reqwest::Client::new()
464        .post(format!("{}/connect/token", base.trim_end_matches('/')))
465        .header("content-type", "application/x-www-form-urlencoded")
466        .body(body)
467        .send()
468        .await
469        .map_err(|e| format!("switch org: {e}"))?;
470    let status = response.status();
471    let text = response
472        .text()
473        .await
474        .map_err(|e| format!("read switch-org response: {e}"))?;
475    if !status.is_success() {
476        return Err(format!("switch org failed: HTTP {status}: {text}"));
477    }
478    let r: Resp =
479        serde_json::from_str(&text).map_err(|e| format!("parse switch-org response: {e}"))?;
480    persist_refreshed(
481        &base,
482        &RefreshedTokens {
483            access_token: r.access_token,
484            refresh_token: r.refresh_token,
485            expires_in: r.expires_in,
486        },
487    );
488    // Keep the account's default org in sync (best-effort; the token is
489    // already switched regardless of this call's outcome).
490    let _ = set_active_org(Some(&base), org_id).await;
491    Ok(())
492}
493
494// ===== Multi-account: several simultaneous Parslee logins =====
495//
496// The ACTIVE login's tokens live in the fixed PARSLEE_ACCESS_TOKEN/… slots that
497// `car-inference` reads — unchanged, so every token reader keeps working. A
498// registry (`PARSLEE_ACCOUNTS`) tracks each known login + which is active, and
499// each login's token set is stashed under `PARSLEE_TOKENS_<accountId>`.
500// Switching swaps the active slots; readers never learn about the stash.
501
502const PARSLEE_ACCOUNTS_KEY: &str = "PARSLEE_ACCOUNTS";
503
504/// Non-secret metadata for one signed-in Parslee login.
505#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
506pub struct AccountMeta {
507    pub id: String,
508    #[serde(default)]
509    pub email: Option<String>,
510    #[serde(default)]
511    pub name: Option<String>,
512    /// True for the login whose tokens are currently in the active slots.
513    #[serde(default)]
514    pub active: bool,
515}
516
517#[derive(Default, serde::Serialize, serde::Deserialize)]
518struct Registry {
519    #[serde(default)]
520    active: Option<String>,
521    #[serde(default)]
522    accounts: Vec<StoredAccount>,
523}
524
525#[derive(Clone, serde::Serialize, serde::Deserialize)]
526struct StoredAccount {
527    id: String,
528    #[serde(default)]
529    email: Option<String>,
530    #[serde(default)]
531    name: Option<String>,
532}
533
534#[derive(serde::Serialize, serde::Deserialize)]
535struct StashedTokens {
536    access: String,
537    refresh: String,
538    #[serde(default)]
539    expires_at: String,
540    #[serde(default)]
541    api_base: String,
542}
543
544fn tokens_key(id: &str) -> String {
545    format!("PARSLEE_TOKENS_{id}")
546}
547
548fn load_registry() -> Registry {
549    car_secrets::resolve_env_or_keychain(PARSLEE_ACCOUNTS_KEY)
550        .and_then(|s| serde_json::from_str(&s).ok())
551        .unwrap_or_default()
552}
553
554fn save_registry(r: &Registry) {
555    if let Ok(s) = serde_json::to_string(r) {
556        let _ = put(PARSLEE_ACCOUNTS_KEY, &s);
557    }
558}
559
560fn read_active_slots() -> Option<StashedTokens> {
561    Some(StashedTokens {
562        access: car_secrets::resolve_env_or_keychain(PARSLEE_ACCESS_TOKEN_KEY)?,
563        refresh: car_secrets::resolve_env_or_keychain(PARSLEE_REFRESH_TOKEN_KEY)?,
564        expires_at: car_secrets::resolve_env_or_keychain(PARSLEE_EXPIRES_AT_KEY)
565            .unwrap_or_default(),
566        api_base: car_secrets::resolve_env_or_keychain(PARSLEE_API_BASE_KEY)
567            .unwrap_or_else(|| DEFAULT_API_BASE.to_string()),
568    })
569}
570
571fn write_active_slots(t: &StashedTokens) {
572    let _ = put(PARSLEE_ACCESS_TOKEN_KEY, &t.access);
573    let _ = put(PARSLEE_REFRESH_TOKEN_KEY, &t.refresh);
574    let _ = put(PARSLEE_EXPIRES_AT_KEY, &t.expires_at);
575    let base = if t.api_base.is_empty() {
576        DEFAULT_API_BASE
577    } else {
578        &t.api_base
579    };
580    let _ = put(PARSLEE_API_BASE_KEY, base);
581}
582
583fn stash_tokens_for(id: &str, t: &StashedTokens) {
584    if let Ok(s) = serde_json::to_string(t) {
585        let _ = put(&tokens_key(id), &s);
586    }
587}
588
589fn load_stash(id: &str) -> Option<StashedTokens> {
590    car_secrets::resolve_env_or_keychain(&tokens_key(id))
591        .and_then(|s| serde_json::from_str(&s).ok())
592}
593
594/// Save the currently-active fixed-slot tokens into the active login's stash, so
595/// the outgoing login isn't lost on a switch / add-account.
596pub fn stash_active_account() {
597    let reg = load_registry();
598    if let (Some(active), Some(t)) = (reg.active.as_ref(), read_active_slots()) {
599        stash_tokens_for(active, &t);
600    }
601}
602
603/// After a fresh sign-in wrote the fixed slots, fetch the session, register the
604/// login (id/email/name), stash its tokens, and mark it active. Returns the id.
605pub async fn register_active_login(api_base_override: Option<&str>) -> Result<String, String> {
606    // An expired access token makes `/connect/session` return a degraded body
607    // with `Account: null`; refresh first so the identity is populated.
608    let _ = access_token_refreshing().await;
609    let session = fetch_status(api_base_override)
610        .await?
611        .ok_or("not signed in")?;
612    let v: serde_json::Value =
613        serde_json::from_str(&session).map_err(|e| format!("parse session: {e}"))?;
614    let account = v.get("Account");
615    let id = account
616        .and_then(|a| a.get("Id"))
617        .and_then(|x| x.as_str())
618        .ok_or("session has no account id")?
619        .to_string();
620    let email = account
621        .and_then(|a| a.get("Email"))
622        .and_then(|x| x.as_str())
623        .map(String::from);
624    let name = account
625        .and_then(|a| a.get("Name"))
626        .and_then(|x| x.as_str())
627        .map(String::from);
628    if let Some(t) = read_active_slots() {
629        stash_tokens_for(&id, &t);
630    }
631    let mut reg = load_registry();
632    reg.accounts.retain(|a| a.id != id);
633    reg.accounts.push(StoredAccount {
634        id: id.clone(),
635        email,
636        name,
637    });
638    reg.active = Some(id.clone());
639    save_registry(&reg);
640    Ok(id)
641}
642
643/// List every known login (`active` marks the current one). Migrates a
644/// pre-multi-login session (tokens in the fixed slots, no registry entry) in.
645pub async fn list_accounts(api_base_override: Option<&str>) -> Vec<AccountMeta> {
646    let reg = load_registry();
647    if reg.active.is_none() && access_token().is_some() {
648        let _ = register_active_login(api_base_override).await;
649    }
650    let reg = load_registry();
651    reg.accounts
652        .iter()
653        .map(|a| AccountMeta {
654            id: a.id.clone(),
655            email: a.email.clone(),
656            name: a.name.clone(),
657            active: reg.active.as_deref() == Some(&a.id),
658        })
659        .collect()
660}
661
662/// Switch the active login: stash the current active's latest tokens, load the
663/// target's stashed tokens into the fixed slots. Sync (keychain only) — the
664/// caller should refresh/re-fetch the session afterward.
665pub fn switch_account(account_id: &str) -> Result<(), String> {
666    let mut reg = load_registry();
667    if !reg.accounts.iter().any(|a| a.id == account_id) {
668        return Err(format!("unknown account: {account_id}"));
669    }
670    if reg.active.as_deref() == Some(account_id) {
671        return Ok(());
672    }
673    stash_active_account();
674    let stashed = load_stash(account_id)
675        .ok_or("no stored tokens for that login — sign in again to re-add it")?;
676    write_active_slots(&stashed);
677    reg.active = Some(account_id.to_string());
678    save_registry(&reg);
679    Ok(())
680}
681
682/// Remove a login (deletes its stashed tokens). If it was active, switch to
683/// another remaining login, or clear the session when none remain.
684pub fn remove_account(account_id: &str) -> Result<(), String> {
685    let mut reg = load_registry();
686    reg.accounts.retain(|a| a.id != account_id);
687    let _ = SecretStore::new().delete(&SecretRef::with_default_service(tokens_key(account_id)));
688    if reg.active.as_deref() == Some(account_id) {
689        if let Some(next) = reg.accounts.first().map(|a| a.id.clone()) {
690            reg.active = Some(next.clone());
691            if let Some(stashed) = load_stash(&next) {
692                write_active_slots(&stashed);
693            }
694        } else {
695            reg.active = None;
696            clear_tokens()?;
697        }
698    }
699    save_registry(&reg);
700    Ok(())
701}
702
703// First-login onboarding is intentionally NOT here. Brand-new users
704// are routed through Parslee's existing hosted web consent/org page
705// during the `/connect/authorize` browser hand-off (see m365dotnet
706// `specs/draft/car-inference-gateway-auth.md` B6), so the token CAR
707// redeems already carries `active_org`. CAR is a pure OAuth client and
708// never touches consent — there is no `ensure_org`, by design.
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    #[test]
715    fn pkce_challenge_is_s256_urlsafe_nopad() {
716        let v = pkce_verifier();
717        let c = pkce_challenge(&v);
718        assert!(!c.contains('=') && !c.contains('+') && !c.contains('/'));
719        assert_eq!(c, pkce_challenge(&v)); // deterministic
720    }
721
722    #[test]
723    fn authorize_url_has_pkce_and_provider() {
724        let u = authorize_url(
725            "https://api.parslee.ai/",
726            "parslee-car",
727            "http://localhost:8765/auth/callback",
728            "st8",
729            "chal",
730            Some("microsoft"),
731            Some("select_account"),
732        )
733        .unwrap();
734        assert!(u.starts_with("https://api.parslee.ai/connect/authorize?"));
735        assert!(u.contains("code_challenge=chal"));
736        assert!(u.contains("code_challenge_method=S256"));
737        assert!(u.contains("client_id=parslee-car"));
738        assert!(u.contains("provider=microsoft"));
739        assert!(u.contains("prompt=select_account"));
740    }
741
742    #[test]
743    fn api_base_precedence() {
744        assert_eq!(api_base(Some("https://x.test/")), "https://x.test");
745    }
746
747    /// Hand-rolled loopback HTTP mock — no extra prod dep, no feature
748    /// flags. Serves exactly `expected` one-shot requests, records
749    /// what came in, and replies with whatever `respond` returns.
750    /// Lets the networked auth fns be exercised end-to-end in CI
751    /// without the real Parslee backend (or the OS keychain — the
752    /// token is injected via the `PARSLEE_ACCESS_TOKEN` env override).
753    mod mock {
754        use std::io::{Read, Write};
755        use std::net::TcpListener;
756        use std::sync::{Arc, Mutex};
757        use std::thread;
758
759        pub struct Recorded {
760            pub method: String,
761            pub path: String,
762            pub authorization: Option<String>,
763            #[allow(dead_code)] // captured for completeness; not asserted on in tests
764            pub content_type: Option<String>,
765            pub body: String,
766        }
767
768        pub struct Mock {
769            pub base: String,
770            pub recorded: Arc<Mutex<Vec<Recorded>>>,
771            handle: Option<thread::JoinHandle<()>>,
772        }
773
774        impl Drop for Mock {
775            fn drop(&mut self) {
776                if let Some(h) = self.handle.take() {
777                    let _ = h.join();
778                }
779            }
780        }
781
782        fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
783            hay.windows(needle.len()).position(|w| w == needle)
784        }
785
786        pub fn start(
787            expected: usize,
788            respond: impl Fn(&Recorded) -> (u16, String) + Send + 'static,
789        ) -> Mock {
790            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
791            let port = listener.local_addr().unwrap().port();
792            let recorded = Arc::new(Mutex::new(Vec::new()));
793            let rec = recorded.clone();
794            let handle = thread::spawn(move || {
795                for _ in 0..expected {
796                    let (mut stream, _) = listener.accept().unwrap();
797                    let mut buf = Vec::new();
798                    let mut tmp = [0u8; 1024];
799                    loop {
800                        let n = stream.read(&mut tmp).unwrap();
801                        if n == 0 {
802                            break;
803                        }
804                        buf.extend_from_slice(&tmp[..n]);
805                        let Some(hdr_end) = find(&buf, b"\r\n\r\n") else {
806                            continue;
807                        };
808                        let headers = String::from_utf8_lossy(&buf[..hdr_end]).into_owned();
809                        let content_length = headers
810                            .lines()
811                            .find_map(|l| {
812                                let (k, v) = l.split_once(':')?;
813                                if k.eq_ignore_ascii_case("content-length") {
814                                    v.trim().parse::<usize>().ok()
815                                } else {
816                                    None
817                                }
818                            })
819                            .unwrap_or(0);
820                        let body_start = hdr_end + 4;
821                        while buf.len() < body_start + content_length {
822                            let n = stream.read(&mut tmp).unwrap();
823                            if n == 0 {
824                                break;
825                            }
826                            buf.extend_from_slice(&tmp[..n]);
827                        }
828                        let mut header_lines = headers.lines();
829                        let req_line = header_lines.next().unwrap_or("");
830                        let mut rl = req_line.split_whitespace();
831                        let method = rl.next().unwrap_or("").to_string();
832                        let path = rl.next().unwrap_or("").to_string();
833                        let mut authorization = None;
834                        let mut content_type = None;
835                        for l in header_lines {
836                            if let Some((k, v)) = l.split_once(':') {
837                                if k.eq_ignore_ascii_case("authorization") {
838                                    authorization = Some(v.trim().to_string());
839                                } else if k.eq_ignore_ascii_case("content-type") {
840                                    content_type = Some(v.trim().to_string());
841                                }
842                            }
843                        }
844                        let body = String::from_utf8_lossy(
845                            &buf[body_start..(body_start + content_length).min(buf.len())],
846                        )
847                        .into_owned();
848                        let r = Recorded {
849                            method,
850                            path,
851                            authorization,
852                            content_type,
853                            body,
854                        };
855                        let (code, resp_body) = respond(&r);
856                        rec.lock().unwrap().push(r);
857                        let resp = format!(
858                            "HTTP/1.1 {code} OK\r\ncontent-type: application/json\r\n\
859                             content-length: {}\r\nconnection: close\r\n\r\n{}",
860                            resp_body.len(),
861                            resp_body
862                        );
863                        stream.write_all(resp.as_bytes()).unwrap();
864                        let _ = stream.flush();
865                        break;
866                    }
867                }
868            });
869            Mock {
870                base: format!("http://127.0.0.1:{port}"),
871                recorded,
872                handle: Some(handle),
873            }
874        }
875    }
876
877    #[tokio::test]
878    async fn exchange_code_round_trips_token() {
879        let mock = mock::start(1, |_r| {
880            (
881                200,
882                r#"{"access_token":"a","refresh_token":"r","expires_in":3600,"token_type":"Bearer"}"#
883                    .to_string(),
884            )
885        });
886        let token = exchange_code(
887            &mock.base,
888            "parslee-car",
889            "http://localhost:1/cb",
890            "thecode",
891            "theverifier",
892        )
893        .await
894        .unwrap();
895        assert_eq!(token.access_token, "a");
896        assert_eq!(token.refresh_token, "r");
897        assert_eq!(token.expires_in, 3600);
898
899        let reqs = mock.recorded.lock().unwrap();
900        assert_eq!(reqs.len(), 1);
901        assert_eq!(reqs[0].method, "POST");
902        assert_eq!(reqs[0].path, "/connect/token");
903        assert!(reqs[0].body.contains("grant_type=authorization_code"));
904        assert!(reqs[0].body.contains("code=thecode"));
905        assert!(reqs[0].body.contains("code_verifier=theverifier"));
906    }
907
908    #[tokio::test]
909    async fn refresh_grant_round_trips_token() {
910        // Gateway reuses the refresh token (omits it from the response) — the
911        // `Option` fields must tolerate that.
912        let mock = mock::start(1, |_r| {
913            (
914                200,
915                r#"{"access_token":"a2","expires_in":3600,"token_type":"Bearer"}"#.to_string(),
916            )
917        });
918        let tokens = refresh_grant(&mock.base, "the-refresh-token")
919            .await
920            .unwrap();
921        assert_eq!(tokens.access_token, "a2");
922        assert_eq!(tokens.refresh_token, None);
923        assert_eq!(tokens.expires_in, Some(3600));
924
925        let reqs = mock.recorded.lock().unwrap();
926        assert_eq!(reqs.len(), 1);
927        assert_eq!(reqs[0].method, "POST");
928        assert_eq!(reqs[0].path, "/connect/token");
929        assert!(reqs[0].body.contains("grant_type=refresh_token"));
930        assert!(reqs[0].body.contains("refresh_token=the-refresh-token"));
931        // Public-client refresh: no client_id is sent (matches the daemon).
932        assert!(!reqs[0].body.contains("client_id"));
933    }
934
935    #[tokio::test]
936    async fn fetch_status_sends_bearer() {
937        // Inject the token via the env override so the keychain is
938        // never touched. No other car-auth test reads this var.
939        std::env::set_var(PARSLEE_ACCESS_TOKEN_KEY, "test-token");
940
941        let mock = mock::start(1, |_r| (200, r#"{"authenticated":true}"#.to_string()));
942
943        let session = fetch_status(Some(&mock.base)).await.unwrap();
944        assert_eq!(session.as_deref(), Some(r#"{"authenticated":true}"#));
945
946        let reqs = mock.recorded.lock().unwrap();
947        assert_eq!(reqs.len(), 1);
948        let sess = &reqs[0];
949        assert_eq!(sess.method, "GET");
950        assert_eq!(sess.path, "/connect/session");
951        assert_eq!(sess.authorization.as_deref(), Some("Bearer test-token"));
952
953        std::env::remove_var(PARSLEE_ACCESS_TOKEN_KEY);
954    }
955}