// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 //! [`MagicLinkProvider`] — passwordless `AuthProvider` impl. //! //! Stateless HMAC-signed tokens. No DB. The provider is a value-typed //! cube: `dna`, `parent`, `hmac_key`, `ttl_secs`. Construct via //! [`MagicLinkProvider::new`] (explicit) or [`MagicLinkProvider::from_env`] //! (reads `MAGICLINK_HMAC_KEY` and `MAGICLINK_TTL_SECS`). //! //! ## Trait convention quirk //! //! [`AuthChallenge::MagicLink`] only carries an `email` field. Two paths use it: //! //! - [`MagicLinkProvider::issue_challenge`] — `email` is the user's address. //! The provider does NOT send the email itself (no dependency on //! `kei-notify-*`); callers build the URL via //! [`MagicLinkProvider::build_magic_url`] and dispatch through their own //! notify channel. //! - [`MagicLinkProvider::verify`] — `email` MUST be the FULL token string //! returned in the URL's `?token=…` query param. Callers wire the web //! handler to slot the token into the `email` field. This is the minimum //! change consistent with the trait surface as of v0.1; a future //! `AuthChallenge::MagicLinkVerify { token }` variant would be cleaner. use crate::env::{read_env, MIN_KEY_LEN}; use crate::error::{Error, Result}; use crate::token::{build_token, parse_token}; use async_trait::async_trait; use kei_runtime_core::dna::{Dna, DnaBuilder, HasDna}; use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider, AuthSession}; /// Stateless HMAC-SHA256 magic-link provider. #[derive(Debug)] pub struct MagicLinkProvider { dna: Dna, parent: Dna, hmac_key: Vec, ttl_secs: i64, } impl MagicLinkProvider { /// Construct with an explicit parent DNA, key bytes, and TTL. pub fn new(parent: Dna, hmac_key: Vec, ttl_secs: i64) -> Result { if hmac_key.len() < MIN_KEY_LEN { return Err(Error::KeyMissing(format!( "hmac key must be ≥ {MIN_KEY_LEN} bytes, got {}", hmac_key.len() ))); } let dna = build_provider_dna()?; Ok(Self { dna, parent, hmac_key, ttl_secs }) } /// Construct from environment. See [`crate::env`] for variable names. pub fn from_env(parent: Dna) -> Result { let (key, ttl) = read_env()?; Self::new(parent, key, ttl) } /// Build the URL the caller emails to the user. pub fn build_magic_url(&self, base_url: &str, email: &str) -> String { let expires = now_unix_ms().saturating_add(self.ttl_secs * 1000); let token = build_token(email, expires, &self.hmac_key); format!("{}/auth/magic?token={}", base_url.trim_end_matches('/'), token) } /// Configured TTL in seconds. pub fn ttl_secs(&self) -> i64 { self.ttl_secs } } impl HasDna for MagicLinkProvider { fn dna(&self) -> &Dna { &self.dna } fn parent_dna(&self) -> Option<&Dna> { Some(&self.parent) } } #[async_trait] impl AuthProvider for MagicLinkProvider { fn provider_name(&self) -> &'static str { "magiclink" } async fn issue_challenge( &self, c: &AuthChallenge, ) -> kei_runtime_core::Result<()> { match c { AuthChallenge::MagicLink { email } if !email.is_empty() => { // Stateless: issuing IS building a token. Caller emails it. // We pre-flight build to fail fast if HMAC machinery breaks. let expires = now_unix_ms().saturating_add(self.ttl_secs * 1000); let _ = build_token(email, expires, &self.hmac_key); Ok(()) } AuthChallenge::MagicLink { .. } => Err(kei_runtime_core::Error::Auth( "magiclink: empty email".into(), )), _ => Err(kei_runtime_core::Error::Auth( "magiclink: unsupported challenge variant".into(), )), } } async fn verify( &self, c: &AuthChallenge, ) -> kei_runtime_core::Result { let token = match c { AuthChallenge::MagicLink { email } => email, _ => { return Err(kei_runtime_core::Error::Auth( "magiclink: unsupported challenge variant".into(), )) } }; let (email, expires_unix_ms) = parse_token(token, &self.hmac_key, now_unix_ms()).map_err(|e: Error| -> kei_runtime_core::Error { e.into() })?; let session_dna = build_session_dna(&email).map_err(|e: Error| -> kei_runtime_core::Error { e.into() })?; Ok(AuthSession { dna: session_dna, parent_dna: self.parent.clone(), user_id: email, expires_unix_ms, user_agent: None, }) } async fn revoke(&self, _session: &Dna) -> kei_runtime_core::Result<()> { // SECURITY: do NOT silently return Ok here. Stateless HMAC tokens // cannot be server-side revoked, and a silent Ok() would lie to // every caller that thought it had killed a session. Surface the // truth so callers can either rotate the HMAC key (kills ALL live // tokens) or maintain an external deny-list keyed on the token's // first two parts (email + expiry). Err(Error::Unsupported( "magiclink: stateless tokens cannot be server-side revoked. \ Rotate KEI_MAGICLINK_HMAC_KEY to invalidate all live tokens, \ OR maintain a deny-list at the caller layer keyed on the \ token's first two parts." .into(), ) .into()) } fn is_passwordless(&self) -> bool { true } } fn build_provider_dna() -> Result { DnaBuilder::new("primitive") .caps(["PR", "AP", "ML"]) .scope("keiseikit.dev/primitives/kei-auth-magiclink") .body(b"magiclink-v1") .build() .map_err(|e| Error::Dna(e.to_string())) } fn build_session_dna(email: &str) -> Result { DnaBuilder::new("session") .caps(["AS", "ML"]) .scope("keiseikit.dev/sessions/magiclink") .body(email.as_bytes()) .build() .map_err(|e| Error::Dna(e.to_string())) } fn now_unix_ms() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0) }