wowsql/
auth.rs

1use crate::errors::WOWSQLError;
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::time::Duration;
6
7/// Project authentication configuration
8/// UNIFIED AUTHENTICATION: Uses the same API keys (anon/service) as database operations.
9#[derive(Debug, Clone)]
10pub struct ProjectAuthConfig {
11    pub project_url: String,
12    pub base_domain: String,
13    pub secure: bool,
14    pub timeout_seconds: u64,
15    /// Unified API key - Anonymous Key (wowsql_anon_...) for client-side,
16    /// or Service Role Key (wowsql_service_...) for server-side.
17    /// UNIFIED AUTHENTICATION: Same key works for both auth and database operations.
18    pub api_key: Option<String>,
19    /// Deprecated: Use api_key instead. Kept for backward compatibility.
20    pub public_api_key: Option<String>,
21}
22
23impl Default for ProjectAuthConfig {
24    fn default() -> Self {
25        Self {
26            project_url: String::new(),
27            base_domain: "wowsql.com".to_string(),
28            secure: true,
29            timeout_seconds: 30,
30            api_key: None,
31            public_api_key: None,
32        }
33    }
34}
35
36/// Authenticated user
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AuthUser {
39    pub id: String,
40    pub email: String,
41    #[serde(rename = "full_name")]
42    pub full_name: Option<String>,
43    #[serde(rename = "avatar_url")]
44    pub avatar_url: Option<String>,
45    #[serde(rename = "email_verified")]
46    pub email_verified: bool,
47    #[serde(rename = "user_metadata")]
48    pub user_metadata: Value,
49    #[serde(rename = "app_metadata")]
50    pub app_metadata: Value,
51    #[serde(rename = "created_at")]
52    pub created_at: Option<String>,
53}
54
55/// Authentication session
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct AuthSession {
58    #[serde(rename = "access_token")]
59    pub access_token: String,
60    #[serde(rename = "refresh_token")]
61    pub refresh_token: String,
62    #[serde(rename = "token_type")]
63    pub token_type: String,
64    #[serde(rename = "expires_in")]
65    pub expires_in: i32,
66}
67
68/// Authentication result
69#[derive(Debug, Clone)]
70pub struct AuthResult {
71    pub user: Option<AuthUser>,
72    pub session: AuthSession,
73}
74
75/// OAuth authorization response
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct OAuthAuthorizationResponse {
78    #[serde(rename = "authorization_url")]
79    pub authorization_url: String,
80    pub provider: String,
81    #[serde(rename = "redirect_uri")]
82    pub redirect_uri: String,
83    #[serde(rename = "backend_callback_url")]
84    pub backend_callback_url: Option<String>,
85    #[serde(rename = "frontend_redirect_uri")]
86    pub frontend_redirect_uri: Option<String>,
87}
88
89/// Sign up request
90#[derive(Debug, Clone)]
91pub struct SignUpRequest {
92    pub email: String,
93    pub password: String,
94    pub full_name: Option<String>,
95    pub user_metadata: Option<Value>,
96}
97
98/// Sign in request
99#[derive(Debug, Clone)]
100pub struct SignInRequest {
101    pub email: String,
102    pub password: String,
103}
104
105/// Project authentication client
106pub struct ProjectAuthClient {
107    config: ProjectAuthConfig,
108    client: Client,
109    access_token: Option<String>,
110    refresh_token: Option<String>,
111}
112
113impl ProjectAuthClient {
114    /// Create a new auth client
115    pub fn new(config: ProjectAuthConfig) -> Result<Self, WOWSQLError> {
116        let client = Client::builder()
117            .timeout(Duration::from_secs(config.timeout_seconds))
118            .build()
119            .map_err(|e| WOWSQLError::Network(format!("Failed to create HTTP client: {}", e)))?;
120
121        Ok(Self {
122            config,
123            client,
124            access_token: None,
125            refresh_token: None,
126        })
127    }
128
129    /// Sign up a new user
130    pub async fn sign_up(&mut self, request: SignUpRequest) -> Result<AuthResult, WOWSQLError> {
131        let url = self.build_auth_url("/signup");
132        let mut body = serde_json::json!({
133            "email": request.email,
134            "password": request.password
135        });
136
137        if let Some(full_name) = request.full_name {
138            body["full_name"] = Value::String(full_name);
139        }
140
141        if let Some(metadata) = request.user_metadata {
142            body["user_metadata"] = metadata;
143        }
144
145        let response: Value = self.execute_request(&url, "POST", Some(body)).await?;
146        let session = self.parse_session(&response)?;
147        self.persist_session(&session);
148
149        let user = self.parse_user(response.get("user"));
150        Ok(AuthResult { user, session })
151    }
152
153    /// Sign in an existing user
154    pub async fn sign_in(&mut self, request: SignInRequest) -> Result<AuthResult, WOWSQLError> {
155        let url = self.build_auth_url("/login");
156        let body = serde_json::json!({
157            "email": request.email,
158            "password": request.password
159        });
160
161        let response: Value = self.execute_request(&url, "POST", Some(body)).await?;
162        let session = self.parse_session(&response)?;
163        self.persist_session(&session);
164
165        Ok(AuthResult {
166            user: None,
167            session,
168        })
169    }
170
171    /// Get current user information
172    pub async fn get_user(&self, token_override: Option<&str>) -> Result<AuthUser, WOWSQLError> {
173        let token = token_override
174            .or(self.access_token.as_deref())
175            .ok_or_else(|| {
176                WOWSQLError::Authentication(
177                    "Access token is required. Call sign_in first.".to_string(),
178                )
179            })?;
180
181        let url = self.build_auth_url("/me");
182        let mut request = self
183            .client
184            .get(&url)
185            .header("Content-Type", "application/json")
186            .header("Authorization", format!("Bearer {}", token));
187        request = self.apply_api_key_to_request(request);
188        let response: Value = request
189            .send()
190            .await
191            .map_err(|e| WOWSQLError::Network(format!("Request failed: {}", e)))?
192            .json()
193            .await
194            .map_err(|e| WOWSQLError::Network(format!("Failed to parse response: {}", e)))?;
195
196        self.parse_user(Some(&response))
197            .ok_or_else(|| WOWSQLError::Authentication("Invalid user response".to_string()))
198    }
199
200    /// Get OAuth authorization URL
201    pub async fn get_oauth_authorization_url(
202        &self,
203        provider: &str,
204        redirect_uri: &str,
205    ) -> Result<OAuthAuthorizationResponse, WOWSQLError> {
206        let encoded_uri = urlencoding::encode(redirect_uri);
207        let url = format!(
208            "{}?frontend_redirect_uri={}",
209            self.build_auth_url(&format!("/oauth/{}", provider)),
210            encoded_uri
211        );
212
213        let response: Value = self.execute_request(&url, "GET", None).await?;
214        serde_json::from_value(response)
215            .map_err(|e| WOWSQLError::General(format!("Failed to parse OAuth response: {}", e)))
216    }
217
218    /// Exchange OAuth callback code for access tokens
219    pub async fn exchange_oauth_callback(
220        &mut self,
221        provider: &str,
222        code: &str,
223        redirect_uri: Option<&str>,
224    ) -> Result<AuthResult, WOWSQLError> {
225        let url = self.build_auth_url(&format!("/oauth/{}/callback", provider));
226        let mut body = serde_json::json!({ "code": code });
227
228        if let Some(redirect_uri) = redirect_uri {
229            body["redirect_uri"] = Value::String(redirect_uri.to_string());
230        }
231
232        let response: Value = self.execute_request(&url, "POST", Some(body)).await?;
233        let session = self.parse_session(&response)?;
234        self.persist_session(&session);
235
236        let user = self.parse_user(response.get("user"));
237        Ok(AuthResult { user, session })
238    }
239
240    /// Request password reset
241    pub async fn forgot_password(&self, email: &str) -> Result<Value, WOWSQLError> {
242        let url = self.build_auth_url("/forgot-password");
243        let body = serde_json::json!({ "email": email });
244        self.execute_request(&url, "POST", Some(body)).await
245    }
246
247    /// Reset password with token
248    pub async fn reset_password(
249        &self,
250        token: &str,
251        new_password: &str,
252    ) -> Result<Value, WOWSQLError> {
253        let url = self.build_auth_url("/reset-password");
254        let body = serde_json::json!({
255            "token": token,
256            "new_password": new_password
257        });
258        self.execute_request(&url, "POST", Some(body)).await
259    }
260
261    /// Set session tokens
262    pub fn set_session(&mut self, access_token: String, refresh_token: Option<String>) {
263        self.access_token = Some(access_token);
264        self.refresh_token = refresh_token;
265    }
266
267    /// Clear session tokens
268    pub fn clear_session(&mut self) {
269        self.access_token = None;
270        self.refresh_token = None;
271    }
272
273    /// Get current session
274    pub fn get_session(&self) -> Option<AuthSession> {
275        self.access_token.as_ref().map(|token| AuthSession {
276            access_token: token.clone(),
277            refresh_token: self.refresh_token.clone().unwrap_or_default(),
278            token_type: "bearer".to_string(),
279            expires_in: 0,
280        })
281    }
282
283    /// Send OTP code to user's email
284    /// Supports login, signup, and password_reset purposes
285    pub async fn send_otp(&self, email: &str, purpose: &str) -> Result<Value, WOWSQLError> {
286        if purpose != "login" && purpose != "signup" && purpose != "password_reset" {
287            return Err(WOWSQLError::General(
288                "Purpose must be 'login', 'signup', or 'password_reset'".to_string(),
289            ));
290        }
291
292        let url = self.build_auth_url("/otp/send");
293        let body = serde_json::json!({
294            "email": email,
295            "purpose": purpose
296        });
297        self.execute_request(&url, "POST", Some(body)).await
298    }
299
300    /// Verify OTP and complete authentication
301    /// For signup: Creates new user if doesn't exist
302    /// For login: Authenticates existing user
303    /// For password_reset: Updates password if new_password provided
304    pub async fn verify_otp(
305        &mut self,
306        email: &str,
307        otp: &str,
308        purpose: &str,
309        new_password: Option<&str>,
310    ) -> Result<AuthResult, WOWSQLError> {
311        if purpose != "login" && purpose != "signup" && purpose != "password_reset" {
312            return Err(WOWSQLError::General(
313                "Purpose must be 'login', 'signup', or 'password_reset'".to_string(),
314            ));
315        }
316
317        if purpose == "password_reset" && new_password.is_none() {
318            return Err(WOWSQLError::General(
319                "new_password is required for password_reset purpose".to_string(),
320            ));
321        }
322
323        let url = self.build_auth_url("/otp/verify");
324        let mut body = serde_json::json!({
325            "email": email,
326            "otp": otp,
327            "purpose": purpose
328        });
329
330        if let Some(new_password) = new_password {
331            body["new_password"] = Value::String(new_password.to_string());
332        }
333
334        let response: Value = self.execute_request(&url, "POST", Some(body)).await?;
335
336        if purpose == "password_reset" {
337            return Ok(AuthResult {
338                user: None,
339                session: AuthSession {
340                    access_token: String::new(),
341                    refresh_token: String::new(),
342                    token_type: "bearer".to_string(),
343                    expires_in: 0,
344                },
345            });
346        }
347
348        let session = self.parse_session(&response)?;
349        self.persist_session(&session);
350        let user = self.parse_user(response.get("user"));
351        Ok(AuthResult { user, session })
352    }
353
354    /// Send magic link to user's email
355    /// Supports login, signup, and email_verification purposes
356    pub async fn send_magic_link(&self, email: &str, purpose: &str) -> Result<Value, WOWSQLError> {
357        if purpose != "login" && purpose != "signup" && purpose != "email_verification" {
358            return Err(WOWSQLError::General(
359                "Purpose must be 'login', 'signup', or 'email_verification'".to_string(),
360            ));
361        }
362
363        let url = self.build_auth_url("/magic-link/send");
364        let body = serde_json::json!({
365            "email": email,
366            "purpose": purpose
367        });
368        self.execute_request(&url, "POST", Some(body)).await
369    }
370
371    /// Verify email using token (from magic link or OTP verification)
372    pub async fn verify_email(&self, token: &str) -> Result<Value, WOWSQLError> {
373        let url = self.build_auth_url("/verify-email");
374        let body = serde_json::json!({ "token": token });
375        self.execute_request(&url, "POST", Some(body)).await
376    }
377
378    /// Resend verification email
379    /// Always returns success to prevent email enumeration
380    pub async fn resend_verification(&self, email: &str) -> Result<Value, WOWSQLError> {
381        let url = self.build_auth_url("/resend-verification");
382        let body = serde_json::json!({ "email": email });
383        self.execute_request(&url, "POST", Some(body)).await
384    }
385
386    // Private methods
387
388    fn build_auth_url(&self, path: &str) -> String {
389        let mut normalized = self.config.project_url.trim().to_string();
390
391        // If it's already a full URL, use it as-is
392        if normalized.starts_with("http://") || normalized.starts_with("https://") {
393            normalized = normalized.trim_end_matches('/').to_string();
394            if normalized.ends_with("/api") {
395                normalized = normalized.trim_end_matches("/api").to_string();
396            }
397            return format!("{}/api/auth{}", normalized, path);
398        }
399
400        // Build URL from project slug
401        let protocol = if self.config.secure { "https" } else { "http" };
402        if normalized.contains(&format!(".{}", self.config.base_domain))
403            || normalized.ends_with(&self.config.base_domain)
404        {
405            normalized = format!("{}://{}", protocol, normalized);
406        } else {
407            normalized = format!("{}://{}.{}", protocol, normalized, self.config.base_domain);
408        }
409
410        normalized = normalized.trim_end_matches('/').to_string();
411        if normalized.ends_with("/api") {
412            normalized = normalized.trim_end_matches("/api").to_string();
413        }
414
415        format!("{}/api/auth{}", normalized, path)
416    }
417
418    async fn execute_request(
419        &self,
420        url: &str,
421        method: &str,
422        body: Option<Value>,
423    ) -> Result<Value, WOWSQLError> {
424        let mut request = self
425            .client
426            .request(
427                method
428                    .parse()
429                    .map_err(|_| WOWSQLError::General("Invalid method".to_string()))?,
430                url,
431            )
432            .header("Content-Type", "application/json");
433        request = self.apply_api_key_to_request(request);
434
435        if let Some(body) = body {
436            request = request.json(&body);
437        }
438
439        let response = request
440            .send()
441            .await
442            .map_err(|e| WOWSQLError::Network(format!("Request failed: {}", e)))?;
443
444        let status = response.status();
445        let text = response
446            .text()
447            .await
448            .map_err(|e| WOWSQLError::Network(format!("Failed to read response: {}", e)))?;
449
450        if !status.is_success() {
451            return Err(self.handle_error(status.as_u16(), &text));
452        }
453
454        serde_json::from_str(&text)
455            .map_err(|e| WOWSQLError::General(format!("Failed to parse response: {}", e)))
456    }
457
458    fn handle_error(&self, status_code: u16, body: &str) -> WOWSQLError {
459        let error_response: Value = serde_json::from_str(body).unwrap_or(Value::Null);
460
461        let message = error_response
462            .get("detail")
463            .and_then(|v| v.as_str())
464            .or_else(|| error_response.get("message").and_then(|v| v.as_str()))
465            .or_else(|| error_response.get("error").and_then(|v| v.as_str()))
466            .unwrap_or(&format!("Request failed with status {}", status_code))
467            .to_string();
468
469        match status_code {
470            401 | 403 => WOWSQLError::Authentication(message),
471            404 => WOWSQLError::NotFound(message),
472            429 => WOWSQLError::RateLimit(message),
473            _ => WOWSQLError::General(message),
474        }
475    }
476
477    fn parse_session(&self, response: &Value) -> Result<AuthSession, WOWSQLError> {
478        serde_json::from_value(response.clone())
479            .map_err(|e| WOWSQLError::Authentication(format!("Invalid session response: {}", e)))
480    }
481
482    fn parse_user(&self, user_value: Option<&Value>) -> Option<AuthUser> {
483        user_value.and_then(|v| serde_json::from_value(v.clone()).ok())
484    }
485
486    fn persist_session(&mut self, session: &AuthSession) {
487        self.access_token = Some(session.access_token.clone());
488        self.refresh_token = Some(session.refresh_token.clone());
489    }
490}
491
492// Helper extension for applying API key header
493impl ProjectAuthClient {
494    fn apply_api_key_to_request(
495        &self,
496        mut request: reqwest::RequestBuilder,
497    ) -> reqwest::RequestBuilder {
498        // UNIFIED AUTHENTICATION: Use api_key (new) or public_api_key (deprecated) for backward compatibility
499        let unified_key = self
500            .config
501            .api_key
502            .as_ref()
503            .or(self.config.public_api_key.as_ref());
504        if let Some(api_key) = unified_key {
505            // UNIFIED AUTHENTICATION: Use Authorization header (same as database operations)
506            request = request.header("Authorization", format!("Bearer {}", api_key));
507        }
508        request
509    }
510}