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 verified email, provider name, and provider-specific user ID.
11//! 2. The store checks whether an account with that email already exists.
12//!    - **Existing account**: the new provider credential is linked to the existing account and the
13//!      existing `user_id` is returned.
14//!    - **New account**: a fresh `user_id` is generated, the account is stored, and the new
15//!      `user_id` is returned.
16//! 3. The caller creates or refreshes a session keyed by the returned `user_id`.
17
18use async_trait::async_trait;
19use dashmap::DashMap;
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23use crate::{
24    audit::logger::{AuditEventType, SecretType, get_audit_logger},
25    error::{AuthError, Result},
26};
27
28// ─── Domain types ─────────────────────────────────────────────────────────────
29
30/// A single provider credential linked to an account.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct ProviderLink {
33    /// Provider name (e.g. `"github"`, `"google"`).
34    pub provider:    String,
35    /// Provider-specific user identifier (opaque string from the provider).
36    pub provider_id: String,
37}
38
39/// A FraiseQL user account, potentially linked to multiple `OAuth` providers.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct AccountRecord {
42    /// Internal FraiseQL user identifier (stable across providers).
43    pub user_id:   String,
44    /// Verified email address shared across all linked providers.
45    pub email:     String,
46    /// All provider credentials linked to this account.
47    pub providers: Vec<ProviderLink>,
48}
49
50// ─── Trait ────────────────────────────────────────────────────────────────────
51
52/// Storage backend for account linking.
53///
54/// Implementations must be `Send + Sync` and handle concurrent access safely.
55///
56/// # Implementations
57///
58/// - [`InMemoryAccountStore`] — for single-node deployments and testing.
59// Reason: used as dyn Trait (Arc<dyn AccountStore>); async_trait ensures Send bounds and
60// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
61#[async_trait]
62pub trait AccountStore: Send + Sync {
63    /// Return the `user_id` for the given email+provider pair, creating or linking as needed.
64    ///
65    /// # Semantics
66    ///
67    /// - If no account exists for `email`: creates a new account, stores the `provider` /
68    ///   `provider_id` link, and returns the new `user_id`.
69    /// - If an account already exists for `email`:
70    ///   - If the `provider` / `provider_id` pair is new, adds it as a linked credential.
71    ///   - Returns the **existing** `user_id` (same as on first sign-in).
72    ///
73    /// # Errors
74    ///
75    /// Returns [`AuthError::DatabaseError`] if the backing store fails.
76    async fn link_or_create_user(
77        &self,
78        email: &str,
79        provider: &str,
80        provider_id: &str,
81    ) -> Result<AccountLinkResult>;
82
83    /// Look up the full account record for a `user_id`.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`AuthError::TokenNotFound`] if no account exists for `user_id`.
88    async fn get_account(&self, user_id: &str) -> Result<AccountRecord>;
89}
90
91/// Result from [`AccountStore::link_or_create_user`].
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct AccountLinkResult {
94    /// Stable internal user identifier.
95    pub user_id: String,
96    /// Whether a new account was created (`true`) or an existing one was linked (`false`).
97    pub is_new:  bool,
98    /// Whether a new provider link was added to an existing account.
99    pub linked:  bool,
100}
101
102// ─── In-memory backend ────────────────────────────────────────────────────────
103
104/// Thread-safe in-memory account store.
105///
106/// **Warning**: data is lost on process restart. For production use a persistent
107/// backend (PostgreSQL, etc.). Suitable for single-node deployments and tests.
108///
109/// # Thread Safety
110///
111/// Uses `DashMap` for lock-free concurrent reads and fine-grained write locking.
112pub struct InMemoryAccountStore {
113    /// email → user_id (fast lookup by email)
114    by_email:   DashMap<String, String>,
115    /// user_id → AccountRecord
116    by_user_id: DashMap<String, AccountRecord>,
117}
118
119impl InMemoryAccountStore {
120    /// Create a new empty account store.
121    #[must_use]
122    pub fn new() -> Self {
123        Self {
124            by_email:   DashMap::new(),
125            by_user_id: DashMap::new(),
126        }
127    }
128
129    /// Return the number of accounts in the store (useful for tests).
130    #[must_use]
131    pub fn len(&self) -> usize {
132        self.by_user_id.len()
133    }
134
135    /// Return `true` if no accounts are stored.
136    #[must_use]
137    pub fn is_empty(&self) -> bool {
138        self.by_user_id.is_empty()
139    }
140}
141
142impl Default for InMemoryAccountStore {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148// Reason: async_trait required for dyn-compatibility; remove when RTN + Send is stable
149#[async_trait]
150impl AccountStore for InMemoryAccountStore {
151    async fn link_or_create_user(
152        &self,
153        email: &str,
154        provider: &str,
155        provider_id: &str,
156    ) -> Result<AccountLinkResult> {
157        let logger = get_audit_logger();
158        let email_normalized = normalize_email(email);
159        let new_link = ProviderLink {
160            provider:    provider.to_string(),
161            provider_id: provider_id.to_string(),
162        };
163
164        // Check whether an account already exists for this email.
165        if let Some(existing_user_id) = self.by_email.get(&email_normalized).map(|r| r.clone()) {
166            let mut record = self.by_user_id.get_mut(&existing_user_id).ok_or_else(|| {
167                AuthError::DatabaseError {
168                    message: format!(
169                        "account store inconsistency: email '{}' maps to missing user_id '{}'",
170                        email, existing_user_id
171                    ),
172                }
173            })?;
174
175            // Link the new provider if it isn't already present.
176            let already_linked = record.providers.contains(&new_link);
177            if !already_linked {
178                record.providers.push(new_link);
179                logger.log_success(
180                    AuditEventType::AuthSuccess,
181                    SecretType::SessionToken,
182                    Some(existing_user_id.clone()),
183                    &format!("account_linked:{provider}"),
184                );
185            }
186
187            return Ok(AccountLinkResult {
188                user_id: existing_user_id.clone(),
189                is_new:  false,
190                linked:  !already_linked,
191            });
192        }
193
194        // No existing account — create a new one.
195        let user_id = format!("user_{}", Uuid::new_v4().as_simple());
196        let record = AccountRecord {
197            user_id:   user_id.clone(),
198            email:     email_normalized.clone(),
199            providers: vec![new_link],
200        };
201        self.by_email.insert(email_normalized, user_id.clone());
202        self.by_user_id.insert(user_id.clone(), record);
203
204        logger.log_success(
205            AuditEventType::SessionTokenCreated,
206            SecretType::SessionToken,
207            Some(user_id.clone()),
208            &format!("account_created:{provider}"),
209        );
210
211        Ok(AccountLinkResult {
212            user_id,
213            is_new: true,
214            linked: false,
215        })
216    }
217
218    async fn get_account(&self, user_id: &str) -> Result<AccountRecord> {
219        self.by_user_id.get(user_id).map(|r| r.clone()).ok_or(AuthError::TokenNotFound)
220    }
221}
222
223// ─── Helper ───────────────────────────────────────────────────────────────────
224
225/// Normalize an email address for storage and lookup.
226///
227/// Converts to lowercase and trims whitespace so that `Alice@Example.com` and
228/// `alice@example.com` resolve to the same account.
229#[must_use]
230pub fn normalize_email(email: &str) -> String {
231    email.trim().to_lowercase()
232}
233
234// ─── Tests ────────────────────────────────────────────────────────────────────
235
236#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
237#[cfg(test)]
238mod tests;