test(login/register): via password or magic-link

Password login

┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                        Case                        │         Where          │                                          Assertion                                          │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L1  │ Login by username                                  │ auth_login.hurl Case 1 │ 200 + access_token, user.email match                                                        │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L2  │ Login by email (dispatch on @)                     │ auth_login.hurl Case 2 │ 200, same session shape as L1                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L3  │ Bad password on username path                      │ auth_login.hurl Case 3 │ 403 anti-enum                                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L4  │ Bad password on email path                         │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L5  │ Unknown username                                   │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L6  │ Unknown email                                      │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L7  │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │
└─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘

Password registration

┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐
│  #  │                       Case                        │            Where             │                        Assertion                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R1  │ Classic username + email + password → uniform 200 │ registration.hurl Step 2     │ anti-enum message contains "request received"           │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R2  │ Login after register works                        │ registration.hurl Step 2b    │ 200 + session for the new user                          │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R3  │ Email collision → uniform 200 (no rewrite)        │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R4  │ Username collision → uniform 200                  │ registration.hurl Step 11    │ same anti-enum shape                                    │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R5  │ Off-domain rejection                              │ registration.hurl Step 12    │ 403 RegistrationDomainNotAllowed                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R6  │ Case-insensitive domain match                     │ registration.hurl Step 12b   │ uniform 200 on charlie@EXAMPLE.COM                      │
└─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘

Magic-link registration (email-only signup)

┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐
│  #  │                                               Case                                               │             Where             │                   Assertion                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR1 │ Email-only signup → welcome mail queued                                                          │ registration.hurl Step 3      │ uniform 200 + browser-binding cookie set       │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR2 │ Welcome mail contains magic-link URL                                                             │ registration.hurl Step 4      │ captured from mock SMTP                        │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR3 │ PR 22 cross-browser confirmation page                                                            │ registration.hurl Step 5a     │ 200 HTML "different browser"                   │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR4 │ Cookie-bound redemption lands on SPA                                                             │ registration.hurl Step 5b     │ 302 → /files (SvelteKit route, post-migration) │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR5 │ email_verified_at stamped after redemption                                                       │ registration.hurl Step 6      │ field present on /api/auth/me                  │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR6 │ Second magic-link post-signup                                                                    │ registration.hurl Step 7      │ uniform 200                                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle                         │
└─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘

Magic-link login (existing account)

┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐
│  #  │                           Case                           │                Where                 │                             Assertion                              │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML1 │ Baseline password login still works                      │ auth_magic_link_login.hurl Steps 1-2 │ 200                                                                │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML2 │ magic-link/send with email identifier                    │ auth_magic_link_login.hurl Step 3    │ uniform 200 + cookie                                               │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4    │ uniform 200                                                        │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML4 │ Password-user policy: mail actually sent                 │ auth_magic_link_login.hurl Step 5    │ SMTP capture proves permit_magic_link_for_password_users in effect │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML5 │ Redemption creates a session                             │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user                   │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML6 │ Anti-enum on unknown identifier                          │ auth_magic_link_login.hurl Step 8    │ same uniform 200 shape as ML3                                      │
└─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘

OIDC

┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                                  Case                                  │       Where       │                                                        Assertion                                                        │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O1  │ Setup local admin (bootstrap)                                          │ oidc.hurl Step 1  │ 201                                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2  │ Providers endpoint — OIDC visible                                      │ oidc.hurl Step 2  │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2b │ Magic-link/send refused (endpoint layer)                               │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired                                       │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O3  │ Authorize redirect includes PKCE + state                               │ oidc.hurl Step 3  │ 307 to fake IdP                                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O4  │ IdP round-trip + JIT provisioning                                      │ oidc.hurl Step 4  │ Callback lands on /login?oidc_code=…                                                                                    │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O5  │ Code exchange → session cookies                                        │ oidc.hurl Step 5  │ 200 + all three cookies                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O6  │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6  │ every claim reflected on /api/auth/me                                                                                   │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O7  │ Refresh rotation on OIDC session                                       │ oidc.hurl Step 7  │ new access/refresh/CSRF cookies                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O8  │ Refreshed cookies authenticate                                         │ oidc.hurl Step 8  │ 200 on /api/auth/me                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O9  │ Repeat login = same local user (no dup)                                │ oidc.hurl Step 9  │ user_id stable                                                                                                          │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O10 │ Anti-takeover: unverified email → refused                              │ oidc.hurl Step 10 │ 401/403                                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O11 │ One-time code replay refused                                           │ oidc.hurl Step 11 │ second /exchange → 401                                                                                                  │
└─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

test
This commit is contained in:
Edouard Vanbelle
2026-07-14 01:46:33 +02:00
parent 01da450cf6
commit e94063d96a
27 changed files with 1746 additions and 300 deletions
+14 -1
View File
@@ -264,13 +264,26 @@ pub struct OidcExchangeDto {
pub code: String,
}
/// Information about available OIDC providers
/// Information about available OIDC providers + self-service auth
/// methods enabled on the deployment. Consumed by the login page to
/// decide which forms/buttons to render.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct OidcProviderInfoDto {
pub enabled: bool,
pub provider_name: String,
pub authorize_endpoint: String,
pub password_login_enabled: bool,
/// True iff the server accepts magic-link login requests
/// (`OXICLOUD_AUTH_METHODS` includes `magic_link` AND SMTP is
/// configured). Frontend renders the magic-link form when true.
#[serde(default)]
pub magic_link_login_enabled: bool,
/// True iff `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set. Frontend uses
/// this hint to explain the `EmailNotVerified` login response and
/// to nudge new users toward the magic-link verification path
/// straight after signup.
#[serde(default)]
pub require_verified_email: bool,
}
/// Claims extracted from the validated OIDC ID token
@@ -7,7 +7,7 @@ use crate::application::ports::auth_ports::{
};
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
use crate::application::services::user_lifecycle_service::UserLifecycleService;
use crate::common::config::OidcConfig;
use crate::common::config::{AuthMethod, OidcConfig};
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus};
use crate::domain::entities::session::Session;
@@ -148,6 +148,16 @@ pub struct AuthApplicationService {
/// per request; the known mutation paths (`change_user_role`,
/// `set_user_active`) also invalidate eagerly.
user_flags_cache: Cache<Uuid, UserFlags>,
/// Self-service auth-method allowlist (mirrors
/// `AuthConfig::allowed_auth_methods`). Empty = both methods
/// allowed. Consulted by login / register / magic-link handlers via
/// `is_password_login_allowed()` / `is_magic_link_login_allowed()`
/// so callers don't have to reach for the app config.
allowed_auth_methods: Vec<AuthMethod>,
/// Whether `POST /api/auth/login` refuses accounts whose
/// `email_verified_at IS NULL`. Mirrors
/// `AuthConfig::require_verified_email`.
require_verified_email: bool,
}
/// TTL for [`AuthApplicationService::user_flags_cache`]. Upper bound on how
@@ -191,9 +201,95 @@ impl AuthApplicationService {
.max_capacity(10_000)
.time_to_live(USER_FLAGS_CACHE_TTL)
.build(),
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
require_verified_email: false,
}
}
/// Populates the auth-method allowlist + `require_verified_email`
/// snapshot from the loaded config. Called by the DI factory. If
/// left uncalled (test builds), defaults are permissive: both
/// methods enabled, verified-email not required.
pub fn with_auth_policy(
mut self,
allowed_methods: Vec<AuthMethod>,
require_verified_email: bool,
) -> Self {
self.allowed_auth_methods = allowed_methods;
self.require_verified_email = require_verified_email;
self
}
/// True iff `POST /api/auth/login` is a supported endpoint on this
/// deployment. Composes the OIDC `disable_password_login` legacy
/// flag with the newer `OXICLOUD_AUTH_METHODS` allowlist.
pub fn is_password_login_allowed(&self) -> bool {
!self.password_login_disabled()
&& (self.allowed_auth_methods.is_empty()
|| self.allowed_auth_methods.contains(&AuthMethod::Password))
}
/// True iff `POST /api/auth/magic-link/send` should mint tokens for
/// end-user login on this deployment.
///
/// Requires ALL of:
/// * repo wired (SMTP configured, tokens can actually be minted);
/// * allowlist permits `MagicLink` (or is empty = permissive);
/// * OIDC is NOT enabled at the deployment level.
///
/// The OIDC guard is a hard rule: when OIDC is enabled it is the
/// master identity provider — magic-link would bypass any 2FA / step-up
/// policy that the IdP enforces. An operator running OIDC + local
/// accounts hybrid must NOT expose magic-link login for the local
/// accounts either, because a user provisioned via OIDC-JIT could
/// receive a magic-link on the same mailbox and sidestep MFA. Admin-
/// mediated invites use OIDC or password bootstrap instead.
pub fn is_magic_link_login_allowed(&self) -> bool {
self.magic_link_enabled()
&& !self.oidc_enabled()
&& (self.allowed_auth_methods.is_empty()
|| self.allowed_auth_methods.contains(&AuthMethod::MagicLink))
}
/// True iff login should reject accounts with `email_verified_at IS
/// NULL`. Backed by `OXICLOUD_REQUIRE_VERIFIED_EMAIL`.
pub fn require_verified_email(&self) -> bool {
self.require_verified_email
}
/// Resolve a login-identifier (username OR email) to the account's
/// registered email address. Mirrors the `POST /api/auth/login`
/// dispatcher (`@` presence → email lookup, else → username
/// lookup). Returns `None` when the identifier doesn't match any
/// account — callers that need anti-enumeration semantics MUST
/// still return their uniform response after logging the reason.
///
/// The username namespace forbids `@` (PR 16), so the two paths
/// are disjoint — no ambiguity.
pub async fn resolve_login_identifier_to_email(&self, identifier: &str) -> Option<String> {
if identifier.contains('@') {
Some(identifier.to_string())
} else {
self.user_storage
.get_user_by_username(identifier)
.await
.ok()
.map(|u| u.email().to_string())
}
}
/// Direct lookup helpers used by handlers that need the full `User`
/// entity (not just the email). Mirrors the internal `user_storage`
/// calls the service already makes in `login`. Currently used by
/// the login handler to auto-mint a verification magic-link after
/// a successful password check.
pub async fn find_user_by_email(&self, email: &str) -> Result<User, DomainError> {
self.user_storage.get_user_by_email(email).await
}
pub async fn find_user_by_username(&self, username: &str) -> Result<User, DomainError> {
self.user_storage.get_user_by_username(username).await
}
/// Wire the magic-link token repository. Called from the DI factory
/// when the magic-link feature is configured. Mirrors the
/// `with_oidc` / `with_user_lifecycle` builder pattern.
@@ -508,6 +604,13 @@ impl AuthApplicationService {
)
})?;
// First-run admin is authoritative by definition — they set the
// password themselves, at the console, on a fresh install. Mark
// verified so `OXICLOUD_REQUIRE_VERIFIED_EMAIL` never locks the
// sole account with root-level power out of their own instance.
let mut user = user;
user.mark_email_verified();
let created_user = self.user_storage.create_user(user).await?;
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
@@ -527,6 +630,26 @@ impl AuthApplicationService {
}
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
// Gate: policy may forbid password logins entirely (either the
// legacy OIDC-only mode or the newer `OXICLOUD_AUTH_METHODS`
// allowlist without `password`). Refuse BEFORE the user lookup
// so we don't leak account existence via timing on a disabled
// endpoint.
if !self.is_password_login_allowed() {
tracing::info!(
target: "audit",
event = "auth.login_rejected",
reason = "password_login_disabled",
attempted_username = %dto.username,
"🔐 login rejected: password login disabled by policy",
);
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Password login is disabled",
));
}
// Dispatch on `@` in the input: presence of `@` means an email
// was typed, absence means a username. The two namespaces are
// provably disjoint (PR 16 forbids `@` in usernames), so this
@@ -612,6 +735,45 @@ impl AuthApplicationService {
));
}
// Gate: `OXICLOUD_REQUIRE_VERIFIED_EMAIL`. Checked AFTER password
// validation so an attacker with only a username cannot probe
// account verification state (the response shape is
// `Invalid credentials` for bad passwords regardless of whether
// the email is verified — a wrong-password observer learns
// nothing).
//
// ADMIN EXEMPTION: admins are trusted by fiat and predate this
// gate. Fresh admin accounts (admin_create_user /
// setup_create_admin) are stamped verified at creation; the
// exemption covers pre-existing admin accounts installed before
// the flag shipped.
//
// The auto-send of a verification magic-link when this branch
// fires is done at the handler layer (login handler triggers
// `send_verification_link_authenticated`) rather than here —
// the service returns the distinguished error and the handler
// orchestrates the side effect. Keeps this method side-effect-
// free on the audit path.
if self.require_verified_email
&& !matches!(user.role(), UserRole::Admin)
&& !user.is_email_verified()
{
tracing::info!(
target: "audit",
event = "auth.login_rejected",
reason = "email_not_verified",
user_id = %user.id(),
username = %user.display_for_audit(),
"🔐 login rejected: email not verified for '{}' (password OK)",
user.display_for_audit(),
);
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Email not verified",
));
}
// Lifecycle: dispatch login BEFORE register_login() so hooks
// observing `last_login_at().is_none()` see "first ever login"
// correctly. See tip #1 in user_lifecycle.rs.
@@ -689,6 +851,17 @@ impl AuthApplicationService {
)
})?;
// Defense-in-depth: if magic-link login was minted under an older
// policy and the operator has since flipped OIDC on (or dropped
// `MagicLink` from `OXICLOUD_AUTH_METHODS`), we must not honour
// pre-existing login tokens. Invitation tokens (resource_kind =
// File / Folder) are checked separately below — they represent
// an admin-mediated invite, which is a distinct policy question
// from "self-service login via email".
//
// We do the token lookup FIRST so we can classify by
// `resource_kind()` before applying the gate — invitations
// survive, plain logins do not.
let mlt = repo.find_by_token(token).await?.ok_or_else(|| {
// Audit: unknown / forged magic-link redemption. The first
// 8 chars of the bogus token are logged so a recurring
@@ -710,6 +883,27 @@ impl AuthApplicationService {
)
})?;
// Enforce the login-magic-link policy on stale tokens.
// resource_kind = None means "plain login-via-email"; anything
// else is an invite (which follows its own admin-mediated
// trust chain). Refuse the login case if the current policy
// forbids magic-link login.
if mlt.resource_kind().is_none() && !self.is_magic_link_login_allowed() {
tracing::info!(
target: "audit",
event = "magic_link.redemption_rejected",
reason = "login_disabled_by_policy",
token_id = %mlt.id(),
user_id = %mlt.user_id(),
"🔗 magic-link rejected: login-via-email disabled by policy (OIDC-master or allowlist)",
);
return Err(DomainError::new(
ErrorKind::AccessDenied,
"MagicLink",
"magic-link login is disabled",
));
}
// Friendly early-rejection messages. The atomic `mark_used`
// below is the canonical single-use guard.
if mlt.status() == MagicLinkStatus::Used {
@@ -1717,6 +1911,16 @@ impl AuthApplicationService {
)
})?;
// Admin fiat counts as verification. When
// `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set, admin-created users
// still get to log in without a magic-link round-trip — the
// operator explicitly vouched for the address at creation. This
// mirrors the OIDC-JIT convention (see `redeem_pending_oidc_token`
// and `login_oidc_callback` which also stamp
// `email_verified_at` on first sight).
let mut user = user;
user.mark_email_verified();
// Persist
let created = self.user_storage.create_user(user).await?;
@@ -615,6 +615,123 @@ impl MagicLinkInviteService {
Ok(())
}
/// Mint + email a magic-link for **email verification**, called
/// only after another authentication factor has already proven the
/// caller's identity (currently: the login handler after a
/// successful password check).
///
/// Contract: the caller MUST have validated the user's identity via
/// an independent factor before invoking this. The method does NOT
/// re-verify credentials — it exists specifically to bypass the
/// `has_password` eligibility gate, which would otherwise deadlock
/// the `OXICLOUD_REQUIRE_VERIFIED_EMAIL` flow (login rejected as
/// unverified → user asks for a verification link → refused
/// because they have a password).
///
/// Rejected: OIDC-linked users, deactivated users. Everything else
/// gets a token — including the "has password" case that
/// `send_login_link` refuses.
pub async fn send_verification_link_authenticated(
&self,
user: &User,
request_challenge: &str,
) -> Result<(), DomainError> {
// OIDC boundary is unconditional even here — the IdP owns the
// identity contract and we must not mint a session-primitive
// for a user it manages.
if user.is_oidc_user() {
tracing::info!(
target: "audit",
event = "auth.magic_link_send",
reason = "oidc_user",
user_id = %user.id(),
username = %user.display_for_audit(),
"🔗 verify-link suppressed: OIDC user",
);
return Ok(());
}
if !user.is_active() {
tracing::info!(
target: "audit",
event = "auth.magic_link_send",
reason = "account_deactivated",
user_id = %user.id(),
username = %user.display_for_audit(),
"🔗 verify-link suppressed: account deactivated",
);
return Ok(());
}
let token = MagicLinkToken::new(
user.id(),
chrono::Duration::minutes(self.magic_link_cfg.login_ttl_minutes as i64),
None,
Some(request_challenge.to_string()),
);
self.magic_link_repo.create(&token).await?;
let link = format!(
"{}/magic/v1/{}",
self.public_base_url.trim_end_matches('/'),
token.token(),
);
// Reuses the login email template for now — same call to
// action (click the link), same TTL, same challenge binding.
// A dedicated "verify your email" template can land later
// without wire changes.
let locale = self.locale_for(user);
let ttl_minutes = self.magic_link_cfg.login_ttl_minutes.to_string();
let login_args: Vec<(&str, &str)> = vec![("link", &link), ("ttl_minutes", &ttl_minutes)];
let subject = self
.i18n_or(
"server.magic_link.email.login.subject",
&locale,
&login_args,
)
.await;
let text_body = self
.render_bilingual("server.magic_link.email.login.body", &locale, &login_args)
.await;
let message = EmailMessage {
to: user.email().to_string(),
subject,
text_body,
html_body: None,
};
match self.email_sender.send(message).await {
Ok(outcome) => {
tracing::info!(
target: "audit",
event = "auth.magic_link_send",
reason = "sent_verification",
user_id = %user.id(),
username = %user.display_for_audit(),
email = %user.email(),
smtp_code = outcome.code,
smtp_message = %outcome.message,
"🔗 verify-link sent to '{}'",
user.email(),
);
}
Err(e) => {
tracing::warn!(
target: "audit",
event = "auth.magic_link_send_failed",
user_id = %user.id(),
email = %user.email(),
error = %e.message,
"🔗 verify-link SMTP send failed for '{}'",
user.email(),
);
}
}
Ok(())
}
/// Resolve a translation, falling back to the literal key on any
/// lookup error. Identical to the handler-side helper — kept inline
/// here because the service layer can't pull in a UI util module
@@ -486,7 +486,7 @@ impl RecipientNotificationService {
// body — same pattern as `MagicLinkInviteService::issue_invitation`.
let inviter_short = granter.display_full(false);
let inviter_full = granter.display_full(true);
let login_link = format!("{}/#/login", self.public_base_url.trim_end_matches('/'),);
let login_link = format!("{}/login", self.public_base_url.trim_end_matches('/'),);
let args: Vec<(&str, &str)> = vec![
("inviter", inviter_short.as_str()),