diff --git a/crates/utopia-server/src/api/oidc_exchange_tests.rs b/crates/utopia-server/src/api/oidc_exchange_tests.rs index 817c68dfa..d2b0cf35d 100644 --- a/crates/utopia-server/src/api/oidc_exchange_tests.rs +++ b/crates/utopia-server/src/api/oidc_exchange_tests.rs @@ -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}"); diff --git a/crates/utopia-store/src/accounts.rs b/crates/utopia-store/src/accounts.rs index b665ed6c2..b55da1264 100644 --- a/crates/utopia-store/src/accounts.rs +++ b/crates/utopia-store/src/accounts.rs @@ -377,6 +377,8 @@ pub async fn set_system_admin( /// subject 绑上。开户与绑定同一个事务:绑定撞车(`taken` / `already_linked`)时 /// 不留下一个没人能登进去的账号。 /// +/// 大小写不敏感的邮箱匹配必须只有一个活动账号;多个匹配返回冲突,不开户也不绑定。 +/// /// 这个 email 只剩一个停用的账号时返回 `None`:停用的人不能换个新号回来—— /// email 的唯一索引只管启用中的账号,挡不住这一步,得在这里查。 /// @@ -389,13 +391,20 @@ pub async fn account_for_sso( display_name: &str, ) -> AppResult> { let mut tx = pool.begin().await?; - let active: Option = sqlx::query_as( - "SELECT * FROM users WHERE lower(email) = lower($1) AND deactivated_at IS NULL", + // 本次只拒绝快照中已有的歧义;阻止并发开户产生重复邮箱需要另行迁移并处理存量重复。 + let mut active: Vec = 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( diff --git a/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs b/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs index 7aa956c40..a8ea51095 100644 --- a/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs +++ b/crates/utopia-store/tests/store/an_sso_login_finds_or_opens_its_account.rs @@ -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!("{}-ann@sso.test", 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 +} + /// 一个比库里其它组织都早的组织,开户会落进它的默认工作区 /// 用「最早的组织」的测试一个一个跑,组织的时间也比库里现有的都早一天: /// 并行时谁更早说不准,中断的旧测试留下的组织也会抢走「最早」 diff --git a/docs/decisions/0069-the-identity-provider-vouches-for-listed-domains.md b/docs/decisions/0069-the-identity-provider-vouches-for-listed-domains.md index 9449a47e9..22c220ce7 100644 --- a/docs/decisions/0069-the-identity-provider-vouches-for-listed-domains.md +++ b/docs/decisions/0069-the-identity-provider-vouches-for-listed-domains.md @@ -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) @@ -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.