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;