Skip to main content

macro_factor_api/
auth.rs

1use anyhow::{anyhow, Result};
2use reqwest::Client;
3use serde::Deserialize;
4use std::sync::Arc;
5use tokio::sync::Mutex;
6
7/// Public Firebase Web API key for the MacroFactor project.
8/// This is not a secret — it is embedded in every copy of the app
9/// and only usable with Firebase's configured auth providers.
10const FIREBASE_WEB_API_KEY: &str = "AIzaSyA17Uwy37irVEQSwz6PIyX3wnkHrDBeleA";
11pub const PROJECT_ID: &str = "sbs-diet-app";
12
13#[derive(Debug, Deserialize)]
14struct RefreshTokenResponse {
15    id_token: String,
16    refresh_token: String,
17    expires_in: String,
18}
19
20#[derive(Debug, Clone)]
21struct CachedToken {
22    id_token: String,
23    expires_at: chrono::DateTime<chrono::Utc>,
24}
25
26#[derive(Clone)]
27pub struct FirebaseAuth {
28    client: Client,
29    refresh_token: Arc<Mutex<String>>,
30    cached_token: Arc<Mutex<Option<CachedToken>>>,
31}
32
33#[derive(Debug, Deserialize)]
34struct SignInResponse {
35    #[serde(rename = "idToken")]
36    id_token: String,
37    #[serde(rename = "refreshToken")]
38    refresh_token: String,
39    #[serde(rename = "expiresIn")]
40    expires_in: String,
41    #[allow(dead_code)]
42    #[serde(rename = "localId")]
43    local_id: String,
44}
45
46impl FirebaseAuth {
47    pub fn new(refresh_token: String) -> Self {
48        Self {
49            client: Client::new(),
50            refresh_token: Arc::new(Mutex::new(refresh_token)),
51            cached_token: Arc::new(Mutex::new(None)),
52        }
53    }
54
55    /// Sign in with email and password, returning a FirebaseAuth with a fresh refresh token.
56    pub async fn sign_in_with_email(email: &str, password: &str) -> Result<Self> {
57        let client = Client::new();
58        let url = format!(
59            "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key={}",
60            FIREBASE_WEB_API_KEY
61        );
62
63        let resp = client
64            .post(&url)
65            .header("X-Ios-Bundle-Identifier", "com.sbs.diet")
66            .json(&serde_json::json!({
67                "email": email,
68                "password": password,
69                "returnSecureToken": true
70            }))
71            .send()
72            .await?;
73
74        if !resp.status().is_success() {
75            let status = resp.status();
76            let body = resp.text().await.unwrap_or_default();
77            return Err(anyhow!("Sign-in failed: {} - {}", status, body));
78        }
79
80        let sign_in: SignInResponse = resp.json().await?;
81
82        let expires_in: i64 = sign_in.expires_in.parse().unwrap_or(3600);
83        let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
84
85        Ok(Self {
86            client,
87            refresh_token: Arc::new(Mutex::new(sign_in.refresh_token)),
88            cached_token: Arc::new(Mutex::new(Some(CachedToken {
89                id_token: sign_in.id_token,
90                expires_at,
91            }))),
92        })
93    }
94
95    pub async fn get_id_token(&self) -> Result<String> {
96        // Check if we have a valid cached token (with 60s margin)
97        {
98            let cached = self.cached_token.lock().await;
99            if let Some(ref token) = *cached {
100                if token.expires_at > chrono::Utc::now() + chrono::Duration::seconds(60) {
101                    return Ok(token.id_token.clone());
102                }
103            }
104        }
105
106        self.refresh_id_token().await
107    }
108
109    async fn refresh_id_token(&self) -> Result<String> {
110        let refresh_token = self.refresh_token.lock().await.clone();
111
112        let url = format!(
113            "https://securetoken.googleapis.com/v1/token?key={}",
114            FIREBASE_WEB_API_KEY
115        );
116
117        let resp = self
118            .client
119            .post(&url)
120            .header("X-Ios-Bundle-Identifier", "com.sbs.diet")
121            .form(&[
122                ("grant_type", "refresh_token"),
123                ("refresh_token", &refresh_token),
124            ])
125            .send()
126            .await?;
127
128        if !resp.status().is_success() {
129            let status = resp.status();
130            let body = resp.text().await.unwrap_or_default();
131            return Err(anyhow!(
132                "Failed to refresh token: {} - {}",
133                status,
134                body
135            ));
136        }
137
138        let token_resp: RefreshTokenResponse = resp.json().await?;
139
140        let expires_in: i64 = token_resp
141            .expires_in
142            .parse()
143            .unwrap_or(3600);
144        let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
145
146        // Update refresh token if it changed
147        *self.refresh_token.lock().await = token_resp.refresh_token;
148
149        // Cache the new ID token
150        let id_token = token_resp.id_token.clone();
151        *self.cached_token.lock().await = Some(CachedToken {
152            id_token: token_resp.id_token,
153            expires_at,
154        });
155
156        Ok(id_token)
157    }
158
159    pub async fn get_user_id(&self) -> Result<String> {
160        let token = self.get_id_token().await?;
161        // Decode the JWT payload (middle part) to get the user ID
162        let parts: Vec<&str> = token.split('.').collect();
163        if parts.len() != 3 {
164            return Err(anyhow!("Invalid JWT format"));
165        }
166
167        // Add padding if needed for base64
168        let payload = parts[1];
169        let padded = match payload.len() % 4 {
170            2 => format!("{}==", payload),
171            3 => format!("{}=", payload),
172            _ => payload.to_string(),
173        };
174
175        let decoded = base64_decode(&padded)?;
176        let claims: serde_json::Value = serde_json::from_slice(&decoded)?;
177        claims["user_id"]
178            .as_str()
179            .or_else(|| claims["sub"].as_str())
180            .map(|s| s.to_string())
181            .ok_or_else(|| anyhow!("No user_id or sub claim in token"))
182    }
183}
184
185fn base64_decode(input: &str) -> Result<Vec<u8>> {
186    // URL-safe base64 decode without pulling in a base64 crate
187    let input = input.replace('-', "+").replace('_', "/");
188    let mut result = Vec::new();
189    let chars: Vec<u8> = input.bytes().collect();
190
191    let decode_char = |c: u8| -> Result<u8> {
192        match c {
193            b'A'..=b'Z' => Ok(c - b'A'),
194            b'a'..=b'z' => Ok(c - b'a' + 26),
195            b'0'..=b'9' => Ok(c - b'0' + 52),
196            b'+' => Ok(62),
197            b'/' => Ok(63),
198            b'=' => Ok(0),
199            _ => Err(anyhow!("Invalid base64 character: {}", c as char)),
200        }
201    };
202
203    for chunk in chars.chunks(4) {
204        if chunk.len() < 4 {
205            break;
206        }
207        let b0 = decode_char(chunk[0])?;
208        let b1 = decode_char(chunk[1])?;
209        let b2 = decode_char(chunk[2])?;
210        let b3 = decode_char(chunk[3])?;
211
212        result.push((b0 << 2) | (b1 >> 4));
213        if chunk[2] != b'=' {
214            result.push((b1 << 4) | (b2 >> 2));
215        }
216        if chunk[3] != b'=' {
217            result.push((b2 << 6) | b3);
218        }
219    }
220
221    Ok(result)
222}