Skip to content
This repository was archived by the owner on Oct 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions crates/auth-magic-link/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ It is also the answer to three things other modules leave undone:
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.

## Rate limits

As with passwords, this repo owns the key strings and the 429 behaviour,
not the numbers. Quotas are enforced by the harness `RateLimiter`
adapter and set in deployment config; keys are
`auth-magic-link:{key}` over `rate_limit_keys(ip, email)`. In-memory
adapters are per-isolate (a Known gap below), so a multi-isolate
deployment needs a KV-backed limiter. Every refusal is `429` with a
`Retry-After`.

| Scope | Recommended quota | Why |
|---|---|---|
| Request per IP | 5/10min | Every request can send a mail, so each one costs money and annoys somebody. 5 per 10 minutes is tighter than login for that reason, while still loose enough for shared addresses behind NAT. |
| Request per normalised email, any IP | 3/hour | Caps one victim's inbox: nobody gets mail-bombed through this endpoint. Against the 15-minute link TTL it means roughly one live link at a time, and refusals are `429` either way — the limit check runs before the account lookup, so it cannot be used to probe whether an address has an account. |

A password lockout does not block this route: the lockout freezes the
password row only, and a magic-link sign-in bypasses it without clearing
it — the freeze ages out on its own. See the "Rate limits" section in
`../auth-password/README.md` for the full lockout story.

Captcha, where the port is present, is required on `POST /request`,
with the same fail-closed behaviour as login.

**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.
Expand Down
67 changes: 66 additions & 1 deletion crates/auth-magic-link/tests/magic_link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

use async_trait::async_trait;
use cratefield_core::{
Clock, Config, Database, MailError, Mailer, MapConfig, Message, SendOutcome, Statement,
Clock, Config, Database, Decision, MailError, Mailer, MapConfig, Message, SendOutcome,
Statement,
};
use cratefield_testing::TestHarness;
use factory0_auth_core::{AuthCore, STATUS_ACTIVE, UserRow, insert_user, user_by_primary_email};
Expand Down Expand Up @@ -71,6 +72,7 @@ impl Outbox {
struct Kit {
harness: TestHarness,
outbox: Outbox,
limiter: cratefield_testing::FakeRateLimiter,
clock: Arc<TestClock>,
db: Arc<dyn Database>,
}
Expand All @@ -80,6 +82,25 @@ fn kit() -> Kit {
}

fn kit_with(extra: &[(&str, &str)]) -> Kit {
kit_limited(extra, cratefield_testing::FakeRateLimiter::always_allow())
}

/// A kit whose limiter refuses everything, for the request endpoint anyone
/// can call.
fn kit_rate_limited() -> Kit {
kit_limited(
&[],
cratefield_testing::FakeRateLimiter::scripted(
Vec::new(),
Decision {
ok: false,
retry_after: Some(std::time::Duration::from_secs(30)),
},
),
)
}

fn kit_limited(extra: &[(&str, &str)], limiter: cratefield_testing::FakeRateLimiter) -> Kit {
let mut pairs = vec![
("AUTH_MAGIC_LINK_PUBLIC_BASE".to_owned(), BASE.to_owned()),
(
Expand All @@ -95,12 +116,14 @@ fn kit_with(extra: &[(&str, &str)]) -> Kit {
let config: Arc<dyn Config> = Arc::new(MapConfig::from_pairs(pairs));

let mailer = outbox.clone();
let limiter_for_ports = limiter.clone();
let clock_for_ports = clock.clone();
let config_for_ports = config.clone();
let harness = TestHarness::with_ports(
vec![Box::new(AuthCore::new()), Box::new(MagicLink::new())],
move |ports| {
ports.mailer = Some(Arc::new(mailer));
ports.rate_limiter = Some(Arc::new(limiter_for_ports));
ports.clock = Some(clock_for_ports);
ports.config = config_for_ports;
},
Expand All @@ -109,6 +132,7 @@ fn kit_with(extra: &[(&str, &str)]) -> Kit {
Kit {
harness,
outbox,
limiter,
clock,
db,
}
Expand Down Expand Up @@ -585,3 +609,44 @@ fn a_token_of_another_kind_cannot_be_spent_here() {
assert_eq!(count(&kit, "sessions"), 0);
});
}

/// The request endpoint anyone can call is rate limited: a refused caller
/// is told to wait, and nothing is written on the way to the refusal — no
/// mail, no token row for a mail cannon to harvest.
#[test]
fn a_rate_limited_caller_is_told_to_wait_and_nothing_is_written() {
pollster::block_on(async {
let kit = kit_rate_limited();
seed(&kit, "ada@example.com", false).await;

let response = request_link(&kit, "ada@example.com").await;
assert_eq!(
response.status,
StatusCode::TOO_MANY_REQUESTS,
"{}",
response.text()
);
assert!(response.headers.contains_key(header::RETRY_AFTER));

assert_eq!(kit.outbox.count(), 0, "a refused caller was mailed");
assert_eq!(count(&kit, "single_use_tokens"), 0);
});
}

#[test]
fn the_limit_is_keyed_on_the_address_as_well_as_the_caller() {
pollster::block_on(async {
let kit = kit();

// The limit is consulted once per key, and there is more than one
// key: the caller and the address, so a botnet cannot walk past the
// per-caller limit by spreading one address across many machines —
// and cannot turn the endpoint into a mail cannon for one victim.
request_link(&kit, "ada@example.com").await;
assert!(
kit.limiter.calls() >= 2,
"the address was not a limit key: one request consulted the limiter {}",
kit.limiter.calls()
);
});
}
28 changes: 28 additions & 0 deletions crates/auth-password/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,34 @@ first two expensive to reach. Not required — a module that refused to
start without a captcha would take the whole service down — but `fz
doctor` refuses a production venture with public writes and no captcha.

## Rate limits

This repo owns the key strings and the 429 behaviour, not the numbers.
Quotas are enforced by the harness `RateLimiter` adapter and set in
deployment config; keys are `auth-password:{key}` over
`rate_limit_keys(ip, email)`, i.e. one per-IP bucket and one per
normalised-email bucket. In-memory adapters are per-isolate, so a
multi-isolate deployment needs a KV-backed limiter. Every refusal is
`429` with a `Retry-After`.

| Scope | Recommended quota | Why |
|---|---|---|
| Login per IP | 10/min | A person mistypes a few times; a guesser needs thousands. 10/min fits the former and is noise against Argon2id, while staying loose enough for an office or campus behind one NAT address. |
| Login per normalised email, any IP | 5/15min | The per-IP bucket does nothing against a botnet guessing one account from many addresses. The email bucket is what catches that: 5 per 15 minutes still tolerates real mistyping but caps distributed guessing at under 500 tries a day per account, before the lockout below even matters. |
| Lockout | 10 failures in an hour, frozen 15 minutes | Owned here (`AUTH_PASSWORD_LOCKOUT_*`, env-overridable, min-clamped). Ten is far above mistyping and far below a useful guessing rate; fifteen minutes makes guessing pointless without stranding somebody whose only login method is a password for the day. |
| Registration and password change per IP | Same bucket policy as login | The proposal sets no number for these, so use the login one: both are anonymous (registration) or low-frequency (change) writes with the same abuse shape, and one knob is easier to operate than three. |

The lockout freezes the **password**, not the person: passkey, OIDC and
magic-link sign-ins still work during it (see
`../auth-magic-link/README.md` for the way back in). Nothing clears the
lockout row except wall-clock expiry and a successful password
login/change — an alternative-method sign-in bypasses the freeze, it
does not lift it.

Captcha, where the port is present, is required on login (and on
magic-link request): verification failure refuses the request rather
than letting it through, so a captcha outage fails closed.

## Nothing here says whether an address has an account

- **Registration** answers `202` and the same body whether it created an
Expand Down
Loading