From 46f5379deff98e4d5741151d6031b013ff0680b6 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 2 Jun 2026 22:39:46 +0200 Subject: [PATCH] doc(auth): flag account when email has been verified (security for future) --- docs/plan/auth-simplification.md | 77 ++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/docs/plan/auth-simplification.md b/docs/plan/auth-simplification.md index f4e4b8d0..2628b4b3 100644 --- a/docs/plan/auth-simplification.md +++ b/docs/plan/auth-simplification.md @@ -100,6 +100,7 @@ Forward-only migrations. The previous `…000003_users_username_email_login.sql` | **20** | Anti-enumeration `register`: uniform 200 regardless of outcome, real reasons in audit channel. | Closes the username/email enumeration oracle that survives from the original schema. | | **21** | `docs/architecture/auth-model.md` (~250 lines) + sidebar + cross-references. Acceptance gate. | Big-picture documentation of the final identity / credential / login surface. | | **22** | Device-bound magic-link redemption (challenge cookie) + asymmetric TTLs: login-via-email tokens live 10 minutes, invitation tokens live 24 hours. | Closes the mailbox-as-bearer-token attack class on the login flow. Invitations stay cross-device (recipient has no prior browser context with the server). | +| **23** | `auth.users.email_verified_at` column + flip-to-verified on magic-link redemption / OIDC-with-verified-claim. Read-only API surface for now (future PRs gate features on it). | Establishes a verified-control-of-inbox signal so later policy can require it (e.g. block uploads / shares for unverified users). | ## Critical files @@ -222,6 +223,82 @@ Items the conversation explicitly deferred. Each has a clear future trigger. - **Recovery codes / passkey enrolment.** Tied to native 2FA above. - **Per-user opt-out of magic-link when lenient mode is on.** Today the env flag is instance-wide. A future per-account toggle (e.g. high-privilege admins disabling magic-link for themselves) would need an `auth.users.magic_link_disabled BOOLEAN` column and one extra branch in eligibility. Listed in `auth-model.md`. +### PR 23 — Email-verified signal (design recap) + +**Goal**: track whether the user has demonstrated control of their email address. The signal is data-only in PR 23 — future PRs will gate features (uploads, shares, sensitive operations) on it via an env switch. + +**Schema migration**: + +```sql +ALTER TABLE auth.users + ADD COLUMN email_verified_at TIMESTAMPTZ NULL; + +-- Backfill: anyone who has successfully been through a flow that +-- proves email control gets stamped retroactively. OIDC implies the +-- IdP confirmed the email; external users who've logged in at least +-- once must have clicked an invitation link. +UPDATE auth.users + SET email_verified_at = COALESCE(last_login_at, created_at) + WHERE oidc_subject IS NOT NULL + OR (is_external = TRUE AND last_login_at IS NOT NULL); + +COMMENT ON COLUMN auth.users.email_verified_at IS + 'When the user demonstrated control of their email. NULL = unverified + (password-only signup whose user never clicked a verification link, or + admin-created user who hasn''t logged in via magic-link). Set on + successful magic-link redemption OR OIDC JIT with email_verified=true claim.'; +``` + +**Entity additions** (`domain/entities/user.rs`): + +```rust +pub email_verified_at: Option>, + +impl User { + pub fn is_email_verified(&self) -> bool { + self.email_verified_at.is_some() + } + /// Stamp the verification time. Idempotent — keeps the first + /// verification timestamp on re-verification to preserve the + /// "first proof of control" semantics. + pub fn mark_email_verified(&mut self) { + if self.email_verified_at.is_none() { + self.email_verified_at = Some(Utc::now()); + self.updated_at = Utc::now(); + } + } +} +``` + +**Trigger points** (one call per flow): + +1. `magic_link_invite_service::redeem` — after successful token consumption (clicking the link IS the proof). Set on **every** magic-link redemption regardless of whether it's an invitation or a login-via-email. +2. `auth_application_service::oidc_callback` JIT-create branch — when claims include `email_verified=true`. The existing `email_verified` check (around line 1666) already enforces this for OIDC; we just persist the timestamp. +3. `auth_application_service::oidc_callback` existing-user branch — if the IdP STILL says verified AND our column is NULL (e.g. user predates this PR), upgrade them retroactively. + +**Trigger points that do NOT set it** (worth being explicit): + +- Classic password registration via `/api/auth/register` with both email + password — the user gave us an email but hasn't proven it works. +- Admin-create-user via the admin panel — admin asserts the email; user hasn't. +- The email-only signup welcome path: the FIRST magic-link redemption stamps the flag (PR 23 hook). So between "user signed up email-only" and "user clicked welcome link", they're unverified. + +**API surface**: + +- `UserDto.email_verified_at: Option>` (with `#[serde(skip_serializing_if = "Option::is_none")]` to match the existing optional fields). +- `GET /api/auth/me` and `GET /api/users/{id}` carry it through transparently. No new endpoints in PR 23. + +**Hurl coverage**: + +- After bob redeems his invitation: `GET /api/users/{bob_user_id}` returns `email_verified_at` set. +- After charlie (classic password registration): `GET /api/auth/me` returns no `email_verified_at` (omitted from JSON). +- After charlie later runs through magic-link (lenient mode): flag flips to non-NULL. + +**What PR 23 does NOT do** (deferred): + +- The env switch `OXICLOUD_REQUIRE_EMAIL_VERIFICATION` that gates uploads/shares/etc. — that's a future feature PR. PR 23 just establishes the signal. +- UI affordances ("verify your email" banner, resend button) — future frontend PR. +- Per-feature thresholds (e.g. "verified users can share publicly, unverified can only share with internal users") — future policy PRs. + ### PR 22 — Device-bound magic-link redemption (design recap) **Threat closed**: today a magic-link URL is a bearer token — anyone who reads the recipient's email can redeem it. PR 22 binds the login-via-email path to the originating browser so mailbox compromise alone no longer grants a session.