Skip to main content

supabase_client_auth/
params.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value as JsonValue;
3
4use crate::types::{FactorType, OAuthClientGrantType, OAuthClientResponseType, OtpChannel, OtpType};
5
6/// Parameters for updating the current user.
7///
8/// Matches the `UserAttributes` parameter in Supabase JS `updateUser()`.
9#[derive(Debug, Default, Clone, Serialize, Deserialize)]
10pub struct UpdateUserParams {
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub email: Option<String>,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub phone: Option<String>,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub password: Option<String>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub data: Option<JsonValue>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub nonce: Option<String>,
21}
22
23/// Parameters for verifying an OTP token.
24///
25/// Matches the `VerifyOtpParams` union type in Supabase JS.
26#[derive(Debug, Clone, Serialize)]
27pub struct VerifyOtpParams {
28    pub token: String,
29    #[serde(rename = "type")]
30    pub otp_type: OtpType,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub email: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub phone: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub token_hash: Option<String>,
37}
38
39/// Parameters for admin user creation.
40///
41/// Matches `AdminUserAttributes` in Supabase JS `admin.createUser()`.
42#[derive(Debug, Default, Clone, Serialize, Deserialize)]
43pub struct AdminCreateUserParams {
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub email: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub phone: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub password: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub email_confirm: Option<bool>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub phone_confirm: Option<bool>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub user_metadata: Option<JsonValue>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub app_metadata: Option<JsonValue>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub ban_duration: Option<String>,
60}
61
62/// Parameters for admin user update.
63///
64/// Matches `AdminUserAttributes` in Supabase JS `admin.updateUserById()`.
65#[derive(Debug, Default, Clone, Serialize, Deserialize)]
66pub struct AdminUpdateUserParams {
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub email: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub phone: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub password: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub email_confirm: Option<bool>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub phone_confirm: Option<bool>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub user_metadata: Option<JsonValue>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub app_metadata: Option<JsonValue>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub ban_duration: Option<String>,
83}
84
85/// Parameters for generating a link (admin).
86#[derive(Debug, Clone, Serialize)]
87pub struct GenerateLinkParams {
88    #[serde(rename = "type")]
89    pub link_type: GenerateLinkType,
90    pub email: String,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub password: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub data: Option<JsonValue>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub redirect_to: Option<String>,
97}
98
99/// Types of links that can be generated.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum GenerateLinkType {
103    Signup,
104    Invite,
105    #[serde(rename = "magiclink")]
106    MagicLink,
107    Recovery,
108    #[serde(rename = "email_change_new")]
109    EmailChangeNew,
110    #[serde(rename = "email_change_current")]
111    EmailChangeCurrent,
112}
113
114// ─── MFA Params ───────────────────────────────────────────────
115
116/// Parameters for enrolling a new MFA factor.
117///
118/// Use `MfaEnrollParams::totp()` or `MfaEnrollParams::phone(number)` to create.
119#[derive(Debug, Clone, Serialize)]
120pub struct MfaEnrollParams {
121    pub factor_type: FactorType,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub friendly_name: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub issuer: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub phone: Option<String>,
128}
129
130impl MfaEnrollParams {
131    /// Create params for enrolling a TOTP factor.
132    pub fn totp() -> Self {
133        Self {
134            factor_type: FactorType::Totp,
135            friendly_name: None,
136            issuer: None,
137            phone: None,
138        }
139    }
140
141    /// Create params for enrolling a phone factor.
142    pub fn phone(number: &str) -> Self {
143        Self {
144            factor_type: FactorType::Phone,
145            friendly_name: None,
146            issuer: None,
147            phone: Some(number.to_string()),
148        }
149    }
150
151    /// Set a friendly name for the factor.
152    pub fn friendly_name(mut self, name: &str) -> Self {
153        self.friendly_name = Some(name.to_string());
154        self
155    }
156
157    /// Set the TOTP issuer (only for TOTP factors).
158    pub fn issuer(mut self, issuer: &str) -> Self {
159        self.issuer = Some(issuer.to_string());
160        self
161    }
162}
163
164/// Parameters for verifying an MFA challenge.
165#[derive(Debug, Clone, Serialize)]
166pub struct MfaVerifyParams {
167    pub challenge_id: String,
168    pub code: String,
169}
170
171impl MfaVerifyParams {
172    /// Create new verify params.
173    pub fn new(challenge_id: &str, code: &str) -> Self {
174        Self {
175            challenge_id: challenge_id.to_string(),
176            code: code.to_string(),
177        }
178    }
179}
180
181/// Parameters for creating an MFA challenge.
182#[derive(Debug, Clone, Default, Serialize)]
183pub struct MfaChallengeParams {
184    /// Channel for phone factors (sms or whatsapp). Ignored for TOTP.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub channel: Option<OtpChannel>,
187}
188
189// ─── SSO Params ───────────────────────────────────────────────
190
191/// Parameters for enterprise SAML SSO sign-in.
192///
193/// Use `SsoSignInParams::domain(d)` or `SsoSignInParams::provider_id(id)` to create.
194#[derive(Debug, Clone, Serialize)]
195pub struct SsoSignInParams {
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub domain: Option<String>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub provider_id: Option<String>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub redirect_to: Option<String>,
202    /// Always true for REST clients (prevents HTTP redirect).
203    pub skip_http_redirect: bool,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub code_challenge: Option<String>,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub code_challenge_method: Option<String>,
208}
209
210impl SsoSignInParams {
211    /// Create SSO sign-in params by domain.
212    pub fn domain(domain: &str) -> Self {
213        Self {
214            domain: Some(domain.to_string()),
215            provider_id: None,
216            redirect_to: None,
217            skip_http_redirect: true,
218            code_challenge: None,
219            code_challenge_method: None,
220        }
221    }
222
223    /// Create SSO sign-in params by provider ID.
224    pub fn provider_id(id: &str) -> Self {
225        Self {
226            domain: None,
227            provider_id: Some(id.to_string()),
228            redirect_to: None,
229            skip_http_redirect: true,
230            code_challenge: None,
231            code_challenge_method: None,
232        }
233    }
234
235    /// Set the redirect URL after SSO sign-in.
236    pub fn redirect_to(mut self, url: &str) -> Self {
237        self.redirect_to = Some(url.to_string());
238        self
239    }
240
241    /// Set the PKCE code challenge.
242    pub fn code_challenge(mut self, challenge: &str, method: &str) -> Self {
243        self.code_challenge = Some(challenge.to_string());
244        self.code_challenge_method = Some(method.to_string());
245        self
246    }
247}
248
249// ─── ID Token Params ──────────────────────────────────────────
250
251/// Parameters for signing in with an external OIDC ID token.
252///
253/// Used for native mobile auth (e.g., Google, Apple Sign-In).
254#[derive(Debug, Clone, Serialize)]
255pub struct SignInWithIdTokenParams {
256    pub provider: String,
257    #[serde(rename = "id_token")]
258    pub id_token: String,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub access_token: Option<String>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub nonce: Option<String>,
263}
264
265impl SignInWithIdTokenParams {
266    /// Create new params.
267    pub fn new(provider: &str, id_token: &str) -> Self {
268        Self {
269            provider: provider.to_string(),
270            id_token: id_token.to_string(),
271            access_token: None,
272            nonce: None,
273        }
274    }
275
276    /// Set the nonce for verification.
277    pub fn nonce(mut self, nonce: &str) -> Self {
278        self.nonce = Some(nonce.to_string());
279        self
280    }
281
282    /// Set the provider's access token.
283    pub fn access_token(mut self, token: &str) -> Self {
284        self.access_token = Some(token.to_string());
285        self
286    }
287}
288
289// ─── Resend Params ────────────────────────────────────────────
290
291/// Type of OTP/confirmation to resend.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum ResendType {
295    Signup,
296    EmailChange,
297    Sms,
298    PhoneChange,
299}
300
301impl std::fmt::Display for ResendType {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        match self {
304            Self::Signup => write!(f, "signup"),
305            Self::EmailChange => write!(f, "email_change"),
306            Self::Sms => write!(f, "sms"),
307            Self::PhoneChange => write!(f, "phone_change"),
308        }
309    }
310}
311
312/// Parameters for resending an OTP or confirmation.
313#[derive(Debug, Clone, Serialize)]
314pub struct ResendParams {
315    #[serde(rename = "type")]
316    pub resend_type: ResendType,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub email: Option<String>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub phone: Option<String>,
321}
322
323impl ResendParams {
324    /// Create resend params for email-based OTP types (Signup or EmailChange).
325    pub fn email(email: &str, resend_type: ResendType) -> Self {
326        Self {
327            resend_type,
328            email: Some(email.to_string()),
329            phone: None,
330        }
331    }
332
333    /// Create resend params for phone-based OTP types (Sms or PhoneChange).
334    pub fn phone(phone: &str, resend_type: ResendType) -> Self {
335        Self {
336            resend_type,
337            email: None,
338            phone: Some(phone.to_string()),
339        }
340    }
341}
342
343// ─── OAuth Client Params ─────────────────────────────────────
344
345/// Parameters for creating an OAuth client (admin).
346#[derive(Debug, Clone, Serialize)]
347pub struct CreateOAuthClientParams {
348    pub client_name: String,
349    pub redirect_uris: Vec<String>,
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub client_uri: Option<String>,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub grant_types: Option<Vec<OAuthClientGrantType>>,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub response_types: Option<Vec<OAuthClientResponseType>>,
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub scope: Option<String>,
358}
359
360impl CreateOAuthClientParams {
361    /// Create params with required fields.
362    pub fn new(client_name: &str, redirect_uris: Vec<String>) -> Self {
363        Self {
364            client_name: client_name.to_string(),
365            redirect_uris,
366            client_uri: None,
367            grant_types: None,
368            response_types: None,
369            scope: None,
370        }
371    }
372
373    /// Set the client URI.
374    pub fn client_uri(mut self, uri: &str) -> Self {
375        self.client_uri = Some(uri.to_string());
376        self
377    }
378
379    /// Set the grant types.
380    pub fn grant_types(mut self, types: Vec<OAuthClientGrantType>) -> Self {
381        self.grant_types = Some(types);
382        self
383    }
384
385    /// Set the response types.
386    pub fn response_types(mut self, types: Vec<OAuthClientResponseType>) -> Self {
387        self.response_types = Some(types);
388        self
389    }
390
391    /// Set the scope.
392    pub fn scope(mut self, scope: &str) -> Self {
393        self.scope = Some(scope.to_string());
394        self
395    }
396}
397
398/// Parameters for updating an OAuth client (admin).
399#[derive(Debug, Clone, Default, Serialize)]
400pub struct UpdateOAuthClientParams {
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub client_name: Option<String>,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub client_uri: Option<String>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub logo_uri: Option<String>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub redirect_uris: Option<Vec<String>>,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub grant_types: Option<Vec<OAuthClientGrantType>>,
411}
412
413impl UpdateOAuthClientParams {
414    /// Create empty update params.
415    pub fn new() -> Self {
416        Self::default()
417    }
418
419    /// Set the client name.
420    pub fn client_name(mut self, name: &str) -> Self {
421        self.client_name = Some(name.to_string());
422        self
423    }
424
425    /// Set the client URI.
426    pub fn client_uri(mut self, uri: &str) -> Self {
427        self.client_uri = Some(uri.to_string());
428        self
429    }
430
431    /// Set the logo URI.
432    pub fn logo_uri(mut self, uri: &str) -> Self {
433        self.logo_uri = Some(uri.to_string());
434        self
435    }
436
437    /// Set the redirect URIs.
438    pub fn redirect_uris(mut self, uris: Vec<String>) -> Self {
439        self.redirect_uris = Some(uris);
440        self
441    }
442
443    /// Set the grant types.
444    pub fn grant_types(mut self, types: Vec<OAuthClientGrantType>) -> Self {
445        self.grant_types = Some(types);
446        self
447    }
448}
449
450// ─── OAuth Client-Side Flow Params ───────────────────────────
451
452/// Parameters for building an OAuth authorization URL.
453#[derive(Debug, Clone)]
454pub struct OAuthAuthorizeUrlParams {
455    pub client_id: String,
456    pub redirect_uri: String,
457    pub scope: Option<String>,
458    pub state: Option<String>,
459    pub code_challenge: Option<String>,
460    pub code_challenge_method: Option<String>,
461}
462
463impl OAuthAuthorizeUrlParams {
464    /// Create params with required fields.
465    pub fn new(client_id: &str, redirect_uri: &str) -> Self {
466        Self {
467            client_id: client_id.to_string(),
468            redirect_uri: redirect_uri.to_string(),
469            scope: None,
470            state: None,
471            code_challenge: None,
472            code_challenge_method: None,
473        }
474    }
475
476    /// Set the scope.
477    pub fn scope(mut self, scope: &str) -> Self {
478        self.scope = Some(scope.to_string());
479        self
480    }
481
482    /// Set the state parameter (for CSRF protection).
483    pub fn state(mut self, state: &str) -> Self {
484        self.state = Some(state.to_string());
485        self
486    }
487
488    /// Set the PKCE code challenge from a `PkceCodeChallenge`.
489    pub fn pkce(mut self, challenge: &crate::types::PkceCodeChallenge) -> Self {
490        self.code_challenge = Some(challenge.as_str().to_string());
491        self.code_challenge_method = Some("S256".to_string());
492        self
493    }
494
495    /// Set the PKCE code challenge from a raw string (with method).
496    pub fn code_challenge(mut self, challenge: &str, method: &str) -> Self {
497        self.code_challenge = Some(challenge.to_string());
498        self.code_challenge_method = Some(method.to_string());
499        self
500    }
501}
502
503/// Parameters for exchanging an authorization code for tokens.
504#[derive(Debug, Clone)]
505pub struct OAuthTokenExchangeParams {
506    pub code: String,
507    pub redirect_uri: String,
508    pub client_id: String,
509    pub client_secret: Option<String>,
510    pub code_verifier: Option<String>,
511}
512
513impl OAuthTokenExchangeParams {
514    /// Create params with required fields.
515    pub fn new(code: &str, redirect_uri: &str, client_id: &str) -> Self {
516        Self {
517            code: code.to_string(),
518            redirect_uri: redirect_uri.to_string(),
519            client_id: client_id.to_string(),
520            client_secret: None,
521            code_verifier: None,
522        }
523    }
524
525    /// Set the client secret (for confidential clients).
526    pub fn client_secret(mut self, secret: &str) -> Self {
527        self.client_secret = Some(secret.to_string());
528        self
529    }
530
531    /// Set the PKCE code verifier.
532    pub fn code_verifier(mut self, verifier: &str) -> Self {
533        self.code_verifier = Some(verifier.to_string());
534        self
535    }
536
537    /// Set the PKCE code verifier from a `PkceCodeVerifier`.
538    pub fn pkce_verifier(mut self, verifier: &crate::types::PkceCodeVerifier) -> Self {
539        self.code_verifier = Some(verifier.as_str().to_string());
540        self
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn mfa_enroll_params_totp_builder() {
550        let params = MfaEnrollParams::totp()
551            .friendly_name("My Auth")
552            .issuer("MyApp");
553        assert_eq!(params.factor_type, FactorType::Totp);
554        assert_eq!(params.friendly_name.as_deref(), Some("My Auth"));
555        assert_eq!(params.issuer.as_deref(), Some("MyApp"));
556        assert!(params.phone.is_none());
557    }
558
559    #[test]
560    fn mfa_enroll_params_phone_builder() {
561        let params = MfaEnrollParams::phone("+1234567890")
562            .friendly_name("My Phone");
563        assert_eq!(params.factor_type, FactorType::Phone);
564        assert_eq!(params.phone.as_deref(), Some("+1234567890"));
565        assert_eq!(params.friendly_name.as_deref(), Some("My Phone"));
566    }
567
568    #[test]
569    fn mfa_enroll_params_totp_serialization() {
570        let params = MfaEnrollParams::totp().friendly_name("Test");
571        let json = serde_json::to_value(&params).unwrap();
572        assert_eq!(json["factor_type"], "totp");
573        assert_eq!(json["friendly_name"], "Test");
574        assert!(json.get("phone").is_none());
575        assert!(json.get("issuer").is_none());
576    }
577
578    #[test]
579    fn mfa_verify_params_new() {
580        let params = MfaVerifyParams::new("challenge-id", "123456");
581        assert_eq!(params.challenge_id, "challenge-id");
582        assert_eq!(params.code, "123456");
583    }
584
585    #[test]
586    fn sso_sign_in_params_domain_builder() {
587        let params = SsoSignInParams::domain("company.com")
588            .redirect_to("https://app.com/callback");
589        assert_eq!(params.domain.as_deref(), Some("company.com"));
590        assert!(params.provider_id.is_none());
591        assert_eq!(params.redirect_to.as_deref(), Some("https://app.com/callback"));
592        assert!(params.skip_http_redirect);
593    }
594
595    #[test]
596    fn sso_sign_in_params_provider_id_builder() {
597        let params = SsoSignInParams::provider_id("uuid-123");
598        assert!(params.domain.is_none());
599        assert_eq!(params.provider_id.as_deref(), Some("uuid-123"));
600        assert!(params.skip_http_redirect);
601    }
602
603    #[test]
604    fn sign_in_with_id_token_params_builder() {
605        let params = SignInWithIdTokenParams::new("google", "eyJ...")
606            .nonce("random-nonce")
607            .access_token("goog-access");
608        assert_eq!(params.provider, "google");
609        assert_eq!(params.id_token, "eyJ...");
610        assert_eq!(params.nonce.as_deref(), Some("random-nonce"));
611        assert_eq!(params.access_token.as_deref(), Some("goog-access"));
612    }
613
614    #[test]
615    fn sign_in_with_id_token_serialization() {
616        let params = SignInWithIdTokenParams::new("apple", "token123");
617        let json = serde_json::to_value(&params).unwrap();
618        assert_eq!(json["provider"], "apple");
619        assert_eq!(json["id_token"], "token123");
620        assert!(json.get("nonce").is_none());
621    }
622
623    #[test]
624    fn resend_params_email_builder() {
625        let params = ResendParams::email("user@example.com", ResendType::Signup);
626        assert_eq!(params.resend_type, ResendType::Signup);
627        assert_eq!(params.email.as_deref(), Some("user@example.com"));
628        assert!(params.phone.is_none());
629    }
630
631    #[test]
632    fn resend_params_phone_builder() {
633        let params = ResendParams::phone("+1234567890", ResendType::PhoneChange);
634        assert_eq!(params.resend_type, ResendType::PhoneChange);
635        assert_eq!(params.phone.as_deref(), Some("+1234567890"));
636        assert!(params.email.is_none());
637    }
638
639    #[test]
640    fn resend_type_display() {
641        assert_eq!(ResendType::Signup.to_string(), "signup");
642        assert_eq!(ResendType::EmailChange.to_string(), "email_change");
643        assert_eq!(ResendType::Sms.to_string(), "sms");
644        assert_eq!(ResendType::PhoneChange.to_string(), "phone_change");
645    }
646
647    #[test]
648    fn resend_params_serialization() {
649        let params = ResendParams::email("test@example.com", ResendType::Signup);
650        let json = serde_json::to_value(&params).unwrap();
651        assert_eq!(json["type"], "signup");
652        assert_eq!(json["email"], "test@example.com");
653        assert!(json.get("phone").is_none());
654    }
655
656    // ─── OAuth Params Tests ──────────────────────────────────
657
658    #[test]
659    fn create_oauth_client_params_builder() {
660        let params = CreateOAuthClientParams::new(
661            "My App",
662            vec!["https://myapp.com/callback".to_string()],
663        )
664        .client_uri("https://myapp.com")
665        .scope("openid profile");
666
667        assert_eq!(params.client_name, "My App");
668        assert_eq!(params.redirect_uris.len(), 1);
669        assert_eq!(params.client_uri.as_deref(), Some("https://myapp.com"));
670        assert_eq!(params.scope.as_deref(), Some("openid profile"));
671        assert!(params.grant_types.is_none());
672        assert!(params.response_types.is_none());
673    }
674
675    #[test]
676    fn create_oauth_client_params_serialization() {
677        let params = CreateOAuthClientParams::new(
678            "Test App",
679            vec!["https://test.com/cb".to_string()],
680        );
681        let json = serde_json::to_value(&params).unwrap();
682        assert_eq!(json["client_name"], "Test App");
683        assert_eq!(json["redirect_uris"][0], "https://test.com/cb");
684        // Optional fields should be absent
685        assert!(json.get("client_uri").is_none());
686        assert!(json.get("grant_types").is_none());
687        assert!(json.get("scope").is_none());
688    }
689
690    #[test]
691    fn update_oauth_client_params_builder() {
692        let params = UpdateOAuthClientParams::new()
693            .client_name("Updated App")
694            .logo_uri("https://app.com/logo.png")
695            .redirect_uris(vec!["https://app.com/new-cb".to_string()]);
696
697        assert_eq!(params.client_name.as_deref(), Some("Updated App"));
698        assert_eq!(params.logo_uri.as_deref(), Some("https://app.com/logo.png"));
699        assert!(params.redirect_uris.is_some());
700        assert!(params.client_uri.is_none());
701        assert!(params.grant_types.is_none());
702    }
703
704    #[test]
705    fn update_oauth_client_params_serialization() {
706        let params = UpdateOAuthClientParams::new()
707            .client_name("New Name");
708        let json = serde_json::to_value(&params).unwrap();
709        assert_eq!(json["client_name"], "New Name");
710        // Optional fields should be absent
711        assert!(json.get("client_uri").is_none());
712        assert!(json.get("logo_uri").is_none());
713        assert!(json.get("redirect_uris").is_none());
714        assert!(json.get("grant_types").is_none());
715    }
716
717    // ─── OAuth Client-Side Flow Params Tests ─────────────────
718
719    #[test]
720    fn oauth_authorize_url_params_builder() {
721        let params = OAuthAuthorizeUrlParams::new("client-123", "https://app.com/cb")
722            .scope("openid profile")
723            .state("random-state");
724        assert_eq!(params.client_id, "client-123");
725        assert_eq!(params.redirect_uri, "https://app.com/cb");
726        assert_eq!(params.scope.as_deref(), Some("openid profile"));
727        assert_eq!(params.state.as_deref(), Some("random-state"));
728        assert!(params.code_challenge.is_none());
729    }
730
731    #[test]
732    fn oauth_authorize_url_params_with_pkce() {
733        let params = OAuthAuthorizeUrlParams::new("client-123", "https://app.com/cb")
734            .code_challenge("challenge-abc", "S256");
735        assert_eq!(params.code_challenge.as_deref(), Some("challenge-abc"));
736        assert_eq!(params.code_challenge_method.as_deref(), Some("S256"));
737    }
738
739    #[test]
740    fn oauth_token_exchange_params_builder() {
741        let params = OAuthTokenExchangeParams::new("code-abc", "https://app.com/cb", "client-123")
742            .client_secret("secret-456")
743            .code_verifier("verifier-789");
744        assert_eq!(params.code, "code-abc");
745        assert_eq!(params.redirect_uri, "https://app.com/cb");
746        assert_eq!(params.client_id, "client-123");
747        assert_eq!(params.client_secret.as_deref(), Some("secret-456"));
748        assert_eq!(params.code_verifier.as_deref(), Some("verifier-789"));
749    }
750
751    #[test]
752    fn oauth_token_exchange_params_minimal() {
753        let params = OAuthTokenExchangeParams::new("code-abc", "https://app.com/cb", "client-123");
754        assert!(params.client_secret.is_none());
755        assert!(params.code_verifier.is_none());
756    }
757}