Skip to content

feat: dedicated verify-before-apply email-change flow - #115

Open
felixgateru wants to merge 8 commits into
mainfrom
feat/verified-email-change-flow
Open

felixgateru wants to merge 8 commits into
mainfrom
feat/verified-email-change-flow

Conversation

@felixgateru

@felixgateru felixgateru commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Problem

Fix

  • POST /auth/email/change/request — real session only (no access token, scoped or unscoped), rejects a session older than ATOM_EMAIL_CHANGE_MAX_SESSION_AGE_SECS (stand-in for step-up reauth), enumeration-resistant, mutates nothing — only mints a single-use token
  • POST /auth/email/change/confirm — unauthenticated (the token, provable only by receipt at the proposed mailbox, is the credential); locks the entity then the canonical entity_emails row, fails safely (without consuming the token) if the email drifted since the request, atomically updates the canonical email + password credential identifier + attributes.email mirror, invalidates stale tokens, revokes every session, notifies the old address with no secret in it
  • publishes the existing frozen entity.update event rather than a new event name (v1 event names are frozen)

Security hardening

  • fixed a real concurrency gap: upsert_oauth_identity's auto-link lookup only locked the entities row, not entity_emails — could commit a link keyed to an email being concurrently moved away, including against the existing admin sync path. Now locks both.
  • explicit case-insensitive collision recheck at confirmation, independent of the DB index's case sensitivity

Tests

  • 14 new tests: request/confirm isolation, wrong/expired/replayed/superseded tokens, atomic multi-table update, case-variant collision recheck, old-login-fails/new-login-succeeds, stale-token invalidation, safe drift handling, a lock-contention proof for the OAuth fix, single-connection-pool coverage
  • full unit + contract suite and 75 tests in adjacent identity/bootstrap suites, no regressions

Part of #110 (workstream A).

…ability so it matches the migration-seeded database contract

Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
…ady hit the undeclared api_endpoint applicability error

Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
… verified email-change flow

Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
…confirm flow

Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
…contracts

Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
@arvindh123

Copy link
Copy Markdown
Contributor

Re-reviewed PR #115 at b4eaaa06f0a8419ace2a7b6c270a9173153206f4. I recommend addressing the following findings before merging.

  1. [P1] Pending email-change tokens survive password recovery.
    src/identity/service.rs:870–915
    Password reset replaces the password and revokes sessions, but does not invalidate email_change_tokens. An attacker with a briefly compromised recent session can request a change to their mailbox, wait for the owner to reset their password, then confirm the pending change and regain control through password recovery. Invalidate pending email-change tokens inside the password-reset transaction and add a regression test. This can preserve cross-device confirmation without requiring the requesting session to remain alive.

  2. [P2] Confirmation bypasses config-managed identity protection.
    src/identity/service.rs:1194–1217
    Confirmation updates credentials and the attributes.email mirror without checking managed_by = 'config'. A bootstrap-provisioned global human can therefore change configuration-owned identity data. On restart, bootstrap rejects the changed attributes as “different semantics,” preventing startup. Apply the existing ownership guards inside the confirmation transaction and cover config-managed identities in the tests.

  3. [P2] Concurrent requests can leave multiple valid pending tokens.
    src/identity/service.rs:1004–1031
    The supersession transaction does not lock the entity before updating and inserting tokens. Two overlapping requests can each miss the other’s uncommitted token, leaving both pending; either proposed address can win confirmation, violating the documented “latest request only” behavior. I reproduced two remaining pending tokens using this SQL in disposable PostgreSQL during the initial review. The SQL is unchanged at this head. Serialize issuance using the entity lock and add concurrent-request coverage.

CI blocker: An extra blank line before the compatibility-test comment in tests/m26_config_managed_capabilities.rs fails formatting. I reproduced the formatting failure against the exact PR file. The Rust CI job stops at formatting, before Clippy and tests; the other three reported checks pass.

Validation boundary: this re-review inspected the exact PR commits and confirmed that the email-change implementation is unchanged from the initial review. git diff --check passed. The Rust and database integration suites were not rerun during the re-review.

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