Skip to main content

supabase_rust/
auth.rs

1use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
2use reqwest::{Error, 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/// Error returned when logout fails due to missing bearer token.
27#[derive(Debug)]
28pub struct LogoutError;
29
30impl std::fmt::Display for LogoutError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "bearer token required for logout")
33    }
34}
35
36impl std::error::Error for LogoutError {}
37
38impl Supabase {
39    /// Validates a JWT token and returns its claims.
40    ///
41    /// Returns an error if the token is invalid or expired.
42    pub fn jwt_valid(&self, jwt: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
43        let decoding_key = DecodingKey::from_secret(self.jwt.as_bytes());
44        let validation = Validation::new(Algorithm::HS256);
45        let token_data = decode::<Claims>(jwt, &decoding_key, &validation)?;
46        Ok(token_data.claims)
47    }
48
49    /// Signs in a user with email and password.
50    ///
51    /// Returns the response containing access and refresh tokens.
52    pub async fn sign_in_password(&self, email: &str, password: &str) -> Result<Response, Error> {
53        let url = format!("{}/auth/v1/token?grant_type=password", self.url);
54
55        self.client
56            .post(&url)
57            .header("apikey", &self.api_key)
58            .header("Content-Type", "application/json")
59            .json(&Credentials { email, password })
60            .send()
61            .await
62    }
63
64    /// Refreshes an access token using a refresh token.
65    ///
66    /// Note: This may fail if "Enable automatic reuse detection" is enabled in Supabase.
67    pub async fn refresh_token(&self, refresh_token: &str) -> Result<Response, Error> {
68        let url = format!("{}/auth/v1/token?grant_type=refresh_token", self.url);
69
70        self.client
71            .post(&url)
72            .header("apikey", &self.api_key)
73            .header("Content-Type", "application/json")
74            .json(&RefreshTokenRequest { refresh_token })
75            .send()
76            .await
77    }
78
79    /// Logs out the current user.
80    ///
81    /// Requires a bearer token to be set on the client.
82    /// Returns `Err(LogoutError)` if no bearer token is set.
83    pub async fn logout(&self) -> Result<Result<Response, Error>, LogoutError> {
84        let token = self.bearer_token.as_ref().ok_or(LogoutError)?;
85        let url = format!("{}/auth/v1/logout", self.url);
86
87        Ok(self
88            .client
89            .post(&url)
90            .header("apikey", &self.api_key)
91            .header("Content-Type", "application/json")
92            .bearer_auth(token)
93            .send()
94            .await)
95    }
96
97    /// Signs up a new user with email and password.
98    pub async fn signup_email_password(
99        &self,
100        email: &str,
101        password: &str,
102    ) -> Result<Response, Error> {
103        let url = format!("{}/auth/v1/signup", self.url);
104
105        self.client
106            .post(&url)
107            .header("apikey", &self.api_key)
108            .header("Content-Type", "application/json")
109            .json(&Credentials { email, password })
110            .send()
111            .await
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn client() -> Supabase {
120        Supabase::new(None, None, None)
121    }
122
123    async fn sign_in_password() -> Result<Response, Error> {
124        let client = client();
125        let test_email = std::env::var("SUPABASE_TEST_EMAIL").unwrap_or_default();
126        let test_pass = std::env::var("SUPABASE_TEST_PASS").unwrap_or_default();
127        client.sign_in_password(&test_email, &test_pass).await
128    }
129
130    #[tokio::test]
131    async fn test_token_with_password() {
132        let response = match sign_in_password().await {
133            Ok(resp) => resp,
134            Err(e) => {
135                println!("Test skipped due to network error: {e}");
136                return;
137            }
138        };
139
140        let json: serde_json::Value = response.json().await.unwrap();
141
142        let Some(token) = json["access_token"].as_str() else {
143            println!("Test skipped: invalid credentials or server response");
144            return;
145        };
146        let Some(refresh) = json["refresh_token"].as_str() else {
147            println!("Test skipped: invalid credentials or server response");
148            return;
149        };
150
151        assert!(!token.is_empty());
152        assert!(!refresh.is_empty());
153    }
154
155    #[tokio::test]
156    async fn test_refresh() {
157        let response = match sign_in_password().await {
158            Ok(resp) => resp,
159            Err(e) => {
160                println!("Test skipped due to network error: {e}");
161                return;
162            }
163        };
164
165        let json: serde_json::Value = response.json().await.unwrap();
166        let Some(refresh_token) = json["refresh_token"].as_str() else {
167            println!("Test skipped: no refresh token in response");
168            return;
169        };
170
171        let response = match client().refresh_token(refresh_token).await {
172            Ok(resp) => resp,
173            Err(e) => {
174                println!("Test skipped due to network error: {e}");
175                return;
176            }
177        };
178
179        if response.status() == 400 {
180            println!("Skipping: automatic reuse detection is enabled");
181            return;
182        }
183
184        let json: serde_json::Value = response.json().await.unwrap();
185        let Some(token) = json["access_token"].as_str() else {
186            println!("Test skipped: no access token in refresh response");
187            return;
188        };
189
190        assert!(!token.is_empty());
191    }
192
193    #[tokio::test]
194    async fn test_logout() {
195        let response = match sign_in_password().await {
196            Ok(resp) => resp,
197            Err(e) => {
198                println!("Test skipped due to network error: {e}");
199                return;
200            }
201        };
202
203        let json: serde_json::Value = response.json().await.unwrap();
204        let Some(access_token) = json["access_token"].as_str() else {
205            println!("Test skipped: no access token in response");
206            return;
207        };
208
209        let mut client = client();
210        client.set_bearer_token(access_token);
211
212        let response = match client.logout().await {
213            Ok(Ok(resp)) => resp,
214            Ok(Err(e)) => {
215                println!("Test skipped due to network error: {e}");
216                return;
217            }
218            Err(e) => {
219                println!("Test skipped: {e}");
220                return;
221            }
222        };
223
224        assert_eq!(response.status(), 204);
225    }
226
227    #[tokio::test]
228    async fn test_signup_email_password() {
229        use rand::distr::Alphanumeric;
230        use rand::{rng, Rng};
231
232        let client = client();
233
234        let rand_string: String = rng()
235            .sample_iter(&Alphanumeric)
236            .take(20)
237            .map(char::from)
238            .collect();
239
240        let email = format!("{rand_string}@a-rust-domain-that-does-not-exist.com");
241
242        let response = match client.signup_email_password(&email, &rand_string).await {
243            Ok(resp) => resp,
244            Err(e) => {
245                println!("Test skipped due to network error: {e}");
246                return;
247            }
248        };
249
250        assert_eq!(response.status(), 200);
251    }
252
253    #[tokio::test]
254    async fn test_authenticate_token() {
255        let client = client();
256
257        let response = match sign_in_password().await {
258            Ok(resp) => resp,
259            Err(e) => {
260                println!("Test skipped due to network error: {e}");
261                return;
262            }
263        };
264
265        let json: serde_json::Value = response.json().await.unwrap();
266        let Some(token) = json["access_token"].as_str() else {
267            println!("Test skipped: no access token in response");
268            return;
269        };
270
271        assert!(client.jwt_valid(token).is_ok());
272    }
273
274    #[test]
275    fn test_logout_requires_bearer_token() {
276        // Verify the error type displays correctly
277        assert_eq!(format!("{}", LogoutError), "bearer token required for logout");
278    }
279}