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
This commit is contained in:
Edouard Vanbelle
2026-06-01 21:57:07 +02:00
parent 2011d19e71
commit c3fa1b3e93
15 changed files with 1076 additions and 56 deletions
@@ -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.';
@@ -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<MagicLinkResourceKind>,
pub resource_id: Option<Uuid>,
}
/// 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<String, PendingOidcToken>,
/// Magic-link token repository — populated when the magic-link feature
/// is enabled (PR 8+). `None` means redemption endpoints return 503.
magic_link_repo: Option<Arc<dyn MagicLinkTokenRepository>>,
}
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<dyn MagicLinkTokenRepository>) -> 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<MagicLinkRedemption, DomainError> {
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,
@@ -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<Arc<dyn MagicLinkTokenRepository>>,
}
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<dyn MagicLinkTokenRepository>) -> 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(())
}
}
+42
View File
@@ -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::<u64>()
&& 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::<bool>().unwrap_or(true);
}
config
}
+23 -10
View File
@@ -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()),
)),
);
+281
View File
@@ -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<Self> {
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<Self> {
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<Utc>,
expires_at: DateTime<Utc>,
used_at: Option<DateTime<Utc>>,
/// 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<MagicLinkResourceKind>,
resource_id: Option<Uuid>,
}
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<Utc>,
expires_at: DateTime<Utc>,
used_at: Option<DateTime<Utc>>,
resource_kind: Option<MagicLinkResourceKind>,
resource_id: Option<Uuid>,
) -> 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<Utc> {
self.issued_at
}
pub fn expires_at(&self) -> DateTime<Utc> {
self.expires_at
}
pub fn used_at(&self) -> Option<DateTime<Utc>> {
self.used_at
}
pub fn resource_kind(&self) -> Option<MagicLinkResourceKind> {
self.resource_kind
}
pub fn resource_id(&self) -> Option<Uuid> {
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));
}
}
}
+1
View File
@@ -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;
@@ -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<Option<MagicLinkToken>, 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<bool, DomainError>;
/// 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<u64, DomainError>;
/// 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<u64, DomainError>;
}
+1
View File
@@ -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;
+12
View File
@@ -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<dyn MagicLinkTokenRepository> =
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!(
@@ -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<PgPool>,
}
impl MagicLinkTokenPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
fn map_row(row: &sqlx::postgres::PgRow) -> Result<MagicLinkToken, DomainError> {
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<String> = row.try_get("resource_type").ok();
let resource_kind = resource_type.and_then(|s| MagicLinkResourceKind::parse(&s));
let resource_id: Option<Uuid> = 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<Option<MagicLinkToken>, 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<bool, DomainError> {
// 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<u64, DomainError> {
// 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<u64, DomainError> {
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())
}
}
@@ -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,
@@ -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<Arc<AppState>> {
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<Arc<AppState>>,
Path(token): Path<String>,
) -> 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<AppState>, 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<MagicLinkResourceKind>, id: Option<uuid::Uuid>) -> 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!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>OxiCloud</title>\
<style>body{{font-family:system-ui,sans-serif;max-width:640px;margin:6em auto;\
padding:0 1em;color:#333}}h1{{font-size:1.4em}}p{{line-height:1.5}}</style>\
</head><body><h1>Sign-in link</h1><p>{}</p>\
<p><a href=\"/\">Return to OxiCloud</a></p></body></html>",
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
+1
View File
@@ -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;
+8
View File
@@ -392,9 +392,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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)