Private
Public Access
1
0

fix: handle SSO email collision by linking existing local users
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m9s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 13s
CI Pipeline / Build .deb & Release (push) Has been skipped

When a user already exists with auth_provider=local and the same email,
the SSO callback now links the existing user to the new SSO provider
instead of failing with a unique constraint violation on email.

Flow: 1) Try exact match (email+auth_provider), 2) If not found,
try email-only lookup, 3) If found with different provider, link
by updating auth_provider, azure_oid, oidc_sub, 4) If not found,
create new user as before.
This commit is contained in:
2026-05-13 18:23:32 +00:00
parent d76450759a
commit 83a0c29f3d

View File

@ -416,6 +416,7 @@ async fn sso_callback(
_ => "oidc", _ => "oidc",
}; };
// First try exact match: email AND auth_provider
let user_opt: Option<DbUserForSso> = match sqlx::query_as( let user_opt: Option<DbUserForSso> = match sqlx::query_as(
r#"SELECT id, username, display_name, role, is_active, mfa_enabled r#"SELECT id, username, display_name, role, is_active, mfa_enabled
FROM users WHERE email = $1 AND auth_provider = $2"#, FROM users WHERE email = $1 AND auth_provider = $2"#,
@ -438,47 +439,113 @@ async fn sso_callback(
}, },
Some(u) => u, Some(u) => u,
None => { None => {
let id: Uuid = match sqlx::query_scalar( // Try to find existing user by email alone (may have different auth_provider)
r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid, oidc_sub) let existing_user: Option<DbUserForSso> = match sqlx::query_as(
VALUES ($1, $2, $3, 'operator', $4, $5, $6) r#"SELECT id, username, display_name, role, is_active, mfa_enabled
RETURNING id"#, FROM users WHERE email = $1"#,
) )
.bind(&preferred_username)
.bind(&name)
.bind(&email) .bind(&email)
.bind(auth_provider) .fetch_optional(&state.db)
.bind(if azure_oid.is_empty() { None } else { Some(azure_oid.as_str()) })
.bind(if provider_subject.is_empty() { None } else { Some(provider_subject.as_str()) })
.fetch_one(&state.db)
.await .await
{ {
Ok(id) => id, Ok(o) => o,
Err(e) => { Err(e) => {
tracing::error!(error = %e, "Failed to create SSO user"); tracing::error!(error = %e, "Failed to look up existing user by email");
return Err(error_redirect("internal_error", "Failed to create user")); return Err(error_redirect("internal_error", "Database error"));
}, },
}; };
log_event( match existing_user {
&state.db, Some(existing) if !existing.is_active => {
AuditAction::UserCreated, return Err(error_redirect("account_disabled", "Account is disabled"));
None, },
Some(auth_provider), Some(existing) => {
Some("user"), // Link existing local user to SSO provider
Some(&id.to_string()), tracing::info!(user_id = %existing.id, "Linking existing user to SSO provider");
json!({ "auth_provider": auth_provider, "email": email }), if let Err(e) = sqlx::query(
None, "UPDATE users SET auth_provider = $1, azure_oid = COALESCE(azure_oid, $2), oidc_sub = COALESCE(oidc_sub, $3) WHERE id = $4",
None, )
) .bind(auth_provider)
.await; .bind(if azure_oid.is_empty() { None } else { Some(azure_oid.as_str()) })
.bind(if provider_subject.is_empty() { None } else { Some(provider_subject.as_str()) })
.bind(existing.id)
.execute(&state.db)
.await
{
tracing::error!(error = %e, "Failed to link user to SSO provider");
return Err(error_redirect("internal_error", "Failed to link SSO account"));
}
DbUserForSso { log_event(
id, &state.db,
username: preferred_username, AuditAction::UserCreated,
display_name: name, None,
role: "operator".to_string(), Some(auth_provider),
is_active: true, Some("user"),
mfa_enabled: false, Some(&existing.id.to_string()),
json!({ "action": "sso_link", "auth_provider": auth_provider, "email": email }),
None,
None,
)
.await;
DbUserForSso {
id: existing.id,
username: existing.username.clone(),
display_name: name
.is_empty()
.then(|| existing.display_name.clone())
.unwrap_or(name),
role: existing.role.clone(),
is_active: existing.is_active,
mfa_enabled: existing.mfa_enabled,
}
},
None => {
// No existing user - create new one
let id: Uuid = match sqlx::query_scalar(
r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid, oidc_sub)
VALUES ($1, $2, $3, 'operator', $4, $5, $6)
RETURNING id"#,
)
.bind(&preferred_username)
.bind(&name)
.bind(&email)
.bind(auth_provider)
.bind(if azure_oid.is_empty() { None } else { Some(azure_oid.as_str()) })
.bind(if provider_subject.is_empty() { None } else { Some(provider_subject.as_str()) })
.fetch_one(&state.db)
.await
{
Ok(id) => id,
Err(e) => {
tracing::error!(error = %e, "Failed to create SSO user");
return Err(error_redirect("internal_error", "Failed to create user"));
},
};
log_event(
&state.db,
AuditAction::UserCreated,
None,
Some(auth_provider),
Some("user"),
Some(&id.to_string()),
json!({ "auth_provider": auth_provider, "email": email }),
None,
None,
)
.await;
DbUserForSso {
id,
username: preferred_username,
display_name: name,
role: "operator".to_string(),
is_active: true,
mfa_enabled: false,
}
},
} }
}, },
}; };