1use 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#[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
38pub 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
48pub fn new_state() -> String {
50 uuid::Uuid::new_v4().simple().to_string()
51}
52
53pub 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
59pub 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 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
119pub 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
169pub 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
182pub 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
196pub fn access_token() -> Option<String> {
198 car_secrets::resolve_env_or_keychain(PARSLEE_ACCESS_TOKEN_KEY)
199}
200
201pub const REFRESH_SKEW_SECS: u64 = 120;
205
206#[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
215pub 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
260fn 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
277pub async fn access_token_refreshing() -> Option<String> {
288 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 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 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
326pub 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
363pub 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
371pub 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
397pub 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 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
437pub 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 let _ = set_active_org(Some(&base), org_id).await;
491 Ok(())
492}
493
494const PARSLEE_ACCOUNTS_KEY: &str = "PARSLEE_ACCOUNTS";
503
504#[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 #[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
594pub 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
603pub async fn register_active_login(api_base_override: Option<&str>) -> Result<String, String> {
606 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(®);
640 Ok(id)
641}
642
643pub 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
662pub 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(®);
679 Ok(())
680}
681
682pub 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(®);
700 Ok(())
701}
702
703#[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)); }
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 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)] 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 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 assert!(!reqs[0].body.contains("client_id"));
933 }
934
935 #[tokio::test]
936 async fn fetch_status_sends_bearer() {
937 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}