Skip to content

Reject ambiguous email matches so SSO cannot link the wrong account - #1258

Merged
WaylandYang merged 3 commits into
deeplethe:devfrom
Floating-Y:fix/sso-ambiguous-email
Oct 18, 2026
Merged

WaylandYang merged 3 commits into
deeplethe:devfrom
Floating-Y:fix/sso-ambiguous-email

Conversation

@Floating-Y

@Floating-Y Floating-Y commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Why

Related to #1237. Registration and administrator-created accounts preserve email casing, while the active-email unique index compares the stored value. SSO auto-linking used fetch_optional on a case-insensitive query, so multiple active matches could link an arbitrary account or reject login because that arbitrary account already had a binding.

What changes

Read at most two active matches in account_for_sso and reject ambiguity before any account or identity write. Neither exact casing nor an unlinked candidate takes precedence. Return CodedConflict with code unlinked, so the browser callback and audit classify this as an account-linking problem rather than an outage; the diagnostic message remains in logs. The exchange keeps its existing generic public failure response.

Keep unique-email matching, provisioning, deactivated-account refusal and binding-conflict rollback. The store regression checks both exact spellings, with and without an existing binding, and compares complete account and identity snapshots. The exchange regression checks generic refusal and no new identity write.

Add a dated revision under ADR 0069 Decision 1, preserving the original claim and explaining why its uniqueness assumption was wrong. Update its status line to reference this PR and the remaining uniqueness boundary.

Boundary: the check covers the SELECT snapshot. Concurrent ordinary registration can still insert a differently cased address afterward. A unique index on lower(email), normalization in registration/admin creation, and handling existing duplicates are separate work. No migration or stored email is changed; existing token-consumption and failure-audit writes remain.

How it was checked

Revision d3de92a097fd0c2b8e84b8239cc68722224c84cd passed CI run 38900751321: lint, test, build, migrations, web and backend.

  • CI ran cargo test --workspace on Ubuntu with an isolated pgvector/pgvector:pg16 service, migrations applied, UTOPIA_DATABASE_URL set and UTOPIA_TEST_REQUIRE_DB=1.
  • Logs confirm all 5 SSO store tests and all 10 OIDC tests passed, including the revised ambiguity regression. Existing ignored workspace tests remain ignored.
  • CI passed cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings.
  • Local Windows checks passed: cargo fmt --all --check, git diff --check, and cargo clippy -p utopia-store -p utopia-server --all-targets -- -D warnings.
  • The local Docker engine failed to start, so this revision's database tests were verified in CI rather than rerun locally. Browser callback end-to-end testing was not run; its shared error-mapping path was inspected.

Before review

  • Every commit is signed off (git commit -s)
  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings and cargo test --workspace pass
  • SQL under crates/utopia-store/ was tested with UTOPIA_DATABASE_URL set (those tests skip without it)

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for catching this. You're right that users_email_active_idx compares the stored spelling, so Ann@x and ann@x can both be active. fetch_optional would then pick one of them arbitrarily, and refusing before any write is the right fix. Preferring the exact spelling would just move the guess somewhere else. A few changes before merge:

  1. What the person sees. The AppError::Conflict lands in the refuse("unavailable", …) arm of account_of. In the browser that shows "Single sign-on is unavailable right now", and the audit row records unavailable. The person retries forever, and the operator sees what looks like an outage. Please return AppError::CodedConflict { code: "unlinked", … } instead. That matches the deactivated-address case (nothing to sign in to, ask an administrator), and the detail still reaches the log.
  2. ADR 0069. Decision 1 says a verified address maps "to the active account with that address (case-insensitive)", which assumes it is unique. Please add a dated revision note under it, per the README's "Revisions stay in place": the index is case-sensitive, so two active accounts can differ only in case, and such an address is now refused rather than mapped.
  3. Tests. The store test is the one that matters. In the exchange test, please drop the last check (sign-in through the existing subject). That path never reaches account_for_sso, so it re-proves behaviour this change leaves alone.
  4. Small one: // shortcut: isn't a convention here, and it repeats the doc line above it. One sentence on why the snapshot is enough for now is plenty.

A unique index on lower(email), plus normalising registration and admin creation, is the real fix. It needs a migration and a decision about existing duplicates, so it belongs in its own issue rather than in this PR.

@Floating-Y

Copy link
Copy Markdown
Contributor Author

@WaylandYang Thank you for the review. Addressed all four points in d3de92a:

  1. Ambiguous matches now return AppError::CodedConflict { code: "unlinked", ... }. The browser callback and audit retain that classification, and the diagnostic detail stays in logs. The exchange keeps its existing generic public response.
  2. Added a dated revision directly below ADR 0069 Decision 1, preserving the original statement and explaining the case-sensitive index and refusal of ambiguous matches. Its status line now references this PR and the remaining uniqueness work.
  3. Removed the final exchange assertion that re-tested sign-in through an existing subject. The store regression now asserts unlinked and still verifies unchanged account/identity snapshots, both exact spellings, and the already-bound candidate case.
  4. Replaced the shortcut: comments with one sentence explaining the snapshot scope and why concurrent uniqueness needs a separate migration and duplicate-handling decision.

CI run 38900751321 is green for this revision. The full workspace tests ran with PostgreSQL and UTOPIA_TEST_REQUIRE_DB=1; logs confirm the 5 SSO store tests and 10 OIDC tests passed. Workspace Clippy, formatting, build, migrations and web checks passed too. Local formatting, diff checks and scoped Clippy passed; the local Docker engine could not start, so this revision's database verification was done in CI.

Global case-insensitive email uniqueness, normalization and handling existing duplicates remain outside this PR. No migration or stored email was changed.

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, all four points are addressed. Ambiguous matches now come back as CodedConflict { code: "unlinked" }, so the browser and the audit say "not linked" instead of reporting an outage; the 0069 revision note says what was assumed, why it was wrong and what changed; the exchange test no longer re-proves the subject path; and the comment explains its scope. A unique index on lower(email), with normalisation and a decision on existing duplicates, belongs in its own issue. Merging.

@WaylandYang
WaylandYang merged commit ff52e45 into deeplethe:dev Oct 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants