Skip to main content

eero_api/api/
auth.rs

1use reqwest::header::{HeaderValue, COOKIE};
2
3use crate::client::EeroClient;
4use crate::error::{Error, Result};
5use crate::types::auth::{LoginRequest, LoginResponse, VerifyRequest};
6use crate::types::envelope::ApiResponse;
7
8/// Parse the session token from Set-Cookie headers manually.
9///
10/// Handles formats like `s=TOKEN; Path=/; HttpOnly` by extracting the
11/// value of the cookie named `s`.
12fn extract_session_cookie(headers: &reqwest::header::HeaderMap) -> Option<String> {
13    headers
14        .get_all(reqwest::header::SET_COOKIE)
15        .iter()
16        .filter_map(|val| val.to_str().ok())
17        .find_map(|cookie_str| {
18            let pair = cookie_str.split(';').next()?;
19            let (name, value) = pair.split_once('=')?;
20            if name.trim() == "s" {
21                Some(value.trim().to_string())
22            } else {
23                None
24            }
25        })
26}
27
28impl EeroClient {
29    /// Begin login by sending a verification code to the user's email or phone.
30    ///
31    /// After calling this, the user will receive a verification code. Call
32    /// [`verify`](Self::verify) with that code to complete authentication.
33    #[tracing::instrument(skip(self, login))]
34    pub async fn login(&self, login: &str) -> Result<LoginResponse> {
35        let url = self.url("/login");
36        let body = LoginRequest {
37            login: login.to_string(),
38        };
39
40        let body_json = serde_json::to_string(&body)?;
41        tracing::debug!(request_body = %body_json, "login request");
42        let resp = self
43            .http_client()
44            .post(&url)
45            .header("content-type", "application/json")
46            .body(body_json)
47            .send()
48            .await?;
49        tracing::debug!(status = %resp.status(), response_headers = ?resp.headers(), "login response");
50        let text = resp.text().await?;
51        tracing::debug!(body = %text, "login response body");
52        let api_resp: ApiResponse<LoginResponse> = serde_json::from_str(&text)?;
53
54        let data = Self::unwrap_response(api_resp)?;
55        self.credentials()
56            .set_user_token(&data.user_token)
57            .await?;
58        Ok(data)
59    }
60
61    /// Complete login by verifying the code sent to the user's device.
62    ///
63    /// On success, the session token is extracted from the response cookies
64    /// and stored in the credential store.
65    #[tracing::instrument(skip(self, code))]
66    pub async fn verify(&self, code: &str) -> Result<()> {
67        let user_token = self
68            .credentials()
69            .get_user_token()
70            .await?
71            .ok_or(Error::NotAuthenticated)?;
72
73        let url = self.url("/login/verify");
74        let body = VerifyRequest {
75            code: code.to_string(),
76        };
77
78        let cookie = HeaderValue::from_str(&format!("s={user_token}"))
79            .map_err(|e| Error::CredentialStore(e.to_string()))?;
80        let body_json = serde_json::to_string(&body)?;
81        tracing::debug!(request_headers = ?cookie, request_body = %body_json, "verify request");
82        let resp = self
83            .http_client()
84            .post(&url)
85            .header(COOKIE, cookie)
86            .header("content-type", "application/json")
87            .body(body_json)
88            .send()
89            .await?;
90
91        // Extract session token from Set-Cookie header, parsing manually
92        // rather than using resp.cookies() which can miss cookies from
93        // intermediate redirect responses.
94        tracing::debug!(status = %resp.status(), response_headers = ?resp.headers(), "verify response");
95        let session_token = extract_session_cookie(resp.headers());
96
97        let text = resp.text().await?;
98        tracing::debug!(body = %text, "verify response body");
99        let api_resp: ApiResponse<serde_json::Value> = serde_json::from_str(&text)?;
100        let code = api_resp.meta.code;
101        if !(200..300).contains(&code) {
102            return Err(Error::Api {
103                code,
104                message: api_resp
105                    .meta
106                    .error
107                    .unwrap_or_else(|| "verification failed".into()),
108            });
109        }
110
111        // If the server returns a new session cookie, use it. Otherwise
112        // the user_token from login doubles as the session token.
113        if let Some(token) = session_token {
114            self.credentials().set_session_token(&token).await?;
115        } else {
116            let user_token = self.credentials().get_user_token().await?;
117            if let Some(token) = user_token {
118                self.credentials().set_session_token(&token).await?;
119            }
120        }
121
122        // Clean up user token
123        self.credentials().delete_user_token().await?;
124
125        Ok(())
126    }
127
128    /// Log out and clear stored credentials.
129    #[tracing::instrument(skip(self))]
130    pub async fn logout(&self) -> Result<()> {
131        let url = self.url("/logout");
132        // Best-effort POST to logout endpoint
133        let _ = self.post_empty::<serde_json::Value>(&url).await;
134
135        self.credentials().delete_session_token().await?;
136        self.credentials().delete_user_token().await?;
137        Ok(())
138    }
139}