From c3fa1b3e93c8e6738e11ea265d7447952bf228ef Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 1 Jun 2026 21:57:07 +0200 Subject: [PATCH] feat(magiclink) prepare magic link support (login via email) imortant on security side: magic link will be enabled only for users who don't have password nor OIDC --- .../20260612000004_magic_link_tokens.sql | 84 ++++++ .../services/auth_application_service.rs | 150 ++++++++++ .../services/external_identity_service.rs | 144 ++++++--- src/common/config.rs | 42 +++ src/common/di.rs | 33 +- src/domain/entities/magic_link_token.rs | 281 ++++++++++++++++++ src/domain/entities/mod.rs | 1 + .../magic_link_token_repository.rs | 49 +++ src/domain/repositories/mod.rs | 1 + src/infrastructure/auth_factory.rs | 12 + .../pg/magic_link_token_pg_repository.rs | 169 +++++++++++ src/infrastructure/repositories/pg/mod.rs | 2 + .../api/handlers/magic_link_handler.rs | 155 ++++++++++ src/interfaces/api/handlers/mod.rs | 1 + src/main.rs | 8 + 15 files changed, 1076 insertions(+), 56 deletions(-) create mode 100644 migrations/20260612000004_magic_link_tokens.sql create mode 100644 src/domain/entities/magic_link_token.rs create mode 100644 src/domain/repositories/magic_link_token_repository.rs create mode 100644 src/infrastructure/repositories/pg/magic_link_token_pg_repository.rs create mode 100644 src/interfaces/api/handlers/magic_link_handler.rs diff --git a/migrations/20260612000004_magic_link_tokens.sql b/migrations/20260612000004_magic_link_tokens.sql new file mode 100644 index 00000000..c384c383 --- /dev/null +++ b/migrations/20260612000004_magic_link_tokens.sql @@ -0,0 +1,84 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- Magic-link authentication tokens +-- ════════════════════════════════════════════════════════════════════════════ +-- One-shot opaque tokens issued in two situations: +-- +-- 1. Invitation flow — an internal user shares a resource with someone by +-- email; if the recipient has no account yet, OxiCloud creates an +-- external user (`is_external = TRUE`, no password) and mints a token +-- pointed at the target resource. Mail with `/magic/v1/{token}` is +-- delivered; clicking lands on the resource directly. +-- +-- 2. Login-via-email flow — a user with no other credential (typically a +-- previously-invited external user) requests a fresh login link from +-- `/login`. Token has NO resource target; redemption lands on +-- `/shared-with-me`. +-- +-- Tokens are 32 random bytes encoded as URL-safe base64 (43 chars). They +-- are stored in plaintext (single-use; revealed in the URL anyway) and the +-- table is indexed on `token` for O(1) redemption lookup. +-- +-- Lifecycle states: +-- pending → used (successful redemption; `used_at` stamped) +-- pending → expired (background sweep when `expires_at < NOW()`) +-- +-- The schema is intentionally close to `auth.device_codes` (initial_schema) +-- so future maintenance lessons learnt on one transfer to the other. + +DO $BODY$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'magic_link_status' AND n.nspname = 'auth' + ) THEN + CREATE TYPE auth.magic_link_status AS ENUM ( + 'pending', -- Issued, not yet redeemed + 'used', -- Redeemed exactly once; cannot be reused + 'expired' -- TTL exceeded without redemption + ); + END IF; +END $BODY$; + +CREATE TABLE IF NOT EXISTS auth.magic_link_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Plaintext base64url-encoded random bytes. The URL-as-credential model + -- means this column is the secret; access is restricted by table-level + -- permissions, not column-level hashing (matches device_codes). + token TEXT NOT NULL UNIQUE, + user_id UUID NOT NULL + REFERENCES auth.users(id) ON DELETE CASCADE, + status auth.magic_link_status NOT NULL DEFAULT 'pending', + issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE, + -- Optional deep-link target. Both columns NULL → generic "login via + -- email" flow, lands on /shared-with-me. Both NOT NULL → invitation + -- flow, lands directly on /folders/{id} or /files/{id}. The XOR-on- + -- NULL CHECK keeps the row consistent. + resource_type TEXT + CHECK (resource_type IS NULL OR resource_type IN ('file', 'folder')), + resource_id UUID, + CONSTRAINT magic_link_tokens_resource_pair + CHECK ((resource_type IS NULL) = (resource_id IS NULL)) +); + +-- Single-row lookup on every magic-link redemption. +CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_token + ON auth.magic_link_tokens (token); + +-- Sweep of expired pending tokens (cleanup job). +CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_expires_at + ON auth.magic_link_tokens (expires_at) + WHERE status = 'pending'; + +-- "List a user's outstanding tokens" (admin UI, or future +-- on_external_user_credential_set invalidation flow). +CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user_status + ON auth.magic_link_tokens (user_id, status); + +COMMENT ON TABLE auth.magic_link_tokens IS + 'One-shot opaque tokens for magic-link authentication (invitation + login-via-email flows). See migration file for the lifecycle and security model.'; + +COMMENT ON COLUMN auth.magic_link_tokens.token IS + 'URL-safe base64 of 32 random bytes (≈43 chars). Stored plaintext — the URL it sits in is the credential; column-level hashing would not change the threat model.'; diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 7038b2ec..9ec6ebe8 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -9,8 +9,10 @@ use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason}; use crate::application::services::user_lifecycle_service::UserLifecycleService; use crate::common::config::OidcConfig; use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus}; use crate::domain::entities::session::Session; use crate::domain::entities::user::{User, UserRole}; +use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository; use crate::infrastructure::repositories::pg::SessionPgRepository; use crate::infrastructure::repositories::pg::UserPgRepository; use crate::infrastructure::services::jwt_service::JwtTokenService; @@ -39,6 +41,17 @@ pub enum OidcCallbackResult { }, } +/// Outcome of a successful magic-link redemption. The auth tokens are +/// the same shape as a password login; the optional resource fields tell +/// the handler whether to deep-link to the invited resource or fall back +/// to the generic `/shared-with-me` landing. +#[derive(Debug, Clone)] +pub struct MagicLinkRedemption { + pub auth: AuthResponseDto, + pub resource_kind: Option, + pub resource_id: Option, +} + /// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce) #[derive(Clone)] struct PendingOidcFlow { @@ -86,6 +99,9 @@ pub struct AuthApplicationService { /// Pending one-time token codes for secure token delivery after OIDC callback. /// Auto-expires after 60 seconds via moka TTL; max 10 000 entries for DoS protection. pending_oidc_tokens: Cache, + /// Magic-link token repository — populated when the magic-link feature + /// is enabled (PR 8+). `None` means redemption endpoints return 503. + magic_link_repo: Option>, } impl AuthApplicationService { @@ -115,9 +131,24 @@ impl AuthApplicationService { .max_capacity(10_000) .time_to_live(Duration::from_secs(60)) .build(), + magic_link_repo: None, } } + /// 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. + pub fn with_magic_link_repo(mut self, repo: Arc) -> Self { + self.magic_link_repo = Some(repo); + self + } + + /// Whether magic-link redemption is wired. Handlers should check this + /// before attempting to redeem a token; `false` → return 503. + pub fn magic_link_enabled(&self) -> bool { + self.magic_link_repo.is_some() + } + /// Returns the default quota for the given role, capped to the available /// disk space on the filesystem that hosts the storage directory. fn capped_quota(&self, role: &UserRole) -> i64 { @@ -453,6 +484,125 @@ impl AuthApplicationService { }) } + /// Redeem a magic-link token and emit a fresh session in one shot. + /// + /// The flow: + /// 1. Look up the token in the repo. Unknown token → `NotFound`. + /// 2. Atomically transition `Pending → Used` via the repo's + /// `mark_used()` (single SQL UPDATE with `WHERE status='pending'`). + /// A second redemption attempt receives `Ok(false)` and is rejected + /// as `AccessDenied`. + /// 3. Load the user, verify they're active. + /// 4. Dispatch `on_user_login` (so HomeFolderLifecycleHook can + /// safety-net any internal user whose first credential happens + /// to be a magic link — externals short-circuit by `is_external()`). + /// 5. Register login + persist + issue session in the same pipeline + /// as password login. + /// + /// The returned `MagicLinkRedemption` carries the resource target so + /// the handler can build the redirect URL. + /// + /// Returns `ServiceUnavailable` (mapped from `NotImplemented`) when + /// the magic-link repo isn't wired — the handler maps that to HTTP 503. + pub async fn redeem_magic_link(&self, token: &str) -> Result { + let repo = self.magic_link_repo.as_ref().ok_or_else(|| { + DomainError::new( + ErrorKind::NotImplemented, + "MagicLink", + "magic-link feature is not configured on this server", + ) + })?; + + let mlt = repo.find_by_token(token).await?.ok_or_else(|| { + DomainError::new( + ErrorKind::NotFound, + "MagicLink", + "unknown or invalid magic link", + ) + })?; + + // Friendly early-rejection messages. The atomic `mark_used` + // below is the canonical single-use guard. + if mlt.status() == MagicLinkStatus::Used { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "MagicLink", + "this magic link has already been used", + )); + } + if mlt.is_expired() { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "MagicLink", + "this magic link has expired", + )); + } + + let consumed = repo.mark_used(mlt.id()).await?; + if !consumed { + // Either a concurrent redemption beat us, or the row was + // marked expired by the sweeper between our find and update. + return Err(DomainError::new( + ErrorKind::AccessDenied, + "MagicLink", + "this magic link has already been used", + )); + } + + let mut user = self.user_storage.get_user_by_id(mlt.user_id()).await?; + if !user.is_active() { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Auth", + "Account deactivated", + )); + } + + // Dispatch BEFORE register_login so hooks observing + // `last_login_at().is_none()` see "first ever login" correctly. + if let Some(lc) = &self.user_lifecycle { + lc.dispatch_login(&user).await; + } + user.register_login(); + self.user_storage.update_user(user.clone()).await?; + + let access_token = self.token_service.generate_access_token(&user)?; + let refresh_token = self.token_service.generate_refresh_token(); + let session = Session::new( + user.id(), + refresh_token.clone(), + None, + None, + self.token_service.refresh_token_expiry_days(), + Uuid::new_v4(), + ); + self.session_storage.create_session(session).await?; + + tracing::info!( + target: "audit", + event = "magic_link.redeemed", + user_id = %user.id(), + username = %user.username(), + is_external = user.is_external(), + resource_kind = ?mlt.resource_kind(), + resource_id = ?mlt.resource_id(), + ); + + let auth = AuthResponseDto { + user: UserDto::from(user), + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: self.token_service.refresh_token_expiry_secs(), + }; + + Ok(MagicLinkRedemption { + auth, + resource_kind: mlt.resource_kind(), + resource_id: mlt.resource_id(), + }) + } + /// Verifies username/password credentials without creating a session. pub async fn verify_credentials( &self, diff --git a/src/application/services/external_identity_service.rs b/src/application/services/external_identity_service.rs index ac76d28b..1ae12afe 100644 --- a/src/application/services/external_identity_service.rs +++ b/src/application/services/external_identity_service.rs @@ -2,51 +2,72 @@ //! //! Houses the lifecycle hook for grant-only external users — recipients //! authenticating via magic-link, OIDC-only, or OCM federation rather than -//! a local password. Today the module ships only a **stubbed -//! `ExternalIdentityLifecycleHook`**: it's registered on the dispatcher -//! so the slot exists in DI, but every method is an explicit `Ok(())` -//! no-op. The magic-link PR sequence will fill in the bodies. +//! a local password. PR 8 populates two of the four hook methods: //! -//! # What the populated hook will do (forward reference) +//! | Event | Today's action | +//! |-------------------|----------------------------------------------------| +//! | `on_user_created` | Audit event when the new user is external | +//! | `on_user_login` | Audit event when the logging-in user is external | +//! | `on_user_logout` | `Ok(())` — provenance is connection-level | +//! | `on_user_deleted` | Explicit cleanup of outstanding magic-link tokens | //! -//! A future `auth.user_external_identity` side-table will store provenance -//! per external user: +//! The token cleanup on deletion is technically redundant with the +//! `ON DELETE CASCADE` FK on `auth.magic_link_tokens.user_id`, but +//! calling it explicitly lets us: +//! - Emit a single audit event with the row count. +//! - Run inside the same transaction as the user DELETE so a hook +//! failure aborts the whole thing (matches the `on_user_deleted` +//! contract — see `user_lifecycle.rs` tip #7). //! -//! ```text -//! user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE -//! source TEXT NOT NULL CHECK (source IN ('magic_link','oidc','ocm')) -//! issuer TEXT -- OIDC iss URL or OCM partner FQDN -//! external_sub TEXT -- OIDC sub or OCM remote user id; NULL for magic_link -//! last_verified_at TIMESTAMPTZ NOT NULL DEFAULT now() -//! UNIQUE (source, issuer, external_sub) -//! ``` +//! # Future work //! -//! Then this hook will: +//! A future `auth.user_external_identity` side-table will store +//! provenance per external user (source, issuer, external_sub, +//! last_verified_at). When that lands, this hook will: +//! - `on_user_created` → INSERT the provenance row. +//! - `on_user_login` → UPDATE `last_verified_at`. +//! - `on_user_deleted` → no extra work (FK CASCADE handles it). //! -//! | Event | Action | -//! |-------------------|--------| -//! | `on_user_created` | If `user.is_external()`, INSERT a row into `auth.user_external_identity` with the source/issuer/sub captured from the create flow (magic-link bootstrap, OIDC JIT, OCM federation). | -//! | `on_user_login` | If `user.is_external()`, `UPDATE … SET last_verified_at = NOW()` for the user's provenance row. Used by the GDPR sweeper to identify "external users we haven't heard from in 13 months". | -//! | `on_user_logout` | `Ok(())` — provenance is connection-level, not session-level. | -//! | `on_user_deleted` | `Ok(())` — the FK CASCADE on `user_external_identity.user_id` handles row removal. | -//! -//! Today (PR 5): all four methods return `Ok(())` so the dispatcher -//! exercises the registration path without any side effect. +//! The current implementation reserves the slot without committing to +//! the schema yet. + +use std::sync::Arc; use async_trait::async_trait; use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook}; use crate::common::errors::DomainError; use crate::domain::entities::user::User; +use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository; -/// **Stubbed for now.** Populates the future `auth.user_external_identity` -/// side-table when the magic-link / external-user flow ships. Registered -/// on the dispatcher today as a no-op so the magic-link PR doesn't need to -/// touch DI — it only fills in the hook body. -/// -/// All four `UserLifecycleHook` methods are explicit `Ok(())` per the -/// "no defaults — every event acknowledged" convention. -pub struct ExternalIdentityLifecycleHook; +pub struct ExternalIdentityLifecycleHook { + /// `None` when the magic-link feature is disabled in this build — + /// the cleanup path becomes a no-op. Production DI always wires this. + magic_link_repo: Option>, +} + +impl ExternalIdentityLifecycleHook { + /// Construct a no-op hook. Used by test stubs that don't exercise + /// the magic-link path. + pub fn new() -> Self { + Self { + magic_link_repo: None, + } + } + + /// Wire the magic-link token repo. Called by DI when the magic-link + /// feature is enabled (PR 8 onwards). + pub fn with_magic_link_repo(mut self, repo: Arc) -> Self { + self.magic_link_repo = Some(repo); + self + } +} + +impl Default for ExternalIdentityLifecycleHook { + fn default() -> Self { + Self::new() + } +} #[async_trait] impl UserLifecycleHook for ExternalIdentityLifecycleHook { @@ -54,33 +75,64 @@ impl UserLifecycleHook for ExternalIdentityLifecycleHook { "external_identity" } - async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> { - // STUB: magic-link / OIDC JIT / OCM bootstrap PR will INSERT the - // provenance row here when `user.is_external()`. + async fn on_user_created(&self, user: &User) -> Result<(), DomainError> { + // Only externals are interesting to this hook — internal users go + // through the regular registration path that the audit hook + // already records. Future PRs (provenance side-table) will turn + // this into a SQL INSERT. + if user.is_external() { + tracing::info!( + target: "audit", + event = "external_user.created", + user_id = %user.id(), + username = %user.username(), + email = %user.email(), + ); + } Ok(()) } - async fn on_user_login(&self, _user: &User) -> Result<(), DomainError> { - // STUB: magic-link PR will UPDATE `last_verified_at` here so the - // GDPR sweeper can identify dormant external users. + async fn on_user_login(&self, user: &User) -> Result<(), DomainError> { + if user.is_external() { + tracing::info!( + target: "audit", + event = "external_user.login", + user_id = %user.id(), + username = %user.username(), + first_login = user.last_login_at().is_none(), + ); + } Ok(()) } async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> { - // Provenance is connection-level, not session-level — no work - // to do on logout even in the populated future version. + // Provenance is connection-level, not session-level. No work today. Ok(()) } async fn on_user_deleted( &self, - _user: &User, + user: &User, _mode: DeletionMode, - _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DomainError> { - // FK CASCADE on `auth.user_external_identity.user_id` will - // handle row removal automatically — no work needed here even - // in the populated future version. + // Best-effort cleanup of outstanding magic-link tokens. The + // `ON DELETE CASCADE` on the FK would handle this automatically + // after the user row is removed — calling it explicitly inside + // the same transaction lets us record an audit count, and + // ensures the cleanup is visible to any subsequent hook in the + // same dispatcher chain. + if let Some(repo) = &self.magic_link_repo { + let removed = repo.delete_all_for_user_tx(user.id(), tx).await?; + if removed > 0 { + tracing::info!( + target: "audit", + event = "external_user.tokens_cleared", + user_id = %user.id(), + tokens_removed = removed, + ); + } + } Ok(()) } } diff --git a/src/common/config.rs b/src/common/config.rs index 052dfa95..54e0ddf6 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -684,6 +684,34 @@ impl SmtpConfig { } } +/// Magic-link authentication configuration. Knobs that are specific to +/// the invite-by-email / login-via-email flow. +#[derive(Debug, Clone)] +pub struct MagicLinkConfig { + /// How long a freshly-minted magic-link token stays valid before the + /// background sweeper marks it expired. Default: 24 hours. + pub ttl_hours: u64, + /// Kill switch for the whole magic-link flow. When `false`: + /// - `POST /api/grants` rejects `subject.type = "email"` for unknown + /// email addresses (no lazy external-user creation). + /// - `POST /api/auth/magic-link/send` returns the uniform stub + /// response without actually issuing a token. + /// + /// This is the coarser "turn it all off" switch; the future + /// `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` allowlist is the fine-grained + /// version. + pub allow_external_users: bool, +} + +impl Default for MagicLinkConfig { + fn default() -> Self { + Self { + ttl_hours: 24, + allow_external_users: true, + } + } +} + /// Feature configuration (feature flags) #[derive(Debug, Clone)] pub struct FeaturesConfig { @@ -747,6 +775,8 @@ pub struct AppConfig { pub nextcloud: NextcloudConfig, /// Outbound SMTP configuration (magic-link invitations, etc.) pub smtp: SmtpConfig, + /// Magic-link authentication configuration (TTL, external-users kill switch) + pub magic_link: MagicLinkConfig, } impl Default for AppConfig { @@ -768,6 +798,7 @@ impl Default for AppConfig { wopi: WopiConfig::default(), nextcloud: NextcloudConfig::default(), smtp: SmtpConfig::default(), + magic_link: MagicLinkConfig::default(), } } } @@ -1262,6 +1293,17 @@ impl AppConfig { ); } + // Magic-link configuration + if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_TTL_HOURS") + && let Ok(h) = v.parse::() + && h > 0 + { + config.magic_link.ttl_hours = h; + } + if let Ok(v) = env::var("OXICLOUD_ALLOW_EXTERNAL_USERS") { + config.magic_link.allow_external_users = v.parse::().unwrap_or(true); + } + config } diff --git a/src/common/di.rs b/src/common/di.rs index 5f520de1..0644f82d 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -711,17 +711,29 @@ impl AppServiceFactory { // delete (with audit) — // replaces the silent FK // CASCADE. - // 5. ExternalIdentityLifecycleHook — STUB. No-op for every - // event today; the - // magic-link / OIDC-only / - // OCM PR will fill it in - // to populate - // `auth.user_external_identity`. + // 5. ExternalIdentityLifecycleHook — audit + magic-link + // token cleanup. Logs an + // audit event for any + // external user that gets + // created or logs in; + // transactionally clears + // outstanding magic-link + // tokens on delete (so a + // new user reusing the + // same id can never + // inherit an old token). // Last in the chain so it - // observes the latest user - // state before the chain - // commits. + // observes the latest + // user state before the + // chain commits. let session_repo_for_hook = Arc::new(SessionPgRepository::new(pool.clone())); + let magic_link_repo: Arc< + dyn crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository::new( + pool.clone(), + ), + ); let user_lifecycle = Arc::new( crate::application::services::user_lifecycle_service::UserLifecycleService::new() .with_hook(Arc::new( @@ -743,7 +755,8 @@ impl AppServiceFactory { ), )) .with_hook(Arc::new( - crate::application::services::external_identity_service::ExternalIdentityLifecycleHook, + crate::application::services::external_identity_service::ExternalIdentityLifecycleHook::new() + .with_magic_link_repo(magic_link_repo.clone()), )), ); diff --git a/src/domain/entities/magic_link_token.rs b/src/domain/entities/magic_link_token.rs new file mode 100644 index 00000000..55174527 --- /dev/null +++ b/src/domain/entities/magic_link_token.rs @@ -0,0 +1,281 @@ +//! Magic-link authentication tokens. +//! +//! Two distinct flows mint these tokens: +//! +//! - **Invitation** (PR 9). An internal user shares a resource with an email +//! address. If the recipient has no account yet, an external user is +//! lazily provisioned and a token is minted pointing at the resource. +//! Mail with `/magic/v1/{token}` is delivered; clicking the link +//! authenticates the recipient and 302s them to the resource. +//! +//! - **Login-via-email** (PR 10). A user without any other credential (an +//! already-existing external user who hasn't set a password) requests a +//! login link from `/login`. Token has NO resource target; redemption +//! lands on `/shared-with-me`. +//! +//! The two flows share the same redemption endpoint — the deep-link +//! decision is made by inspecting whether `resource_type/resource_id` are +//! present on the token row. +//! +//! Single-use is enforced by the `status` enum transitioning from +//! `Pending` → `Used` exactly once. The redemption endpoint runs the +//! transition inside a SQL transaction (`UPDATE ... WHERE status='pending'` +//! returning the row) so concurrent redemption attempts can't both +//! succeed. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::{DateTime, Duration, Utc}; +use rand_core::{OsRng, RngCore}; +use uuid::Uuid; + +/// Resource targeted by an invitation token. Mirrors +/// `domain::services::authorization::ResourceKind` but is duplicated here +/// to keep the entity self-contained (no auth-domain dependency). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MagicLinkResourceKind { + File, + Folder, +} + +impl MagicLinkResourceKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::File => "file", + Self::Folder => "folder", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "file" => Some(Self::File), + "folder" => Some(Self::Folder), + _ => None, + } + } +} + +/// Lifecycle state of a magic-link token. Strict one-way transitions: +/// `Pending → Used` (successful redemption) or `Pending → Expired` +/// (background sweep after `expires_at`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MagicLinkStatus { + Pending, + Used, + Expired, +} + +impl MagicLinkStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Used => "used", + Self::Expired => "expired", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "pending" => Some(Self::Pending), + "used" => Some(Self::Used), + "expired" => Some(Self::Expired), + _ => None, + } + } +} + +impl std::fmt::Display for MagicLinkStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Domain entity for a magic-link token row. +#[derive(Debug, Clone)] +pub struct MagicLinkToken { + id: Uuid, + /// 32 bytes of CSPRNG output, URL-safe base64 (no padding), ≈43 chars. + token: String, + user_id: Uuid, + status: MagicLinkStatus, + issued_at: DateTime, + expires_at: DateTime, + used_at: Option>, + /// Optional deep-link target. Both `Some` together → invitation flow; + /// both `None` together → login-via-email flow. Mismatched is a + /// schema-level error guarded by the DB CHECK `magic_link_tokens_resource_pair`. + resource_kind: Option, + resource_id: Option, +} + +impl MagicLinkToken { + /// Mint a fresh pending token. Generates 32 CSPRNG bytes, encodes them + /// URL-safe base64 (no padding), and stamps `issued_at = now`, + /// `expires_at = now + ttl_hours`. + /// + /// `resource` is `Some((kind, id))` for invitations (deep-link to a + /// specific file/folder) or `None` for login-via-email (lands on + /// `/shared-with-me`). The XOR-on-NULL DB CHECK enforces this + /// invariant; the entity exposes it as a single `Option` for + /// clarity. + pub fn new( + user_id: Uuid, + ttl_hours: u64, + resource: Option<(MagicLinkResourceKind, Uuid)>, + ) -> Self { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + let token = URL_SAFE_NO_PAD.encode(bytes); + + let now = Utc::now(); + let (resource_kind, resource_id) = match resource { + Some((k, id)) => (Some(k), Some(id)), + None => (None, None), + }; + + Self { + id: Uuid::new_v4(), + token, + user_id, + status: MagicLinkStatus::Pending, + issued_at: now, + expires_at: now + Duration::hours(ttl_hours as i64), + used_at: None, + resource_kind, + resource_id, + } + } + + /// Reconstruct from a database row. + #[allow(clippy::too_many_arguments)] + pub fn from_raw( + id: Uuid, + token: String, + user_id: Uuid, + status: MagicLinkStatus, + issued_at: DateTime, + expires_at: DateTime, + used_at: Option>, + resource_kind: Option, + resource_id: Option, + ) -> Self { + Self { + id, + token, + user_id, + status, + issued_at, + expires_at, + used_at, + resource_kind, + resource_id, + } + } + + // ── Getters ────────────────────────────────────────────────── + + pub fn id(&self) -> Uuid { + self.id + } + + pub fn token(&self) -> &str { + &self.token + } + + pub fn user_id(&self) -> Uuid { + self.user_id + } + + pub fn status(&self) -> MagicLinkStatus { + self.status + } + + pub fn issued_at(&self) -> DateTime { + self.issued_at + } + + pub fn expires_at(&self) -> DateTime { + self.expires_at + } + + pub fn used_at(&self) -> Option> { + self.used_at + } + + pub fn resource_kind(&self) -> Option { + self.resource_kind + } + + pub fn resource_id(&self) -> Option { + self.resource_id + } + + // ── Business logic ─────────────────────────────────────────── + + /// `true` once `expires_at < now`. The status column may still be + /// `Pending` if the background sweep hasn't run yet; treat this + /// method as authoritative at redemption time. + pub fn is_expired(&self) -> bool { + Utc::now() > self.expires_at + } + + /// `true` iff the token is in a state where it can be redeemed + /// (pending + not yet past TTL). The redemption endpoint should + /// check this; the DB-level `UPDATE WHERE status='pending'` is the + /// definitive single-use guard. + pub fn is_redeemable(&self) -> bool { + self.status == MagicLinkStatus::Pending && !self.is_expired() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_token_is_pending_and_within_ttl() { + let user_id = Uuid::new_v4(); + let token = MagicLinkToken::new(user_id, 24, None); + assert_eq!(token.status(), MagicLinkStatus::Pending); + assert_eq!(token.user_id(), user_id); + assert!(token.resource_kind().is_none()); + assert!(token.resource_id().is_none()); + assert!(token.is_redeemable()); + assert!(!token.is_expired()); + // 32 bytes → 43 chars URL-safe base64 (no padding). + assert_eq!(token.token().len(), 43); + } + + #[test] + fn new_token_with_resource_carries_both_fields() { + let user_id = Uuid::new_v4(); + let folder_id = Uuid::new_v4(); + let token = MagicLinkToken::new( + user_id, + 24, + Some((MagicLinkResourceKind::Folder, folder_id)), + ); + assert_eq!(token.resource_kind(), Some(MagicLinkResourceKind::Folder)); + assert_eq!(token.resource_id(), Some(folder_id)); + } + + #[test] + fn each_token_is_unique() { + let user_id = Uuid::new_v4(); + let a = MagicLinkToken::new(user_id, 24, None); + let b = MagicLinkToken::new(user_id, 24, None); + assert_ne!(a.token(), b.token()); + assert_ne!(a.id(), b.id()); + } + + #[test] + fn status_round_trip() { + for s in [ + MagicLinkStatus::Pending, + MagicLinkStatus::Used, + MagicLinkStatus::Expired, + ] { + assert_eq!(MagicLinkStatus::parse(s.as_str()), Some(s)); + } + } +} diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index abfc2d6e..f020f3d8 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -6,6 +6,7 @@ pub mod device_code; pub mod entity_errors; pub mod file; pub mod folder; +pub mod magic_link_token; pub mod playlist; pub mod session; pub mod share; diff --git a/src/domain/repositories/magic_link_token_repository.rs b/src/domain/repositories/magic_link_token_repository.rs new file mode 100644 index 00000000..22709c18 --- /dev/null +++ b/src/domain/repositories/magic_link_token_repository.rs @@ -0,0 +1,49 @@ +//! Storage port for [`MagicLinkToken`]. +//! +//! Minimal CRUD surface — magic-link tokens have only three lifecycle +//! states (`Pending`, `Used`, `Expired`) and three callers (mint at invite +//! time, redeem at click time, sweep at maintenance time). New methods +//! should be resisted until a concrete consumer needs them. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::common::errors::DomainError; +use crate::domain::entities::magic_link_token::MagicLinkToken; + +#[async_trait] +pub trait MagicLinkTokenRepository: Send + Sync + 'static { + /// Persist a freshly-minted pending token. + async fn create(&self, token: &MagicLinkToken) -> Result<(), DomainError>; + + /// Look up a token by its opaque value. Returns `Ok(None)` when no row + /// matches (use this for "unknown token" rather than treating it as an + /// error). The caller is responsible for checking `is_redeemable()` + /// before honouring the token. + async fn find_by_token(&self, token: &str) -> Result, DomainError>; + + /// Atomically transition a token from `Pending` → `Used`. Returns + /// `Ok(true)` exactly when this call performed the transition; a + /// concurrent redemption attempt receives `Ok(false)` and must reject + /// the request. Implementations MUST do this in a single SQL + /// statement (`UPDATE … WHERE status='pending' …`) — the row-level + /// lock provided by Postgres' MVCC is what makes single-use + /// enforcement race-free. + async fn mark_used(&self, id: Uuid) -> Result; + + /// Delete every token that has expired (status pending, expires_at + /// in the past). Returns the number of rows removed; called from a + /// background sweeper that runs on a slow cadence (≤ once per hour). + async fn delete_expired(&self) -> Result; + + /// Hard-delete every still-outstanding token for a user. Called by + /// the user-lifecycle `on_user_deleted` hook so an admin's delete + /// can't leave dangling tokens behind. Operates inside the caller's + /// transaction so the cleanup commits atomically with the user + /// DELETE. + async fn delete_all_for_user_tx( + &self, + user_id: Uuid, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result; +} diff --git a/src/domain/repositories/mod.rs b/src/domain/repositories/mod.rs index de7e365b..2a4e51b2 100644 --- a/src/domain/repositories/mod.rs +++ b/src/domain/repositories/mod.rs @@ -4,6 +4,7 @@ pub mod calendar_repository; pub mod contact_repository; pub mod file_repository; pub mod folder_repository; +pub mod magic_link_token_repository; pub mod playlist_repository; pub mod session_repository; pub mod settings_repository; diff --git a/src/infrastructure/auth_factory.rs b/src/infrastructure/auth_factory.rs index 42ce88a8..2003464f 100644 --- a/src/infrastructure/auth_factory.rs +++ b/src/infrastructure/auth_factory.rs @@ -7,6 +7,8 @@ use crate::application::services::auth_application_service::AuthApplicationServi use crate::application::services::user_lifecycle_service::UserLifecycleService; use crate::common::config::AppConfig; use crate::common::di::AuthServices; +use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository; +use crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository; use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository}; use crate::infrastructure::services::jwt_service::JwtTokenService; use crate::infrastructure::services::oidc_service::OidcService; @@ -50,6 +52,16 @@ pub async fn create_auth_services( // direct FolderService dependency for that path. auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle); + // Wire the magic-link token repo. Enables `GET /magic/v1/{token}` + // and the future `POST /api/auth/magic-link/send` endpoint to mint + // and consume tokens. The repo is unconditional (it's just SQL on + // an empty table when the feature is dormant); the feature kill + // switch lives in `config.magic_link.allow_external_users`, checked + // by the issuance side, not by the redemption side. + let magic_link_repo: Arc = + Arc::new(MagicLinkTokenPgRepository::new(pool.clone())); + auth_app_service = auth_app_service.with_magic_link_repo(magic_link_repo); + // Configure OIDC service if enabled if config.oidc.enabled { tracing::info!( diff --git a/src/infrastructure/repositories/pg/magic_link_token_pg_repository.rs b/src/infrastructure/repositories/pg/magic_link_token_pg_repository.rs new file mode 100644 index 00000000..7061cb70 --- /dev/null +++ b/src/infrastructure/repositories/pg/magic_link_token_pg_repository.rs @@ -0,0 +1,169 @@ +//! PostgreSQL implementation of [`MagicLinkTokenRepository`]. +//! +//! Mirrors the layout of `device_code_pg_repository.rs` — same crate +//! conventions (handcrafted SQL, `Row` extraction in a `map_row` helper, +//! enum cast in the INSERT statement). + +use async_trait::async_trait; +use sqlx::{PgPool, Postgres, Row, Transaction}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::magic_link_token::{ + MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken, +}; +use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository; + +pub struct MagicLinkTokenPgRepository { + pool: Arc, +} + +impl MagicLinkTokenPgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + fn map_row(row: &sqlx::postgres::PgRow) -> Result { + let status_str: String = row.try_get("status").map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "MagicLinkToken", + format!("read status: {}", e), + ) + })?; + let status = MagicLinkStatus::parse(&status_str).unwrap_or(MagicLinkStatus::Expired); + + let resource_type: Option = row.try_get("resource_type").ok(); + let resource_kind = resource_type.and_then(|s| MagicLinkResourceKind::parse(&s)); + let resource_id: Option = row.try_get("resource_id").ok(); + + Ok(MagicLinkToken::from_raw( + row.try_get("id").unwrap(), + row.try_get("token").unwrap_or_default(), + row.try_get("user_id").unwrap(), + status, + row.try_get("issued_at").unwrap_or_default(), + row.try_get("expires_at").unwrap_or_default(), + row.try_get("used_at").ok(), + resource_kind, + resource_id, + )) + } +} + +#[async_trait] +impl MagicLinkTokenRepository for MagicLinkTokenPgRepository { + async fn create(&self, token: &MagicLinkToken) -> Result<(), DomainError> { + sqlx::query( + r#" + INSERT INTO auth.magic_link_tokens ( + id, token, user_id, status, + issued_at, expires_at, used_at, + resource_type, resource_id + ) VALUES ( + $1, $2, $3, $4::auth.magic_link_status, + $5, $6, $7, + $8, $9 + ) + "#, + ) + .bind(token.id()) + .bind(token.token()) + .bind(token.user_id()) + .bind(token.status().as_str()) + .bind(token.issued_at()) + .bind(token.expires_at()) + .bind(token.used_at()) + .bind(token.resource_kind().map(|k| k.as_str())) + .bind(token.resource_id()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("MagicLinkToken", format!("insert: {}", e)))?; + Ok(()) + } + + async fn find_by_token(&self, token: &str) -> Result, DomainError> { + let row = sqlx::query( + r#" + SELECT id, token, user_id, status::text AS status, + issued_at, expires_at, used_at, + resource_type, resource_id + FROM auth.magic_link_tokens + WHERE token = $1 + "#, + ) + .bind(token) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("MagicLinkToken", format!("find_by_token: {}", e)) + })?; + + row.map(|r| Self::map_row(&r)).transpose() + } + + async fn mark_used(&self, id: Uuid) -> Result { + // The `status = 'pending'` predicate is what makes single-use + // race-free: a concurrent redemption attempt sees the row + // already updated (or in the middle of being updated, blocking + // on Postgres' row lock) and gets `rows_affected = 0`. + let result = sqlx::query( + r#" + UPDATE auth.magic_link_tokens + SET status = 'used'::auth.magic_link_status, + used_at = NOW() + WHERE id = $1 + AND status = 'pending'::auth.magic_link_status + AND expires_at > NOW() + "#, + ) + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("MagicLinkToken", format!("mark_used: {}", e)))?; + + Ok(result.rows_affected() == 1) + } + + async fn delete_expired(&self) -> Result { + // Hard-delete: the audit trail lives in the `tracing` log, not + // the table. Keeping expired rows around would just bloat the + // index without adding security value. + let result = sqlx::query( + r#" + DELETE FROM auth.magic_link_tokens + WHERE status = 'pending'::auth.magic_link_status + AND expires_at < NOW() + "#, + ) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("MagicLinkToken", format!("delete_expired: {}", e)) + })?; + + Ok(result.rows_affected()) + } + + async fn delete_all_for_user_tx( + &self, + user_id: Uuid, + tx: &mut Transaction<'_, Postgres>, + ) -> Result { + let result = sqlx::query( + r#" + DELETE FROM auth.magic_link_tokens + WHERE user_id = $1 + "#, + ) + .bind(user_id) + .execute(&mut **tx) + .await + .map_err(|e| { + DomainError::internal_error("MagicLinkToken", format!("delete_all_for_user_tx: {}", e)) + })?; + + Ok(result.rows_affected()) + } +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 34e535f1..6a1aaf22 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -8,6 +8,7 @@ mod contact_pg_repository; mod device_code_pg_repository; mod favorites_pg_repository; pub mod file_metadata_repository; +mod magic_link_token_pg_repository; mod nextcloud_object_id_repository; pub mod playlist_pg_repository; mod recent_items_pg_repository; @@ -37,6 +38,7 @@ pub use file_blob_read_repository::FileBlobReadRepository; pub use file_blob_write_repository::FileBlobWriteRepository; pub use file_metadata_repository::FileMetadataRepository; pub use folder_db_repository::FolderDbRepository; +pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository; pub use nextcloud_object_id_repository::NextcloudObjectIdRepository; pub use playlist_pg_repository::{ AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository, diff --git a/src/interfaces/api/handlers/magic_link_handler.rs b/src/interfaces/api/handlers/magic_link_handler.rs new file mode 100644 index 00000000..eb3aaa5c --- /dev/null +++ b/src/interfaces/api/handlers/magic_link_handler.rs @@ -0,0 +1,155 @@ +//! Magic-link redemption endpoint. +//! +//! Single public route: `GET /magic/v1/{token}`. Validating the token is +//! the entire authentication — the URL is the credential. +//! +//! Successful redemption: +//! 1. Atomically marks the token used (single-use, race-free). +//! 2. Issues access + refresh JWT for the token's owning user. +//! 3. Sets the standard `oxicloud_access` / `oxicloud_refresh` / +//! `oxicloud_csrf` cookies (same as `POST /api/auth/login`). +//! 4. 302-redirects to a frontend hash-route based on the token's +//! resource target: +//! - Folder → `/#/files/folder/{id}` +//! - File or NULL → `/#/sharedwithme` +//! +//! Files don't have a deep-link route today; v1 lands file invitations +//! on Shared With Me where the file shows up. +//! +//! Failure cases (all return 4xx without setting cookies): +//! - Token not found / expired / already used → 410 Gone. +//! - Magic-link feature disabled (no SMTP / repo) → 503. +//! - Owning user deactivated → 410 Gone. + +use std::sync::Arc; + +use axum::{ + Router, + extract::{Path, State}, + http::{HeaderValue, StatusCode, header::CONTENT_TYPE, header::LOCATION}, + response::{IntoResponse, Response}, + routing::get, +}; + +use crate::application::services::auth_application_service::MagicLinkRedemption; +use crate::common::di::AppState; +use crate::common::errors::ErrorKind; +use crate::domain::entities::magic_link_token::MagicLinkResourceKind; +use crate::interfaces::api::cookie_auth; + +/// Build the `/magic/v1/{token}` router. Mounted at the top of the +/// application tree in `main.rs` — no auth middleware, no CSRF (the +/// token is the credential, the route is GET-only). +pub fn magic_link_routes() -> Router> { + Router::new().route("/magic/v1/{token}", get(redeem_magic_link)) +} + +#[utoipa::path( + get, + path = "/magic/v1/{token}", + params(("token" = String, Path, description = "Opaque magic-link token")), + responses( + (status = 302, description = "Redemption succeeded — redirects to the resource or to /#/sharedwithme"), + (status = 410, description = "Token is unknown, expired, or already used"), + (status = 503, description = "Magic-link feature is not configured on this server"), + ), + tag = "magic-link", +)] +async fn redeem_magic_link( + State(state): State>, + Path(token): Path, +) -> Response { + let Some(auth_svc) = state.auth_service.as_ref() else { + return error_page( + StatusCode::SERVICE_UNAVAILABLE, + "Authentication subsystem is not configured.", + ); + }; + + match auth_svc + .auth_application_service + .redeem_magic_link(&token) + .await + { + Ok(redemption) => build_success_response(&state, redemption), + Err(e) => { + // Log the cause for ops; the user gets a generic page so the + // outcome can't be used as an enumeration oracle. + tracing::info!( + target: "audit", + event = "magic_link.redemption_failed", + error_kind = ?e.kind, + error = %e.message, + ); + match e.kind { + ErrorKind::NotImplemented => error_page( + StatusCode::SERVICE_UNAVAILABLE, + "Magic-link sign-in is not enabled on this server.", + ), + ErrorKind::NotFound | ErrorKind::AccessDenied => error_page( + StatusCode::GONE, + "This sign-in link is no longer valid. It may have already been \ + used or expired. Request a fresh link from the login page.", + ), + _ => error_page( + StatusCode::INTERNAL_SERVER_ERROR, + "Something went wrong while signing you in. Please try again.", + ), + } + } + } +} + +fn build_success_response(state: &Arc, redemption: MagicLinkRedemption) -> Response { + let target = redirect_target(redemption.resource_kind, redemption.resource_id); + + let mut response = (StatusCode::FOUND, [(LOCATION, target.as_str())]).into_response(); + + cookie_auth::append_auth_cookies( + response.headers_mut(), + &redemption.auth.access_token, + &redemption.auth.refresh_token, + redemption.auth.expires_in, + state.core.config.auth.refresh_token_expiry_secs, + ); + cookie_auth::append_csrf_cookie(response.headers_mut(), redemption.auth.expires_in); + + response +} + +/// Build the SPA hash-route the redemption should land on. Mirrors the +/// front-end's `deserializeHash()` parser at `static/js/app/main.js`. +fn redirect_target(kind: Option, id: Option) -> String { + match (kind, id) { + (Some(MagicLinkResourceKind::Folder), Some(folder_id)) => { + format!("/#/files/folder/{}", folder_id) + } + _ => "/#/sharedwithme".to_string(), + } +} + +fn error_page(status: StatusCode, message: &str) -> Response { + let body = format!( + "OxiCloud\ + \ +

Sign-in link

{}

\ +

Return to OxiCloud

", + html_escape(message) + ); + + let mut response = (status, body).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response +} + +/// Tiny HTML escape — only used in the error fallback page. Anything more +/// elaborate belongs in a templating layer (not in scope here). +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index d41a37e8..382808a7 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -13,6 +13,7 @@ pub mod file_handler; pub mod folder_handler; pub mod grant_handler; pub mod i18n_handler; +pub mod magic_link_handler; pub mod music_handler; pub mod photos_handler; pub mod recent_handler; diff --git a/src/main.rs b/src/main.rs index 19a5130d..b40dfb5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -392,9 +392,17 @@ async fn main() -> Result<(), Box> { auth_middleware, )); + // Magic-link redemption — public, no CSRF, no rate limit (the token IS + // the credential and `mark_used` is single-use). PR 12 will add a + // per-IP limiter on top. + let magic_link_router = interfaces::api::handlers::magic_link_handler::magic_link_routes() + .with_state(app_state.clone()); + app = Router::new() // Health / readiness probes — no auth, mounted at root .merge(health_routes) + // Magic-link redemption — top-level, no `/api/` prefix + .merge(magic_link_router) // Rate-limited auth endpoints (login, register, refresh) .nest("/api/auth", auth_login) .nest("/api/auth", auth_register)