From 6aba7cbbbf7754b07e24003effe7145f699357ee Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 2 Jun 2026 23:55:50 +0200 Subject: [PATCH] feat(email_verified): store email verification on a user --- ...622000000_auth_users_email_verified_at.sql | 51 +++++++++++++++++++ src/application/dtos/user_dto.rs | 8 +++ .../services/auth_application_service.rs | 28 ++++++++-- src/domain/entities/user.rs | 44 ++++++++++++++++ .../repositories/pg/user_pg_repository.rs | 30 +++++++---- tests/api/external_users.hurl | 6 +++ tests/api/registration.hurl | 5 ++ 7 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 migrations/20260622000000_auth_users_email_verified_at.sql diff --git a/migrations/20260622000000_auth_users_email_verified_at.sql b/migrations/20260622000000_auth_users_email_verified_at.sql new file mode 100644 index 00000000..95e956d5 --- /dev/null +++ b/migrations/20260622000000_auth_users_email_verified_at.sql @@ -0,0 +1,51 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- Email-verified signal (PR 23) +-- ════════════════════════════════════════════════════════════════════════════ +-- Tracks when the user demonstrated control of their email address. +-- +-- NULL — unverified. Classic password-only signup whose user +-- never clicked any magic-link, or admin-created user +-- who hasn't logged in via magic-link. +-- non-NULL — timestamp of the FIRST proof of control. Stamped on: +-- * successful magic-link redemption (invitation OR +-- login-via-email — clicking the link IS the proof). +-- * OIDC JIT-provisioning when the IdP's claim +-- `email_verified` was true. The OIDC callback already +-- refuses to proceed without that claim, so the +-- timestamp is set unconditionally at JIT creation. +-- * Retroactive OIDC upgrade: existing user whose +-- email_verified_at is NULL but whose next OIDC login +-- carries a verified claim gets the stamp at that +-- login. +-- +-- PR 23 introduces the signal only — no policy gates yet. Future +-- env (e.g. OXICLOUD_REQUIRE_EMAIL_VERIFICATION) will block uploads / +-- shares / etc. for unverified users. +-- +-- Backfill rules: +-- * OIDC-linked users — the IdP already vetted the email at +-- provisioning time. Use last_login_at if set (typical), else +-- created_at as the verification timestamp. +-- * External users who have logged in at least once — they must have +-- clicked their invitation link to land last_login_at. Use the +-- last login time as a conservative lower bound on when the +-- verification proof happened. +-- * Everyone else stays NULL — including OIDC-less external users +-- who got invited but never clicked (the magic-link is still +-- sitting in their inbox), and classic password users who never +-- went through a magic-link flow. + +ALTER TABLE auth.users + ADD COLUMN email_verified_at TIMESTAMPTZ NULL; + +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 address. + NULL = unverified. Set on successful magic-link redemption OR + OIDC JIT with email_verified=true claim. Idempotent — the first + verification timestamp is preserved. PR 23 ships the signal; + future policy PRs gate features on it.'; diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index fd90861a..0de91de0 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -40,6 +40,13 @@ pub struct UserDto { /// `given_name`. #[serde(skip_serializing_if = "Option::is_none")] pub family_name: Option, + /// When the user first demonstrated control of their email (PR 23). + /// `None` = unverified (omitted from JSON). Stamped on the first + /// successful magic-link redemption or OIDC JIT with verified + /// claim. Idempotent — the original timestamp is preserved on + /// subsequent verifications. + #[serde(skip_serializing_if = "Option::is_none")] + pub email_verified_at: Option>, } impl From for UserDto { @@ -61,6 +68,7 @@ impl From for UserDto { is_external: user.is_external(), given_name: user.given_name().map(str::to_string), family_name: user.family_name().map(str::to_string), + email_verified_at: user.email_verified_at(), } } } diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 6069a526..901abb04 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -794,6 +794,11 @@ impl AuthApplicationService { lc.dispatch_login(&user).await; } user.register_login(); + // PR 23: clicking the magic-link IS proof of email control — + // stamp the verification (idempotent, preserves the first + // timestamp). Applies to both invitation and login-via-email + // tokens. + user.mark_email_verified(); self.user_storage.update_user(user.clone()).await?; let access_token = self.token_service.generate_access_token(&user)?; @@ -827,11 +832,13 @@ impl AuthApplicationService { expires_in: self.token_service.refresh_token_expiry_secs(), }; - Ok(MagicLinkRedeemResult::Allowed(Box::new(MagicLinkRedemption { - auth, - resource_kind: mlt.resource_kind(), - resource_id: mlt.resource_id(), - }))) + Ok(MagicLinkRedeemResult::Allowed(Box::new( + MagicLinkRedemption { + auth, + resource_kind: mlt.resource_kind(), + resource_id: mlt.resource_id(), + }, + ))) } /// Verifies username/password credentials without creating a session. @@ -1887,6 +1894,12 @@ impl AuthApplicationService { } existing_user.register_login(); existing_user.set_image(claims.picture.clone()); + // PR 23: retroactive email verification for OIDC users + // who predate the column. The OIDC callback already + // enforced `claims.email_verified == true` upstream, so + // any user reaching this branch has a verified email + // by the IdP's word; stamping is safe and idempotent. + existing_user.mark_email_verified(); self.user_storage.update_user(existing_user.clone()).await?; existing_user } @@ -1983,6 +1996,11 @@ impl AuthApplicationService { new_user.set_image(claims.picture.clone()); new_user.set_given_name(claims.given_name.clone()); new_user.set_family_name(claims.family_name.clone()); + // PR 23: the OIDC callback rejected any caller upstream + // whose `email_verified` claim wasn't true, so users + // reaching this branch have an IdP-vetted email. Stamp + // the verification at JIT-create time. + new_user.mark_email_verified(); let created_user = self.user_storage.create_user(new_user).await?; diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index 98093953..f6288826 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -61,6 +61,17 @@ pub struct User { /// standard claim `family_name` at JIT provisioning, or via the /// profile-edit endpoint. External users start with `None`. family_name: Option, + /// When the user demonstrated control of their email address (PR 23). + /// `None` = unverified. `Some(ts)` = timestamp of the first proof, + /// preserved across subsequent verifications. + /// + /// Set on successful magic-link redemption (invitation OR + /// login-via-email — clicking the link proves the inbox is theirs) + /// or on OIDC JIT with `email_verified=true` claim. Classic password + /// signups stay `None` until the user goes through a magic-link + /// flow. PR 23 ships the signal only — future policy PRs gate + /// features (uploads, shares, etc.) on this column. + email_verified_at: Option>, } impl User { @@ -146,6 +157,10 @@ impl User { is_external, given_name: None, family_name: None, + // PR 23: unverified at creation. Stamped on the first + // magic-link redemption or OIDC JIT (where the IdP has + // already confirmed the email). + email_verified_at: None, }) } @@ -187,6 +202,7 @@ impl User { is_external: false, given_name: None, family_name: None, + email_verified_at: None, } } @@ -209,6 +225,7 @@ impl User { is_external: bool, given_name: Option, family_name: Option, + email_verified_at: Option>, ) -> Self { Self { id, @@ -228,6 +245,7 @@ impl User { is_external, given_name, family_name, + email_verified_at, } } @@ -331,6 +349,32 @@ impl User { self.family_name.as_deref() } + /// When the user first demonstrated control of their email (PR 23). + /// `None` = unverified. See `mark_email_verified` for the trigger + /// points (magic-link redemption, OIDC JIT with verified claim). + pub fn email_verified_at(&self) -> Option> { + self.email_verified_at + } + + /// `true` iff the user has demonstrated control of their email. + /// Convenience wrapper over `email_verified_at().is_some()`. + pub fn is_email_verified(&self) -> bool { + self.email_verified_at.is_some() + } + + /// Stamp the first proof-of-email-control timestamp. **Idempotent**: + /// if `email_verified_at` is already `Some`, this is a no-op so + /// re-verifications preserve the original time. Call from the + /// magic-link redemption path and from OIDC JIT when the IdP + /// confirms the email. + pub fn mark_email_verified(&mut self) { + if self.email_verified_at.is_none() { + let now = Utc::now(); + self.email_verified_at = Some(now); + self.updated_at = now; + } + } + pub fn set_image(&mut self, image: Option) { self.image = image; self.updated_at = Utc::now(); diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 56daffd8..94c536f3 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -96,10 +96,10 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, is_external, - given_name, family_name + given_name, family_name, email_verified_at ) VALUES ( $1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11, - $12, $13, $14, $15, $16 + $12, $13, $14, $15, $16, $17 ) RETURNING * "#, @@ -120,6 +120,7 @@ impl UserRepository for UserPgRepository { .bind(user_clone.is_external()) .bind(user_clone.given_name()) .bind(user_clone.family_name()) + .bind(user_clone.email_verified_at()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -144,7 +145,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name + given_name, family_name, email_verified_at FROM auth.users WHERE id = $1 "#, @@ -179,6 +180,7 @@ impl UserRepository for UserPgRepository { row.get("is_external"), row.get("given_name"), row.get("family_name"), + row.get("email_verified_at"), )) } @@ -191,7 +193,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name + given_name, family_name, email_verified_at FROM auth.users WHERE username = $1 "#, @@ -226,6 +228,7 @@ impl UserRepository for UserPgRepository { row.get("is_external"), row.get("given_name"), row.get("family_name"), + row.get("email_verified_at"), )) } @@ -238,7 +241,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name + given_name, family_name, email_verified_at FROM auth.users WHERE email = $1 "#, @@ -273,6 +276,7 @@ impl UserRepository for UserPgRepository { row.get("is_external"), row.get("given_name"), row.get("family_name"), + row.get("email_verified_at"), )) } @@ -299,7 +303,8 @@ impl UserRepository for UserPgRepository { active = $10, image = $11, given_name = $12, - family_name = $13 + family_name = $13, + email_verified_at = $14 WHERE id = $1 "#, ) @@ -316,6 +321,7 @@ impl UserRepository for UserPgRepository { .bind(user_clone.image()) .bind(user_clone.given_name()) .bind(user_clone.family_name()) + .bind(user_clone.email_verified_at()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -388,7 +394,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name + given_name, family_name, email_verified_at FROM auth.users WHERE ($3 OR is_external = FALSE) ORDER BY created_at DESC @@ -430,6 +436,7 @@ impl UserRepository for UserPgRepository { row.get("is_external"), row.get("given_name"), row.get("family_name"), + row.get("email_verified_at"), ) }) .collect(); @@ -451,7 +458,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name + given_name, family_name, email_verified_at FROM auth.users WHERE (username ILIKE $1 OR email ILIKE $1) AND ($3 OR is_external = FALSE) @@ -493,6 +500,7 @@ impl UserRepository for UserPgRepository { row.get("is_external"), row.get("given_name"), row.get("family_name"), + row.get("email_verified_at"), ) }) .collect(); @@ -580,7 +588,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name + given_name, family_name, email_verified_at FROM auth.users WHERE role::text = $1 ORDER BY created_at DESC @@ -619,6 +627,7 @@ impl UserRepository for UserPgRepository { row.get("is_external"), row.get("given_name"), row.get("family_name"), + row.get("email_verified_at"), ) }) .collect(); @@ -655,7 +664,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name + given_name, family_name, email_verified_at FROM auth.users WHERE oidc_provider = $1 AND oidc_subject = $2 "#, @@ -690,6 +699,7 @@ impl UserRepository for UserPgRepository { row.get("is_external"), row.get("given_name"), row.get("family_name"), + row.get("email_verified_at"), )) } diff --git a/tests/api/external_users.hurl b/tests/api/external_users.hurl index d07b42f3..3a1ea8e8 100644 --- a/tests/api/external_users.hurl +++ b/tests/api/external_users.hurl @@ -224,6 +224,9 @@ jsonpath "$.id" == "{{bob_user_id}}" jsonpath "$.is_external" == true jsonpath "$.email" == "bob@externalcompany.com" jsonpath "$.username" not exists +# PR 23 — bob redeemed his invitation magic-link in Step 8, so his +# email_verified_at was stamped at that time and stays set. +jsonpath "$.email_verified_at" exists # 11d — bob CAN look up Alice (his granter) — shared-grant relationship # lets the external recipient resolve the sharer's display name + @@ -235,6 +238,9 @@ HTTP 200 [Asserts] jsonpath "$.id" == "{{alice_user_id}}" jsonpath "$.is_external" == false +# PR 23 — alice is the admin set up via classic password registration +# and has never clicked a magic-link, so her email is unverified. +jsonpath "$.email_verified_at" not exists # 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404 # (anti-enumeration; same response as "user doesn't exist"). diff --git a/tests/api/registration.hurl b/tests/api/registration.hurl index 925de708..68df1d54 100644 --- a/tests/api/registration.hurl +++ b/tests/api/registration.hurl @@ -137,6 +137,11 @@ HTTP 200 jsonpath "$.email" == "pr18-emailonly@example.com" jsonpath "$.is_external" == false jsonpath "$.username" not exists +# PR 23 — the user redeemed the welcome magic-link in Step 5b, so +# email_verified_at is stamped (the click IS the proof of inbox +# control, regardless of whether the redemption went through the +# direct or cross-browser-confirm path). +jsonpath "$.email_verified_at" exists [Captures] pr18_user_id: jsonpath "$.id"