diff --git a/Cargo.lock b/Cargo.lock index 3777403..6737544 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -764,6 +764,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "factory0-auth-magic-link" +version = "0.1.0" +dependencies = [ + "askama", + "async-trait", + "axum", + "base64ct", + "bytes", + "factory0-adapter-sqlite", + "factory0-auth-core", + "factory0-core", + "factory0-testing", + "futures-lite", + "getrandom 0.4.3", + "http", + "pollster", + "serde", + "serde_json", + "sha2 0.11.0", + "time", + "tower", + "tracing", + "url", +] + [[package]] name = "factory0-auth-meta" version = "0.1.0" @@ -936,6 +962,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" @@ -1004,6 +1036,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1698,6 +1743,12 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "password-hash" version = "0.6.1" diff --git a/README.md b/README.md index 63bcf20..79127b6 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ validated before the issues were written, and what is deferred. | `auth-oidc` | Google and Apple: discovery, PKCE, ID tokens, minted Apple client secret, form_post callback | | `auth-meta` | Facebook Login: OAuth 2.0 plus a Graph profile call, with no OpenID Connect anywhere. The data deletion callback is #18 | | `auth-password` | Email and password: argon2id, a per-account lockout, a breach check, and answers that reveal nothing about who has an account. **Needs the paid Workers plan** (ADR 0100) | -| `auth-magic-link` | request and single-use consume | +| `auth-magic-link` | Sign in by email: a single-use bearer credential, the way an address gets verified, and the way back in for a locked or passwordless account | ## The login chooser diff --git a/crates/auth-core/src/lib.rs b/crates/auth-core/src/lib.rs index 9d2d859..4ed7d58 100644 --- a/crates/auth-core/src/lib.rs +++ b/crates/auth-core/src/lib.rs @@ -65,9 +65,10 @@ pub use store::{ pending_deletion_jobs, purge_expired_sessions, purge_expired_single_use_tokens, purge_user, redirect_uris_for_client, replace_redirect_uris, revoke_all_sessions, revoke_session, rotate_client_secret, session_by_id, session_by_token_hash, sessions_by_user, - set_password_hash, set_password_lockout, single_use_token_by_hash, slide_session, - touch_credential_used, touch_identity_login, touch_session_seen, update_client_name, - update_client_status, update_passkey_sign_count, user_by_id, user_by_primary_email, + set_password_hash, set_password_lockout, set_primary_email_verified, single_use_token_by_hash, + slide_session, touch_credential_used, touch_identity_login, touch_session_seen, + update_client_name, update_client_status, update_passkey_sign_count, user_by_id, + user_by_primary_email, }; pub use tokens::{ ACCESS_TOKEN_SECS, JWKS_CACHE_CONTROL, OIDC_CACHE_CONTROL, REFRESH_TOKEN_DAYS, RefreshGrant, diff --git a/crates/auth-core/src/store.rs b/crates/auth-core/src/store.rs index 171bde0..a0f8d27 100644 --- a/crates/auth-core/src/store.rs +++ b/crates/auth-core/src/store.rs @@ -1603,3 +1603,28 @@ pub async fn set_password_lockout( .and_where(Expr::col(iden("id")).eq(id)); db.execute(&Statement::render(&update)).await } + +/// Marks a user's primary address verified. +/// +/// Only ever called after proof: consuming a link sent to that address +/// (#21). It matters because the linking rules auto-link on a verified +/// address, so a wrong write here hands somebody an account. +/// +/// # Errors +/// +/// [`DbError::Execute`] when the statement fails. +pub async fn set_primary_email_verified( + db: &dyn Database, + id: &str, + updated_at: &str, +) -> Result { + let mut update = Query::update(); + update + .table(iden("users")) + .values([ + (iden("primary_email_verified"), true.into()), + (iden("updated_at"), updated_at.into()), + ]) + .and_where(Expr::col(iden("id")).eq(id)); + db.execute(&Statement::render(&update)).await +} diff --git a/crates/auth-magic-link/Cargo.toml b/crates/auth-magic-link/Cargo.toml new file mode 100644 index 0000000..97206ce --- /dev/null +++ b/crates/auth-magic-link/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "factory0-auth-magic-link" +description = "Magic links for the Factory Zero auth service: a single-use bearer credential sent by mail, which is also how an address gets verified and how a locked or passwordless account gets back in (issue #21)" +readme = "README.md" +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +factory0-core = { workspace = true } +factory0-auth-core = { path = "../auth-core" } +askama = { workspace = true } +axum = { workspace = true } +http = { workspace = true } +bytes = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +time = { workspace = true } +tracing = { workspace = true } +base64ct = { workspace = true } +getrandom = { workspace = true } +sha2 = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +factory0-testing = { workspace = true } +factory0-adapter-sqlite = { workspace = true } +pollster = { workspace = true } +tower = { workspace = true } +async-trait = { workspace = true } +futures-lite = { version = "2", default-features = false, features = ["std"] } +sha2 = { workspace = true } diff --git a/crates/auth-magic-link/README.md b/crates/auth-magic-link/README.md new file mode 100644 index 0000000..b2167b5 --- /dev/null +++ b/crates/auth-magic-link/README.md @@ -0,0 +1,105 @@ +# auth-magic-link + +Sign in by email (issue #21). Mounted at `/v1/auth-magic-link`. + +A magic link is a **bearer credential sent over email** — a channel this +service does not control and cannot audit. Everything here follows from +that: 32 random bytes, stored only as a SHA-256, valid fifteen minutes, +single-use under concurrency rather than by convention. + +It is also the answer to three things other modules leave undone: + +- **Verifying an address.** Password registration creates an account with + `primary_email_verified = 0`, because registering must not be a way to + claim somebody else's address. Consuming a link sent to that address is + the proof, and the linking rules only ever auto-link a verified one. +- **Getting back in.** A password lockout is deliberately not a dead end, + and a passwordless account has no other way in at all. +- **Registration by mail**, for a venture that wants no passwords. + +## Routes + +| Route | What it does | +|---|---| +| `POST /request` | `{ email, return_to? }`. Always `202`, always the same body | +| `GET /consume?token=…` | The URL in the mail. Signs in, or shows a confirm button | +| `POST /consume` | The confirm button | + +## Configuration + +| Key | Required | Notes | +|---|---|---| +| `AUTH_MAGIC_LINK_PUBLIC_BASE` | yes | The origin the mailed link points at | +| `AUTH_MAGIC_LINK_MAIL_FROM` | yes | The `From` address. Its domain must be verified with the mailer | +| `AUTH_MAGIC_LINK_TTL_SECS` | no | Default 900. Accepted range 60–86400 | +| `AUTH_MAGIC_LINK_ALLOW_REGISTRATION` | no | Default **false** | +| `AUTH_MAGIC_LINK_DEFAULT_RETURN_TO` | no | Default `/` | + +A link that lasts a day is a password with a long tail; one that lasts a +minute does not survive a slow mail queue. Hence the range. + +**Registration is off by default and that is deliberate.** With it on, +anyone can create an account for any address they can type. A venture that +wants passwordless sign-up turns it on knowing that. + +## Mail clients prefetch links + +Outlook, corporate scanners and several mobile clients fetch every URL in a +message to check it for malware. A prefetch that consumed a single-use +token would sign nobody in and leave the person holding a link that has +already been used — the failure mode that makes people give up on magic +links entirely. + +So `GET /consume` only signs somebody in when the request **looks like a +person clicking**: `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: +document`, which every current browser sends on a top-level navigation and +an out-of-band fetch does not. Anything else gets a confirm button. + +**What the heuristic does not catch**, said plainly because it matters: a +scanner that copies a browser's headers, or one that renders the message in +a real browser engine. What it must never do is refuse a real person, so a +request with no fetch metadata at all — an old browser, a stripped proxy — +gets the button rather than a refusal. That costs one click and works +everywhere. + +The prefetch page is also not an oracle: a real token and an invented one +get the same page, and nothing is read or spent to produce it. + +## Single-use under concurrency + +`consume_single_use_token` updates `where consumed_at is null and +expires_at > now` and checks the affected row count. Two simultaneous +consumes therefore produce **one session and one refusal**, rather than two +sessions or a lost update. Tested by driving both futures together; +replacing the statement with a read-then-write fails that test. + +## What a caller can never learn + +- A known and an unknown address get the same `202` and the same body. +- So does a disabled account, and so does a string that is not an address. +- An expired token, an already-used one, and one that was never issued all + fail identically. +- A token of another kind — an authorization code, say — is refused here + rather than turned into a session. +- The address is never written to a log. A log line naming who asked to + sign in is exactly what this endpoint refuses to say out loud. + +## The mail + +Rendered through the harness template registry under +`auth-magic-link.sign-in`, so a venture can override the wording, with the +compiled default as the fallback. The **text part carries the raw link**, +because a client that shows only text must still be usable, and the HTML +part carries it twice — once as a button, once as copyable text — for +clients that strip anchors. + +## Known gaps + +- **Nothing rate-limits per address across deployments.** The `RateLimiter` + port is per-isolate where the adapter is in-memory; a KV-backed limiter + is the deployment's choice. +- **No "resend" throttle beyond the rate limiter.** Asking twice sends two + mails, both valid until one is used. +- **The link does not carry the client's `state`.** A magic link that + resumes a pending `/authorize` carries it as `return_to`, which is enough + today because `/authorize` re-reads its own query. diff --git a/crates/auth-magic-link/src/handlers.rs b/crates/auth-magic-link/src/handlers.rs new file mode 100644 index 0000000..067301d --- /dev/null +++ b/crates/auth-magic-link/src/handlers.rs @@ -0,0 +1,653 @@ +//! The two routes (issue #21): request and consume. + +use axum::extract::{Query, State}; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::{get, post}; +use base64ct::{Base64UrlUnpadded, Encoding as _}; +use factory0_auth_core::{ + Redacted, STATUS_ACTIVE, SingleUseTokenRow, TOKEN_MAGIC_LINK, UserRow, + consume_single_use_token, cookie_value as session_cookie_value, insert_single_use_token, + insert_user, set_cookie, single_use_token_by_hash, user_by_id, user_by_primary_email, +}; +use factory0_core::{Json, Message, Problem, Scope, SendOutcome}; +use http::{HeaderMap, StatusCode, header}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; + +use crate::mail::{self, MagicLinkMail}; +use crate::{ModuleState, NOT_READY, REQUEST_REFUSED}; + +pub(crate) const EVENT_REQUESTED: &str = "auth-magic-link.requested"; +pub(crate) const EVENT_LOGGED_IN: &str = "auth-magic-link.logged_in"; +pub(crate) const EVENT_EMAIL_VERIFIED: &str = "auth-magic-link.email_verified"; + +/// RFC 8176: possession of the mailbox, and nothing else. Not `mfa`: one +/// factor, and the factor is an inbox. +const AMR: [&str; 1] = ["email"]; + +/// 32 random bytes. The token is the whole credential, so it is sized to +/// be unguessable rather than to be typed. +const TOKEN_BYTES: usize = 32; + +/// A `return_to` longer than this is not a path anyone meant. Matches the +/// providers' limit so the login chooser's `/authorize` fits. +const MAX_RETURN_TO: usize = 4096; + +pub(crate) fn router() -> axum::Router> { + axum::Router::new() + .route("/request", post(request)) + .route("/consume", get(consume).post(confirm)) +} + +#[derive(Debug, Default, Deserialize)] +struct RequestBody { + #[serde(default)] + email: String, + #[serde(default)] + return_to: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ConsumeQuery { + token: Option, +} + +/// Only a path on this service, for the same reason every other module +/// checks: an absolute URL here is an open redirect. +pub(crate) fn safe_return_to(candidate: Option<&str>) -> Option { + let value = candidate?.trim(); + if value.len() > MAX_RETURN_TO || !value.starts_with('/') { + return None; + } + if value.starts_with("//") || value.starts_with("/\\") { + return None; + } + if value.chars().any(char::is_control) { + return None; + } + Some(value.to_owned()) +} + +fn hash(token: &str) -> Vec { + Sha256::digest(token.as_bytes()).to_vec() +} + +fn page(status: StatusCode, message: &str, confirm: Option<&str>) -> Response { + let action = confirm.map_or_else(String::new, |token| { + format!( + "
\ +
" + ) + }); + let body = format!( + "\ +\ +\ +Sign in\ +\ +

{message}

{action}" + ); + ( + status, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + Html(body), + ) + .into_response() +} + +async fn limit(state: &ModuleState, headers: &HeaderMap, email: Option<&str>) -> Option { + let limiter = state.ctx.ports.rate_limiter.as_deref()?; + let ip = factory0_core::client_ip(headers); + // Keyed on the address as well as the caller: an unlimited request + // endpoint is a way to send somebody a hundred emails. + for key in factory0_core::rate_limit_keys(ip.as_deref(), email) { + match limiter.limit(&format!("auth-magic-link:{key}")).await { + Ok(decision) if !decision.ok => { + return Some(factory0_core::rate_limited(decision.retry_after).into_response()); + } + Ok(_) => {} + Err(err) => { + tracing::warn!(error = %err, "the auth-magic-link rate limiter is unavailable"); + return None; + } + } + } + None +} + +async fn captcha_ok(state: &ModuleState, token: Option<&str>, headers: &HeaderMap) -> bool { + let Some(captcha) = state.ctx.ports.captcha.as_deref() else { + return true; + }; + let ip = factory0_core::client_ip(headers); + match captcha + .verify(token.unwrap_or_default(), ip.as_deref()) + .await + { + Ok(verdict) => verdict.ok, + Err(err) => { + // Fail closed: an outage at the captcha provider must not turn + // this into an open mail relay. + tracing::warn!(error = %err, "the captcha could not be verified"); + false + } + } +} + +/// `POST /request`. +/// +/// Always `202`, always the same body. A known address gets a mail; an +/// unknown one gets nothing, and the caller cannot tell which. +async fn request( + State(state): State>, + scope: Scope, + headers: HeaderMap, + raw: bytes::Bytes, +) -> Result { + let body: Value = serde_json::from_slice(&raw).unwrap_or(Value::Null); + let parsed: RequestBody = serde_json::from_value(body.clone()).unwrap_or_default(); + let email = factory0_core::normalize_email(&parsed.email); + + if let Some(limited) = limit(&state, &headers, Some(&email)).await { + return Ok(limited); + } + let captcha_token = body + .get("captchaToken") + .and_then(Value::as_str) + .or_else(|| body.get("captcha_token").and_then(Value::as_str)); + if !captcha_ok(&state, captcha_token, &headers).await { + // Even this is the accepted answer: a caller who can tell a failed + // captcha from a sent mail learns nothing useful, but a caller who + // can tell it from an unknown address learns plenty. + return Ok(accepted()); + } + + let Some(settings) = state.settings.as_ref() else { + return Err(Problem::new(&NOT_READY)); + }; + let ctx = state.ctx.as_ref(); + let (Some(db), Some(clock), Some(id_gen), Some(mailer)) = ( + ctx.ports.db.as_deref(), + ctx.ports.clock.as_deref(), + ctx.ports.id_gen.as_deref(), + ctx.ports.mailer.as_deref(), + ) else { + return Err(Problem::new(&NOT_READY)); + }; + + if !email.contains('@') { + return Ok(accepted()); + } + + let user = match user_by_primary_email(db, &email).await { + Ok(user) => user, + Err(err) => { + tracing::error!(error = %err, "could not look up an address"); + return Err(Problem::internal().instance(&scope.request_id)); + } + }; + + let user_id = match (user, settings.allow_registration) { + (Some(user), _) => { + // A disabled account gets no link. Answered like everything + // else, because "that account is switched off" is not a thing + // an unauthenticated caller may learn. + if user.status != STATUS_ACTIVE { + return Ok(accepted()); + } + user.id + } + (None, true) => match create_account(db, clock, id_gen, &email).await { + Ok(id) => id, + Err(err) => { + tracing::error!(error = %err, "could not create an account for a magic link"); + return Err(Problem::internal().instance(&scope.request_id)); + } + }, + // No account, and this venture does not register by link. + (None, false) => return Ok(accepted()), + }; + + if let Err(err) = issue_link( + db, + clock, + id_gen, + mailer, + ctx, + settings, + &user_id, + &email, + parsed.return_to.as_deref(), + ) + .await + { + tracing::error!(error = %err, "could not issue a sign-in link"); + return Err(Problem::internal().instance(&scope.request_id)); + } + + ctx.events + .emit_in(&scope, EVENT_REQUESTED, json!({ "user_id": user_id })); + Ok(accepted()) +} + +/// Mints the token, stores its digest and sends the mail. +/// +/// Split out so `request` stays readable. A send that fails is logged and +/// swallowed by the caller: the answer is the same either way, and telling +/// a caller the mail bounced would tell them the address exists. +#[allow(clippy::too_many_arguments)] +async fn issue_link( + db: &dyn factory0_core::Database, + clock: &dyn factory0_core::Clock, + id_gen: &dyn factory0_core::IdGen, + mailer: &dyn factory0_core::Mailer, + ctx: &factory0_core::ModuleContext, + settings: &crate::Settings, + user_id: &str, + email: &str, + return_to: Option<&str>, +) -> Result<(), factory0_core::DbError> { + let Some(token) = random_token() else { + return Err(factory0_core::DbError::Query( + "the entropy source failed".to_owned(), + )); + }; + let expires_at = crate::handlers::iso( + clock + .now() + .saturating_add(time::Duration::seconds(settings.ttl_secs)), + ); + let row = SingleUseTokenRow { + id: id_gen.ulid(), + kind: TOKEN_MAGIC_LINK.to_owned(), + // Only the digest is stored. A leaked database row is not a way in. + token_hash: Redacted(hash(&token)), + user_id: Some(user_id.to_owned()), + client_id: None, + payload: safe_return_to(return_to) + .map(|return_to| json!({ "return_to": return_to }).to_string()), + expires_at, + consumed_at: None, + }; + insert_single_use_token(db, &row).await?; + + let link = format!( + "{}/v1/auth-magic-link/consume?token={token}", + settings.public_base + ); + let rendered = match mail::render( + &ctx.templates, + &MagicLinkMail { + venture: ctx.venture.name.clone(), + link, + minutes: settings.ttl_secs / 60, + }, + "en", + ) { + Ok(rendered) => rendered, + Err(err) => { + tracing::error!(error = %err, "could not render the sign-in mail"); + return Err(factory0_core::DbError::Query(err.to_string())); + } + }; + + match mailer + .send(Message { + to: email.to_owned(), + from: settings.mail_from.clone(), + reply_to: None, + subject: rendered.subject, + html: rendered.html, + text: rendered.text, + idempotency_key: Some(row.id.clone()), + tags: vec!["auth-magic-link".to_owned()], + }) + .await + { + Ok(SendOutcome::Sent { .. }) => {} + Ok(SendOutcome::NotConfigured) => { + // The adapter has no key. Logged loudly, because every request + // will silently do nothing until somebody notices. + tracing::error!("the mailer is not configured; no sign-in link was sent"); + } + Err(err) => { + // The address is never logged: a log line naming who asked to + // sign in is the thing this endpoint refuses to say out loud. + tracing::error!(error = %err, "could not send a sign-in link"); + } + } + + Ok(()) +} + +/// The one answer a request gives. +fn accepted() -> Response { + ( + StatusCode::ACCEPTED, + Json(json!({ + "status": "accepted", + "message": "If that address can sign in, a link is on its way.", + })), + ) + .into_response() +} + +fn random_token() -> Option { + let mut bytes = [0u8; TOKEN_BYTES]; + getrandom::fill(&mut bytes).ok()?; + Some(Base64UrlUnpadded::encode_string(&bytes)) +} + +pub(crate) fn iso(at: time::OffsetDateTime) -> String { + at.replace_nanosecond(0) + .unwrap_or(at) + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default() +} + +async fn create_account( + db: &dyn factory0_core::Database, + clock: &dyn factory0_core::Clock, + id_gen: &dyn factory0_core::IdGen, + email: &str, +) -> Result { + let now = iso(clock.now()); + let id = id_gen.ulid(); + insert_user( + db, + &UserRow { + id: id.clone(), + display_name: None, + primary_email: Some(email.to_owned()), + // Unverified until the link is consumed. Creating the row is + // not proof of anything; opening the mail is. + primary_email_verified: false, + status: STATUS_ACTIVE.to_owned(), + created_at: now.clone(), + updated_at: now, + }, + ) + .await?; + Ok(id) +} + +/// Whether this request looks like a person clicking rather than a mail +/// client checking the link. +/// +/// **A heuristic, and the limits are the point.** Fetch metadata headers +/// are sent by every current browser on a top-level navigation: +/// `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document`. A scanner +/// fetching the URL out of band sends neither, or sends `empty`/`no-cors`. +/// +/// What it does **not** catch: a scanner that copies a real browser's +/// headers, or one that renders the message in a real browser engine. +/// What it must not do is refuse a real person — so a request with no +/// fetch metadata at all (an old browser, a stripped proxy) gets the +/// confirm button rather than a refusal, which costs one click and works +/// everywhere. +fn looks_like_a_click(headers: &HeaderMap) -> bool { + let value = |name: &str| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_ascii_lowercase) + }; + value("sec-fetch-mode").as_deref() == Some("navigate") + && value("sec-fetch-dest").as_deref() == Some("document") +} + +/// `GET /consume?token=…`, the URL in the mail. +/// +/// Does **not** spend the token unless the request looks like a person +/// clicking. Mail clients and corporate scanners prefetch links, and a +/// prefetch that consumed a single-use token would sign nobody in and +/// leave the person with a link that has already been used. +async fn consume( + State(state): State>, + scope: Scope, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let token = query.token.unwrap_or_default(); + if token.is_empty() { + return Ok(expired_page()); + } + if !looks_like_a_click(&headers) { + // Nothing is read, nothing is spent: the answer is the same page + // whether or not the token is real, so a scanner cannot use this + // to test one. + return Ok(page( + StatusCode::OK, + "Confirm that it was you who asked to sign in.", + Some(&token), + )); + } + spend(&state, &scope, &headers, &token).await +} + +/// `POST /consume`, the confirm button. +async fn confirm( + State(state): State>, + scope: Scope, + headers: HeaderMap, + body: String, +) -> Result { + let token = url::form_urlencoded::parse(body.as_bytes()) + .find(|(key, _)| key == "token") + .map(|(_, value)| value.to_string()) + .unwrap_or_default(); + if token.is_empty() { + return Ok(expired_page()); + } + spend(&state, &scope, &headers, &token).await +} + +/// Spends the token and signs the person in. +/// +/// Single-use is enforced by `consume_single_use_token`, which updates +/// `where consumed_at is null and expires_at > now` and checks the +/// affected row count. Two simultaneous consumes therefore produce one +/// session and one refusal, rather than two sessions or a lost update. +async fn spend( + state: &ModuleState, + scope: &Scope, + headers: &HeaderMap, + token: &str, +) -> Result { + let Some(settings) = state.settings.as_ref() else { + return Err(Problem::new(&NOT_READY)); + }; + let ctx = state.ctx.as_ref(); + let (Some(db), Some(clock), Some(id_gen)) = ( + ctx.ports.db.as_deref(), + ctx.ports.clock.as_deref(), + ctx.ports.id_gen.as_deref(), + ) else { + return Err(Problem::new(&NOT_READY)); + }; + let now = iso(clock.now()); + + let Ok(Some(row)) = single_use_token_by_hash(db, &hash(token)).await else { + return Ok(expired_page()); + }; + // A token of the wrong kind is not this endpoint's business: an + // authorization code presented here must not become a session. + if row.kind != TOKEN_MAGIC_LINK { + tracing::warn!(kind = %row.kind, "a token of another kind was presented to the magic link"); + return Ok(expired_page()); + } + + // The one statement that makes this single-use. + let Ok(Some(spent)) = consume_single_use_token(db, &row.id, &now).await else { + // Expired, or somebody else got there first. Both answer the same + // way: an attacker racing a real person must not learn they lost. + return Ok(expired_page()); + }; + + let Some(user_id) = spent.user_id.clone() else { + return Ok(expired_page()); + }; + let Ok(Some(user)) = user_by_id(db, &user_id).await else { + return Ok(expired_page()); + }; + if user.status != STATUS_ACTIVE { + return Ok(expired_page()); + } + + // Opening a link sent to an address is the proof that the address is + // theirs, and the only proof this service has. The linking rules only + // ever auto-link a verified address, so this is what makes one. + if !user.primary_email_verified { + if let Err(err) = factory0_auth_core::set_primary_email_verified(db, &user.id, &now).await { + tracing::warn!(error = %err, "could not record a verified address"); + } else { + ctx.events + .emit_in(scope, EVENT_EMAIL_VERIFIED, json!({ "user_id": user.id })); + } + } + + let presented = session_cookie_value(headers); + let session = factory0_auth_core::issue( + db, + clock, + id_gen, + factory0_auth_core::Login { + user_id: &user.id, + ip: factory0_core::client_ip(headers).as_deref(), + user_agent: headers + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()), + presented_cookie: presented.as_deref(), + amr: &AMR, + }, + ) + .await + .map_err(|err| { + tracing::error!(error = %err, "could not issue a session"); + Problem::new(&REQUEST_REFUSED).instance(&scope.request_id) + })?; + + ctx.events.emit_in( + scope, + EVENT_LOGGED_IN, + json!({ "user_id": user.id, "session_id": session.session_id }), + ); + + let return_to = spent + .payload + .as_deref() + .and_then(|payload| serde_json::from_str::(payload).ok()) + .and_then(|payload| { + payload + .get("return_to") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .and_then(|candidate| safe_return_to(Some(&candidate))) + .unwrap_or_else(|| settings.default_return_to.clone()); + + let mut response = (StatusCode::FOUND, [(header::LOCATION, return_to)]).into_response(); + if let Ok(value) = header::HeaderValue::from_str(&set_cookie(&session.value)) { + response.headers_mut().append(header::SET_COOKIE, value); + } + Ok(response) +} + +fn expired_page() -> Response { + page( + StatusCode::BAD_REQUEST, + "That sign-in link has expired or was already used. Ask for another.", + None, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_return_to_may_only_be_a_path_on_this_service() { + assert_eq!( + safe_return_to(Some("/account")).as_deref(), + Some("/account") + ); + for bad in [ + "https://evil.example", + "//evil.example", + "/\\evil.example", + "javascript:alert(1)", + "", + "/ok\nSet-Cookie: x", + ] { + assert_eq!(safe_return_to(Some(bad)), None, "{bad} was accepted"); + } + } + + #[test] + fn only_a_browser_navigation_counts_as_a_click() { + let with = |pairs: &[(&str, &str)]| { + let mut headers = HeaderMap::new(); + for (name, value) in pairs { + headers.insert( + http::HeaderName::from_bytes(name.as_bytes()).expect("name"), + value.parse().expect("value"), + ); + } + headers + }; + + assert!(looks_like_a_click(&with(&[ + ("sec-fetch-mode", "navigate"), + ("sec-fetch-dest", "document"), + ]))); + // Case is not significant in the header value. + assert!(looks_like_a_click(&with(&[ + ("sec-fetch-mode", "Navigate"), + ("sec-fetch-dest", "Document"), + ]))); + + // What a scanner sends, and what an old browser sends: neither is + // refused, both get the confirm button. + for headers in [ + with(&[]), + with(&[("sec-fetch-mode", "no-cors"), ("sec-fetch-dest", "empty")]), + with(&[("sec-fetch-mode", "navigate")]), + with(&[("sec-fetch-dest", "document")]), + with(&[("sec-fetch-mode", "cors"), ("sec-fetch-dest", "document")]), + ] { + assert!(!looks_like_a_click(&headers), "{headers:?}"); + } + } + + #[test] + fn a_token_is_stored_only_as_its_digest() { + let token = random_token().expect("entropy"); + let digest = hash(&token); + assert_eq!(digest.len(), 32); + assert_ne!( + digest, + token.as_bytes(), + "the token itself must not be stored" + ); + // Deterministic, so a lookup by digest finds it. + assert_eq!(digest, hash(&token)); + assert_ne!(digest, hash(&random_token().expect("entropy"))); + } + + #[test] + fn tokens_are_unguessable_and_url_safe() { + let first = random_token().expect("entropy"); + let second = random_token().expect("entropy"); + assert_ne!(first, second); + // It goes in a URL in an email, which some clients rewrite. + assert!( + first + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + ); + assert!(first.len() >= 43, "32 bytes of entropy: {first}"); + } +} diff --git a/crates/auth-magic-link/src/lib.rs b/crates/auth-magic-link/src/lib.rs new file mode 100644 index 0000000..b635fbe --- /dev/null +++ b/crates/auth-magic-link/src/lib.rs @@ -0,0 +1,341 @@ +//! `factory0-auth-magic-link`: sign in by email (issue #21). +//! +//! ```no_run +//! use factory0_auth_magic_link::MagicLink; +//! +//! let module = MagicLink::new(); +//! ``` +//! +//! A magic link is a **bearer credential sent over email**, which is a +//! channel this service does not control and cannot audit. Everything here +//! follows from that: the token is 32 random bytes, stored only as its +//! SHA-256, valid for fifteen minutes, and single-use under concurrency +//! rather than by convention. +//! +//! It is also the answer to three things other modules leave undone: +//! +//! - **Verifying an address.** Password registration (#19) creates an +//! account with `primary_email_verified = 0`, because registering must +//! not be a way to claim somebody else's address. Consuming a link sent +//! to that address is the proof, and the linking rules only ever +//! auto-link a verified one. +//! - **Getting back in.** A password lockout (#12) is deliberately not a +//! dead end; a passwordless account has no other way in at all. +//! - **Registration by mail**, for a venture that wants no passwords. +//! +//! **Mail clients prefetch links.** Outlook, some corporate scanners and +//! several mobile clients fetch every URL in a message to check it for +//! malware, which would consume a single-use token before the person ever +//! clicked. `consume` therefore only signs somebody in on a request that +//! looks like a human clicking, and shows a confirm button otherwise. The +//! heuristic and its limits are documented on the handler. + +#![forbid(unsafe_code)] + +mod handlers; +mod mail; + +use factory0_core::{ + Config, ConfigError, Migrations, Module, ModuleConfig, ModuleContext, Port, ProblemDef, +}; +use http::StatusCode; +use std::sync::Arc; + +pub use mail::{MagicLinkMail, TEMPLATE_MAGIC_LINK, default_templates}; + +/// The one answer a request gives, whatever it found. +pub const REQUEST_REFUSED: ProblemDef = ProblemDef { + slug: "auth/magic-link-refused", + status: StatusCode::BAD_REQUEST, + title: "That sign-in link is no longer valid", + description: "Missing, expired, already used, or never issued", +}; + +/// The module is mounted but cannot send. +pub const NOT_READY: ProblemDef = ProblemDef { + slug: "auth/magic-link-not-ready", + status: StatusCode::SERVICE_UNAVAILABLE, + title: "Email sign-in is not available", + description: "The module is missing a port it requires", +}; + +/// How long a link lasts. Long enough for mail to arrive and a person to +/// read it, short enough that a message sitting in an unlocked inbox stops +/// being a way in. +pub const DEFAULT_TTL_SECS: i64 = 900; + +/// The resolved configuration. +#[derive(Debug, Clone)] +pub(crate) struct Settings { + /// The public origin. The link is built from it and must be reachable + /// from a mail client. + pub public_base: String, + /// Where to send somebody after a successful consume, when the request + /// carried no `return_to`. + pub default_return_to: String, + pub ttl_secs: i64, + /// Whether a request for an address with no account creates one. + /// + /// Off by default. A venture that wants passwordless sign-up turns it + /// on deliberately, because when it is on, anyone can create an + /// account for any address they can type. + pub allow_registration: bool, + /// The `From` address. Mail from an unverified domain is refused by + /// the adapter, which is why this is configuration and not a guess. + pub mail_from: String, +} + +fn resolve_settings(cfg: &dyn Config) -> Result> { + let module = ModuleConfig::new("auth-magic-link", cfg); + let mut problems = Vec::new(); + + let public_base = module.get_opt("PUBLIC_BASE").unwrap_or_default(); + let public_base = public_base.trim().trim_end_matches('/').to_owned(); + if public_base.is_empty() { + problems.push(format!( + "{} is required (the public origin a mailed link points at)", + module.key("PUBLIC_BASE") + )); + } else if !(public_base.starts_with("https://") + || public_base.starts_with("http://localhost") + || public_base.starts_with("http://127.0.0.1")) + { + problems.push(format!( + "{} must be https (localhost may be http), got {public_base:?}", + module.key("PUBLIC_BASE") + )); + } + + let mail_from = module.get_opt("MAIL_FROM").unwrap_or_default(); + if mail_from.trim().is_empty() { + problems.push(format!( + "{} is required (the From address the link is sent from)", + module.key("MAIL_FROM") + )); + } + + let default_return_to = module.get_str("DEFAULT_RETURN_TO", "/"); + if handlers::safe_return_to(Some(&default_return_to)).is_none() { + problems.push(format!( + "{} must be a path on this service beginning with a single /, got {default_return_to:?}", + module.key("DEFAULT_RETURN_TO") + )); + } + + let ttl_secs = match module.get_opt("TTL_SECS") { + None => DEFAULT_TTL_SECS, + Some(raw) => match raw.trim().parse::() { + // A link that lasts a day is a password with a long tail; one + // that lasts a minute does not survive a slow mail queue. + Ok(value) if (60..=86_400).contains(&value) => value, + _ => { + problems.push(format!( + "{} must be between 60 and 86400 seconds, got {raw:?}", + module.key("TTL_SECS") + )); + DEFAULT_TTL_SECS + } + }, + }; + + let allow_registration = match module.get_opt("ALLOW_REGISTRATION") { + None => false, + Some(raw) => match raw.trim() { + "true" | "1" => true, + "false" | "0" => false, + other => { + problems.push(format!( + "{} must be true or false, got {other:?}", + module.key("ALLOW_REGISTRATION") + )); + false + } + }, + }; + + if problems.is_empty() { + Ok(Settings { + public_base, + default_return_to, + ttl_secs, + allow_registration, + mail_from: mail_from.trim().to_owned(), + }) + } else { + Err(problems) + } +} + +pub(crate) struct ModuleState { + pub ctx: Arc, + pub settings: Option, +} + +/// Sign in by email. +pub struct MagicLink; + +impl Default for MagicLink { + fn default() -> Self { + Self::new() + } +} + +impl MagicLink { + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl Module for MagicLink { + fn name(&self) -> &'static str { + "auth-magic-link" + } + + fn version(&self) -> &'static str { + env!("CARGO_PKG_VERSION") + } + + fn requires(&self) -> &'static [Port] { + &[Port::Db, Port::Clock, Port::IdGen, Port::Mailer] + } + + fn optional(&self) -> &'static [Port] { + &[Port::RateLimiter, Port::Captcha] + } + + /// None. `auth-core` owns `single_use_tokens` and everything else this + /// module touches. + fn tables(&self) -> &'static [&'static str] { + &[] + } + + fn emits(&self) -> &'static [&'static str] { + &[ + handlers::EVENT_REQUESTED, + handlers::EVENT_LOGGED_IN, + handlers::EVENT_EMAIL_VERIFIED, + ] + } + + fn public_writes(&self) -> bool { + true + } + + fn migrations(&self) -> Migrations { + Migrations::EMPTY + } + + fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> { + match resolve_settings(cfg) { + Ok(_) => Ok(()), + Err(problems) => { + let mut errors = ConfigError::default(); + for problem in problems { + errors.push(format!("auth-magic-link: {problem}")); + } + Err(errors) + } + } + } + + fn router(&self, ctx: ModuleContext) -> axum::Router { + let settings = match resolve_settings(&*ctx.config) { + Ok(settings) => Some(settings), + Err(problems) => { + tracing::error!( + problems = problems.join("; "), + "auth-magic-link configuration is unusable" + ); + None + } + }; + handlers::router().with_state(Arc::new(ModuleState { + ctx: Arc::new(ctx), + settings, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use factory0_core::MapConfig; + + fn config(pairs: &[(&str, &str)]) -> MapConfig { + MapConfig::from_pairs( + pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())), + ) + } + + fn valid() -> Vec<(&'static str, &'static str)> { + vec![ + ("AUTH_MAGIC_LINK_PUBLIC_BASE", "https://auth.example.com"), + ("AUTH_MAGIC_LINK_MAIL_FROM", "sign-in@example.com"), + ] + } + + #[test] + fn the_module_owns_no_tables_and_needs_a_mailer() { + let module = MagicLink::new(); + assert_eq!(module.name(), "auth-magic-link"); + assert!(module.tables().is_empty()); + // Required, not optional: a magic link with nowhere to send it is + // not a degraded feature, it is a broken one. + assert!(module.requires().contains(&Port::Mailer)); + assert!(module.public_writes()); + } + + #[test] + fn the_defaults_are_a_quarter_hour_and_no_registration() { + let settings = resolve_settings(&config(&valid())).expect("valid"); + assert_eq!(settings.ttl_secs, DEFAULT_TTL_SECS); + assert_eq!(settings.ttl_secs, 900); + assert!( + !settings.allow_registration, + "registration by link creates an account for any address somebody types" + ); + } + + #[test] + fn a_link_must_point_somewhere_a_mail_client_can_reach() { + for bad in ["", " ", "auth.example.com", "ftp://auth.example.com"] { + let mut pairs = valid(); + pairs[0] = ("AUTH_MAGIC_LINK_PUBLIC_BASE", bad); + assert!( + resolve_settings(&config(&pairs)).is_err(), + "{bad:?} was accepted as a public base" + ); + } + // Localhost over http is the one exception, for `wrangler dev`. + let mut pairs = valid(); + pairs[0] = ("AUTH_MAGIC_LINK_PUBLIC_BASE", "http://localhost:8787"); + assert!(resolve_settings(&config(&pairs)).is_ok()); + } + + #[test] + fn a_ttl_outside_the_sane_range_is_a_build_failure() { + // A link that lasts a day is a password with a long tail; one that + // lasts a minute does not survive a slow mail queue. + for bad in ["0", "59", "86401", "a while", "-900"] { + let mut pairs = valid(); + pairs.push(("AUTH_MAGIC_LINK_TTL_SECS", bad)); + assert!( + resolve_settings(&config(&pairs)).is_err(), + "{bad:?} was accepted as a ttl" + ); + } + } + + #[test] + fn a_from_address_is_required_because_the_adapter_refuses_without_one() { + let pairs = vec![("AUTH_MAGIC_LINK_PUBLIC_BASE", "https://auth.example.com")]; + let problems = resolve_settings(&config(&pairs)).expect_err("refused"); + assert!( + problems.join("; ").contains("AUTH_MAGIC_LINK_MAIL_FROM"), + "{problems:?}" + ); + } +} diff --git a/crates/auth-magic-link/src/mail.rs b/crates/auth-magic-link/src/mail.rs new file mode 100644 index 0000000..db03778 --- /dev/null +++ b/crates/auth-magic-link/src/mail.rs @@ -0,0 +1,198 @@ +//! The sign-in mail (issue #21). +//! +//! Rendered through the harness template registry so a venture can +//! override the wording, with the compiled default as the fallback — the +//! same shape `module-email-signup` uses, and the reason the conformance +//! kit (which registers nothing) still works. + +use askama::Template as _; +use factory0_core::{Rendered, Template, TemplateError, TemplateRegistry}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// The registry id a venture overrides. +pub const TEMPLATE_MAGIC_LINK: &str = "auth-magic-link.sign-in"; + +/// What the template is given. Serialized through the registry, so it is +/// a wire format: adding a field is fine, renaming one breaks overrides. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MagicLinkMail { + /// The venture's display name. + pub venture: String, + /// The full consume URL, token included. + pub link: String, + /// How long the link lasts, for the sentence that says so. + pub minutes: i64, +} + +#[derive(askama::Template)] +#[template(path = "magic_link.html")] +struct MagicLinkHtml<'a> { + venture: &'a str, + link: &'a str, + minutes: i64, +} + +#[derive(askama::Template)] +#[template(path = "magic_link.txt")] +struct MagicLinkText<'a> { + venture: &'a str, + link: &'a str, + minutes: i64, +} + +struct MagicLinkTemplate; + +impl Template for MagicLinkTemplate { + fn render(&self, data: &Value, _locale: &str) -> Result { + let data: MagicLinkMail = + serde_json::from_value(data.clone()).map_err(|_| TemplateError::RenderFailed { + id: TEMPLATE_MAGIC_LINK.to_owned(), + reason: "the mail data is not the shape this template takes".to_owned(), + })?; + Ok(Rendered { + subject: format!("Sign in to {}", data.venture), + html: MagicLinkHtml { + venture: &data.venture, + link: &data.link, + minutes: data.minutes, + } + .render() + .map_err(|err| askama_failed(&err))?, + text: MagicLinkText { + venture: &data.venture, + link: &data.link, + minutes: data.minutes, + } + .render() + .map_err(|err| askama_failed(&err))?, + }) + } +} + +fn askama_failed(err: &askama::Error) -> TemplateError { + TemplateError::RenderFailed { + id: TEMPLATE_MAGIC_LINK.to_owned(), + reason: err.to_string(), + } +} + +/// The module's default template, for `Harness::builder().templates(..)`. +#[must_use] +pub fn default_templates() -> Vec<(String, Box)> { + vec![( + TEMPLATE_MAGIC_LINK.to_owned(), + Box::new(MagicLinkTemplate) as Box, + )] +} + +/// Renders through the venture's registry, falling back to the compiled +/// default when the registry misses. +/// +/// # Errors +/// +/// Whatever the template returns. +pub(crate) fn render( + registry: &TemplateRegistry, + data: &MagicLinkMail, + locale: &str, +) -> Result { + let value = serde_json::to_value(data).map_err(|err| TemplateError::RenderFailed { + id: TEMPLATE_MAGIC_LINK.to_owned(), + reason: err.to_string(), + })?; + match registry.render(TEMPLATE_MAGIC_LINK, &value, locale) { + Err(TemplateError::UnknownTemplate { .. }) => MagicLinkTemplate.render(&value, locale), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn data() -> MagicLinkMail { + MagicLinkMail { + venture: "Factory Zero".to_owned(), + link: "https://auth.factory0.ventures/v1/auth-magic-link/consume?token=abc123" + .to_owned(), + minutes: 15, + } + } + + #[test] + fn the_text_part_carries_the_raw_link() { + // The acceptance criterion, and it matters: a mail client that + // shows only the text part must still be usable, and a person + // copying the link out of it must get the whole thing. + let rendered = MagicLinkTemplate + .render(&serde_json::to_value(data()).expect("json"), "en") + .expect("renders"); + assert!(rendered.text.contains(&data().link), "{}", rendered.text); + assert!(rendered.subject.contains("Factory Zero")); + assert!(rendered.text.contains("15 minutes")); + // No HTML in the text part. + assert!(!rendered.text.contains('<'), "{}", rendered.text); + } + + #[test] + fn the_html_part_carries_the_link_twice_and_says_it_expires() { + let rendered = MagicLinkTemplate + .render(&serde_json::to_value(data()).expect("json"), "en") + .expect("renders"); + // Once as the button, once as copyable text: a mail client that + // strips the anchor still leaves something usable. + assert_eq!( + rendered.html.matches(&data().link).count(), + 2, + "{}", + rendered.html + ); + assert!(rendered.html.contains("expires in 15 minutes")); + } + + #[test] + fn a_venture_override_wins_and_a_missing_one_falls_back() { + struct Override; + impl Template for Override { + fn render(&self, _data: &Value, _locale: &str) -> Result { + Ok(Rendered { + subject: "the venture's own subject".to_owned(), + html: "

theirs

".to_owned(), + text: "theirs".to_owned(), + }) + } + } + + // Empty registry: the compiled default answers, which is what + // makes the conformance kit work. + let empty = TemplateRegistry::new(); + let fallback = render(&empty, &data(), "en").expect("falls back"); + assert!(fallback.subject.contains("Factory Zero")); + + let mut registry = TemplateRegistry::new(); + registry.register(TEMPLATE_MAGIC_LINK, Box::new(Override)); + let overridden = render(®istry, &data(), "en").expect("renders"); + assert_eq!(overridden.subject, "the venture's own subject"); + } + + #[test] + fn nothing_from_the_link_can_break_out_of_the_html() { + // The link is built by this service, not by a caller, but askama + // escapes it anyway and this asserts that it does: a future change + // that let a `return_to` reach the link must not become an + // injection into somebody's inbox. + let hostile = MagicLinkMail { + link: "https://auth.example/x?t=a\">".to_owned(), + ..data() + }; + let rendered = MagicLinkTemplate + .render(&serde_json::to_value(&hostile).expect("json"), "en") + .expect("renders"); + assert!( + !rendered.html.contains("