Skip to main content

rointe_core/
auth.rs

1use std::time::{Duration, Instant};
2
3use reqwest::Client;
4use serde::Deserialize;
5use tracing::{debug, info};
6
7use crate::error::{Result, RointeError};
8
9// Firebase Identity Toolkit API key for the Rointe Connect application.
10//
11// This key is intentionally public — it is embedded in the Rointe Connect web
12// and mobile app binaries and is not a secret. It identifies the Firebase
13// project (elife-prod) but does not grant any elevated privileges on its own;
14// authentication still requires valid user credentials.
15const API_KEY: &str = "AIzaSyBi1DFJlBr9Cezf2BwfaT-PRPYmi3X3pdA";
16
17// Firebase Identity Toolkit endpoint for email/password login.
18const VERIFY_PASSWORD_URL: &str =
19    "https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword";
20
21// Google Secure Token Service endpoint for refreshing id tokens.
22const REFRESH_TOKEN_URL: &str = "https://securetoken.googleapis.com/v1/token";
23
24// Refresh the token this many seconds before it actually expires to avoid
25// using a token that expires mid-request.
26const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(300);
27
28#[derive(Debug, Deserialize)]
29struct LoginResponse {
30    #[serde(rename = "idToken")]
31    id_token: String,
32    #[serde(rename = "refreshToken")]
33    refresh_token: String,
34    #[serde(rename = "expiresIn")]
35    expires_in: String,
36    #[serde(rename = "localId")]
37    local_id: String,
38}
39
40#[derive(Debug, Deserialize)]
41struct RefreshResponse {
42    id_token: String,
43    refresh_token: String,
44    expires_in: String,
45}
46
47/// Holds Firebase auth tokens and handles transparent refresh.
48///
49/// Obtain an instance via [`FirebaseAuth::login`]. The short-lived `id_token`
50/// is refreshed automatically when it is within 5 minutes of
51/// expiry; only the long-lived `refresh_token` needs to be persisted across
52/// restarts.
53#[derive(Debug, Clone)]
54pub struct FirebaseAuth {
55    /// Current Firebase ID token (valid for ~1 hour).
56    pub id_token: String,
57    /// Long-lived refresh token used to obtain new ID tokens.
58    pub refresh_token: String,
59    /// Firebase user ID (`localId`) returned at login.
60    pub local_id: String,
61    expires_at: Instant,
62    client: Client,
63}
64
65impl FirebaseAuth {
66    /// Authenticate with email and password, returning a `FirebaseAuth` with
67    /// fresh tokens.
68    ///
69    /// Uses the Firebase Identity Toolkit `verifyPassword` endpoint.
70    pub async fn login(client: Client, email: &str, password: &str) -> Result<Self> {
71        let url = format!("{}?key={}", VERIFY_PASSWORD_URL, API_KEY);
72        let params = [
73            ("email", email),
74            ("password", password),
75            ("returnSecureToken", "true"),
76        ];
77
78        let resp = client
79            .post(&url)
80            .form(&params)
81            .send()
82            .await
83            .map_err(RointeError::Network)?;
84
85        if !resp.status().is_success() {
86            let body = resp.text().await.unwrap_or_default();
87            return Err(RointeError::Auth(format!("Login failed: {body}")));
88        }
89
90        let login: LoginResponse = resp.json().await.map_err(RointeError::Network)?;
91        let expires_in: u64 = login.expires_in.parse().unwrap_or(3600);
92        let expires_at = Instant::now() + Duration::from_secs(expires_in);
93
94        info!("Authenticated as localId={}", login.local_id);
95
96        Ok(Self {
97            id_token: login.id_token,
98            refresh_token: login.refresh_token,
99            local_id: login.local_id,
100            expires_at,
101            client,
102        })
103    }
104
105    /// Exchange the refresh token for a new id token.
106    ///
107    /// Called automatically by [`Self::ensure_valid_token`]; you rarely need
108    /// to call this directly.
109    pub async fn refresh(&mut self) -> Result<()> {
110        let url = format!("{}?key={}", REFRESH_TOKEN_URL, API_KEY);
111        let params = [
112            ("grant_type", "refresh_token"),
113            ("refresh_token", self.refresh_token.as_str()),
114        ];
115
116        let resp = self
117            .client
118            .post(&url)
119            .form(&params)
120            .send()
121            .await
122            .map_err(RointeError::Network)?;
123
124        if !resp.status().is_success() {
125            let body = resp.text().await.unwrap_or_default();
126            return Err(RointeError::Auth(format!("Token refresh failed: {body}")));
127        }
128
129        let refreshed: RefreshResponse = resp.json().await.map_err(RointeError::Network)?;
130        let expires_in: u64 = refreshed.expires_in.parse().unwrap_or(3600);
131
132        self.id_token = refreshed.id_token;
133        self.refresh_token = refreshed.refresh_token;
134        self.expires_at = Instant::now() + Duration::from_secs(expires_in);
135
136        debug!("Token refreshed, valid for {expires_in}s");
137        Ok(())
138    }
139
140    /// Return the current id token, refreshing it first if it is about to expire.
141    ///
142    /// This is the primary method used by [`crate::RointeClient`] before every
143    /// Firebase request.
144    pub async fn ensure_valid_token(&mut self) -> Result<String> {
145        if Instant::now() + TOKEN_REFRESH_BUFFER >= self.expires_at {
146            self.refresh().await?;
147        }
148        Ok(self.id_token.clone())
149    }
150}