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 async_trait::async_trait;
22use dashmap::DashMap;
23use serde::{Deserialize, Serialize};
24use uuid::Uuid;
25
26use crate::{
27 audit::logger::{AuditEventType, SecretType, get_audit_logger},
28 error::{AuthError, Result},
29};
30
31// ─── Domain types ─────────────────────────────────────────────────────────────
32
33/// A single provider credential linked to an account.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct ProviderLink {
36 /// Provider name (e.g. `"github"`, `"google"`).
37 pub provider: String,
38 /// Provider-specific user identifier (opaque string from the provider).
39 pub provider_id: String,
40}
41
42/// A FraiseQL user account, potentially linked to multiple `OAuth` providers.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AccountRecord {
45 /// Internal FraiseQL user identifier (stable across providers).
46 pub user_id: String,
47 /// Verified email address shared across all linked providers, when the account
48 /// is keyed on a verified email. `None` for accounts keyed on
49 /// `(provider, provider_id)` because the provider supplied no verified email.
50 pub email: Option<String>,
51 /// All provider credentials linked to this account.
52 pub providers: Vec<ProviderLink>,
53}
54
55// ─── Trait ────────────────────────────────────────────────────────────────────
56
57/// Storage backend for account linking.
58///
59/// Implementations must be `Send + Sync` and handle concurrent access safely.
60///
61/// # Implementations
62///
63/// - [`InMemoryAccountStore`] — for single-node deployments and testing.
64// Reason: used as dyn Trait (Arc<dyn AccountStore>); async_trait ensures Send bounds and
65// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
66#[async_trait]
67pub trait AccountStore: Send + Sync {
68 /// Return the `user_id` for the given identity, creating or linking as needed.
69 ///
70 /// # Account-linking key (security-critical)
71 ///
72 /// Cross-provider account linking happens **only** when the provider supplies a
73 /// non-empty, *verified* email. Otherwise each `(provider, provider_id)` pair is its
74 /// own account:
75 ///
76 /// - `email = Some(non-empty)` **and** `email_verified = true` → identity is keyed on the
77 /// normalized email. A second provider presenting the same verified email links into the
78 /// existing account.
79 /// - `email = None`, empty/whitespace, **or** `email_verified = false` → identity is keyed on
80 /// `(provider, provider_id)`. This is fail-closed: an absent or unverified email can never
81 /// collapse two distinct provider identities into one account, and can never link into
82 /// another user's email-keyed account (H26).
83 ///
84 /// # Semantics
85 ///
86 /// - If no account exists for the resolved identity key: creates a new account, stores the
87 /// `provider` / `provider_id` link, and returns the new `user_id`.
88 /// - If an account already exists for the key:
89 /// - If the `provider` / `provider_id` pair is new, adds it as a linked credential.
90 /// - Returns the **existing** `user_id` (same as on first sign-in).
91 ///
92 /// # Errors
93 ///
94 /// Returns [`AuthError::DatabaseError`] if the backing store fails.
95 async fn link_or_create_user(
96 &self,
97 email: Option<&str>,
98 email_verified: bool,
99 provider: &str,
100 provider_id: &str,
101 ) -> Result<AccountLinkResult>;
102
103 /// Look up the full account record for a `user_id`.
104 ///
105 /// # Errors
106 ///
107 /// Returns [`AuthError::TokenNotFound`] if no account exists for `user_id`.
108 async fn get_account(&self, user_id: &str) -> Result<AccountRecord>;
109}
110
111/// Result from [`AccountStore::link_or_create_user`].
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct AccountLinkResult {
114 /// Stable internal user identifier.
115 pub user_id: String,
116 /// Whether a new account was created (`true`) or an existing one was linked (`false`).
117 pub is_new: bool,
118 /// Whether a new provider link was added to an existing account.
119 pub linked: bool,
120}
121
122// ─── In-memory backend ────────────────────────────────────────────────────────
123
124/// Thread-safe in-memory account store.
125///
126/// **Warning**: data is lost on process restart. For production use a persistent
127/// backend (PostgreSQL, etc.). Suitable for single-node deployments and tests.
128///
129/// # Thread Safety
130///
131/// Uses `DashMap` for lock-free concurrent reads and fine-grained write locking.
132pub struct InMemoryAccountStore {
133 /// identity key → user_id (fast lookup). The key is either `email:<normalized>`
134 /// for verified-email identities or `provider:<provider>\u{1f}<provider_id>` for
135 /// email-less / unverified identities — see [`identity_key`].
136 by_identity: DashMap<String, String>,
137 /// user_id → AccountRecord
138 by_user_id: DashMap<String, AccountRecord>,
139}
140
141impl InMemoryAccountStore {
142 /// Create a new empty account store.
143 #[must_use]
144 pub fn new() -> Self {
145 Self {
146 by_identity: DashMap::new(),
147 by_user_id: DashMap::new(),
148 }
149 }
150
151 /// Return the number of accounts in the store (useful for tests).
152 #[must_use]
153 pub fn len(&self) -> usize {
154 self.by_user_id.len()
155 }
156
157 /// Return `true` if no accounts are stored.
158 #[must_use]
159 pub fn is_empty(&self) -> bool {
160 self.by_user_id.is_empty()
161 }
162}
163
164impl Default for InMemoryAccountStore {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170// Reason: async_trait required for dyn-compatibility; remove when RTN + Send is stable
171#[async_trait]
172impl AccountStore for InMemoryAccountStore {
173 async fn link_or_create_user(
174 &self,
175 email: Option<&str>,
176 email_verified: bool,
177 provider: &str,
178 provider_id: &str,
179 ) -> Result<AccountLinkResult> {
180 let logger = get_audit_logger();
181 // Resolve the linking key. A verified, non-empty email links across providers;
182 // anything else is keyed on (provider, provider_id) so distinct identities can
183 // never collapse (H26).
184 let verified_email = email.map(normalize_email).filter(|e| !e.is_empty() && email_verified);
185 let key = identity_key(verified_email.as_deref(), provider, provider_id);
186 let new_link = ProviderLink {
187 provider: provider.to_string(),
188 provider_id: provider_id.to_string(),
189 };
190
191 // Check whether an account already exists for this identity.
192 if let Some(existing_user_id) = self.by_identity.get(&key).map(|r| r.clone()) {
193 let mut record = self.by_user_id.get_mut(&existing_user_id).ok_or_else(|| {
194 AuthError::DatabaseError {
195 message: format!(
196 "account store inconsistency: identity '{key}' maps to missing user_id \
197 '{existing_user_id}'"
198 ),
199 }
200 })?;
201
202 // Link the new provider if it isn't already present.
203 let already_linked = record.providers.contains(&new_link);
204 if !already_linked {
205 record.providers.push(new_link);
206 logger.log_success(
207 AuditEventType::AuthSuccess,
208 SecretType::SessionToken,
209 Some(existing_user_id.clone()),
210 &format!("account_linked:{provider}"),
211 );
212 }
213
214 return Ok(AccountLinkResult {
215 user_id: existing_user_id.clone(),
216 is_new: false,
217 linked: !already_linked,
218 });
219 }
220
221 // No existing account — create a new one.
222 let user_id = format!("user_{}", Uuid::new_v4().as_simple());
223 let record = AccountRecord {
224 user_id: user_id.clone(),
225 email: verified_email,
226 providers: vec![new_link],
227 };
228 self.by_identity.insert(key, user_id.clone());
229 self.by_user_id.insert(user_id.clone(), record);
230
231 logger.log_success(
232 AuditEventType::SessionTokenCreated,
233 SecretType::SessionToken,
234 Some(user_id.clone()),
235 &format!("account_created:{provider}"),
236 );
237
238 Ok(AccountLinkResult {
239 user_id,
240 is_new: true,
241 linked: false,
242 })
243 }
244
245 async fn get_account(&self, user_id: &str) -> Result<AccountRecord> {
246 self.by_user_id.get(user_id).map(|r| r.clone()).ok_or(AuthError::TokenNotFound)
247 }
248}
249
250// ─── Helper ───────────────────────────────────────────────────────────────────
251
252/// Normalize an email address for storage and lookup.
253///
254/// Converts to lowercase and trims whitespace so that `Alice@Example.com` and
255/// `alice@example.com` resolve to the same account.
256#[must_use]
257pub fn normalize_email(email: &str) -> String {
258 email.trim().to_lowercase()
259}
260
261/// Compute the account-linking key for an identity.
262///
263/// When `verified_email` is `Some`, the identity links across providers and is keyed on
264/// `email:<normalized>`. When `None` (the provider supplied no verified, non-empty email),
265/// the identity is unique to the `(provider, provider_id)` pair, keyed on
266/// `provider:<provider>\u{1f}<provider_id>` (`\u{1f}`, the ASCII unit separator, cannot
267/// appear in a provider name, so the two key spaces and distinct pairs never collide).
268fn identity_key(verified_email: Option<&str>, provider: &str, provider_id: &str) -> String {
269 match verified_email {
270 Some(email) => format!("email:{email}"),
271 None => format!("provider:{provider}\u{1f}{provider_id}"),
272 }
273}
274
275// ─── Tests ────────────────────────────────────────────────────────────────────
276
277#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
278#[cfg(test)]
279mod tests;