Skip to main content

supabase_client_auth/
client.rs

1use std::sync::Arc;
2
3use base64::Engine;
4use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
5use serde_json::{json, Value as JsonValue};
6use sha2::{Digest, Sha256};
7use tokio::sync::{broadcast, Mutex, RwLock};
8use url::Url;
9
10use crate::admin::AdminClient;
11use crate::error::{AuthError, GoTrueErrorResponse};
12use crate::params::{
13    MfaChallengeParams, MfaEnrollParams, MfaVerifyParams, OAuthAuthorizeUrlParams,
14    OAuthTokenExchangeParams, ResendParams, SignInWithIdTokenParams, SsoSignInParams,
15    UpdateUserParams, VerifyOtpParams,
16};
17use crate::types::*;
18
19/// Broadcast channel capacity for auth state change events.
20const EVENT_CHANNEL_CAPACITY: usize = 64;
21
22struct AuthClientInner {
23    http: reqwest::Client,
24    base_url: Url,
25    api_key: String,
26    // Session state management
27    session: RwLock<Option<Session>>,
28    event_tx: broadcast::Sender<AuthStateChange>,
29    auto_refresh_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
30}
31
32impl std::fmt::Debug for AuthClientInner {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("AuthClientInner")
35            .field("base_url", &self.base_url)
36            .field("api_key", &"***")
37            .finish()
38    }
39}
40
41/// HTTP client for Supabase GoTrue auth API.
42///
43/// Communicates with GoTrue REST endpoints at `/auth/v1/...`.
44/// Provides built-in session state management, event broadcasting,
45/// and optional automatic token refresh.
46///
47/// # Example
48/// ```ignore
49/// use supabase_client_auth::AuthClient;
50///
51/// let auth = AuthClient::new("https://your-project.supabase.co", "your-anon-key")?;
52/// let session = auth.sign_in_with_password_email("user@example.com", "password").await?;
53///
54/// // Session is automatically stored — retrieve it later:
55/// let stored = auth.get_session().await;
56///
57/// // Subscribe to auth state changes:
58/// let mut sub = auth.on_auth_state_change();
59/// ```
60#[derive(Debug, Clone)]
61pub struct AuthClient {
62    inner: Arc<AuthClientInner>,
63}
64
65impl AuthClient {
66    /// Create a new auth client.
67    ///
68    /// `supabase_url` is the project URL (e.g., `https://your-project.supabase.co`).
69    /// `api_key` is the Supabase anon key, sent as the `apikey` header.
70    pub fn new(supabase_url: &str, api_key: &str) -> Result<Self, AuthError> {
71        let base = supabase_url.trim_end_matches('/');
72        let base_url = Url::parse(&format!("{}/auth/v1", base))?;
73
74        let mut default_headers = HeaderMap::new();
75        default_headers.insert(
76            "apikey",
77            HeaderValue::from_str(api_key)
78                .map_err(|e| AuthError::InvalidConfig(format!("Invalid API key header: {}", e)))?,
79        );
80        default_headers.insert(
81            CONTENT_TYPE,
82            HeaderValue::from_static("application/json"),
83        );
84
85        let http = reqwest::Client::builder()
86            .default_headers(default_headers)
87            .build()
88            .map_err(AuthError::Http)?;
89
90        let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
91
92        Ok(Self {
93            inner: Arc::new(AuthClientInner {
94                http,
95                base_url,
96                api_key: api_key.to_string(),
97                session: RwLock::new(None),
98                event_tx,
99                auto_refresh_handle: Mutex::new(None),
100            }),
101        })
102    }
103
104    /// Get the base URL for the auth API.
105    pub fn base_url(&self) -> &Url {
106        &self.inner.base_url
107    }
108
109    // ─── Session State Management ─────────────────────────────
110
111    /// Get the currently stored session (no network call).
112    ///
113    /// Returns `None` if no session has been stored (e.g., user hasn't signed in yet).
114    pub async fn get_session(&self) -> Option<Session> {
115        self.inner.session.read().await.clone()
116    }
117
118    /// Set/replace the stored session and emit `SignedIn`.
119    ///
120    /// Use this to restore a session from external storage (e.g., persisted tokens).
121    pub async fn set_session(&self, session: Session) {
122        self.store_session(&session, AuthChangeEvent::SignedIn).await;
123    }
124
125    /// Clear the stored session and emit `SignedOut`.
126    ///
127    /// This is a local operation — it does NOT call GoTrue `/logout`.
128    /// Use [`sign_out_current()`](AuthClient::sign_out_current) to also invalidate server-side.
129    pub async fn clear_session(&self) {
130        self.emit_signed_out().await;
131    }
132
133    // ─── Event Subscription ───────────────────────────────────
134
135    /// Subscribe to auth state change events.
136    ///
137    /// Returns an [`AuthSubscription`] that receives events via [`next()`](AuthSubscription::next).
138    /// Multiple subscriptions can be active simultaneously.
139    pub fn on_auth_state_change(&self) -> AuthSubscription {
140        AuthSubscription {
141            rx: self.inner.event_tx.subscribe(),
142        }
143    }
144
145    // ─── Auto-Refresh ─────────────────────────────────────────
146
147    /// Start automatic token refresh with default configuration.
148    ///
149    /// Spawns a background task that checks the stored session periodically
150    /// and refreshes it before expiry.
151    pub fn start_auto_refresh(&self) {
152        self.start_auto_refresh_with(AutoRefreshConfig::default());
153    }
154
155    /// Start automatic token refresh with custom configuration.
156    pub fn start_auto_refresh_with(&self, config: AutoRefreshConfig) {
157        // Stop any existing auto-refresh first
158        self.stop_auto_refresh_inner();
159
160        let inner = Arc::clone(&self.inner);
161        let handle = tokio::spawn(async move {
162            auto_refresh_loop(inner, config).await;
163        });
164
165        // Use try_lock to avoid blocking — if it fails, the old handle will be dropped
166        if let Ok(mut guard) = self.inner.auto_refresh_handle.try_lock() {
167            *guard = Some(handle);
168        }
169    }
170
171    /// Stop automatic token refresh.
172    pub fn stop_auto_refresh(&self) {
173        self.stop_auto_refresh_inner();
174    }
175
176    fn stop_auto_refresh_inner(&self) {
177        if let Ok(mut guard) = self.inner.auto_refresh_handle.try_lock() {
178            if let Some(handle) = guard.take() {
179                handle.abort();
180            }
181        }
182    }
183
184    // ─── Session-Aware Convenience Methods ────────────────────
185
186    /// Get the user from the stored session (calls GoTrue `/user`).
187    ///
188    /// Returns `AuthError::NoSession` if no session is stored.
189    pub async fn get_session_user(&self) -> Result<User, AuthError> {
190        let session = self.inner.session.read().await.clone();
191        match session {
192            Some(s) => self.get_user(&s.access_token).await,
193            None => Err(AuthError::NoSession),
194        }
195    }
196
197    /// Refresh the stored session using its refresh_token.
198    ///
199    /// Returns `AuthError::NoSession` if no session is stored.
200    pub async fn refresh_current_session(&self) -> Result<Session, AuthError> {
201        let session = self.inner.session.read().await.clone();
202        match session {
203            Some(s) => self.refresh_session(&s.refresh_token).await,
204            None => Err(AuthError::NoSession),
205        }
206    }
207
208    /// Sign out using the stored session's access_token, then clear session.
209    ///
210    /// Returns `AuthError::NoSession` if no session is stored.
211    pub async fn sign_out_current(&self) -> Result<(), AuthError> {
212        self.sign_out_current_with_scope(SignOutScope::Global).await
213    }
214
215    /// Sign out with scope using the stored session's access_token.
216    ///
217    /// Returns `AuthError::NoSession` if no session is stored.
218    pub async fn sign_out_current_with_scope(
219        &self,
220        scope: SignOutScope,
221    ) -> Result<(), AuthError> {
222        let session = self.inner.session.read().await.clone();
223        match session {
224            Some(s) => self.sign_out_with_scope(&s.access_token, scope).await,
225            None => Err(AuthError::NoSession),
226        }
227    }
228
229    // ─── Sign Up ───────────────────────────────────────────────
230
231    /// Sign up a new user with email and password.
232    ///
233    /// Mirrors `supabase.auth.signUp({ email, password })`.
234    /// If the response includes a session, it is stored and `SignedIn` is emitted.
235    pub async fn sign_up_with_email(
236        &self,
237        email: &str,
238        password: &str,
239    ) -> Result<AuthResponse, AuthError> {
240        self.sign_up_with_email_and_data(email, password, None).await
241    }
242
243    /// Sign up a new user with email, password, and custom user metadata.
244    ///
245    /// Mirrors `supabase.auth.signUp({ email, password, options: { data } })`.
246    /// If the response includes a session, it is stored and `SignedIn` is emitted.
247    pub async fn sign_up_with_email_and_data(
248        &self,
249        email: &str,
250        password: &str,
251        data: Option<JsonValue>,
252    ) -> Result<AuthResponse, AuthError> {
253        let mut body = json!({
254            "email": email,
255            "password": password,
256        });
257        if let Some(data) = data {
258            body["data"] = data;
259        }
260
261        let url = self.url("/signup");
262        let resp = self.inner.http.post(url).json(&body).send().await?;
263        let auth_resp = self.handle_auth_response(resp).await?;
264        if let Some(session) = &auth_resp.session {
265            self.store_session(session, AuthChangeEvent::SignedIn).await;
266        }
267        Ok(auth_resp)
268    }
269
270    /// Sign up a new user with phone and password.
271    ///
272    /// Mirrors `supabase.auth.signUp({ phone, password })`.
273    /// If the response includes a session, it is stored and `SignedIn` is emitted.
274    pub async fn sign_up_with_phone(
275        &self,
276        phone: &str,
277        password: &str,
278    ) -> Result<AuthResponse, AuthError> {
279        let body = json!({
280            "phone": phone,
281            "password": password,
282        });
283
284        let url = self.url("/signup");
285        let resp = self.inner.http.post(url).json(&body).send().await?;
286        let auth_resp = self.handle_auth_response(resp).await?;
287        if let Some(session) = &auth_resp.session {
288            self.store_session(session, AuthChangeEvent::SignedIn).await;
289        }
290        Ok(auth_resp)
291    }
292
293    // ─── Sign In ───────────────────────────────────────────────
294
295    /// Sign in with email and password.
296    ///
297    /// Mirrors `supabase.auth.signInWithPassword({ email, password })`.
298    /// Stores the session and emits `SignedIn`.
299    pub async fn sign_in_with_password_email(
300        &self,
301        email: &str,
302        password: &str,
303    ) -> Result<Session, AuthError> {
304        let body = json!({
305            "email": email,
306            "password": password,
307        });
308
309        let url = self.url("/token?grant_type=password");
310        let resp = self.inner.http.post(url).json(&body).send().await?;
311        let session = self.handle_session_response(resp).await?;
312        self.store_session(&session, AuthChangeEvent::SignedIn).await;
313        Ok(session)
314    }
315
316    /// Sign in with phone and password.
317    ///
318    /// Mirrors `supabase.auth.signInWithPassword({ phone, password })`.
319    /// Stores the session and emits `SignedIn`.
320    pub async fn sign_in_with_password_phone(
321        &self,
322        phone: &str,
323        password: &str,
324    ) -> Result<Session, AuthError> {
325        let body = json!({
326            "phone": phone,
327            "password": password,
328        });
329
330        let url = self.url("/token?grant_type=password");
331        let resp = self.inner.http.post(url).json(&body).send().await?;
332        let session = self.handle_session_response(resp).await?;
333        self.store_session(&session, AuthChangeEvent::SignedIn).await;
334        Ok(session)
335    }
336
337    /// Send a magic link / OTP to an email address.
338    ///
339    /// Mirrors `supabase.auth.signInWithOtp({ email })`.
340    pub async fn sign_in_with_otp_email(&self, email: &str) -> Result<(), AuthError> {
341        let body = json!({
342            "email": email,
343        });
344
345        let url = self.url("/otp");
346        let resp = self.inner.http.post(url).json(&body).send().await?;
347        self.handle_empty_response(resp).await
348    }
349
350    /// Send an OTP to a phone number.
351    ///
352    /// Mirrors `supabase.auth.signInWithOtp({ phone, options: { channel } })`.
353    pub async fn sign_in_with_otp_phone(
354        &self,
355        phone: &str,
356        channel: OtpChannel,
357    ) -> Result<(), AuthError> {
358        let body = json!({
359            "phone": phone,
360            "channel": channel,
361        });
362
363        let url = self.url("/otp");
364        let resp = self.inner.http.post(url).json(&body).send().await?;
365        self.handle_empty_response(resp).await
366    }
367
368    /// Verify an OTP token.
369    ///
370    /// Mirrors `supabase.auth.verifyOtp(params)`.
371    /// Stores the session and emits `SignedIn`.
372    pub async fn verify_otp(&self, params: VerifyOtpParams) -> Result<Session, AuthError> {
373        let url = self.url("/verify");
374        let resp = self.inner.http.post(url).json(&params).send().await?;
375        let session = self.handle_session_response(resp).await?;
376        self.store_session(&session, AuthChangeEvent::SignedIn).await;
377        Ok(session)
378    }
379
380    /// Sign in anonymously, creating a new anonymous user.
381    ///
382    /// Mirrors `supabase.auth.signInAnonymously()`.
383    /// Stores the session and emits `SignedIn`.
384    pub async fn sign_in_anonymous(&self) -> Result<Session, AuthError> {
385        let url = self.url("/signup");
386        let body = json!({});
387        let resp = self.inner.http.post(url).json(&body).send().await?;
388        let session = self.handle_session_response(resp).await?;
389        self.store_session(&session, AuthChangeEvent::SignedIn).await;
390        Ok(session)
391    }
392
393    // ─── OAuth ─────────────────────────────────────────────────
394
395    /// Build the OAuth sign-in URL for a given provider.
396    ///
397    /// Returns the URL to redirect the user to. This does not make a network request.
398    ///
399    /// Mirrors `supabase.auth.signInWithOAuth({ provider, options })`.
400    pub fn get_oauth_sign_in_url(
401        &self,
402        provider: OAuthProvider,
403        redirect_to: Option<&str>,
404        scopes: Option<&str>,
405    ) -> Result<String, AuthError> {
406        let mut url = self.url("/authorize");
407        url.query_pairs_mut()
408            .append_pair("provider", &provider.to_string());
409
410        if let Some(redirect) = redirect_to {
411            url.query_pairs_mut()
412                .append_pair("redirect_to", redirect);
413        }
414        if let Some(scopes) = scopes {
415            url.query_pairs_mut().append_pair("scopes", scopes);
416        }
417
418        Ok(url.to_string())
419    }
420
421    /// Exchange an auth code (from PKCE flow) for a session.
422    ///
423    /// Mirrors `supabase.auth.exchangeCodeForSession(authCode)`.
424    /// Stores the session and emits `SignedIn`.
425    pub async fn exchange_code_for_session(
426        &self,
427        code: &str,
428        code_verifier: Option<&str>,
429    ) -> Result<Session, AuthError> {
430        let mut body = json!({
431            "auth_code": code,
432        });
433        if let Some(verifier) = code_verifier {
434            body["code_verifier"] = json!(verifier);
435        }
436
437        let url = self.url("/token?grant_type=pkce");
438        let resp = self.inner.http.post(url).json(&body).send().await?;
439        let session = self.handle_session_response(resp).await?;
440        self.store_session(&session, AuthChangeEvent::SignedIn).await;
441        Ok(session)
442    }
443
444    // ─── Session Management ────────────────────────────────────
445
446    /// Refresh a session using a refresh token.
447    ///
448    /// Mirrors `supabase.auth.refreshSession()`.
449    /// Stores the new session and emits `TokenRefreshed`.
450    pub async fn refresh_session(&self, refresh_token: &str) -> Result<Session, AuthError> {
451        let body = json!({
452            "refresh_token": refresh_token,
453        });
454
455        let url = self.url("/token?grant_type=refresh_token");
456        let resp = self.inner.http.post(url).json(&body).send().await?;
457        let session = self.handle_session_response(resp).await?;
458        self.store_session(&session, AuthChangeEvent::TokenRefreshed).await;
459        Ok(session)
460    }
461
462    // ─── User Management ───────────────────────────────────────
463
464    /// Get the user associated with an access token.
465    ///
466    /// Makes a network request to GoTrue to validate the token and fetch the user.
467    ///
468    /// Mirrors `supabase.auth.getUser(jwt?)`.
469    pub async fn get_user(&self, access_token: &str) -> Result<User, AuthError> {
470        let url = self.url("/user");
471        let resp = self
472            .inner
473            .http
474            .get(url)
475            .bearer_auth(access_token)
476            .send()
477            .await?;
478        self.handle_user_response(resp).await
479    }
480
481    /// Update the current user's attributes.
482    ///
483    /// Mirrors `supabase.auth.updateUser(attributes)`.
484    /// Emits `UserUpdated` and updates the user in the stored session (if any).
485    pub async fn update_user(
486        &self,
487        access_token: &str,
488        params: UpdateUserParams,
489    ) -> Result<User, AuthError> {
490        let url = self.url("/user");
491        let resp = self
492            .inner
493            .http
494            .put(url)
495            .bearer_auth(access_token)
496            .json(&params)
497            .send()
498            .await?;
499        let user = self.handle_user_response(resp).await?;
500
501        // Update the user in the stored session and emit UserUpdated
502        let mut guard = self.inner.session.write().await;
503        let session_clone = if let Some(session) = guard.as_mut() {
504            session.user = user.clone();
505            Some(session.clone())
506        } else {
507            None
508        };
509        drop(guard);
510
511        let _ = self.inner.event_tx.send(AuthStateChange {
512            event: AuthChangeEvent::UserUpdated,
513            session: session_clone,
514        });
515
516        Ok(user)
517    }
518
519    // ─── Sign Out ──────────────────────────────────────────────
520
521    /// Sign out the user (global scope by default).
522    ///
523    /// Mirrors `supabase.auth.signOut()`.
524    /// Clears the stored session and emits `SignedOut`.
525    pub async fn sign_out(&self, access_token: &str) -> Result<(), AuthError> {
526        self.sign_out_with_scope(access_token, SignOutScope::Global)
527            .await
528    }
529
530    /// Sign out with a specific scope.
531    ///
532    /// Mirrors `supabase.auth.signOut({ scope })`.
533    /// Clears the stored session and emits `SignedOut`.
534    pub async fn sign_out_with_scope(
535        &self,
536        access_token: &str,
537        scope: SignOutScope,
538    ) -> Result<(), AuthError> {
539        let url = self.url(&format!("/logout?scope={}", scope));
540        let resp = self
541            .inner
542            .http
543            .post(url)
544            .bearer_auth(access_token)
545            .send()
546            .await?;
547        self.handle_empty_response(resp).await?;
548        self.emit_signed_out().await;
549        Ok(())
550    }
551
552    // ─── Password Recovery ─────────────────────────────────────
553
554    /// Send a password reset email.
555    ///
556    /// Mirrors `supabase.auth.resetPasswordForEmail(email, options)`.
557    pub async fn reset_password_for_email(
558        &self,
559        email: &str,
560        redirect_to: Option<&str>,
561    ) -> Result<(), AuthError> {
562        let mut body = json!({ "email": email });
563        if let Some(redirect) = redirect_to {
564            body["redirect_to"] = json!(redirect);
565        }
566
567        let url = self.url("/recover");
568        let resp = self.inner.http.post(url).json(&body).send().await?;
569        self.handle_empty_response(resp).await
570    }
571
572    // ─── Admin ─────────────────────────────────────────────────
573
574    /// Create an admin client using the current API key as the service role key.
575    ///
576    /// The API key must be a `service_role` key for admin operations to work.
577    ///
578    /// Mirrors `supabase.auth.admin`.
579    pub fn admin(&self) -> AdminClient<'_> {
580        AdminClient::new(self)
581    }
582
583    /// Create an admin client with an explicit service role key.
584    pub fn admin_with_key<'a>(&'a self, service_role_key: &'a str) -> AdminClient<'a> {
585        AdminClient::with_key(self, service_role_key)
586    }
587
588    // ─── MFA ───────────────────────────────────────────────────
589
590    /// Enroll a new MFA factor (TOTP or phone).
591    ///
592    /// Mirrors `supabase.auth.mfa.enroll()`.
593    pub async fn mfa_enroll(
594        &self,
595        access_token: &str,
596        params: MfaEnrollParams,
597    ) -> Result<MfaEnrollResponse, AuthError> {
598        let url = self.url("/factors");
599        let resp = self
600            .inner
601            .http
602            .post(url)
603            .bearer_auth(access_token)
604            .json(&params)
605            .send()
606            .await?;
607        let status = resp.status().as_u16();
608        if status >= 400 {
609            return Err(self.parse_error(status, resp).await);
610        }
611        let body: MfaEnrollResponse = resp.json().await?;
612        Ok(body)
613    }
614
615    /// Create a challenge for an enrolled factor.
616    ///
617    /// Mirrors `supabase.auth.mfa.challenge()`.
618    pub async fn mfa_challenge(
619        &self,
620        access_token: &str,
621        factor_id: &str,
622    ) -> Result<MfaChallengeResponse, AuthError> {
623        self.mfa_challenge_with_params(access_token, factor_id, MfaChallengeParams::default())
624            .await
625    }
626
627    /// Create a challenge for an enrolled factor with additional params.
628    ///
629    /// The `params` can specify the channel (sms/whatsapp) for phone factors.
630    pub async fn mfa_challenge_with_params(
631        &self,
632        access_token: &str,
633        factor_id: &str,
634        params: MfaChallengeParams,
635    ) -> Result<MfaChallengeResponse, AuthError> {
636        let url = self.url(&format!("/factors/{}/challenge", factor_id));
637        let resp = self
638            .inner
639            .http
640            .post(url)
641            .bearer_auth(access_token)
642            .json(&params)
643            .send()
644            .await?;
645        let status = resp.status().as_u16();
646        if status >= 400 {
647            return Err(self.parse_error(status, resp).await);
648        }
649        let body: MfaChallengeResponse = resp.json().await?;
650        Ok(body)
651    }
652
653    /// Verify an MFA challenge with a TOTP/SMS code. Returns a new AAL2 session.
654    ///
655    /// Mirrors `supabase.auth.mfa.verify()`.
656    /// Stores the session and emits `SignedIn`.
657    pub async fn mfa_verify(
658        &self,
659        access_token: &str,
660        factor_id: &str,
661        params: MfaVerifyParams,
662    ) -> Result<Session, AuthError> {
663        let url = self.url(&format!("/factors/{}/verify", factor_id));
664        let resp = self
665            .inner
666            .http
667            .post(url)
668            .bearer_auth(access_token)
669            .json(&params)
670            .send()
671            .await?;
672        let session = self.handle_session_response(resp).await?;
673        self.store_session(&session, AuthChangeEvent::SignedIn).await;
674        Ok(session)
675    }
676
677    /// Combined challenge + verify for TOTP factors (convenience).
678    ///
679    /// Mirrors `supabase.auth.mfa.challengeAndVerify()`.
680    /// Stores the session and emits `SignedIn`.
681    pub async fn mfa_challenge_and_verify(
682        &self,
683        access_token: &str,
684        factor_id: &str,
685        code: &str,
686    ) -> Result<Session, AuthError> {
687        let challenge = self.mfa_challenge(access_token, factor_id).await?;
688        self.mfa_verify(
689            access_token,
690            factor_id,
691            MfaVerifyParams::new(&challenge.id, code),
692        )
693        .await
694    }
695
696    /// Unenroll (delete) an MFA factor.
697    ///
698    /// Mirrors `supabase.auth.mfa.unenroll()`.
699    pub async fn mfa_unenroll(
700        &self,
701        access_token: &str,
702        factor_id: &str,
703    ) -> Result<MfaUnenrollResponse, AuthError> {
704        let url = self.url(&format!("/factors/{}", factor_id));
705        let resp = self
706            .inner
707            .http
708            .delete(url)
709            .bearer_auth(access_token)
710            .send()
711            .await?;
712        let status = resp.status().as_u16();
713        if status >= 400 {
714            return Err(self.parse_error(status, resp).await);
715        }
716        let body: MfaUnenrollResponse = resp.json().await?;
717        Ok(body)
718    }
719
720    /// List the user's enrolled MFA factors, categorized by type.
721    ///
722    /// Fetches the user object and categorizes its factors.
723    ///
724    /// Mirrors `supabase.auth.mfa.listFactors()`.
725    pub async fn mfa_list_factors(
726        &self,
727        access_token: &str,
728    ) -> Result<MfaListFactorsResponse, AuthError> {
729        let user = self.get_user(access_token).await?;
730        let all = user.factors.unwrap_or_default();
731        let totp = all
732            .iter()
733            .filter(|f| f.factor_type == "totp")
734            .cloned()
735            .collect();
736        let phone = all
737            .iter()
738            .filter(|f| f.factor_type == "phone")
739            .cloned()
740            .collect();
741        Ok(MfaListFactorsResponse { totp, phone, all })
742    }
743
744    /// Get the user's authenticator assurance level.
745    ///
746    /// Fetches the user object and inspects factors to determine AAL.
747    ///
748    /// Mirrors `supabase.auth.mfa.getAuthenticatorAssuranceLevel()`.
749    pub async fn mfa_get_authenticator_assurance_level(
750        &self,
751        access_token: &str,
752    ) -> Result<AuthenticatorAssuranceLevelInfo, AuthError> {
753        let user = self.get_user(access_token).await?;
754        let factors = user.factors.unwrap_or_default();
755
756        // Parse AMR claims from the access token (JWT payload)
757        let amr = parse_amr_from_jwt(access_token);
758
759        // current_level: aal2 if AMR contains an MFA method, else aal1
760        let has_mfa_amr = amr.iter().any(|e| e.method == "totp" || e.method == "phone");
761        let current_level = if !amr.is_empty() {
762            if has_mfa_amr {
763                Some(AuthenticatorAssuranceLevel::Aal2)
764            } else {
765                Some(AuthenticatorAssuranceLevel::Aal1)
766            }
767        } else {
768            Some(AuthenticatorAssuranceLevel::Aal1)
769        };
770
771        // next_level: aal2 if any verified factor exists, else aal1
772        let has_verified_factor = factors.iter().any(|f| f.status == "verified");
773        let next_level = if has_verified_factor {
774            Some(AuthenticatorAssuranceLevel::Aal2)
775        } else {
776            Some(AuthenticatorAssuranceLevel::Aal1)
777        };
778
779        Ok(AuthenticatorAssuranceLevelInfo {
780            current_level,
781            next_level,
782            current_authentication_methods: amr,
783        })
784    }
785
786    // ─── SSO ───────────────────────────────────────────────────
787
788    /// Sign in with enterprise SAML SSO.
789    ///
790    /// Mirrors `supabase.auth.signInWithSSO()`.
791    pub async fn sign_in_with_sso(
792        &self,
793        params: SsoSignInParams,
794    ) -> Result<SsoSignInResponse, AuthError> {
795        let url = self.url("/sso");
796        let resp = self.inner.http.post(url).json(&params).send().await?;
797        let status = resp.status().as_u16();
798        if status >= 400 {
799            return Err(self.parse_error(status, resp).await);
800        }
801        let body: SsoSignInResponse = resp.json().await?;
802        Ok(body)
803    }
804
805    // ─── ID Token ──────────────────────────────────────────────
806
807    /// Sign in with an external OIDC ID token (e.g., from Google/Apple mobile SDK).
808    ///
809    /// Mirrors `supabase.auth.signInWithIdToken()`.
810    /// Stores the session and emits `SignedIn`.
811    pub async fn sign_in_with_id_token(
812        &self,
813        params: SignInWithIdTokenParams,
814    ) -> Result<Session, AuthError> {
815        let url = self.url("/token?grant_type=id_token");
816        let resp = self.inner.http.post(url).json(&params).send().await?;
817        let session = self.handle_session_response(resp).await?;
818        self.store_session(&session, AuthChangeEvent::SignedIn).await;
819        Ok(session)
820    }
821
822    // ─── Identity Linking ──────────────────────────────────────
823
824    /// Link an OAuth provider identity to the current user.
825    ///
826    /// Returns a URL to redirect the user to for OAuth authorization.
827    ///
828    /// Mirrors `supabase.auth.linkIdentity()`.
829    pub async fn link_identity(
830        &self,
831        access_token: &str,
832        provider: OAuthProvider,
833    ) -> Result<LinkIdentityResponse, AuthError> {
834        let mut url = self.url("/user/identities/authorize");
835        url.query_pairs_mut()
836            .append_pair("provider", &provider.to_string())
837            .append_pair("skip_http_redirect", "true");
838        let resp = self
839            .inner
840            .http
841            .get(url)
842            .bearer_auth(access_token)
843            .send()
844            .await?;
845        let status = resp.status().as_u16();
846        if status >= 400 {
847            return Err(self.parse_error(status, resp).await);
848        }
849        // The response contains a JSON object with a `url` field
850        let body: JsonValue = resp.json().await?;
851        let redirect_url = body
852            .get("url")
853            .and_then(|v| v.as_str())
854            .unwrap_or_default()
855            .to_string();
856        Ok(LinkIdentityResponse { url: redirect_url })
857    }
858
859    /// Unlink an identity from the current user.
860    ///
861    /// Mirrors `supabase.auth.unlinkIdentity()`.
862    pub async fn unlink_identity(
863        &self,
864        access_token: &str,
865        identity_id: &str,
866    ) -> Result<(), AuthError> {
867        let url = self.url(&format!("/user/identities/{}", identity_id));
868        let resp = self
869            .inner
870            .http
871            .delete(url)
872            .bearer_auth(access_token)
873            .send()
874            .await?;
875        self.handle_empty_response(resp).await
876    }
877
878    // ─── Resend & Reauthenticate ───────────────────────────────
879
880    /// Resend an OTP or confirmation email/SMS.
881    ///
882    /// Mirrors `supabase.auth.resend()`.
883    pub async fn resend(&self, params: ResendParams) -> Result<(), AuthError> {
884        let url = self.url("/resend");
885        let resp = self.inner.http.post(url).json(&params).send().await?;
886        self.handle_empty_response(resp).await
887    }
888
889    /// Send a reauthentication nonce to the user's verified email/phone.
890    ///
891    /// The nonce is used via the `nonce` field in `update_user()`.
892    ///
893    /// Mirrors `supabase.auth.reauthenticate()`.
894    pub async fn reauthenticate(&self, access_token: &str) -> Result<(), AuthError> {
895        let url = self.url("/reauthenticate");
896        let resp = self
897            .inner
898            .http
899            .get(url)
900            .bearer_auth(access_token)
901            .send()
902            .await?;
903        self.handle_empty_response(resp).await
904    }
905
906    // ─── User Identities ──────────────────────────────────────
907
908    /// Get the identities linked to the current user.
909    ///
910    /// Convenience method that calls `get_user()` and returns the `identities` field.
911    ///
912    /// Mirrors `supabase.auth.getUserIdentities()`.
913    pub async fn get_user_identities(
914        &self,
915        access_token: &str,
916    ) -> Result<Vec<Identity>, AuthError> {
917        let user = self.get_user(access_token).await?;
918        Ok(user.identities.unwrap_or_default())
919    }
920
921    // ─── OAuth Server ─────────────────────────────────────────
922
923    /// Get authorization details for an OAuth authorization request.
924    ///
925    /// Returns either full details (user must consent) or a redirect (already consented).
926    ///
927    /// Mirrors `supabase.auth.oauth.getAuthorizationDetails()`.
928    pub async fn oauth_get_authorization_details(
929        &self,
930        access_token: &str,
931        authorization_id: &str,
932    ) -> Result<OAuthAuthorizationDetailsResponse, AuthError> {
933        let url = self.url(&format!("/oauth/authorizations/{}", authorization_id));
934        let resp = self
935            .inner
936            .http
937            .get(url)
938            .bearer_auth(access_token)
939            .send()
940            .await?;
941        let status = resp.status().as_u16();
942        if status >= 400 {
943            return Err(self.parse_error(status, resp).await);
944        }
945        let body: OAuthAuthorizationDetailsResponse = resp.json().await?;
946        Ok(body)
947    }
948
949    /// Approve an OAuth authorization request.
950    ///
951    /// Mirrors `supabase.auth.oauth.approveAuthorization()`.
952    pub async fn oauth_approve_authorization(
953        &self,
954        access_token: &str,
955        authorization_id: &str,
956    ) -> Result<OAuthRedirect, AuthError> {
957        self.oauth_consent_action(access_token, authorization_id, "approve")
958            .await
959    }
960
961    /// Deny an OAuth authorization request.
962    ///
963    /// Mirrors `supabase.auth.oauth.denyAuthorization()`.
964    pub async fn oauth_deny_authorization(
965        &self,
966        access_token: &str,
967        authorization_id: &str,
968    ) -> Result<OAuthRedirect, AuthError> {
969        self.oauth_consent_action(access_token, authorization_id, "deny")
970            .await
971    }
972
973    /// List all OAuth grants (permissions) for the current user.
974    ///
975    /// Mirrors `supabase.auth.oauth.listGrants()`.
976    pub async fn oauth_list_grants(
977        &self,
978        access_token: &str,
979    ) -> Result<Vec<OAuthGrant>, AuthError> {
980        let url = self.url("/user/oauth/grants");
981        let resp = self
982            .inner
983            .http
984            .get(url)
985            .bearer_auth(access_token)
986            .send()
987            .await?;
988        let status = resp.status().as_u16();
989        if status >= 400 {
990            return Err(self.parse_error(status, resp).await);
991        }
992        let grants: Vec<OAuthGrant> = resp.json().await?;
993        Ok(grants)
994    }
995
996    /// Revoke an OAuth grant for a specific client.
997    ///
998    /// Mirrors `supabase.auth.oauth.revokeGrant()`.
999    pub async fn oauth_revoke_grant(
1000        &self,
1001        access_token: &str,
1002        client_id: &str,
1003    ) -> Result<(), AuthError> {
1004        let mut url = self.url("/user/oauth/grants");
1005        url.query_pairs_mut()
1006            .append_pair("client_id", client_id);
1007        let resp = self
1008            .inner
1009            .http
1010            .delete(url)
1011            .bearer_auth(access_token)
1012            .send()
1013            .await?;
1014        self.handle_empty_response(resp).await
1015    }
1016
1017    async fn oauth_consent_action(
1018        &self,
1019        access_token: &str,
1020        authorization_id: &str,
1021        action: &str,
1022    ) -> Result<OAuthRedirect, AuthError> {
1023        let url = self.url(&format!(
1024            "/oauth/authorizations/{}/consent",
1025            authorization_id
1026        ));
1027        let body = serde_json::json!({ "action": action });
1028        let resp = self
1029            .inner
1030            .http
1031            .post(url)
1032            .bearer_auth(access_token)
1033            .json(&body)
1034            .send()
1035            .await?;
1036        let status = resp.status().as_u16();
1037        if status >= 400 {
1038            return Err(self.parse_error(status, resp).await);
1039        }
1040        let redirect: OAuthRedirect = resp.json().await?;
1041        Ok(redirect)
1042    }
1043
1044    // ─── OAuth Client-Side Flow ─────────────────────────────────
1045
1046    /// Generate a PKCE (Proof Key for Code Exchange) verifier/challenge pair.
1047    ///
1048    /// Uses S256 method: the challenge is `BASE64URL(SHA256(verifier))`.
1049    /// The verifier is 43 URL-safe random characters.
1050    pub fn generate_pkce_pair() -> PkcePair {
1051        use rand::Rng;
1052
1053        // Generate 32 random bytes → 43 base64url chars (no padding)
1054        let mut rng = rand::rng();
1055        let random_bytes: [u8; 32] = rng.random();
1056        let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random_bytes);
1057
1058        // S256: challenge = BASE64URL(SHA256(verifier))
1059        let hash = Sha256::digest(verifier.as_bytes());
1060        let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash);
1061
1062        PkcePair {
1063            verifier: PkceCodeVerifier(verifier),
1064            challenge: PkceCodeChallenge(challenge),
1065        }
1066    }
1067
1068    /// Build an OAuth authorization URL for the authorization code flow.
1069    ///
1070    /// Returns a URL to redirect the user to. This does not make a network request.
1071    pub fn build_oauth_authorize_url(&self, params: &OAuthAuthorizeUrlParams) -> String {
1072        let mut url = self.url("/oauth/authorize");
1073        {
1074            let mut pairs = url.query_pairs_mut();
1075            pairs.append_pair("client_id", &params.client_id);
1076            pairs.append_pair("redirect_uri", &params.redirect_uri);
1077            pairs.append_pair("response_type", "code");
1078
1079            if let Some(scope) = &params.scope {
1080                pairs.append_pair("scope", scope);
1081            }
1082            if let Some(state) = &params.state {
1083                pairs.append_pair("state", state);
1084            }
1085            if let Some(challenge) = &params.code_challenge {
1086                pairs.append_pair("code_challenge", challenge);
1087                if let Some(method) = &params.code_challenge_method {
1088                    pairs.append_pair("code_challenge_method", method);
1089                }
1090            }
1091        }
1092        url.to_string()
1093    }
1094
1095    /// Exchange an authorization code for tokens.
1096    ///
1097    /// POST to `/oauth/token` with `grant_type=authorization_code`.
1098    /// Uses `application/x-www-form-urlencoded` as per OAuth 2.1 spec.
1099    pub async fn oauth_token_exchange(
1100        &self,
1101        params: OAuthTokenExchangeParams,
1102    ) -> Result<OAuthTokenResponse, AuthError> {
1103        let url = self.url("/oauth/token");
1104        let mut form: Vec<(&str, String)> = vec![
1105            ("grant_type", "authorization_code".to_string()),
1106            ("code", params.code.clone()),
1107            ("redirect_uri", params.redirect_uri.clone()),
1108            ("client_id", params.client_id.clone()),
1109        ];
1110        if let Some(secret) = &params.client_secret {
1111            form.push(("client_secret", secret.clone()));
1112        }
1113        if let Some(verifier) = &params.code_verifier {
1114            form.push(("code_verifier", verifier.clone()));
1115        }
1116
1117        let resp = self.inner.http.post(url).form(&form).send().await?;
1118        let status = resp.status().as_u16();
1119        if status >= 400 {
1120            return Err(self.parse_error(status, resp).await);
1121        }
1122        let token_resp: OAuthTokenResponse = resp.json().await?;
1123        Ok(token_resp)
1124    }
1125
1126    /// Refresh an OAuth token.
1127    ///
1128    /// POST to `/oauth/token` with `grant_type=refresh_token`.
1129    pub async fn oauth_token_refresh(
1130        &self,
1131        client_id: &str,
1132        refresh_token: &str,
1133        client_secret: Option<&str>,
1134    ) -> Result<OAuthTokenResponse, AuthError> {
1135        let url = self.url("/oauth/token");
1136        let mut form: Vec<(&str, String)> = vec![
1137            ("grant_type", "refresh_token".to_string()),
1138            ("refresh_token", refresh_token.to_string()),
1139            ("client_id", client_id.to_string()),
1140        ];
1141        if let Some(secret) = client_secret {
1142            form.push(("client_secret", secret.to_string()));
1143        }
1144
1145        let resp = self.inner.http.post(url).form(&form).send().await?;
1146        let status = resp.status().as_u16();
1147        if status >= 400 {
1148            return Err(self.parse_error(status, resp).await);
1149        }
1150        let token_resp: OAuthTokenResponse = resp.json().await?;
1151        Ok(token_resp)
1152    }
1153
1154    /// Revoke an OAuth token.
1155    ///
1156    /// POST to `/oauth/revoke`.
1157    pub async fn oauth_revoke_token(
1158        &self,
1159        token: &str,
1160        token_type_hint: Option<&str>,
1161    ) -> Result<(), AuthError> {
1162        let url = self.url("/oauth/revoke");
1163        let mut form: Vec<(&str, String)> = vec![("token", token.to_string())];
1164        if let Some(hint) = token_type_hint {
1165            form.push(("token_type_hint", hint.to_string()));
1166        }
1167
1168        let resp = self.inner.http.post(url).form(&form).send().await?;
1169        self.handle_empty_response(resp).await
1170    }
1171
1172    /// Fetch the OpenID Connect discovery document.
1173    ///
1174    /// GET `/.well-known/openid-configuration`.
1175    pub async fn oauth_get_openid_configuration(
1176        &self,
1177    ) -> Result<OpenIdConfiguration, AuthError> {
1178        let url = self.url("/.well-known/openid-configuration");
1179        let resp = self.inner.http.get(url).send().await?;
1180        let status = resp.status().as_u16();
1181        if status >= 400 {
1182            return Err(self.parse_error(status, resp).await);
1183        }
1184        let config: OpenIdConfiguration = resp.json().await?;
1185        Ok(config)
1186    }
1187
1188    /// Fetch the JSON Web Key Set (JWKS) for token verification.
1189    ///
1190    /// GET `/.well-known/jwks.json`.
1191    pub async fn oauth_get_jwks(&self) -> Result<JwksResponse, AuthError> {
1192        let url = self.url("/.well-known/jwks.json");
1193        let resp = self.inner.http.get(url).send().await?;
1194        let status = resp.status().as_u16();
1195        if status >= 400 {
1196            return Err(self.parse_error(status, resp).await);
1197        }
1198        let jwks: JwksResponse = resp.json().await?;
1199        Ok(jwks)
1200    }
1201
1202    /// Fetch user info from the OAuth userinfo endpoint.
1203    ///
1204    /// GET `/oauth/userinfo` with Bearer token.
1205    pub async fn oauth_get_userinfo(
1206        &self,
1207        access_token: &str,
1208    ) -> Result<JsonValue, AuthError> {
1209        let url = self.url("/oauth/userinfo");
1210        let resp = self
1211            .inner
1212            .http
1213            .get(url)
1214            .bearer_auth(access_token)
1215            .send()
1216            .await?;
1217        let status = resp.status().as_u16();
1218        if status >= 400 {
1219            return Err(self.parse_error(status, resp).await);
1220        }
1221        let userinfo: JsonValue = resp.json().await?;
1222        Ok(userinfo)
1223    }
1224
1225    // ─── JWT Claims ────────────────────────────────────────────
1226
1227    /// Extract claims from a JWT access token without verifying the signature.
1228    ///
1229    /// This is a client-side decode only — it does NOT validate the token.
1230    /// Useful for reading `sub`, `exp`, `role`, `email`, custom claims, etc.
1231    ///
1232    /// Returns the payload as a `serde_json::Value` object.
1233    pub fn get_claims(token: &str) -> Result<JsonValue, AuthError> {
1234        let parts: Vec<&str> = token.split('.').collect();
1235        if parts.len() != 3 {
1236            return Err(AuthError::InvalidToken(
1237                "JWT must have 3 parts separated by '.'".to_string(),
1238            ));
1239        }
1240
1241        let payload_b64 = parts[1];
1242        let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1243            .decode(payload_b64)
1244            .map_err(|e| AuthError::InvalidToken(format!("Invalid base64 in JWT payload: {}", e)))?;
1245
1246        serde_json::from_slice(&decoded)
1247            .map_err(|e| AuthError::InvalidToken(format!("Invalid JSON in JWT payload: {}", e)))
1248    }
1249
1250    // ─── Captcha Support ──────────────────────────────────────
1251
1252    /// Sign up with email, password, optional user data, and optional captcha token.
1253    ///
1254    /// This is the full-featured sign-up method that consolidates email sign-up
1255    /// with all optional parameters. The captcha token is sent as
1256    /// `gotrue_meta_security.captcha_token` in the request body.
1257    pub async fn sign_up_with_email_full(
1258        &self,
1259        email: &str,
1260        password: &str,
1261        data: Option<JsonValue>,
1262        captcha_token: Option<&str>,
1263    ) -> Result<AuthResponse, AuthError> {
1264        let mut body = json!({
1265            "email": email,
1266            "password": password,
1267        });
1268        if let Some(data) = data {
1269            body["data"] = data;
1270        }
1271        if let Some(token) = captcha_token {
1272            body["gotrue_meta_security"] = json!({ "captcha_token": token });
1273        }
1274
1275        let url = self.url("/signup");
1276        let resp = self.inner.http.post(url).json(&body).send().await?;
1277        let auth_resp = self.handle_auth_response(resp).await?;
1278        if let Some(session) = &auth_resp.session {
1279            self.store_session(session, AuthChangeEvent::SignedIn).await;
1280        }
1281        Ok(auth_resp)
1282    }
1283
1284    /// Sign in with email and password, with an optional captcha token.
1285    pub async fn sign_in_with_password_email_captcha(
1286        &self,
1287        email: &str,
1288        password: &str,
1289        captcha_token: Option<&str>,
1290    ) -> Result<Session, AuthError> {
1291        let mut body = json!({
1292            "email": email,
1293            "password": password,
1294        });
1295        if let Some(token) = captcha_token {
1296            body["gotrue_meta_security"] = json!({ "captcha_token": token });
1297        }
1298
1299        let url = self.url("/token?grant_type=password");
1300        let resp = self.inner.http.post(url).json(&body).send().await?;
1301        let session = self.handle_session_response(resp).await?;
1302        self.store_session(&session, AuthChangeEvent::SignedIn).await;
1303        Ok(session)
1304    }
1305
1306    /// Send a magic link / OTP to an email, with an optional captcha token.
1307    pub async fn sign_in_with_otp_email_captcha(
1308        &self,
1309        email: &str,
1310        captcha_token: Option<&str>,
1311    ) -> Result<(), AuthError> {
1312        let mut body = json!({
1313            "email": email,
1314        });
1315        if let Some(token) = captcha_token {
1316            body["gotrue_meta_security"] = json!({ "captcha_token": token });
1317        }
1318
1319        let url = self.url("/otp");
1320        let resp = self.inner.http.post(url).json(&body).send().await?;
1321        self.handle_empty_response(resp).await
1322    }
1323
1324    /// Send an OTP to a phone number, with an optional captcha token.
1325    pub async fn sign_in_with_otp_phone_captcha(
1326        &self,
1327        phone: &str,
1328        channel: OtpChannel,
1329        captcha_token: Option<&str>,
1330    ) -> Result<(), AuthError> {
1331        let mut body = json!({
1332            "phone": phone,
1333            "channel": channel,
1334        });
1335        if let Some(token) = captcha_token {
1336            body["gotrue_meta_security"] = json!({ "captcha_token": token });
1337        }
1338
1339        let url = self.url("/otp");
1340        let resp = self.inner.http.post(url).json(&body).send().await?;
1341        self.handle_empty_response(resp).await
1342    }
1343
1344    // ─── Web3 Auth ────────────────────────────────────────────
1345
1346    /// Sign in with a Web3 wallet (Ethereum or Solana).
1347    ///
1348    /// POST to `/token?grant_type=web3` with chain, address, message, signature, nonce.
1349    /// Stores the session and emits `SignedIn`.
1350    pub async fn sign_in_with_web3(
1351        &self,
1352        params: Web3SignInParams,
1353    ) -> Result<Session, AuthError> {
1354        let body = json!({
1355            "chain": params.chain,
1356            "address": params.address,
1357            "message": params.message,
1358            "signature": params.signature,
1359            "nonce": params.nonce,
1360        });
1361
1362        let url = self.url("/token?grant_type=web3");
1363        let resp = self.inner.http.post(url).json(&body).send().await?;
1364        let session = self.handle_session_response(resp).await?;
1365        self.store_session(&session, AuthChangeEvent::SignedIn).await;
1366        Ok(session)
1367    }
1368
1369    // ─── Internal Helpers ──────────────────────────────────────
1370
1371    /// Store session and emit an auth state change event.
1372    async fn store_session(&self, session: &Session, event: AuthChangeEvent) {
1373        *self.inner.session.write().await = Some(session.clone());
1374        let _ = self.inner.event_tx.send(AuthStateChange {
1375            event,
1376            session: Some(session.clone()),
1377        });
1378    }
1379
1380    /// Clear session and emit SignedOut.
1381    async fn emit_signed_out(&self) {
1382        *self.inner.session.write().await = None;
1383        let _ = self.inner.event_tx.send(AuthStateChange {
1384            event: AuthChangeEvent::SignedOut,
1385            session: None,
1386        });
1387    }
1388
1389    pub(crate) fn url(&self, path: &str) -> Url {
1390        let mut url = self.inner.base_url.clone();
1391        let current = url.path().to_string();
1392        // path may contain query string (e.g. "/token?grant_type=password")
1393        if let Some(query_start) = path.find('?') {
1394            url.set_path(&format!("{}{}", current, &path[..query_start]));
1395            url.set_query(Some(&path[query_start + 1..]));
1396        } else {
1397            url.set_path(&format!("{}{}", current, path));
1398        }
1399        url
1400    }
1401
1402    pub(crate) fn http(&self) -> &reqwest::Client {
1403        &self.inner.http
1404    }
1405
1406    pub(crate) fn api_key(&self) -> &str {
1407        &self.inner.api_key
1408    }
1409
1410    async fn handle_auth_response(
1411        &self,
1412        resp: reqwest::Response,
1413    ) -> Result<AuthResponse, AuthError> {
1414        let status = resp.status().as_u16();
1415        if status >= 400 {
1416            return Err(self.parse_error(status, resp).await);
1417        }
1418
1419        let body: AuthResponse = resp.json().await?;
1420        Ok(body)
1421    }
1422
1423    async fn handle_session_response(
1424        &self,
1425        resp: reqwest::Response,
1426    ) -> Result<Session, AuthError> {
1427        let status = resp.status().as_u16();
1428        if status >= 400 {
1429            return Err(self.parse_error(status, resp).await);
1430        }
1431
1432        let session: Session = resp.json().await?;
1433        Ok(session)
1434    }
1435
1436    pub(crate) async fn handle_user_response(
1437        &self,
1438        resp: reqwest::Response,
1439    ) -> Result<User, AuthError> {
1440        let status = resp.status().as_u16();
1441        if status >= 400 {
1442            return Err(self.parse_error(status, resp).await);
1443        }
1444
1445        let user: User = resp.json().await?;
1446        Ok(user)
1447    }
1448
1449    pub(crate) async fn handle_empty_response(
1450        &self,
1451        resp: reqwest::Response,
1452    ) -> Result<(), AuthError> {
1453        let status = resp.status().as_u16();
1454        if status >= 400 {
1455            return Err(self.parse_error(status, resp).await);
1456        }
1457        Ok(())
1458    }
1459
1460    async fn parse_error(&self, status: u16, resp: reqwest::Response) -> AuthError {
1461        match resp.json::<GoTrueErrorResponse>().await {
1462            Ok(err_resp) => {
1463                let error_code = err_resp
1464                    .error_code
1465                    .as_deref()
1466                    .map(|s| s.into());
1467                AuthError::Api {
1468                    status,
1469                    message: err_resp.error_message(),
1470                    error_code,
1471                }
1472            }
1473            Err(_) => AuthError::Api {
1474                status,
1475                message: format!("HTTP {}", status),
1476                error_code: None,
1477            },
1478        }
1479    }
1480}
1481
1482/// Background auto-refresh loop.
1483async fn auto_refresh_loop(inner: Arc<AuthClientInner>, config: AutoRefreshConfig) {
1484    let mut retries = 0u32;
1485    loop {
1486        tokio::time::sleep(config.check_interval).await;
1487
1488        let session = inner.session.read().await.clone();
1489        if let Some(session) = session {
1490            if should_refresh(&session, &config.refresh_margin) {
1491                match refresh_session_internal(&inner, &session.refresh_token).await {
1492                    Ok(new_session) => {
1493                        *inner.session.write().await = Some(new_session.clone());
1494                        let _ = inner.event_tx.send(AuthStateChange {
1495                            event: AuthChangeEvent::TokenRefreshed,
1496                            session: Some(new_session),
1497                        });
1498                        retries = 0;
1499                    }
1500                    Err(_) => {
1501                        retries += 1;
1502                        if retries >= config.max_retries {
1503                            *inner.session.write().await = None;
1504                            let _ = inner.event_tx.send(AuthStateChange {
1505                                event: AuthChangeEvent::SignedOut,
1506                                session: None,
1507                            });
1508                            break;
1509                        }
1510                    }
1511                }
1512            }
1513        }
1514    }
1515}
1516
1517/// Check if a session should be refreshed based on its expiry.
1518fn should_refresh(session: &Session, margin: &std::time::Duration) -> bool {
1519    session.expires_at.map(|exp| {
1520        let now = std::time::SystemTime::now()
1521            .duration_since(std::time::UNIX_EPOCH)
1522            .unwrap_or_default()
1523            .as_secs() as i64;
1524        exp - now < margin.as_secs() as i64
1525    }).unwrap_or(false)
1526}
1527
1528/// Internal refresh call for the auto-refresh loop (doesn't go through AuthClient).
1529async fn refresh_session_internal(
1530    inner: &AuthClientInner,
1531    refresh_token: &str,
1532) -> Result<Session, AuthError> {
1533    let body = json!({
1534        "refresh_token": refresh_token,
1535    });
1536
1537    let mut url = inner.base_url.clone();
1538    let current = url.path().to_string();
1539    url.set_path(&format!("{}/token", current));
1540    url.set_query(Some("grant_type=refresh_token"));
1541
1542    let resp = inner.http.post(url).json(&body).send().await?;
1543    let status = resp.status().as_u16();
1544    if status >= 400 {
1545        return Err(AuthError::Api {
1546            status,
1547            message: format!("Token refresh failed (HTTP {})", status),
1548            error_code: None,
1549        });
1550    }
1551
1552    let session: Session = resp.json().await?;
1553    Ok(session)
1554}
1555
1556/// Parse AMR (Authentication Methods Reference) claims from a JWT access token.
1557///
1558/// The JWT payload contains an `amr` array of `{ method, timestamp }` objects.
1559/// This is a best-effort parse — returns empty vec on any failure.
1560fn parse_amr_from_jwt(token: &str) -> Vec<AmrEntry> {
1561    let parts: Vec<&str> = token.split('.').collect();
1562    if parts.len() != 3 {
1563        return Vec::new();
1564    }
1565
1566    let payload_b64 = parts[1];
1567    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1568        .decode(payload_b64)
1569        .ok();
1570    let decoded = match decoded {
1571        Some(d) => d,
1572        None => return Vec::new(),
1573    };
1574
1575    let payload: JsonValue = match serde_json::from_slice(&decoded) {
1576        Ok(v) => v,
1577        Err(_) => return Vec::new(),
1578    };
1579
1580    match payload.get("amr") {
1581        Some(amr_val) => serde_json::from_value::<Vec<AmrEntry>>(amr_val.clone()).unwrap_or_default(),
1582        None => Vec::new(),
1583    }
1584}
1585
1586#[cfg(test)]
1587mod tests {
1588    use super::*;
1589
1590    #[test]
1591    fn test_oauth_url_google() {
1592        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1593        let url = client
1594            .get_oauth_sign_in_url(OAuthProvider::Google, None, None)
1595            .unwrap();
1596        assert!(url.contains("/auth/v1/authorize"));
1597        assert!(url.contains("provider=google"));
1598    }
1599
1600    #[test]
1601    fn test_oauth_url_with_redirect_and_scopes() {
1602        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1603        let url = client
1604            .get_oauth_sign_in_url(
1605                OAuthProvider::GitHub,
1606                Some("https://myapp.com/callback"),
1607                Some("read:user"),
1608            )
1609            .unwrap();
1610        assert!(url.contains("provider=github"));
1611        assert!(url.contains("redirect_to="));
1612        assert!(url.contains("scopes="));
1613    }
1614
1615    #[test]
1616    fn test_oauth_url_custom_provider() {
1617        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1618        let url = client
1619            .get_oauth_sign_in_url(OAuthProvider::Custom("myidp".into()), None, None)
1620            .unwrap();
1621        assert!(url.contains("provider=myidp"));
1622    }
1623
1624    #[test]
1625    fn test_url_building() {
1626        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1627        let url = client.url("/signup");
1628        assert_eq!(url.path(), "/auth/v1/signup");
1629        assert!(url.query().is_none());
1630
1631        let url = client.url("/token?grant_type=password");
1632        assert_eq!(url.path(), "/auth/v1/token");
1633        assert_eq!(url.query(), Some("grant_type=password"));
1634    }
1635
1636    #[test]
1637    fn test_url_building_trailing_slash() {
1638        let client = AuthClient::new("https://example.supabase.co/", "test-key").unwrap();
1639        let url = client.url("/signup");
1640        assert_eq!(url.path(), "/auth/v1/signup");
1641    }
1642
1643    #[test]
1644    fn test_base_url() {
1645        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1646        assert_eq!(client.base_url().path(), "/auth/v1");
1647    }
1648
1649    // ─── PKCE Tests ──────────────────────────────────────────
1650
1651    #[test]
1652    fn test_generate_pkce_pair() {
1653        let pair = AuthClient::generate_pkce_pair();
1654        // Verifier should be 43 chars (32 bytes base64url no padding)
1655        assert_eq!(pair.verifier.as_str().len(), 43);
1656        // Challenge should be 43 chars (32 bytes SHA256 → base64url no padding)
1657        assert_eq!(pair.challenge.as_str().len(), 43);
1658        // They should be different
1659        assert_ne!(pair.verifier.as_str(), pair.challenge.as_str());
1660    }
1661
1662    #[test]
1663    fn test_pkce_pair_is_deterministic_for_same_verifier() {
1664        // Verify the S256 challenge is correctly computed
1665        use sha2::{Digest, Sha256};
1666        let pair = AuthClient::generate_pkce_pair();
1667        let hash = Sha256::digest(pair.verifier.as_str().as_bytes());
1668        let expected_challenge =
1669            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash);
1670        assert_eq!(pair.challenge.as_str(), expected_challenge);
1671    }
1672
1673    #[test]
1674    fn test_pkce_pairs_are_unique() {
1675        let pair1 = AuthClient::generate_pkce_pair();
1676        let pair2 = AuthClient::generate_pkce_pair();
1677        assert_ne!(pair1.verifier.as_str(), pair2.verifier.as_str());
1678        assert_ne!(pair1.challenge.as_str(), pair2.challenge.as_str());
1679    }
1680
1681    // ─── OAuth Authorize URL Tests ───────────────────────────
1682
1683    #[test]
1684    fn test_build_oauth_authorize_url_basic() {
1685        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1686        let params = OAuthAuthorizeUrlParams::new("client-abc", "https://app.com/callback");
1687        let url = client.build_oauth_authorize_url(&params);
1688        assert!(url.contains("/auth/v1/oauth/authorize"));
1689        assert!(url.contains("client_id=client-abc"));
1690        assert!(url.contains("redirect_uri="));
1691        assert!(url.contains("response_type=code"));
1692    }
1693
1694    #[test]
1695    fn test_build_oauth_authorize_url_with_scope_and_state() {
1696        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1697        let params = OAuthAuthorizeUrlParams::new("client-abc", "https://app.com/callback")
1698            .scope("openid profile")
1699            .state("csrf-token");
1700        let url = client.build_oauth_authorize_url(&params);
1701        assert!(url.contains("scope=openid+profile"));
1702        assert!(url.contains("state=csrf-token"));
1703    }
1704
1705    #[test]
1706    fn test_build_oauth_authorize_url_with_pkce() {
1707        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1708        let pkce = AuthClient::generate_pkce_pair();
1709        let params = OAuthAuthorizeUrlParams::new("client-abc", "https://app.com/callback")
1710            .pkce(&pkce.challenge);
1711        let url = client.build_oauth_authorize_url(&params);
1712        assert!(url.contains(&format!("code_challenge={}", pkce.challenge.as_str())));
1713        assert!(url.contains("code_challenge_method=S256"));
1714    }
1715
1716    // ─── Session State Tests ────────────────────────────────
1717
1718    #[tokio::test]
1719    async fn test_new_client_has_no_session() {
1720        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1721        assert!(client.get_session().await.is_none());
1722    }
1723
1724    #[tokio::test]
1725    async fn test_set_session_stores() {
1726        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1727        let session = make_test_session();
1728        client.set_session(session.clone()).await;
1729        let stored = client.get_session().await.unwrap();
1730        assert_eq!(stored.access_token, session.access_token);
1731    }
1732
1733    #[tokio::test]
1734    async fn test_clear_session() {
1735        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1736        client.set_session(make_test_session()).await;
1737        assert!(client.get_session().await.is_some());
1738        client.clear_session().await;
1739        assert!(client.get_session().await.is_none());
1740    }
1741
1742    #[tokio::test]
1743    async fn test_event_emitted_on_set_session() {
1744        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1745        let mut sub = client.on_auth_state_change();
1746        client.set_session(make_test_session()).await;
1747        let event = tokio::time::timeout(
1748            std::time::Duration::from_millis(100),
1749            sub.next(),
1750        ).await.unwrap().unwrap();
1751        assert_eq!(event.event, AuthChangeEvent::SignedIn);
1752        assert!(event.session.is_some());
1753    }
1754
1755    #[tokio::test]
1756    async fn test_event_emitted_on_clear_session() {
1757        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1758        let mut sub = client.on_auth_state_change();
1759        client.clear_session().await;
1760        let event = tokio::time::timeout(
1761            std::time::Duration::from_millis(100),
1762            sub.next(),
1763        ).await.unwrap().unwrap();
1764        assert_eq!(event.event, AuthChangeEvent::SignedOut);
1765        assert!(event.session.is_none());
1766    }
1767
1768    #[tokio::test]
1769    async fn test_multiple_subscribers() {
1770        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1771        let mut sub1 = client.on_auth_state_change();
1772        let mut sub2 = client.on_auth_state_change();
1773        client.set_session(make_test_session()).await;
1774
1775        let timeout = std::time::Duration::from_millis(100);
1776        let e1 = tokio::time::timeout(timeout, sub1.next()).await.unwrap().unwrap();
1777        let e2 = tokio::time::timeout(timeout, sub2.next()).await.unwrap().unwrap();
1778        assert_eq!(e1.event, AuthChangeEvent::SignedIn);
1779        assert_eq!(e2.event, AuthChangeEvent::SignedIn);
1780    }
1781
1782    #[tokio::test]
1783    async fn test_no_session_error() {
1784        let client = AuthClient::new("https://example.supabase.co", "test-key").unwrap();
1785        let err = client.get_session_user().await.unwrap_err();
1786        assert!(matches!(err, AuthError::NoSession));
1787    }
1788
1789    #[tokio::test]
1790    async fn test_should_refresh_logic() {
1791        let margin = std::time::Duration::from_secs(60);
1792
1793        // Session with expires_at in the past → should refresh
1794        let mut session = make_test_session();
1795        session.expires_at = Some(0);
1796        assert!(should_refresh(&session, &margin));
1797
1798        // Session with no expires_at → should not refresh
1799        session.expires_at = None;
1800        assert!(!should_refresh(&session, &margin));
1801
1802        // Session expiring far in the future → should not refresh
1803        let future = std::time::SystemTime::now()
1804            .duration_since(std::time::UNIX_EPOCH)
1805            .unwrap()
1806            .as_secs() as i64 + 3600;
1807        session.expires_at = Some(future);
1808        assert!(!should_refresh(&session, &margin));
1809
1810        // Session expiring within margin → should refresh
1811        let soon = std::time::SystemTime::now()
1812            .duration_since(std::time::UNIX_EPOCH)
1813            .unwrap()
1814            .as_secs() as i64 + 30;
1815        session.expires_at = Some(soon);
1816        assert!(should_refresh(&session, &margin));
1817    }
1818
1819    /// Helper to create a test session for unit tests.
1820    fn make_test_session() -> Session {
1821        Session {
1822            access_token: "test-access-token".to_string(),
1823            refresh_token: "test-refresh-token".to_string(),
1824            expires_in: 3600,
1825            expires_at: Some(
1826                std::time::SystemTime::now()
1827                    .duration_since(std::time::UNIX_EPOCH)
1828                    .unwrap()
1829                    .as_secs() as i64
1830                    + 3600,
1831            ),
1832            token_type: "bearer".to_string(),
1833            user: User {
1834                id: "test-user-id".to_string(),
1835                aud: Some("authenticated".to_string()),
1836                role: Some("authenticated".to_string()),
1837                email: Some("test@example.com".to_string()),
1838                phone: None,
1839                email_confirmed_at: None,
1840                phone_confirmed_at: None,
1841                confirmation_sent_at: None,
1842                recovery_sent_at: None,
1843                last_sign_in_at: None,
1844                created_at: None,
1845                updated_at: None,
1846                user_metadata: None,
1847                app_metadata: None,
1848                identities: None,
1849                factors: None,
1850                is_anonymous: None,
1851            },
1852        }
1853    }
1854
1855    // ─── get_claims Tests ───────────────────────────────────
1856
1857    #[test]
1858    fn test_get_claims_valid_jwt() {
1859        // Create a minimal JWT: header.payload.signature
1860        // payload = {"sub":"user-123","email":"test@example.com","role":"authenticated"}
1861        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
1862            r#"{"sub":"user-123","email":"test@example.com","role":"authenticated"}"#
1863        );
1864        let token = format!("eyJhbGciOiJIUzI1NiJ9.{}.fake-signature", payload);
1865        let claims = AuthClient::get_claims(&token).unwrap();
1866        assert_eq!(claims["sub"], "user-123");
1867        assert_eq!(claims["email"], "test@example.com");
1868        assert_eq!(claims["role"], "authenticated");
1869    }
1870
1871    #[test]
1872    fn test_get_claims_invalid_format() {
1873        let err = AuthClient::get_claims("not-a-jwt").unwrap_err();
1874        assert!(matches!(err, AuthError::InvalidToken(_)));
1875    }
1876
1877    #[test]
1878    fn test_get_claims_invalid_base64() {
1879        let err = AuthClient::get_claims("a.!!!invalid!!!.c").unwrap_err();
1880        assert!(matches!(err, AuthError::InvalidToken(_)));
1881    }
1882
1883    #[test]
1884    fn test_get_claims_invalid_json() {
1885        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("not json");
1886        let token = format!("a.{}.c", payload);
1887        let err = AuthClient::get_claims(&token).unwrap_err();
1888        assert!(matches!(err, AuthError::InvalidToken(_)));
1889    }
1890
1891    // ─── Web3 Type Tests ────────────────────────────────────
1892
1893    #[test]
1894    fn test_web3_chain_serialization() {
1895        assert_eq!(serde_json::to_string(&Web3Chain::Ethereum).unwrap(), "\"ethereum\"");
1896        assert_eq!(serde_json::to_string(&Web3Chain::Solana).unwrap(), "\"solana\"");
1897    }
1898
1899    #[test]
1900    fn test_web3_chain_display() {
1901        assert_eq!(Web3Chain::Ethereum.to_string(), "ethereum");
1902        assert_eq!(Web3Chain::Solana.to_string(), "solana");
1903    }
1904
1905    #[test]
1906    fn test_web3_sign_in_params() {
1907        let params = Web3SignInParams::new(
1908            Web3Chain::Ethereum,
1909            "0x1234567890abcdef",
1910            "Sign this message",
1911            "0xsignature",
1912            "random-nonce",
1913        );
1914        assert_eq!(params.chain, Web3Chain::Ethereum);
1915        assert_eq!(params.address, "0x1234567890abcdef");
1916        let json = serde_json::to_value(&params).unwrap();
1917        assert_eq!(json["chain"], "ethereum");
1918        assert_eq!(json["address"], "0x1234567890abcdef");
1919    }
1920
1921    // ─── Captcha Body Structure Tests ───────────────────────
1922
1923    #[test]
1924    fn test_captcha_body_structure() {
1925        let mut body = json!({
1926            "email": "test@example.com",
1927            "password": "pass",
1928        });
1929        let token = "captcha-abc-123";
1930        body["gotrue_meta_security"] = json!({ "captcha_token": token });
1931
1932        assert_eq!(body["gotrue_meta_security"]["captcha_token"], "captcha-abc-123");
1933        assert_eq!(body["email"], "test@example.com");
1934    }
1935}