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!("Failed to refresh token: {} - {}", status, body));
132        }
133
134        let token_resp: RefreshTokenResponse = resp.json().await?;
135
136        let expires_in: i64 = token_resp.expires_in.parse().unwrap_or(3600);
137        let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
138
139        // Update refresh token if it changed
140        *self.refresh_token.lock().await = token_resp.refresh_token;
141
142        // Cache the new ID token
143        let id_token = token_resp.id_token.clone();
144        *self.cached_token.lock().await = Some(CachedToken {
145            id_token: token_resp.id_token,
146            expires_at,
147        });
148
149        Ok(id_token)
150    }
151
152    pub async fn get_user_id(&self) -> Result<String> {
153        let token = self.get_id_token().await?;
154        // Decode the JWT payload (middle part) to get the user ID
155        let parts: Vec<&str> = token.split('.').collect();
156        if parts.len() != 3 {
157            return Err(anyhow!("Invalid JWT format"));
158        }
159
160        // Add padding if needed for base64
161        let payload = parts[1];
162        let padded = match payload.len() % 4 {
163            2 => format!("{}==", payload),
164            3 => format!("{}=", payload),
165            _ => payload.to_string(),
166        };
167
168        let decoded = base64_decode(&padded)?;
169        let claims: serde_json::Value = serde_json::from_slice(&decoded)?;
170        claims["user_id"]
171            .as_str()
172            .or_else(|| claims["sub"].as_str())
173            .map(|s| s.to_string())
174            .ok_or_else(|| anyhow!("No user_id or sub claim in token"))
175    }
176}
177
178fn base64_decode(input: &str) -> Result<Vec<u8>> {
179    // URL-safe base64 decode without pulling in a base64 crate
180    let input = input.replace('-', "+").replace('_', "/");
181    let mut result = Vec::new();
182    let chars: Vec<u8> = input.bytes().collect();
183
184    let decode_char = |c: u8| -> Result<u8> {
185        match c {
186            b'A'..=b'Z' => Ok(c - b'A'),
187            b'a'..=b'z' => Ok(c - b'a' + 26),
188            b'0'..=b'9' => Ok(c - b'0' + 52),
189            b'+' => Ok(62),
190            b'/' => Ok(63),
191            b'=' => Ok(0),
192            _ => Err(anyhow!("Invalid base64 character: {}", c as char)),
193        }
194    };
195
196    for chunk in chars.chunks(4) {
197        if chunk.len() < 4 {
198            break;
199        }
200        let b0 = decode_char(chunk[0])?;
201        let b1 = decode_char(chunk[1])?;
202        let b2 = decode_char(chunk[2])?;
203        let b3 = decode_char(chunk[3])?;
204
205        result.push((b0 << 2) | (b1 >> 4));
206        if chunk[2] != b'=' {
207            result.push((b1 << 4) | (b2 >> 2));
208        }
209        if chunk[3] != b'=' {
210            result.push((b2 << 6) | b3);
211        }
212    }
213
214    Ok(result)
215}