Skip to main content

allowthem_core/
social_providers.rs

1//! DB-backed social-login provider configuration and runtime surface.
2//!
3//! ## Relationship to `OAuthProvider`
4//!
5//! [`crate::oauth::OAuthProvider`] is the *static-config* trait used by the
6//! existing auth flow: provider instances are constructed once at startup
7//! from environment/builder config and stored in a
8//! `HashMap<String, Box<dyn OAuthProvider>>`. That path remains
9//! **unchanged** in this task and Epic 7m5.1.
10//!
11//! [`SocialProvider`] is the *DB-backed* future direction: per-tenant
12//! provider configuration is stored in `allowthem_social_providers` with an
13//! encrypted client secret. 7m5.2 will provide concrete impls
14//! (`Google`, `GitHub`, …) and migrate the runtime auth flow to use this
15//! trait. Until then the two traits coexist.
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20use crate::auth_client::AuthFuture;
21use crate::types::SocialProviderId;
22
23// ── ProviderType ─────────────────────────────────────────────────────────────
24
25/// Discriminator for a social-login provider.
26///
27/// Stored in SQLite as TEXT via `sqlx::Type`. The `lowercase` rename
28/// produces `google`, `github`, `apple`, `microsoft`; `CustomOidc` needs an
29/// explicit rename because `lowercase` would emit `customoidc`.
30///
31/// **Deviation from spec §10.3**: the spec uses `'oidc'`; bd uses
32/// `'custom_oidc'` — more explicit and avoids confusion with the OIDC
33/// *protocol* that all built-in providers also use.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
35#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
36pub enum ProviderType {
37    Google,
38    Github, // stored as "github" (not "git_hub")
39    Apple,
40    Microsoft,
41    #[sqlx(rename = "custom_oidc")]
42    #[serde(rename = "custom_oidc")]
43    CustomOidc,
44}
45
46// ── SocialUserInfo ────────────────────────────────────────────────────────────
47
48/// Normalised user identity returned by every [`SocialProvider`] impl.
49///
50/// Wider than [`crate::oauth::OAuthUserInfo`]: adds `avatar_url`.
51#[derive(Debug, Clone)]
52pub struct SocialUserInfo {
53    pub provider_user_id: String,
54    pub email: String,
55    pub email_verified: bool,
56    pub name: Option<String>,
57    pub avatar_url: Option<String>,
58}
59
60// ── SocialProviderRow ─────────────────────────────────────────────────────────
61
62/// Raw database row from `allowthem_social_providers`.
63///
64/// Secrets are still encrypted; `scopes` and `config` are raw JSON strings.
65/// Use [`crate::db::Db::social_provider_to_config`] to decrypt and parse.
66///
67/// **Column name note**: the column is `enabled` (not `is_enabled`); this
68/// deviates from spec §10.3 but matches bd and the project's convention.
69#[derive(Debug, Clone, sqlx::FromRow)]
70pub struct SocialProviderRow {
71    pub id: SocialProviderId,
72    pub provider_type: ProviderType,
73    pub display_name: String,
74    pub client_id: String,
75    pub client_secret_enc: Vec<u8>,
76    pub client_secret_nonce: Vec<u8>,
77    pub scopes: String, // raw JSON array string
78    pub enabled: bool,
79    pub priority: i64,
80    pub config: Option<String>, // raw JSON, custom_oidc only
81    pub created_at: DateTime<Utc>,
82    pub updated_at: DateTime<Utc>,
83}
84
85// ── SocialProviderConfig ──────────────────────────────────────────────────────
86
87/// Runtime form of a social provider — secret decrypted, scopes parsed.
88///
89/// Produced by [`crate::db::Db::social_provider_to_config`].
90/// This is the form 7m5.2 will use when constructing a
91/// `Box<dyn SocialProvider>` for an inbound OAuth callback.
92///
93/// `config` carries custom-OIDC endpoint URLs as a JSON object shaped like:
94/// ```json
95/// {
96///   "discovery_url": "https://issuer/.well-known/openid-configuration",
97///   "authorize_url": "https://issuer/oauth/authorize",
98///   "token_url": "https://issuer/oauth/token",
99///   "userinfo_url": "https://issuer/oauth/userinfo"
100/// }
101/// ```
102/// Built-in providers (`Google`, `Github`, `Apple`, `Microsoft`) leave
103/// `config` as `None`. Validation that the shape matches `provider_type` is
104/// the impl's responsibility (7m5.3 for `CustomOidc`).
105///
106/// **Key rotation note**: the `mfa_key` used to encrypt provider secrets is
107/// the same AES-256-GCM key as for MFA secrets (spec §11 lists per-tenant
108/// root-key derivation as a non-goal). Key rotation requires re-encrypting
109/// all rows.
110#[derive(Debug, Clone)]
111pub struct SocialProviderConfig {
112    pub id: SocialProviderId,
113    pub provider_type: ProviderType,
114    pub display_name: String,
115    pub client_id: String,
116    pub client_secret: String, // decrypted
117    pub scopes: Vec<String>,   // parsed from JSON
118    pub enabled: bool,
119    pub priority: i64,
120    pub config: Option<serde_json::Value>,
121}
122
123// ── SocialProvider trait ──────────────────────────────────────────────────────
124
125/// Runtime surface every DB-backed social-login provider must implement.
126///
127/// Implementations are constructed from a [`SocialProviderConfig`] and are
128/// stateless after construction — configuration is stored internally.
129///
130/// **Trait is `dyn`-safe**: no generics in method signatures, no `Self` in
131/// return types. Verified by the compile-time check in `#[cfg(test)]`.
132pub trait SocialProvider: Send + Sync {
133    /// Which provider type this instance represents.
134    fn provider_type(&self) -> ProviderType;
135
136    /// Build the provider's authorization redirect URL.
137    fn authorize_url(&self, redirect_uri: &str, state: &str, pkce_challenge: &str) -> String;
138
139    /// Exchange an authorization code for an access token.
140    ///
141    /// Returns the raw access token string. Network errors map to
142    /// `AuthError::OAuthExchange` (defined in 7m5.2).
143    fn exchange_code<'a>(
144        &'a self,
145        code: &'a str,
146        redirect_uri: &'a str,
147        pkce_verifier: &'a str,
148    ) -> AuthFuture<'a, String>;
149
150    /// Fetch the authenticated user's identity using the access token.
151    fn fetch_user_info<'a>(&'a self, access_token: &'a str) -> AuthFuture<'a, SocialUserInfo>;
152}
153
154// ── Factory ───────────────────────────────────────────────────────────────────
155
156use crate::error::AuthError;
157
158/// Construct a `Box<dyn SocialProvider>` from a decrypted [`SocialProviderConfig`].
159///
160/// Dispatch table:
161///
162/// | `provider_type` | impl                         |
163/// |-----------------|------------------------------|
164/// | `Google`        | [`crate::social_google::GoogleSocialProvider`] |
165/// | `Github`        | [`crate::social_github::GitHubSocialProvider`] |
166/// | `CustomOidc`    | [`crate::social_oidc::CustomOidcSocialProvider`] |
167/// | `Apple`/`Microsoft` | `Err` — deferred         |
168///
169/// **`async fn` rationale**: the `CustomOidc` branch needs to `await` an OIDC
170/// discovery fetch at construction time.
171pub async fn build_social_provider(
172    config: SocialProviderConfig,
173) -> Result<Box<dyn SocialProvider>, AuthError> {
174    match config.provider_type {
175        ProviderType::Google => Ok(Box::new(crate::social_google::GoogleSocialProvider::new(
176            config,
177        )?)),
178        ProviderType::Github => Ok(Box::new(crate::social_github::GitHubSocialProvider::new(
179            config,
180        )?)),
181        ProviderType::CustomOidc => Ok(Box::new(
182            crate::social_oidc::CustomOidcSocialProvider::new(config).await?,
183        )),
184        ProviderType::Apple | ProviderType::Microsoft => Err(AuthError::Validation(format!(
185            "provider type {:?} not yet supported",
186            config.provider_type
187        ))),
188    }
189}
190
191// ── Db CRUD ───────────────────────────────────────────────────────────────────
192
193use crate::db::Db;
194use crate::social_provider_encrypt::{decrypt_split, encrypt_split};
195
196/// Map a SQLite UNIQUE constraint violation on `(provider_type, display_name)`
197/// to `AuthError::Conflict`. Other errors pass through as `AuthError::Database`.
198fn map_unique_violation(err: sqlx::Error) -> AuthError {
199    if let sqlx::Error::Database(ref db_err) = err {
200        let msg = db_err.message();
201        if msg.contains("UNIQUE constraint failed") {
202            return AuthError::Conflict(
203                "a provider with that type and display name already exists".into(),
204            );
205        }
206    }
207    AuthError::Database(err)
208}
209
210impl Db {
211    /// Insert a new social provider. Encrypts `client_secret` before write.
212    ///
213    /// `scopes` is stored as a JSON array. `config` is serialised to JSON and
214    /// stored only for custom OIDC providers; pass `None` for built-ins.
215    #[allow(clippy::too_many_arguments)]
216    pub async fn create_social_provider(
217        &self,
218        provider_type: ProviderType,
219        display_name: &str,
220        client_id: &str,
221        client_secret: &str,
222        scopes: &[String],
223        config: Option<&serde_json::Value>,
224        priority: i64,
225        mfa_key: &[u8; 32],
226    ) -> Result<SocialProviderId, AuthError> {
227        let id = SocialProviderId::new();
228        let enc = encrypt_split(client_secret.as_bytes(), mfa_key)?;
229        let scopes_json = serde_json::to_string(scopes).expect("Vec<String> serializes to JSON");
230        let config_json =
231            config.map(|v| serde_json::to_string(v).expect("serde_json::Value serializes to JSON"));
232        sqlx::query(
233            "INSERT INTO allowthem_social_providers \
234             (id, provider_type, display_name, client_id, \
235              client_secret_enc, client_secret_nonce, scopes, priority, config) \
236             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
237        )
238        .bind(id)
239        .bind(provider_type)
240        .bind(display_name)
241        .bind(client_id)
242        .bind(enc.ciphertext)
243        .bind(enc.nonce.to_vec())
244        .bind(scopes_json)
245        .bind(priority)
246        .bind(config_json)
247        .execute(self.pool())
248        .await
249        .map_err(map_unique_violation)?;
250        Ok(id)
251    }
252
253    /// List all social providers ordered by `priority ASC, created_at ASC`.
254    pub async fn list_social_providers(&self) -> Result<Vec<SocialProviderRow>, AuthError> {
255        sqlx::query_as::<_, SocialProviderRow>(
256            "SELECT id, provider_type, display_name, client_id, \
257              client_secret_enc, client_secret_nonce, scopes, enabled, \
258              priority, config, created_at, updated_at \
259             FROM allowthem_social_providers \
260             ORDER BY priority ASC, created_at ASC",
261        )
262        .fetch_all(self.pool())
263        .await
264        .map_err(AuthError::Database)
265    }
266
267    /// List enabled social providers ordered by `priority ASC, created_at ASC`.
268    pub async fn list_enabled_social_providers(&self) -> Result<Vec<SocialProviderRow>, AuthError> {
269        sqlx::query_as::<_, SocialProviderRow>(
270            "SELECT id, provider_type, display_name, client_id, \
271              client_secret_enc, client_secret_nonce, scopes, enabled, \
272              priority, config, created_at, updated_at \
273             FROM allowthem_social_providers \
274             WHERE enabled = 1 \
275             ORDER BY priority ASC, created_at ASC",
276        )
277        .fetch_all(self.pool())
278        .await
279        .map_err(AuthError::Database)
280    }
281
282    /// Get a provider by ID. Returns `AuthError::NotFound` if not found.
283    pub async fn get_social_provider(
284        &self,
285        id: SocialProviderId,
286    ) -> Result<SocialProviderRow, AuthError> {
287        sqlx::query_as::<_, SocialProviderRow>(
288            "SELECT id, provider_type, display_name, client_id, \
289              client_secret_enc, client_secret_nonce, scopes, enabled, \
290              priority, config, created_at, updated_at \
291             FROM allowthem_social_providers WHERE id = ?",
292        )
293        .bind(id)
294        .fetch_optional(self.pool())
295        .await
296        .map_err(AuthError::Database)?
297        .ok_or(AuthError::NotFound)
298    }
299
300    /// Update editable fields. Pass `None` to leave a field unchanged.
301    ///
302    /// `client_secret = Some("...")` re-encrypts with a fresh nonce.
303    /// `config = Some(None)` sets the column to NULL.
304    /// A call with all-`None` params is a no-op (returns `Ok(())`).
305    ///
306    /// Follows the string-building + `Box::leak` dynamic-SQL pattern from
307    /// `users.rs::search_users`. SET clauses are collected as `&'static str`
308    /// literals and joined; bind values are applied in the same order via
309    /// chained `q = q.bind(...)` calls.
310    #[allow(clippy::too_many_arguments)]
311    pub async fn update_social_provider(
312        &self,
313        id: SocialProviderId,
314        display_name: Option<&str>,
315        client_id: Option<&str>,
316        client_secret: Option<&str>,
317        scopes: Option<&[String]>,
318        enabled: Option<bool>,
319        priority: Option<i64>,
320        config: Option<Option<&serde_json::Value>>,
321        mfa_key: &[u8; 32],
322    ) -> Result<(), AuthError> {
323        // Encrypt before touching the query builder so errors surface early.
324        let enc = match client_secret {
325            Some(s) => Some(encrypt_split(s.as_bytes(), mfa_key)?),
326            None => None,
327        };
328        let scopes_json =
329            scopes.map(|s| serde_json::to_string(s).expect("Vec<String> serializes to JSON"));
330        // `config: Option<Option<&Value>>`
331        //   None          → skip column
332        //   Some(None)    → set to NULL  (bind None::<String>)
333        //   Some(Some(v)) → set to JSON  (bind Some(json_string))
334        let config_json: Option<Option<String>> =
335            config.map(|c| c.map(|v| serde_json::to_string(v).expect("Value serializes to JSON")));
336
337        let mut set_clauses: Vec<&'static str> = Vec::new();
338        if display_name.is_some() {
339            set_clauses.push("display_name = ?");
340        }
341        if client_id.is_some() {
342            set_clauses.push("client_id = ?");
343        }
344        if enc.is_some() {
345            set_clauses.push("client_secret_enc = ?");
346            set_clauses.push("client_secret_nonce = ?");
347        }
348        if scopes_json.is_some() {
349            set_clauses.push("scopes = ?");
350        }
351        if enabled.is_some() {
352            set_clauses.push("enabled = ?");
353        }
354        if priority.is_some() {
355            set_clauses.push("priority = ?");
356        }
357        if config_json.is_some() {
358            set_clauses.push("config = ?");
359        }
360
361        if set_clauses.is_empty() {
362            return Ok(());
363        }
364
365        set_clauses.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')");
366
367        let sql: &'static str = Box::leak(
368            format!(
369                "UPDATE allowthem_social_providers SET {} WHERE id = ?",
370                set_clauses.join(", ")
371            )
372            .into_boxed_str(),
373        );
374
375        // Bind values in the same order as the SET clauses above.
376        let mut q = sqlx::query(sql);
377        if let Some(v) = display_name {
378            q = q.bind(v);
379        }
380        if let Some(v) = client_id {
381            q = q.bind(v);
382        }
383        if let Some(ref e) = enc {
384            q = q.bind(e.ciphertext.clone());
385            q = q.bind(e.nonce.to_vec());
386        }
387        if let Some(v) = scopes_json {
388            q = q.bind(v);
389        }
390        if let Some(v) = enabled {
391            q = q.bind(v);
392        }
393        if let Some(v) = priority {
394            q = q.bind(v);
395        }
396        if let Some(v) = config_json {
397            q = q.bind(v);
398        }
399        q = q.bind(id);
400
401        q.execute(self.pool()).await.map_err(map_unique_violation)?;
402        Ok(())
403    }
404
405    /// Delete a provider by ID. Returns `true` if a row was deleted.
406    pub async fn delete_social_provider(&self, id: SocialProviderId) -> Result<bool, AuthError> {
407        let result = sqlx::query("DELETE FROM allowthem_social_providers WHERE id = ?")
408            .bind(id)
409            .execute(self.pool())
410            .await
411            .map_err(AuthError::Database)?;
412        Ok(result.rows_affected() > 0)
413    }
414
415    /// Decrypt a row's secret and parse its scopes/config into the runtime form.
416    ///
417    /// Not async — performs no DB I/O. Pure transformation. Placed on `Db` for
418    /// API consistency so callers write `db.social_provider_to_config(row, key)`
419    /// without importing a separate helper. `&self` is unused in the body.
420    pub fn social_provider_to_config(
421        &self,
422        row: SocialProviderRow,
423        mfa_key: &[u8; 32],
424    ) -> Result<SocialProviderConfig, AuthError> {
425        let secret_bytes =
426            decrypt_split(&row.client_secret_nonce, &row.client_secret_enc, mfa_key)?;
427        let client_secret =
428            String::from_utf8(secret_bytes).map_err(|e| AuthError::MfaEncryption(e.to_string()))?;
429        let scopes: Vec<String> =
430            serde_json::from_str(&row.scopes).map_err(|e| AuthError::Validation(e.to_string()))?;
431        let config: Option<serde_json::Value> = row
432            .config
433            .map(|s| serde_json::from_str(&s))
434            .transpose()
435            .map_err(|e| AuthError::Validation(e.to_string()))?;
436        Ok(SocialProviderConfig {
437            id: row.id,
438            provider_type: row.provider_type,
439            display_name: row.display_name,
440            client_id: row.client_id,
441            client_secret,
442            scopes,
443            enabled: row.enabled,
444            priority: row.priority,
445            config,
446        })
447    }
448}
449
450// ── AllowThem wrappers ────────────────────────────────────────────────────────
451
452use crate::handle::AllowThem;
453
454impl AllowThem {
455    /// Create a social provider. Reads `mfa_key` from the builder config.
456    #[allow(clippy::too_many_arguments)]
457    pub async fn create_social_provider(
458        &self,
459        provider_type: ProviderType,
460        display_name: &str,
461        client_id: &str,
462        client_secret: &str,
463        scopes: &[String],
464        config: Option<&serde_json::Value>,
465        priority: i64,
466    ) -> Result<SocialProviderId, AuthError> {
467        self.db()
468            .create_social_provider(
469                provider_type,
470                display_name,
471                client_id,
472                client_secret,
473                scopes,
474                config,
475                priority,
476                self.mfa_key()?,
477            )
478            .await
479    }
480
481    /// List all social providers.
482    pub async fn list_social_providers(&self) -> Result<Vec<SocialProviderRow>, AuthError> {
483        self.db().list_social_providers().await
484    }
485
486    /// List only enabled social providers.
487    pub async fn list_enabled_social_providers(&self) -> Result<Vec<SocialProviderRow>, AuthError> {
488        self.db().list_enabled_social_providers().await
489    }
490
491    /// Get a provider row by ID.
492    pub async fn get_social_provider(
493        &self,
494        id: SocialProviderId,
495    ) -> Result<SocialProviderRow, AuthError> {
496        self.db().get_social_provider(id).await
497    }
498
499    /// Fetch a provider row and decrypt its secret in one call.
500    ///
501    /// Used by the OAuth callback path (7m5.2) when building a
502    /// `Box<dyn SocialProvider>` for an inbound request.
503    pub async fn get_social_provider_decrypted(
504        &self,
505        id: SocialProviderId,
506    ) -> Result<SocialProviderConfig, AuthError> {
507        let row = self.db().get_social_provider(id).await?;
508        self.db().social_provider_to_config(row, self.mfa_key()?)
509    }
510
511    /// Update editable fields. See [`Db::update_social_provider`] for semantics.
512    #[allow(clippy::too_many_arguments)]
513    pub async fn update_social_provider(
514        &self,
515        id: SocialProviderId,
516        display_name: Option<&str>,
517        client_id: Option<&str>,
518        client_secret: Option<&str>,
519        scopes: Option<&[String]>,
520        enabled: Option<bool>,
521        priority: Option<i64>,
522        config: Option<Option<&serde_json::Value>>,
523    ) -> Result<(), AuthError> {
524        self.db()
525            .update_social_provider(
526                id,
527                display_name,
528                client_id,
529                client_secret,
530                scopes,
531                enabled,
532                priority,
533                config,
534                self.mfa_key()?,
535            )
536            .await
537    }
538
539    /// Delete a provider by ID.
540    pub async fn delete_social_provider(&self, id: SocialProviderId) -> Result<bool, AuthError> {
541        self.db().delete_social_provider(id).await
542    }
543}
544
545// ── Tests ─────────────────────────────────────────────────────────────────────
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::handle::{AllowThem, AllowThemBuilder};
551
552    const TEST_KEY: [u8; 32] = [0x42u8; 32];
553    const OTHER_KEY: [u8; 32] = [0x99u8; 32];
554
555    async fn setup() -> AllowThem {
556        AllowThemBuilder::new("sqlite::memory:")
557            .cookie_secure(false)
558            .mfa_key(TEST_KEY)
559            .build()
560            .await
561            .unwrap()
562    }
563
564    async fn setup_no_key() -> AllowThem {
565        AllowThemBuilder::new("sqlite::memory:")
566            .cookie_secure(false)
567            .build()
568            .await
569            .unwrap()
570    }
571
572    // ── Dyn-safety ───────────────────────────────────────────────────────────
573
574    // Compile-time proof that SocialProvider is dyn-compatible.
575    fn _assert_object_safe(_: &dyn SocialProvider) {}
576
577    // ── Stub impl for trait shape tests ─────────────────────────────────────
578
579    struct StubProvider {
580        ptype: ProviderType,
581    }
582
583    impl SocialProvider for StubProvider {
584        fn provider_type(&self) -> ProviderType {
585            self.ptype
586        }
587        fn authorize_url(&self, _r: &str, _s: &str, _p: &str) -> String {
588            "https://example.com/authorize".into()
589        }
590        fn exchange_code<'a>(
591            &'a self,
592            _code: &'a str,
593            _redirect_uri: &'a str,
594            _pkce_verifier: &'a str,
595        ) -> AuthFuture<'a, String> {
596            Box::pin(async move { Ok("stub-token".into()) })
597        }
598        fn fetch_user_info<'a>(&'a self, _access_token: &'a str) -> AuthFuture<'a, SocialUserInfo> {
599            Box::pin(async move {
600                Ok(SocialUserInfo {
601                    provider_user_id: "p1".into(),
602                    email: "stub@example.com".into(),
603                    email_verified: true,
604                    name: None,
605                    avatar_url: None,
606                })
607            })
608        }
609    }
610
611    #[tokio::test]
612    async fn stub_provider_dyn_dispatch() {
613        let p: Box<dyn SocialProvider> = Box::new(StubProvider {
614            ptype: ProviderType::Google,
615        });
616        assert_eq!(p.provider_type(), ProviderType::Google);
617        let token = p.exchange_code("c", "r", "v").await.unwrap();
618        assert_eq!(token, "stub-token");
619        let info = p.fetch_user_info(&token).await.unwrap();
620        assert_eq!(info.email, "stub@example.com");
621    }
622
623    // ── Helpers ──────────────────────────────────────────────────────────────
624
625    fn default_scopes() -> Vec<String> {
626        vec!["openid".into(), "email".into()]
627    }
628
629    async fn create_google(ath: &AllowThem, display_name: &str) -> SocialProviderId {
630        ath.create_social_provider(
631            ProviderType::Google,
632            display_name,
633            "client-id",
634            "client-secret",
635            &default_scopes(),
636            None,
637            0,
638        )
639        .await
640        .unwrap()
641    }
642
643    // ── CRUD tests ───────────────────────────────────────────────────────────
644
645    #[tokio::test]
646    async fn create_then_get_round_trip() {
647        let ath = setup().await;
648        let id = ath
649            .create_social_provider(
650                ProviderType::Google,
651                "Google Login",
652                "my-client-id",
653                "supersecret",
654                &default_scopes(),
655                None,
656                10,
657            )
658            .await
659            .unwrap();
660
661        let config = ath.get_social_provider_decrypted(id).await.unwrap();
662        assert_eq!(config.client_id, "my-client-id");
663        assert_eq!(config.client_secret, "supersecret");
664        assert_eq!(config.scopes, default_scopes());
665        assert_eq!(config.priority, 10);
666        assert!(config.enabled);
667        assert_eq!(config.provider_type, ProviderType::Google);
668        assert_eq!(config.display_name, "Google Login");
669        assert!(config.config.is_none());
670    }
671
672    #[tokio::test]
673    async fn list_orders_by_priority() {
674        let ath = setup().await;
675        let db = ath.db();
676        for (name, pri) in [("P5", 5i64), ("P1", 1), ("P3", 3)] {
677            db.create_social_provider(
678                ProviderType::Google,
679                name,
680                "cid",
681                "sec",
682                &default_scopes(),
683                None,
684                pri,
685                &TEST_KEY,
686            )
687            .await
688            .unwrap();
689        }
690        let rows = db.list_social_providers().await.unwrap();
691        let priorities: Vec<i64> = rows.iter().map(|r| r.priority).collect();
692        assert_eq!(priorities, vec![1, 3, 5]);
693    }
694
695    #[tokio::test]
696    async fn list_enabled_filters_disabled() {
697        let ath = setup().await;
698        let db = ath.db();
699        let enabled_id = db
700            .create_social_provider(
701                ProviderType::Google,
702                "Enabled",
703                "cid",
704                "sec",
705                &default_scopes(),
706                None,
707                0,
708                &TEST_KEY,
709            )
710            .await
711            .unwrap();
712        let disabled_id = db
713            .create_social_provider(
714                ProviderType::Github,
715                "Disabled",
716                "cid2",
717                "sec2",
718                &default_scopes(),
719                None,
720                0,
721                &TEST_KEY,
722            )
723            .await
724            .unwrap();
725        // Disable the second one
726        db.update_social_provider(
727            disabled_id,
728            None,
729            None,
730            None,
731            None,
732            Some(false),
733            None,
734            None,
735            &TEST_KEY,
736        )
737        .await
738        .unwrap();
739
740        let enabled = db.list_enabled_social_providers().await.unwrap();
741        assert_eq!(enabled.len(), 1);
742        assert_eq!(enabled[0].id, enabled_id);
743    }
744
745    #[tokio::test]
746    async fn update_partial_display_name() {
747        let ath = setup().await;
748        let id = create_google(&ath, "Old Name").await;
749        ath.update_social_provider(id, Some("New Name"), None, None, None, None, None, None)
750            .await
751            .unwrap();
752        let row = ath.db().get_social_provider(id).await.unwrap();
753        assert_eq!(row.display_name, "New Name");
754        assert_eq!(row.client_id, "client-id");
755    }
756
757    #[tokio::test]
758    async fn update_secret_re_encrypts_with_fresh_nonce() {
759        let ath = setup().await;
760        let id = create_google(&ath, "G").await;
761        let before = ath.db().get_social_provider(id).await.unwrap();
762
763        ath.update_social_provider(id, None, None, Some("new-secret"), None, None, None, None)
764            .await
765            .unwrap();
766
767        let after = ath.db().get_social_provider(id).await.unwrap();
768        assert_ne!(
769            before.client_secret_nonce, after.client_secret_nonce,
770            "nonce must change on secret rotation"
771        );
772        let config = ath.get_social_provider_decrypted(id).await.unwrap();
773        assert_eq!(config.client_secret, "new-secret");
774    }
775
776    #[tokio::test]
777    async fn delete_returns_true_on_hit_false_on_miss() {
778        let ath = setup().await;
779        let id = create_google(&ath, "G").await;
780        assert!(ath.delete_social_provider(id).await.unwrap());
781        assert!(!ath.delete_social_provider(id).await.unwrap());
782    }
783
784    #[tokio::test]
785    async fn unique_index_blocks_duplicate_type_displayname() {
786        let ath = setup().await;
787        create_google(&ath, "Google").await;
788        let err = ath
789            .create_social_provider(
790                ProviderType::Google,
791                "Google",
792                "other-cid",
793                "sec",
794                &default_scopes(),
795                None,
796                0,
797            )
798            .await
799            .unwrap_err();
800        assert!(matches!(err, AuthError::Conflict(_)));
801    }
802
803    #[tokio::test]
804    async fn decrypt_with_wrong_mfa_key_fails() {
805        let ath = setup().await;
806        let id = create_google(&ath, "G").await;
807        let row = ath.db().get_social_provider(id).await.unwrap();
808        let err = ath
809            .db()
810            .social_provider_to_config(row, &OTHER_KEY)
811            .unwrap_err();
812        assert!(matches!(err, AuthError::MfaEncryption(_)));
813    }
814
815    #[tokio::test]
816    async fn create_without_mfa_key_returns_mfa_not_configured() {
817        let ath = setup_no_key().await;
818        let err = ath
819            .create_social_provider(
820                ProviderType::Google,
821                "G",
822                "cid",
823                "sec",
824                &default_scopes(),
825                None,
826                0,
827            )
828            .await
829            .unwrap_err();
830        assert!(matches!(err, AuthError::MfaNotConfigured));
831    }
832
833    #[tokio::test]
834    async fn provider_type_all_variants_round_trip_through_sqlite() {
835        let ath = setup().await;
836        let db = ath.db();
837        let variants = [
838            (ProviderType::Google, "google-rt"),
839            (ProviderType::Github, "github-rt"),
840            (ProviderType::Apple, "apple-rt"),
841            (ProviderType::Microsoft, "microsoft-rt"),
842            (ProviderType::CustomOidc, "customoidc-rt"),
843        ];
844        for (ptype, name) in variants {
845            let id = db
846                .create_social_provider(
847                    ptype,
848                    name,
849                    "cid",
850                    "sec",
851                    &default_scopes(),
852                    None,
853                    0,
854                    &TEST_KEY,
855                )
856                .await
857                .unwrap();
858            let row = db.get_social_provider(id).await.unwrap();
859            assert_eq!(row.provider_type, ptype, "round-trip for {ptype:?}");
860        }
861    }
862
863    #[tokio::test]
864    async fn scopes_and_config_are_valid_json_as_stored() {
865        let ath = setup().await;
866        let db = ath.db();
867        let cfg_val =
868            serde_json::json!({"discovery_url": "https://issuer/.well-known/openid-configuration"});
869        let id = db
870            .create_social_provider(
871                ProviderType::CustomOidc,
872                "OIDC",
873                "cid",
874                "sec",
875                &default_scopes(),
876                Some(&cfg_val),
877                0,
878                &TEST_KEY,
879            )
880            .await
881            .unwrap();
882        let row = db.get_social_provider(id).await.unwrap();
883        // scopes is valid JSON
884        let parsed_scopes: Vec<String> =
885            serde_json::from_str(&row.scopes).expect("scopes column must be valid JSON array");
886        assert_eq!(parsed_scopes, default_scopes());
887        // config is valid JSON
888        let raw_config = row.config.expect("config must be Some");
889        let _: serde_json::Value =
890            serde_json::from_str(&raw_config).expect("config column must be valid JSON");
891    }
892
893    #[tokio::test]
894    async fn check_constraint_blocks_invalid_provider_type() {
895        let ath = setup().await;
896        let db = ath.db();
897        let err = sqlx::query(
898            "INSERT INTO allowthem_social_providers \
899             (id, provider_type, display_name, client_id, \
900              client_secret_enc, client_secret_nonce, scopes) \
901             VALUES (?, 'badprovider', 'x', 'y', X'00', X'00', '[]')",
902        )
903        .bind(SocialProviderId::new())
904        .execute(db.pool())
905        .await
906        .unwrap_err();
907        assert!(
908            matches!(err, sqlx::Error::Database(_)),
909            "CHECK constraint must fire for invalid provider_type"
910        );
911    }
912
913    #[tokio::test]
914    async fn check_constraint_blocks_invalid_enabled_value() {
915        let ath = setup().await;
916        let db = ath.db();
917        let err = sqlx::query(
918            "INSERT INTO allowthem_social_providers \
919             (id, provider_type, display_name, client_id, \
920              client_secret_enc, client_secret_nonce, scopes, enabled) \
921             VALUES (?, 'google', 'x', 'y', X'00', X'00', '[]', 2)",
922        )
923        .bind(SocialProviderId::new())
924        .execute(db.pool())
925        .await
926        .unwrap_err();
927        assert!(
928            matches!(err, sqlx::Error::Database(_)),
929            "CHECK constraint must fire for enabled = 2"
930        );
931    }
932
933    #[tokio::test]
934    async fn encryption_round_trip_via_db_boundary() {
935        // Verifies that the bytes written by create_social_provider are the
936        // correct wire format for decrypt_split — not just opaque via to_config.
937        use crate::social_provider_encrypt::decrypt_split;
938        let ath = setup().await;
939        let id = create_google(&ath, "Wire").await;
940        let row = ath.db().get_social_provider(id).await.unwrap();
941        let bytes =
942            decrypt_split(&row.client_secret_nonce, &row.client_secret_enc, &TEST_KEY).unwrap();
943        assert_eq!(bytes, b"client-secret");
944    }
945
946    // ── build_social_provider factory ─────────────────────────────────────────
947
948    fn make_config(provider_type: ProviderType, scopes: Vec<String>) -> SocialProviderConfig {
949        use crate::types::SocialProviderId;
950        SocialProviderConfig {
951            id: SocialProviderId::new(),
952            provider_type,
953            display_name: "Test".into(),
954            client_id: "cid".into(),
955            client_secret: "sec".into(),
956            scopes,
957            enabled: true,
958            priority: 0,
959            config: None,
960        }
961    }
962
963    #[tokio::test]
964    async fn build_social_provider_dispatches_to_google() {
965        let cfg = make_config(ProviderType::Google, vec!["openid".into()]);
966        let provider = build_social_provider(cfg).await.unwrap();
967        assert_eq!(provider.provider_type(), ProviderType::Google);
968    }
969
970    #[tokio::test]
971    async fn build_social_provider_dispatches_to_github() {
972        let cfg = make_config(ProviderType::Github, vec!["user:email".into()]);
973        let provider = build_social_provider(cfg).await.unwrap();
974        assert_eq!(provider.provider_type(), ProviderType::Github);
975    }
976
977    #[tokio::test]
978    async fn build_social_provider_rejects_custom_oidc_with_missing_config() {
979        // config: None → CustomOidcSocialProvider::new returns Validation("requires a config object")
980        let cfg = make_config(ProviderType::CustomOidc, vec!["openid".into()]);
981        let err = match build_social_provider(cfg).await {
982            Err(e) => e,
983            Ok(_) => panic!("expected Err, got Ok"),
984        };
985        assert!(
986            matches!(err, AuthError::Validation(ref m) if m.contains("config")),
987            "got: {err:?}"
988        );
989    }
990
991    #[tokio::test]
992    async fn build_social_provider_dispatches_to_custom_oidc() {
993        use wiremock::matchers::{method, path};
994        use wiremock::{Mock, MockServer, ResponseTemplate};
995
996        let server = MockServer::start().await;
997        let base = server.uri();
998        Mock::given(method("GET"))
999            .and(path("/.well-known/openid-configuration"))
1000            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1001                "authorization_endpoint": format!("{base}/authorize"),
1002                "token_endpoint": format!("{base}/token"),
1003                "userinfo_endpoint": format!("{base}/userinfo"),
1004            })))
1005            .mount(&server)
1006            .await;
1007
1008        use crate::types::SocialProviderId;
1009        let cfg = SocialProviderConfig {
1010            id: SocialProviderId::new(),
1011            provider_type: ProviderType::CustomOidc,
1012            display_name: "Test OIDC".into(),
1013            client_id: "cid".into(),
1014            client_secret: "sec".into(),
1015            scopes: vec!["openid".into(), "email".into()],
1016            enabled: true,
1017            priority: 0,
1018            config: Some(serde_json::json!({
1019                "discovery_url": format!("{base}/.well-known/openid-configuration")
1020            })),
1021        };
1022        let provider = build_social_provider(cfg).await.unwrap();
1023        assert_eq!(provider.provider_type(), ProviderType::CustomOidc);
1024    }
1025
1026    #[tokio::test]
1027    async fn build_social_provider_rejects_apple_with_validation_error() {
1028        let cfg = make_config(ProviderType::Apple, vec!["openid".into()]);
1029        let err = match build_social_provider(cfg).await {
1030            Err(e) => e,
1031            Ok(_) => panic!("expected Err, got Ok"),
1032        };
1033        assert!(matches!(err, AuthError::Validation(_)));
1034    }
1035
1036    #[tokio::test]
1037    async fn build_social_provider_rejects_microsoft_with_validation_error() {
1038        let cfg = make_config(ProviderType::Microsoft, vec!["openid".into()]);
1039        let err = match build_social_provider(cfg).await {
1040            Err(e) => e,
1041            Ok(_) => panic!("expected Err, got Ok"),
1042        };
1043        assert!(matches!(err, AuthError::Validation(_)));
1044    }
1045
1046    #[tokio::test]
1047    async fn build_social_provider_propagates_constructor_error_from_inner() {
1048        // Google config with empty scopes — inner constructor returns Validation.
1049        let cfg = make_config(ProviderType::Google, vec![]);
1050        let err = match build_social_provider(cfg).await {
1051            Err(e) => e,
1052            Ok(_) => panic!("expected Err, got Ok"),
1053        };
1054        match err {
1055            AuthError::Validation(msg) => {
1056                assert!(msg.contains("scopes must not be empty"), "got: {msg}");
1057            }
1058            other => panic!("expected Validation, got {other:?}"),
1059        }
1060    }
1061}