feat(oidc): support of +alias email (clean it up to reconciliate)

This commit is contained in:
Edouard Vanbelle
2026-08-08 22:11:31 +02:00
parent bd8e77c3dd
commit 4c34b25a7b
9 changed files with 306 additions and 32 deletions
+8
View File
@@ -109,6 +109,14 @@ pub trait UserStoragePort: Send + Sync + 'static {
/// Gets a user by email
async fn get_user_by_email(&self, email: &str) -> Result<User, DomainError>;
/// Returns every user whose email normalizes to `normalized_email`
/// (see `UserRepository::list_users_by_normalized_email` for the
/// full contract and the auto-link ambiguity-detection use case).
async fn list_users_by_normalized_email(
&self,
normalized_email: &str,
) -> Result<Vec<User>, DomainError>;
/// Updates an existing user
async fn update_user(&self, user: User) -> Result<User, DomainError>;
@@ -3844,26 +3844,38 @@ impl AuthApplicationService {
}
Err(_) => {
// User doesn't exist by federation subject — try to
// match by email. Two possible outcomes:
// * Email matches an existing local user AND the
// auto-link decision tree accepts → auto-link,
// yield the linked user (falls through to session
// mint below).
// * Email matches AND auto-link refuses (config off,
// email not verified, already linked elsewhere) →
// return "contact admin" error (self-service link
// flow remains available).
// * No email match → JIT provision (existing branch).
//
// NOTE (MVP scope): exact-match lookup only. If OxiCloud
// stores `alice+work@example.com` but the IdP returns
// `alice@example.com`, the exact match misses even
// though they normalise to the same value. The user
// falls through to the "contact admin" refusal and can
// self-serve via the profile link flow.
let matched_user = self.user_storage.get_user_by_email(&oidc_email).await.ok();
// match by email under the same normalization the
// self-service link flow uses (lowercase + strip
// `+alias`). Three possible outcomes:
// * 0 matches → JIT provision (existing branch).
// * 1 match → run the auto-link decision tree.
// * >1 match → refuse `email_ambiguous`. Two local
// rows collapsing to the same normalized email
// (`alice@example.com` + `alice+work@example.com`)
// mean we can't safely pick one to auto-link;
// admin must resolve.
let normalized = crate::common::text::normalize_email_for_link(&oidc_email);
let candidates = self
.user_storage
.list_users_by_normalized_email(&normalized)
.await
.unwrap_or_default();
if let Some(matched) = matched_user {
if candidates.len() > 1 {
tracing::info!(
target: "audit",
event = "federation.auto_link_refused",
reason = "email_ambiguous",
normalized_email = %normalized,
candidate_count = candidates.len(),
"🔗 auto-link refused — multiple local users normalize to the IdP email",
);
return Ok(OidcCallbackResult::AutoLinkRefused {
reason: "email_ambiguous",
});
}
if let Some(matched) = candidates.into_iter().next() {
// Auto-link decision tree — see
// docs/plan/oidc-account-linking.md § Auto-link.
let can_auto_link = oidc_config.auto_link_email_match
@@ -107,6 +107,24 @@ pub trait UserRepository: Send + Sync + 'static {
/// Gets a user by email
async fn get_user_by_email(&self, email: &str) -> UserRepositoryResult<User>;
/// Returns every user whose email normalizes to `normalized_email`.
///
/// Normalization matches `common::text::normalize_email_for_link` —
/// lowercase + strip `+alias` sub-addressing — so
/// `Alice+work@Example.com` and `alice@example.com` collapse to the
/// same key. Used by the OIDC auto-link decision tree to detect
/// ambiguity: two local rows normalizing to the IdP-returned email
/// means we can't safely pick one to auto-link, and the callback
/// must refuse (`email_ambiguous`).
///
/// Caller passes the already-normalized value; the SQL applies the
/// same normalization to the stored side symmetrically so casing
/// and `+alias` differences on either side collapse.
async fn list_users_by_normalized_email(
&self,
normalized_email: &str,
) -> UserRepositoryResult<Vec<User>>;
/// Updates an existing user
async fn update_user(&self, user: User) -> UserRepositoryResult<User>;
@@ -498,6 +498,76 @@ impl UserRepository for UserPgRepository {
))
}
/// Returns every user whose email normalizes to `normalized_email`.
///
/// Looks up against `auth.users.identity_lookup_email`, a stored
/// GENERATED column populated by PostgreSQL from the same
/// normalization `common::text::normalize_email_for_link` applies
/// on the caller side. See migration
/// 20261011000000_users_normalized_email_index.sql — the b-tree
/// index on that column makes this an O(log n) probe.
async fn list_users_by_normalized_email(
&self,
normalized_email: &str,
) -> UserRepositoryResult<Vec<User>> {
let rows = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences
FROM auth.users
WHERE identity_lookup_email = $1
"#,
)
.bind(normalized_email)
.fetch_all(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
let users = rows
.into_iter()
.map(|row| {
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
let role = match role_str.as_deref() {
Some("admin") => UserRole::Admin,
_ => UserRole::User,
};
User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
row.get("password_hash"),
role,
row.get("storage_quota_bytes"),
row.get("storage_used_bytes"),
row.get("created_at"),
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
)
})
.collect();
Ok(users)
}
/// Batch loads users by id in one query (avoids N+1 for group-
/// recipient expansion). Missing ids are silently skipped — the
/// caller treats absent rows as "no such recipient", same as
@@ -1250,6 +1320,15 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from)
}
async fn list_users_by_normalized_email(
&self,
normalized_email: &str,
) -> Result<Vec<User>, DomainError> {
UserRepository::list_users_by_normalized_email(self, normalized_email)
.await
.map_err(DomainError::from)
}
async fn update_user(&self, user: User) -> Result<User, DomainError> {
UserRepository::update_user(self, user)
.await
@@ -1610,6 +1610,11 @@ pub async fn oidc_callback(
// JSON body would leave the user staring at raw JSON. The SPA
// login page reads `?login_error=<reason>` on mount, renders a
// localized notice, and strips the param via history.replaceState.
//
// Reasons currently emitted (see auth_application_service.rs
// auto-link decision tree): auto_link_disabled,
// auto_link_email_not_verified, already_linked_elsewhere,
// email_ambiguous.
OidcCallbackResult::AutoLinkRefused { reason } => {
let config = auth_app.oidc_config().unwrap();
let frontend_url = config.frontend_url.trim_end_matches('/');