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