Skip to main content

supabase_rust/
auth.rs

1use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
2use reqwest::Response;
3use serde::{Deserialize, Serialize};
4
5use crate::Supabase;
6
7#[derive(Serialize)]
8struct Credentials<'a> {
9    email: &'a str,
10    password: &'a str,
11}
12
13#[derive(Serialize)]
14struct RefreshTokenRequest<'a> {
15    refresh_token: &'a str,
16}
17
18/// JWT claims extracted from a valid token.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Claims {
21    pub sub: String,
22    pub email: String,
23    pub exp: usize,
24}
25
26/// Response returned by authentication endpoints that issue tokens.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct AuthResponse {
29    /// The JWT access token.
30    pub access_token: String,
31    /// The token type (typically `"bearer"`).
32    pub token_type: String,
33    /// Seconds until the access token expires.
34    pub expires_in: u64,
35    /// Unix timestamp when the access token expires.
36    #[serde(default)]
37    pub expires_at: Option<u64>,
38    /// Token used to obtain a new access token.
39    pub refresh_token: String,
40    /// User information, if returned by the endpoint.
41    #[serde(default)]
42    pub user: Option<serde_json::Value>,
43}
44
45/// Response for endpoints that return no body on success.
46#[derive(Debug, Clone)]
47pub struct EmptyResponse {
48    /// HTTP status code.
49    pub status: u16,
50}
51
52#[derive(Serialize)]
53struct RecoverRequest<'a> {
54    email: &'a str,
55}
56
57#[derive(Serialize)]
58struct PhoneCredentials<'a> {
59    phone: &'a str,
60    password: &'a str,
61}
62
63#[derive(Serialize)]
64struct OtpRequest<'a> {
65    phone: &'a str,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    channel: Option<&'a str>,
68}
69
70#[derive(Serialize)]
71struct VerifyOtpRequest<'a> {
72    phone: &'a str,
73    token: &'a str,
74    #[serde(rename = "type")]
75    verification_type: &'a str,
76}
77
78#[derive(Serialize)]
79struct ResendOtpRequest<'a> {
80    phone: &'a str,
81    #[serde(rename = "type")]
82    verification_type: &'a str,
83}
84
85impl Supabase {
86    /// Sends a POST request to the given auth endpoint path with standard headers.
87    async fn auth_post(
88        &self,
89        path: &str,
90        body: &impl Serialize,
91    ) -> Result<Response, crate::Error> {
92        let url = format!("{}/auth/v1/{path}", self.url);
93
94        let resp = self
95            .client
96            .post(&url)
97            .header("apikey", &self.api_key)
98            .header("Content-Type", "application/json")
99            .json(body)
100            .send()
101            .await?;
102
103        Ok(resp)
104    }
105
106    /// Checks the response status and deserializes as `AuthResponse`.
107    async fn parse_auth_response(response: Response) -> Result<AuthResponse, crate::Error> {
108        let status = response.status().as_u16();
109        if !(200..300).contains(&status) {
110            let message = response.text().await.unwrap_or_default();
111            return Err(crate::Error::Api { status, message });
112        }
113        let auth: AuthResponse = response.json().await?;
114        Ok(auth)
115    }
116
117    /// Checks the response status and returns an `EmptyResponse`.
118    async fn parse_empty_response(response: Response) -> Result<EmptyResponse, crate::Error> {
119        let status = response.status().as_u16();
120        if !(200..300).contains(&status) {
121            let message = response.text().await.unwrap_or_default();
122            return Err(crate::Error::Api { status, message });
123        }
124        Ok(EmptyResponse { status })
125    }
126
127    /// Validates a JWT token and returns its claims.
128    ///
129    /// Returns an error if the token is invalid or expired.
130    pub fn jwt_valid(&self, jwt: &str) -> Result<Claims, crate::Error> {
131        let decoding_key = DecodingKey::from_secret(self.jwt.as_bytes());
132        let validation = Validation::new(Algorithm::HS256);
133        let token_data = decode::<Claims>(jwt, &decoding_key, &validation)?;
134        Ok(token_data.claims)
135    }
136
137    /// Signs in a user with email and password.
138    ///
139    /// Returns an [`AuthResponse`] containing access and refresh tokens.
140    pub async fn sign_in_password(
141        &self,
142        email: &str,
143        password: &str,
144    ) -> Result<AuthResponse, crate::Error> {
145        let resp = self
146            .auth_post(
147                "token?grant_type=password",
148                &Credentials { email, password },
149            )
150            .await?;
151        Self::parse_auth_response(resp).await
152    }
153
154    /// Refreshes an access token using a refresh token.
155    ///
156    /// Note: This may fail if "Enable automatic reuse detection" is enabled in Supabase.
157    pub async fn refresh_token(
158        &self,
159        refresh_token: &str,
160    ) -> Result<AuthResponse, crate::Error> {
161        let resp = self
162            .auth_post(
163                "token?grant_type=refresh_token",
164                &RefreshTokenRequest { refresh_token },
165            )
166            .await?;
167        Self::parse_auth_response(resp).await
168    }
169
170    /// Logs out the current user.
171    ///
172    /// Requires a bearer token to be set on the client.
173    pub async fn logout(&self) -> Result<EmptyResponse, crate::Error> {
174        let token = self.bearer_token.as_ref().ok_or_else(|| {
175            crate::Error::AuthRequired("bearer token required for logout".into())
176        })?;
177        let url = format!("{}/auth/v1/logout", self.url);
178
179        let resp = self
180            .client
181            .post(&url)
182            .header("apikey", &self.api_key)
183            .header("Content-Type", "application/json")
184            .bearer_auth(token)
185            .send()
186            .await?;
187
188        Self::parse_empty_response(resp).await
189    }
190
191    /// Sends a password recovery email to the given address.
192    pub async fn recover_password(
193        &self,
194        email: &str,
195    ) -> Result<EmptyResponse, crate::Error> {
196        let resp = self.auth_post("recover", &RecoverRequest { email }).await?;
197        Self::parse_empty_response(resp).await
198    }
199
200    /// Signs up a new user with phone and password.
201    pub async fn signup_phone_password(
202        &self,
203        phone: &str,
204        password: &str,
205    ) -> Result<AuthResponse, crate::Error> {
206        let resp = self
207            .auth_post("signup", &PhoneCredentials { phone, password })
208            .await?;
209        Self::parse_auth_response(resp).await
210    }
211
212    /// Sends a one-time password to the given phone number.
213    ///
214    /// The `channel` parameter can be `"sms"` or `"whatsapp"`. Defaults to SMS when `None`.
215    pub async fn sign_in_otp(
216        &self,
217        phone: &str,
218        channel: Option<&str>,
219    ) -> Result<EmptyResponse, crate::Error> {
220        let resp = self
221            .auth_post("otp", &OtpRequest { phone, channel })
222            .await?;
223        Self::parse_empty_response(resp).await
224    }
225
226    /// Verifies a one-time password token.
227    ///
228    /// Returns an [`AuthResponse`] containing access and refresh tokens on success.
229    pub async fn verify_otp(
230        &self,
231        phone: &str,
232        token: &str,
233        verification_type: &str,
234    ) -> Result<AuthResponse, crate::Error> {
235        let resp = self
236            .auth_post(
237                "verify",
238                &VerifyOtpRequest {
239                    phone,
240                    token,
241                    verification_type,
242                },
243            )
244            .await?;
245        Self::parse_auth_response(resp).await
246    }
247
248    /// Resends a one-time password to the given phone number.
249    pub async fn resend_otp(
250        &self,
251        phone: &str,
252        verification_type: &str,
253    ) -> Result<EmptyResponse, crate::Error> {
254        let resp = self
255            .auth_post(
256                "resend",
257                &ResendOtpRequest {
258                    phone,
259                    verification_type,
260                },
261            )
262            .await?;
263        Self::parse_empty_response(resp).await
264    }
265
266    /// Signs up a new user with email and password.
267    pub async fn signup_email_password(
268        &self,
269        email: &str,
270        password: &str,
271    ) -> Result<AuthResponse, crate::Error> {
272        let resp = self
273            .auth_post("signup", &Credentials { email, password })
274            .await?;
275        Self::parse_auth_response(resp).await
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn client() -> Supabase {
284        Supabase::new(None, None, None).unwrap_or_else(|_| {
285            Supabase::new(
286                Some("https://example.supabase.co"),
287                Some("test-key"),
288                None,
289            )
290            .unwrap()
291        })
292    }
293
294    async fn sign_in_password() -> Result<AuthResponse, crate::Error> {
295        let client = client();
296        let test_email = std::env::var("SUPABASE_TEST_EMAIL").unwrap_or_default();
297        let test_pass = std::env::var("SUPABASE_TEST_PASS").unwrap_or_default();
298        client.sign_in_password(&test_email, &test_pass).await
299    }
300
301    #[tokio::test]
302    async fn test_token_with_password() {
303        let auth = match sign_in_password().await {
304            Ok(auth) => auth,
305            Err(e) => {
306                println!("Test skipped due to error: {e}");
307                return;
308            }
309        };
310
311        assert!(!auth.access_token.is_empty());
312        assert!(!auth.refresh_token.is_empty());
313    }
314
315    #[tokio::test]
316    async fn test_refresh() {
317        let auth = match sign_in_password().await {
318            Ok(auth) => auth,
319            Err(e) => {
320                println!("Test skipped due to error: {e}");
321                return;
322            }
323        };
324
325        let refreshed = match client().refresh_token(&auth.refresh_token).await {
326            Ok(auth) => auth,
327            Err(crate::Error::Api { status: 400, .. }) => {
328                println!("Skipping: automatic reuse detection is enabled");
329                return;
330            }
331            Err(e) => {
332                println!("Test skipped due to error: {e}");
333                return;
334            }
335        };
336
337        assert!(!refreshed.access_token.is_empty());
338    }
339
340    #[tokio::test]
341    async fn test_logout() {
342        let auth = match sign_in_password().await {
343            Ok(auth) => auth,
344            Err(e) => {
345                println!("Test skipped due to error: {e}");
346                return;
347            }
348        };
349
350        let mut client = client();
351        client.set_bearer_token(&auth.access_token);
352
353        let resp = match client.logout().await {
354            Ok(resp) => resp,
355            Err(e) => {
356                println!("Test skipped: {e}");
357                return;
358            }
359        };
360
361        assert_eq!(resp.status, 204);
362    }
363
364    #[tokio::test]
365    async fn test_signup_email_password() {
366        use rand::distr::Alphanumeric;
367        use rand::{rng, Rng};
368
369        let client = client();
370
371        let rand_string: String = rng()
372            .sample_iter(&Alphanumeric)
373            .take(20)
374            .map(char::from)
375            .collect();
376
377        let email = format!("{rand_string}@a-rust-domain-that-does-not-exist.com");
378
379        match client.signup_email_password(&email, &rand_string).await {
380            Ok(auth) => {
381                assert!(!auth.access_token.is_empty());
382            }
383            Err(e) => {
384                println!("Test skipped due to error: {e}");
385            }
386        }
387    }
388
389    #[tokio::test]
390    async fn test_authenticate_token() {
391        let client = client();
392
393        let auth = match sign_in_password().await {
394            Ok(auth) => auth,
395            Err(e) => {
396                println!("Test skipped due to error: {e}");
397                return;
398            }
399        };
400
401        assert!(client.jwt_valid(&auth.access_token).is_ok());
402    }
403
404    #[test]
405    fn test_logout_requires_bearer_token() {
406        let err = crate::Error::AuthRequired("bearer token required for logout".into());
407        assert!(format!("{err}").contains("bearer token required for logout"));
408    }
409
410    #[tokio::test]
411    async fn test_recover_password() {
412        let client = client();
413
414        match client
415            .recover_password("test@a-rust-domain-that-does-not-exist.com")
416            .await
417        {
418            Ok(resp) => {
419                assert!(resp.status >= 200);
420            }
421            Err(e) => {
422                println!("Test skipped due to error: {e}");
423            }
424        }
425    }
426
427    #[tokio::test]
428    async fn test_signup_phone_password() {
429        let client = client();
430
431        match client
432            .signup_phone_password("+10000000000", "test-password-123")
433            .await
434        {
435            Ok(_auth) => {}
436            Err(crate::Error::Api { status, .. }) => {
437                assert!(
438                    status == 422 || status == 401 || status == 403,
439                    "unexpected API error status: {status}"
440                );
441            }
442            Err(e) => {
443                println!("Test skipped due to error: {e}");
444            }
445        }
446    }
447
448    #[tokio::test]
449    async fn test_sign_in_otp() {
450        let client = client();
451
452        match client.sign_in_otp("+10000000000", Some("sms")).await {
453            Ok(_resp) => {}
454            Err(crate::Error::Api { .. }) => {}
455            Err(e) => {
456                println!("Test skipped due to error: {e}");
457            }
458        }
459    }
460
461    #[tokio::test]
462    async fn test_verify_otp() {
463        let client = client();
464
465        match client.verify_otp("+10000000000", "000000", "sms").await {
466            Ok(_auth) => {}
467            Err(crate::Error::Api { .. }) => {}
468            Err(e) => {
469                println!("Test skipped due to error: {e}");
470            }
471        }
472    }
473
474    #[tokio::test]
475    async fn test_resend_otp() {
476        let client = client();
477
478        match client.resend_otp("+10000000000", "sms").await {
479            Ok(_resp) => {}
480            Err(crate::Error::Api { .. }) => {}
481            Err(e) => {
482                println!("Test skipped due to error: {e}");
483            }
484        }
485    }
486}