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
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:
@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user