Skip to main content

fraiseql_auth/account_linking/
mod.rs

1//! Account linking — merge provider identities sharing the same verified email.
2//!
3//! When a user authenticates with two different `OAuth` providers (e.g. GitHub then Google)
4//! using the same email address, this module ensures they receive the **same `user_id`**
5//! rather than two separate user records.
6//!
7//! # How it works
8//!
9//! 1. After a successful `OAuth` token exchange, call [`AccountStore::link_or_create_user`] with
10//!    the email (and its verified flag), provider name, and provider-specific user ID.
11//! 2. The store resolves an identity key. Cross-provider linking happens **only** when the provider
12//!    supplies a non-empty, verified email; otherwise the identity is keyed on `(provider,
13//!    provider_id)` so that an absent or unverified email can never collapse two distinct provider
14//!    identities into one account (see [`AccountStore::link_or_create_user`]).
15//!    - **Existing account**: the new provider credential is linked to the existing account and the
16//!      existing `user_id` is returned.
17//!    - **New account**: a fresh `user_id` is generated, the account is stored, and the new
18//!      `user_id` is returned.
19//! 3. The caller creates or refreshes a session keyed by the returned `user_id`.
20
21use std::collections::HashSet;
22
23use async_trait::async_trait;
24use dashmap::DashMap;
25use serde::{Deserialize, Serialize};
26use uuid::Uuid;
27
28use crate::{
29    audit::logger::{AuditEventType, SecretType, get_audit_logger},
30    error::{AuthError, Result},
31};
32
33mod postgres;
34pub use postgres::{PostgresAccountStore, SCHEMA_SQL};
35
36// ─── Domain types ─────────────────────────────────────────────────────────────
37
38/// A single provider credential linked to an account.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct ProviderLink {
41    /// Provider name (e.g. `"github"`, `"google"`).
42    pub provider:    String,
43    /// Provider-specific user identifier (opaque string from the provider).
44    pub provider_id: String,
45}
46
47/// A FraiseQL user account, potentially linked to multiple `OAuth` providers.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct AccountRecord {
50    /// Internal FraiseQL user identifier (stable across providers).
51    pub user_id:   String,
52    /// Verified email address shared across all linked providers, when the account
53    /// is keyed on a verified email. `None` for accounts keyed on
54    /// `(provider, provider_id)` because the provider supplied no verified email.
55    pub email:     Option<String>,
56    /// All provider credentials linked to this account.
57    pub providers: Vec<ProviderLink>,
58}
59
60// ─── Trait ────────────────────────────────────────────────────────────────────
61
62/// Storage backend for account linking.
63///
64/// Implementations must be `Send + Sync` and handle concurrent access safely.
65///
66/// # Implementations
67///
68/// - [`InMemoryAccountStore`] — for single-node deployments and testing.
69// Reason: used as dyn Trait (Arc<dyn AccountStore>); async_trait ensures Send bounds and
70// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
71#[async_trait]
72pub trait AccountStore: Send + Sync {
73    /// Return the `user_id` for the given identity, creating or linking as needed.
74    ///
75    /// # Account-linking key (security-critical)
76    ///
77    /// Cross-provider account linking happens **only** when the provider supplies a
78    /// non-empty, *verified* email. Otherwise each `(provider, provider_id)` pair is its
79    /// own account:
80    ///
81    /// - `email = Some(non-empty)` **and** `email_verified = true` → identity is keyed on the
82    ///   normalized email. A second provider presenting the same verified email links into the
83    ///   existing account.
84    /// - `email = None`, empty/whitespace, **or** `email_verified = false` → identity is keyed on
85    ///   `(provider, provider_id)`. This is fail-closed: an absent or unverified email can never
86    ///   collapse two distinct provider identities into one account, and can never link into
87    ///   another user's email-keyed account (H26).
88    ///
89    /// # Semantics
90    ///
91    /// - If no account exists for the resolved identity key: creates a new account, stores the
92    ///   `provider` / `provider_id` link, and returns the new `user_id`.
93    /// - If an account already exists for the key:
94    ///   - If the `provider` / `provider_id` pair is new, adds it as a linked credential.
95    ///   - Returns the **existing** `user_id` (same as on first sign-in).
96    ///
97    /// # Errors
98    ///
99    /// Returns [`AuthError::DatabaseError`] if the backing store fails.
100    async fn link_or_create_user(
101        &self,
102        email: Option<&str>,
103        email_verified: bool,
104        provider: &str,
105        provider_id: &str,
106    ) -> Result<AccountLinkResult>;
107
108    /// Look up the full account record for a `user_id`.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`AuthError::TokenNotFound`] if no account exists for `user_id`.
113    async fn get_account(&self, user_id: &str) -> Result<AccountRecord>;
114}
115
116/// Result from [`AccountStore::link_or_create_user`].
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct AccountLinkResult {
119    /// Stable internal user identifier.
120    pub user_id: String,
121    /// Whether a new account was created (`true`) or an existing one was linked (`false`).
122    pub is_new:  bool,
123    /// Whether a new provider link was added to an existing account.
124    pub linked:  bool,
125}
126
127// ─── In-memory backend ────────────────────────────────────────────────────────
128
129/// Thread-safe in-memory account store.
130///
131/// **Warning**: data is lost on process restart. For production use a persistent
132/// backend (PostgreSQL, etc.). Suitable for single-node deployments and tests.
133///
134/// # Thread Safety
135///
136/// Uses `DashMap` for lock-free concurrent reads and fine-grained write locking.
137pub struct InMemoryAccountStore {
138    /// identity key → user_id (fast lookup). The key is either `email:<normalized>`
139    /// for verified-email identities or `provider:<provider>\u{1f}<provider_id>` for
140    /// email-less / unverified identities — see [`identity_key`].
141    by_identity: DashMap<String, String>,
142    /// user_id → AccountRecord
143    by_user_id:  DashMap<String, AccountRecord>,
144}
145
146impl InMemoryAccountStore {
147    /// Create a new empty account store.
148    #[must_use]
149    pub fn new() -> Self {
150        Self {
151            by_identity: DashMap::new(),
152            by_user_id:  DashMap::new(),
153        }
154    }
155
156    /// Return the number of accounts in the store (useful for tests).
157    #[must_use]
158    pub fn len(&self) -> usize {
159        self.by_user_id.len()
160    }
161
162    /// Return `true` if no accounts are stored.
163    #[must_use]
164    pub fn is_empty(&self) -> bool {
165        self.by_user_id.is_empty()
166    }
167}
168
169impl Default for InMemoryAccountStore {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175// Reason: async_trait required for dyn-compatibility; remove when RTN + Send is stable
176#[async_trait]
177impl AccountStore for InMemoryAccountStore {
178    async fn link_or_create_user(
179        &self,
180        email: Option<&str>,
181        email_verified: bool,
182        provider: &str,
183        provider_id: &str,
184    ) -> Result<AccountLinkResult> {
185        let logger = get_audit_logger();
186        // Resolve the linking key. A verified, non-empty email links across providers;
187        // anything else is keyed on (provider, provider_id) so distinct identities can
188        // never collapse (H26).
189        let verified_email = email.map(normalize_email).filter(|e| !e.is_empty() && email_verified);
190        let key = identity_key(verified_email.as_deref(), provider, provider_id);
191        let new_link = ProviderLink {
192            provider:    provider.to_string(),
193            provider_id: provider_id.to_string(),
194        };
195
196        // Check whether an account already exists for this identity.
197        if let Some(existing_user_id) = self.by_identity.get(&key).map(|r| r.clone()) {
198            let mut record = self.by_user_id.get_mut(&existing_user_id).ok_or_else(|| {
199                AuthError::DatabaseError {
200                    message: format!(
201                        "account store inconsistency: identity '{key}' maps to missing user_id \
202                         '{existing_user_id}'"
203                    ),
204                }
205            })?;
206
207            // Link the new provider if it isn't already present.
208            let already_linked = record.providers.contains(&new_link);
209            if !already_linked {
210                record.providers.push(new_link);
211                logger.log_success(
212                    AuditEventType::AuthSuccess,
213                    SecretType::SessionToken,
214                    Some(existing_user_id.clone()),
215                    &format!("account_linked:{provider}"),
216                );
217            }
218
219            return Ok(AccountLinkResult {
220                user_id: existing_user_id.clone(),
221                is_new:  false,
222                linked:  !already_linked,
223            });
224        }
225
226        // No existing account — create a new one.
227        let user_id = format!("user_{}", Uuid::new_v4().as_simple());
228        let record = AccountRecord {
229            user_id:   user_id.clone(),
230            email:     verified_email,
231            providers: vec![new_link],
232        };
233        self.by_identity.insert(key, user_id.clone());
234        self.by_user_id.insert(user_id.clone(), record);
235
236        logger.log_success(
237            AuditEventType::SessionTokenCreated,
238            SecretType::SessionToken,
239            Some(user_id.clone()),
240            &format!("account_created:{provider}"),
241        );
242
243        Ok(AccountLinkResult {
244            user_id,
245            is_new: true,
246            linked: false,
247        })
248    }
249
250    async fn get_account(&self, user_id: &str) -> Result<AccountRecord> {
251        self.by_user_id.get(user_id).map(|r| r.clone()).ok_or(AuthError::TokenNotFound)
252    }
253}
254
255// ─── Helper ───────────────────────────────────────────────────────────────────
256
257/// Normalize an email address for storage and lookup.
258///
259/// Converts to lowercase and trims whitespace so that `Alice@Example.com` and
260/// `alice@example.com` resolve to the same account.
261#[must_use]
262pub fn normalize_email(email: &str) -> String {
263    email.trim().to_lowercase()
264}
265
266/// Compute the account-linking key for an identity.
267///
268/// When `verified_email` is `Some`, the identity links across providers and is keyed on
269/// `email:<normalized>`. When `None` (the provider supplied no verified, non-empty email),
270/// the identity is unique to the `(provider, provider_id)` pair, keyed on
271/// `provider:<provider>\u{1f}<provider_id>` (`\u{1f}`, the ASCII unit separator, cannot
272/// appear in a provider name, so the two key spaces and distinct pairs never collide).
273fn identity_key(verified_email: Option<&str>, provider: &str, provider_id: &str) -> String {
274    match verified_email {
275        Some(email) => format!("email:{email}"),
276        None => format!("provider:{provider}\u{1f}{provider_id}"),
277    }
278}
279
280// ─── Provider email-trust policy (#368) ─────────────────────────────────────────
281
282/// Set of `OAuth`/OIDC providers whose `email_verified` assertion FraiseQL trusts for
283/// **cross-provider auto-linking**.
284///
285/// # Why this exists (the H26 risk, one level up)
286///
287/// [`AccountStore::link_or_create_user`] merges two provider identities onto one account
288/// when they present the same *verified* email (H26). That is only safe if the provider
289/// asserting `email_verified = true` actually verified the address. A misconfigured or
290/// self-hosted IdP — or any provider that lets a user self-assert their email — can claim
291/// `email_verified = true` for an address it never owned and thereby link into another
292/// user's account (account takeover). This policy is the gate: a provider's verified claim
293/// is honored for auto-linking **only** when the provider is in the trusted set. An
294/// untrusted provider's claim is treated as unverified, so its identity is keyed on
295/// `(provider, provider_id)` and can never collapse into an existing email-keyed account
296/// (fail-closed — the same posture as H26).
297///
298/// # Both sides of the merge
299///
300/// The gate reasons about the *incoming* provider, but the merge is also safe on the
301/// *existing*-account side by construction: only a verified email from a trusted source
302/// ever enters the merge-able `email:<normalized>` key space. Unverified identities
303/// (local-password and phone sign-ups pass `email_verified = false`; see [`crate::local_password`])
304/// live in the `(provider, provider_id)` key space and are therefore structurally never
305/// absorbed by a later trusted sign-in — closing the classic pre-hijack where an
306/// attacker pre-seeds an unverified local account under the victim's email.
307///
308/// # Default trusted set
309///
310/// [`TrustedEmailProviders::default`] (and [`builtin_default`](Self::builtin_default))
311/// trust exactly:
312///
313/// - `google` — Google issues the OIDC `email_verified` claim in the signed ID token and sets it
314///   from its own verification of the address (or domain ownership for Workspace); it is meaningful
315///   to rely on.
316/// - `apple` — Apple issues the address itself (including Private Relay aliases) and always
317///   verifies ownership, so its `email_verified` claim is authoritative.
318///
319/// Deliberately **excluded** from the default (opt in explicitly once vetted):
320///
321/// - `azure_ad` / Microsoft — the `email` claim is **not** reliably verified and is tenant-mutable
322///   (the *nOAuth* class, 2023), so it must not auto-link by default.
323/// - `github` — a verified primary email requires the `/user/emails` second-hop, which is not yet
324///   implemented; its `email_verified` is fail-closed to `false`.
325/// - any generic/custom OIDC provider — FraiseQL cannot vouch for an operator-run IdP.
326///
327/// # Overriding (up *and* down)
328///
329/// Trust is trivially adjustable in either direction and is meant to read explicitly at
330/// the wiring site:
331///
332/// ```
333/// use fraiseql_auth::TrustedEmailProviders;
334///
335/// // Add a vetted provider on top of the defaults.
336/// let extended = TrustedEmailProviders::default().trust("keycloak");
337/// assert!(extended.is_trusted("keycloak") && extended.is_trusted("google"));
338///
339/// // Drop a default.
340/// let narrowed = TrustedEmailProviders::default().distrust("apple");
341/// assert!(!narrowed.is_trusted("apple"));
342///
343/// // High-assurance deployments: trust no one, in one call.
344/// let strict = TrustedEmailProviders::none();
345/// assert!(!strict.is_trusted("google"));
346/// ```
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct TrustedEmailProviders {
349    /// Normalized (lowercased, trimmed) provider names trusted to assert email verification.
350    providers: HashSet<String>,
351}
352
353impl TrustedEmailProviders {
354    /// The built-in default trusted set: `google` and `apple` (see the type docs for the
355    /// per-provider rationale). Equivalent to [`TrustedEmailProviders::default`].
356    #[must_use]
357    pub fn builtin_default() -> Self {
358        Self::only(["google", "apple"])
359    }
360
361    /// Trust **no** provider. Every social identity is keyed on `(provider, provider_id)`
362    /// and can never auto-merge on email — the one-call "trust no one" posture for
363    /// high-assurance or regulated deployments.
364    #[must_use]
365    pub fn none() -> Self {
366        Self {
367            providers: HashSet::new(),
368        }
369    }
370
371    /// Trust exactly the given providers, **replacing** the default set.
372    #[must_use]
373    pub fn only(providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
374        Self {
375            providers: providers.into_iter().map(|p| normalize_provider(&p.into())).collect(),
376        }
377    }
378
379    /// Add a provider to the trusted set (builder-style).
380    #[must_use]
381    pub fn trust(mut self, provider: impl Into<String>) -> Self {
382        self.providers.insert(normalize_provider(&provider.into()));
383        self
384    }
385
386    /// Remove a provider from the trusted set (builder-style) — e.g. to drop a default.
387    #[must_use]
388    pub fn distrust(mut self, provider: &str) -> Self {
389        self.providers.remove(&normalize_provider(provider));
390        self
391    }
392
393    /// Return `true` if `provider` is trusted to assert email verification for
394    /// auto-linking. Matching is case- and surrounding-whitespace-insensitive.
395    #[must_use]
396    pub fn is_trusted(&self, provider: &str) -> bool {
397        self.providers.contains(&normalize_provider(provider))
398    }
399
400    /// Return `true` if no provider is trusted (the "trust no one" posture).
401    #[must_use]
402    pub fn is_empty(&self) -> bool {
403        self.providers.is_empty()
404    }
405}
406
407impl Default for TrustedEmailProviders {
408    /// The built-in default trusted set ([`TrustedEmailProviders::builtin_default`]).
409    fn default() -> Self {
410        Self::builtin_default()
411    }
412}
413
414/// Normalize a provider name for trust comparison (trim + lowercase), matching the way
415/// providers register their names (e.g. `"google"`).
416fn normalize_provider(provider: &str) -> String {
417    provider.trim().to_lowercase()
418}
419
420// ─── Tests ────────────────────────────────────────────────────────────────────
421
422#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
423#[cfg(test)]
424mod tests;