Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/utopia-server/src/api/oidc_exchange_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,24 @@ async fn exchange_maps_only_linked_active_people_and_spends_each_token_once() ->
let (status, body) = exchange(sign_email(&format!("sub-{ann}"), &format!("{ann}@EXAMPLE.test"), true)?).await?;
anyhow::ensure!(status == StatusCode::OK && user_of(&body)? == ann, "existing by email: {status} {body}");

let duplicate = Uuid::now_v7();
sqlx::query("INSERT INTO users(id,org_id,email,password_hash,display_name) VALUES($1,$2,$3,'unused','Duplicate Ann')")
.bind(duplicate)
.bind(org)
.bind(format!("{ann}@EXAMPLE.TEST"))
.execute(&pool)
.await?;
let (status, body) = exchange(sign_email("ambiguous-ann", &format!("{ann}@example.test"), true)?).await?;
anyhow::ensure!(
status == StatusCode::UNPROCESSABLE_ENTITY
&& body["code"] == "oidc_unavailable"
&& body["error"] == "The identity could not be mapped to an account",
"ambiguous email: {status} {body}"
);
let linked: i64 = sqlx::query_scalar("SELECT count(*) FROM oidc_identities WHERE issuer = $1 AND (subject = 'ambiguous-ann' OR user_id = $2)")
.bind(&issuer).bind(duplicate).fetch_one(&pool).await?;
anyhow::ensure!(linked == 0);

let bea_sub = format!("sub-{}", Uuid::now_v7());
let (status, body) = exchange(sign_email(&bea_sub, &bea_email, true)?).await?;
anyhow::ensure!(status == StatusCode::OK, "opened: {status} {body}");
Expand Down
17 changes: 13 additions & 4 deletions crates/utopia-store/src/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@ pub async fn set_system_admin(
/// subject 绑上。开户与绑定同一个事务:绑定撞车(`taken` / `already_linked`)时
/// 不留下一个没人能登进去的账号。
///
/// 大小写不敏感的邮箱匹配必须只有一个活动账号;多个匹配返回冲突,不开户也不绑定。
///
/// 这个 email 只剩一个停用的账号时返回 `None`:停用的人不能换个新号回来——
/// email 的唯一索引只管启用中的账号,挡不住这一步,得在这里查。
///
Expand All @@ -389,13 +391,20 @@ pub async fn account_for_sso(
display_name: &str,
) -> AppResult<Option<User>> {
let mut tx = pool.begin().await?;
let active: Option<User> = sqlx::query_as(
"SELECT * FROM users WHERE lower(email) = lower($1) AND deactivated_at IS NULL",
// 本次只拒绝快照中已有的歧义;阻止并发开户产生重复邮箱需要另行迁移并处理存量重复。
let mut active: Vec<User> = sqlx::query_as(
"SELECT * FROM users WHERE lower(email) = lower($1) AND deactivated_at IS NULL LIMIT 2",
)
.bind(email)
.fetch_optional(&mut *tx)
.fetch_all(&mut *tx)
.await?;
let user = match active {
if active.len() > 1 {
return Err(AppError::CodedConflict {
code: "unlinked",
message: "Multiple active accounts match the SSO email".into(),
});
}
let user = match active.pop() {
Some(u) => u,
None => {
let deactivated: bool = sqlx::query_scalar(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,55 @@ use uuid::Uuid;

const ISSUER: &str = "https://id.sso.test";

#[tokio::test]
async fn ambiguous_active_emails_leave_accounts_and_identities_unchanged() -> anyhow::Result<()> {
let Some(url) = utopia_store::test_db::url() else {
return Ok(());
};
let pool = PgPool::connect(&url).await?;
let org = Uuid::now_v7();
sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'sso-ambiguity-test')")
.bind(org)
.execute(&pool)
.await?;
let run = async {
let email = format!("{}[email protected]", Uuid::now_v7().simple());
let first = Uuid::now_v7();
for (id, address) in [(first, email.to_uppercase()), (Uuid::now_v7(), email.clone())] {
sqlx::query("INSERT INTO users (id, org_id, email, display_name, password_hash) VALUES ($1, $2, $3, 'Ann', 'x')")
.bind(id).bind(org).bind(address).execute(&pool).await?;
}
let snapshot = || async {
sqlx::query_scalar::<_, serde_json::Value>(
"SELECT jsonb_build_object(
'users', (SELECT jsonb_agg(to_jsonb(u) ORDER BY id) FROM users u WHERE org_id = $1),
'identities', (SELECT jsonb_agg(to_jsonb(i) ORDER BY subject) FROM oidc_identities i JOIN users u ON u.id = i.user_id WHERE u.org_id = $1))",
).bind(org).fetch_one(&pool).await
};
for already_linked in [false, true] {
if already_linked {
sqlx::query("INSERT INTO oidc_identities (issuer, subject, user_id) VALUES ($1, $2, $3)")
.bind(ISSUER).bind(format!("existing-{first}")).bind(first).execute(&pool).await?;
}
let before = snapshot().await?;
// 两种精确拼写都必须拒绝;已有绑定也不能让另一个候选变得可选。
for address in [&email, &email.to_uppercase()] {
let result = utopia_store::accounts::account_for_sso(
&pool, ISSUER, &format!("new-{first}"), address, "Changed",
).await;
assert!(matches!(result, Err(utopia_core::AppError::CodedConflict { code: "unlinked", ref message }) if message == "Multiple active accounts match the SSO email"));
assert_eq!(snapshot().await?, before);
}
}
anyhow::Ok(())
}.await;
sqlx::query("DELETE FROM organizations WHERE id = $1")
.bind(org)
.execute(&pool)
.await?;
run
}

/// 一个比库里其它组织都早的组织,开户会落进它的默认工作区
/// 用「最早的组织」的测试一个一个跑,组织的时间也比库里现有的都早一天:
/// 并行时谁更早说不准,中断的旧测试留下的组织也会抢走「最早」
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0069 · The identity provider vouches for listed domains

- **Status**: Implemented · 2026-10-16 · #1237 · open: no per-domain role; an SSO-opened account has no password and no reset
- **Status**: Implemented · 2026-10-16 · #1237, #1258 · open: no per-domain role; an SSO-opened account has no password and no reset; no global case-insensitive email uniqueness
- **Written**: 2026-10-16 (conventions in the [README](README.md))
- **Related**: migration `0056` (an identity is linked by its owner); [0066](0066-another-app-may-trade-a-fresh-id-token-for-a-session.md) (the exchange, whose mapping this widens); [0014](0014-identity-from-the-person-scope-from-the-token.md) (identity comes from the person); [#1237](https://github.com/deeplethe/utopia/pull/1237)

Expand All @@ -14,6 +14,8 @@ Migration 0056 rejected linking by email for a reason that still holds in genera

1. **`UTOPIA_OIDC_ALLOWED_DOMAINS` lists the domains the identity provider is authoritative for.** On a login or an exchange whose subject is not linked, a token whose `email` is in a listed domain and whose `email_verified` is true is mapped by email: to the active account with that address (case-insensitive), or to a new one. The subject is then linked to it, so later sign-ins go by subject as before. The list is empty by default, and an empty list keeps 0056's rule exactly.

**Revision 2026-10-16 (#1258).** This assumed the active match was unique. Checking `users_email_active_idx` showed that it compares the stored spelling, while registration and administrator-created accounts preserve casing: two active accounts can differ only in email case. `account_for_sso` now refuses multiple active case-insensitive matches as `unlinked` before creating an account or writing an identity, rather than choosing one. The check covers the query snapshot; a unique index on `lower(email)`, normalization at registration and administrator creation, and a decision about existing duplicates remain separate work.

2. **The trust assumption.** Listing a domain says: every token this provider signs with a verified address in that domain belongs to the person who owns that address. That holds for a provider that only knows the company's directory. It does not hold for a plain Google OAuth client: Google also signs verified tokens for consumer accounts someone registered with a company address, and that person need not be, or still be, an employee. A deployment on such a client should put a gate in front that admits only the company's Workspace accounts, or restrict the client to its organization at the provider.

3. **The account and its link are written in one transaction** (`accounts::account_for_sso`). If the link fails (the subject is already linked to someone else, or the account already has an identity at this issuer), no account is left behind that nobody could sign in to.
Expand Down
Loading