Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
ec93f90
fix(auth): refuse delegated tokens on account, approval and admin routes
SIIR3X Sep 18, 2026
4f06be3
fix(auth): carry registration credentials in pending account verifica…
SIIR3X Sep 18, 2026
71b6182
fix(account): announce added access and list it after password changes
SIIR3X Sep 18, 2026
d534caf
fix(identity): link external identities only when the binding complet…
SIIR3X Sep 19, 2026
2708168
fix(admin): require a second factor proven by the session and restric…
SIIR3X Sep 19, 2026
e2599ba
fix(admin): require reauthentication for webhooks, suspension and for…
SIIR3X Sep 19, 2026
5f9360b
fix(admin): keep an active role manager through suspensions, deletion…
SIIR3X Sep 19, 2026
d5b380f
fix(db): split schema ownership from the runtime role and stop loggin…
SIIR3X Sep 19, 2026
82e256b
fix(crypto): key one-time code digests, bind ciphertexts to their row…
SIIR3X Sep 19, 2026
e8a7f46
fix(config): bound settings that weaken controls and rehash weaker pa…
SIIR3X Sep 20, 2026
48be030
fix(auth): bound password guesses per session and move client endpoin…
SIIR3X Sep 20, 2026
6296833
fix(session): make revocations take effect at once and consume pre-au…
SIIR3X Sep 20, 2026
78f6ad9
fix(oauth): hold authorization codes until linked and claim cooldowns…
SIIR3X Sep 20, 2026
c4805cf
fix(email): remove the email change oracle, budget notices and codes,…
SIIR3X Sep 20, 2026
c9ef5f4
fix(users): make usernames unique regardless of case and stop recordi…
SIIR3X Sep 20, 2026
695642a
fix(surface): fold unmatched paths, keep readiness detail internal, a…
SIIR3X Sep 21, 2026
949c1f1
docs: align the security model and route reference with the audit fixes
SIIR3X Sep 21, 2026
acf1708
chore(release): 2.1.0
SIIR3X Sep 21, 2026
05c0ecc
refactor(db): define each table whole in the migration that creates it
SIIR3X Sep 21, 2026
ee6b951
fix(register): verify an address only with the password of the regist…
SIIR3X Sep 21, 2026
f888e39
fix(auth): bound the lockout to the password and keep recovery out of…
SIIR3X Sep 21, 2026
ea09d8e
fix(admin): stop self-granted permissions and require reauthenticatio…
SIIR3X Sep 22, 2026
640d919
fix(privacy): hide administrators from the history, keep addresses ou…
SIIR3X Sep 22, 2026
21d2c43
fix(session): forgive a rotated token only to its client and budget r…
SIIR3X Sep 22, 2026
86ed76a
fix(oauth): show and reauthenticate every device approval and keep de…
SIIR3X Sep 22, 2026
c506cd9
fix(db): keep owner-only floors in maintenance functions, read only r…
SIIR3X Sep 22, 2026
d6c6453
fix(config): refuse settings that undo the lockout, the device code l…
SIIR3X Sep 23, 2026
d92cbe3
fix(deploy): align nginx limits with the API, protect the internal li…
SIIR3X Sep 23, 2026
567cd15
docs: record the re-audit fixes in the threat model and the release n…
SIIR3X Sep 23, 2026
3180e05
fix(oauth): require reauthentication for every consent and for signin…
SIIR3X Sep 23, 2026
a88d3bb
fix(auth): reserve password attempts before hashing and let the captc…
SIIR3X Sep 23, 2026
e84a127
fix(two-factor): tighten account budgets, warn the owner and keep res…
SIIR3X Sep 23, 2026
bb61d83
fix(account): remove ways in planted before a reset and let administr…
SIIR3X Sep 24, 2026
b14371f
fix(admin): delegate only held permissions and keep traces of changes…
SIIR3X Sep 24, 2026
70ef70d
fix(db): keep new audit partitions append-only, tie trace erasure to …
SIIR3X Sep 24, 2026
ab34c69
fix(oauth): reserve introspection to resource servers and honour or r…
SIIR3X Sep 24, 2026
ecfdf73
fix(deploy): cap production settings, fence the published ports and m…
SIIR3X Sep 24, 2026
705fb2e
docs: record the third audit's fixes and accepted risks
SIIR3X Sep 24, 2026
edec096
fix(admin): require every permission of a role to grant, withdraw, em…
SIIR3X Sep 25, 2026
1897b7a
fix(admin): revoke every session with the access factors, reset atomi…
SIIR3X Sep 25, 2026
3a69ebe
fix(auth): keep budgets from being turned against the owner and fail …
SIIR3X Sep 25, 2026
8874099
fix(auth): reserve the usernames of registrations on taken addresses
SIIR3X Sep 25, 2026
6d82666
fix(oauth): audit every consent and device approval and record delega…
SIIR3X Sep 25, 2026
720a9a8
fix(oauth): make clients safe by default and keep leaked codes and ac…
SIIR3X Sep 26, 2026
52495e1
fix(2fa): widen the totp replay window on the application clock and d…
SIIR3X Sep 26, 2026
b367af0
fix(config): refuse text keys, non-origin cors entries and shared key…
SIIR3X Sep 26, 2026
d0ce71a
fix(deploy): fence the compose bridge, group ipv6 clients by /64 in n…
SIIR3X Sep 26, 2026
dbb16ce
docs: record the fourth audit's fixes and accepted risks
SIIR3X Sep 26, 2026
25004a5
fix(admin): hold every administrative invariant on every route, from …
SIIR3X Sep 26, 2026
0b11819
fix(auth): answer unverified sign-ins like wrong passwords and keep r…
SIIR3X Sep 27, 2026
94493d8
fix(auth): bind sign-in codes to their challenge, key challenges by d…
SIIR3X Sep 27, 2026
f031495
fix(admin): audit administrative reads, hide operators from owners an…
SIIR3X Sep 27, 2026
cc9bab7
fix(oauth): refuse weaker idp transports and unsafe redirect forms, n…
SIIR3X Sep 27, 2026
806701e
fix(deploy): name trusted proxies by address, harden the broker and n…
SIIR3X Sep 27, 2026
08066eb
docs: record the fifth audit's fixes
SIIR3X Sep 27, 2026
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
404 changes: 402 additions & 2 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "auth-api"
version = "2.0.1"
version = "2.1.0"
edition = "2024"
rust-version = "1.88"
publish = false
Expand Down Expand Up @@ -44,6 +44,7 @@ ciborium = "0.2.2"
form_urlencoded = "1.2"
hmac = "0.13.0"
sha2 = "0.11.0"
subtle = "2.6"
sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "time", "derive", "json", "ipnetwork", "migrate"] }
tera = { version = "2.0", features = ["glob_fs"] }
thiserror = "2.0.18"
Expand All @@ -70,6 +71,7 @@ testkit = { path = "crates/testkit" }
criterion = { version = "0.8.2", features = ["html_reports"] }
futures = "0.3"
proptest = "1.9.0"
regex = "1"
tokio = { version = "1.50.0", features = ["full", "test-util"] }
tower = { version = "0.5.3", features = ["util"] }

Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json

# Cache layer: compile dependencies only
RUN cargo chef cook --release --recipe-path recipe.json
RUN cargo chef cook --release --locked --recipe-path recipe.json

# Compile the binary
COPY . .
RUN cargo build --release --bin auth-api
RUN cargo build --release --locked --bin auth-api

# =============================================================================
# Stage 4: Runtime
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,8 @@ release: infra-check stack-test ## Build a signed release bundle in dist/ (VERSI
docker save auth-api:$(VERSION) | gzip > dist/auth-api-$(VERSION)/auth-api-$(VERSION).image.tar.gz
docker image inspect --format '{{.Id}}' auth-api:$(VERSION) > dist/auth-api-$(VERSION)/IMAGE_ID
git archive HEAD migrations docker-compose.api.yml docker-compose.api.l.yml config.prod.env \
nats.conf deploy/profiles deploy/db nginx/nginx.conf \
scripts/backup-db.sh scripts/restore-db.sh scripts/backup-drill.sh scripts/rolling-update.sh \
nats.conf deploy/profiles deploy/db deploy/api nginx/nginx.conf \
scripts/backup-db.sh scripts/restore-db.sh scripts/backup-drill.sh scripts/rolling-update.sh scripts/write-secrets.sh \
docs/deploy/guides/prometheus-alerts.yml deploy/monitoring | tar -x -C dist/auth-api-$(VERSION)
cd dist/auth-api-$(VERSION) && find . -type f ! -name 'SHA256SUMS*' -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
ssh-keygen -Y sign -q -f $(RELEASE_SIGNING_KEY) -n auth-api-release dist/auth-api-$(VERSION)/SHA256SUMS
Expand Down
4 changes: 3 additions & 1 deletion benches/core_benches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ fn totp_benches(c: &mut Criterion) {
let secret = totp::generate_secret();
// The production path: a keyring and a versioned ciphertext.
let keyring = auth_api::utils::crypto::Keyring::new([7u8; 32], None);
let owner = uuid::Uuid::nil();
let encrypted = keyring
.encrypt(&secret)
.encrypt(&secret, owner.as_bytes())
.expect("failed to encrypt benchmark secret");

group.bench_function("generate_secret", |b| b.iter(totp::generate_secret));
Expand All @@ -111,6 +112,7 @@ fn totp_benches(c: &mut Criterion) {
let code = totp::current_code(&secret).expect("code");
totp::verify_code(
black_box(&encrypted),
owner,
black_box(&code),
black_box(&keyring),
1,
Expand Down
49 changes: 42 additions & 7 deletions crates/testkit/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,18 @@ impl TestApp {
/// Delete the anti-spam cooldown of email 2FA, so the next login can send
/// a code right away.
pub async fn clear_email_2fa_cooldown(&self, user_id: uuid::Uuid) {
self.delete_redis_key(&format!("email2fa_cd:{user_id}"))
// The setup cooldown, each challenge's, and the account's hourly
// budget of sign-in codes.
if let Ok(mut conn) = self.redis.get().await {
let keys: Vec<String> = conn
.keys(format!("email2fa_cd:{user_id}*"))
.await
.unwrap_or_default();
if !keys.is_empty() {
let _: Result<(), _> = conn.del(keys).await;
}
}
self.delete_redis_key(&format!("email2fa_send_user:{user_id}"))
.await;
}

Expand All @@ -384,25 +395,40 @@ impl TestApp {

let mut conn = self.redis.get().await.expect("redis connection failed");
let raw: String = conn
.get(format!("email_change_flow:{flow_token}"))
.get(format!(
"email_change_flow:{}",
auth_api::utils::crypto::token_id(flow_token)
))
.await
.expect("email_change flow state not found in Redis");

let state: serde_json::Value = serde_json::from_str(&raw).unwrap();
let user_id: uuid::Uuid = state["user_id"].as_str().unwrap().parse().unwrap();
let hash_b64 = state["otp_hash"]
.as_str()
.expect("otp_hash missing from flow state");
let hash_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(hash_b64)
.unwrap();

brute_force_otp(&hash_bytes)
brute_force_otp(&hash_bytes, |code| {
self.state
.keyring
.otp_digest("email_change", user_id.as_bytes(), code)
})
}

/// Clear the per-user email-change cooldown so a second flow can start.
pub async fn clear_email_change_cooldown(&self, user_id: uuid::Uuid) {
self.delete_redis_key(&format!("email_change_cd:{user_id}"))
.await;
self.clear_email_change_start_cooldown(user_id).await;
}

/// Lift the minute between two starts of an e-mail change.
pub async fn clear_email_change_start_cooldown(&self, user_id: uuid::Uuid) {
self.delete_redis_key(&format!("email_change_start_cd:{user_id}"))
.await;
}

pub async fn clear_recent_reauth(&self, access_token: &str) {
Expand Down Expand Up @@ -520,12 +546,16 @@ pub fn test_config(db_url: &str, redis_url: &str, nats_url: &str) -> Config {
sensitive_action_reauth_secs: 600,
new_device_alerts: true,
magic_links: true,
registrations_per_ip_per_hour: 10_000,
reset_revokes_factors_added_hours: 72,
},
captcha: CaptchaConfig {
secret: None,
verify_url: "https://hcaptcha.com/siteverify".into(),
request_timeout_secs: 1,
fail_open_on_error: false,
site_key: None,
expected_hostnames: Vec::new(),
},
cors: CorsConfig {
allowed_origins: vec!["*".into()],
Expand Down Expand Up @@ -594,17 +624,21 @@ pub fn test_config(db_url: &str, redis_url: &str, nats_url: &str) -> Config {
},
metrics: MetricsConfig {
enabled: false,
host: "127.0.0.1".into(),
port: 9464,
token: None,
},
}
}

/// Recover a 6-digit OTP from its SHA-256 digest.
pub fn brute_force_otp(expected_hash: &[u8]) -> String {
use sha2::{Digest, Sha256};
/// The six-digit code whose digest, by `digest`, is `expected_hash`: tests
/// hold the application keyring, an attacker holding only the stored digest
/// does not.
pub fn brute_force_otp(expected_hash: &[u8], digest: impl Fn(&str) -> [u8; 32]) -> String {
(0u32..1_000_000)
.map(|n| format!("{n:06}"))
.find(|candidate| Sha256::digest(candidate.as_bytes()).as_slice() == expected_hash)
.find(|candidate| digest(candidate).as_slice() == expected_hash)
.expect("OTP not found in the 6-digit space")
}

Expand Down Expand Up @@ -659,6 +693,7 @@ mod tests {
#[test]
fn otp_is_recovered_from_its_digest() {
use sha2::{Digest, Sha256};
assert_eq!(brute_force_otp(&Sha256::digest(b"004217")), "004217");
let digest = |code: &str| -> [u8; 32] { Sha256::digest(code.as_bytes()).into() };
assert_eq!(brute_force_otp(&digest("004217"), digest), "004217");
}
}
6 changes: 5 additions & 1 deletion crates/testkit/src/mailpit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ impl MailpitClient {
pub async fn wait_for_message(&self, email: &str, subject: &str) -> Option<MessageDetail> {
// Record "now" before we start polling so we can discard any pre-existing
// messages that happen to match the same email + subject.
let not_before = time::OffsetDateTime::now_utc() - time::Duration::milliseconds(500); // small back-buffer for clock skew
// Back-buffer: the message may have been sent a while before this call
// (padded responses, the contract validators built on a process's
// first request). Addresses are unique per test, so older messages
// to the same address come from earlier runs, seconds before.
let not_before = time::OffsetDateTime::now_utc() - time::Duration::seconds(3);

let deadline = std::time::Instant::now() + std::time::Duration::from_millis(5_000);
while std::time::Instant::now() < deadline {
Expand Down
4 changes: 2 additions & 2 deletions crates/verifier/tests/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ async fn forged_expired_and_foreign_tokens_are_refused() {
async fn introspection_catches_a_token_revoked_before_it_expires() {
let app = TestApp::spawn().await;
sqlx::query(
"INSERT INTO registered_clients (client_id, display_name, client_secret_hash)
VALUES ('resource-server', 'Resource server', $1)",
"INSERT INTO registered_clients (client_id, display_name, client_secret_hash, allows_introspection)
VALUES ('resource-server', 'Resource server', $1, TRUE)",
)
.bind(auth_api::utils::crypto::sha256(b"aacs_rs-secret").to_vec())
.execute(&app.db)
Expand Down
21 changes: 21 additions & 0 deletions deploy/api/logrotate-auth-api
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Rotation of the auth-api access log: 14 days at most. The log holds client
# addresses and user agents; keeping them longer than the application keeps
# full addresses in its audit log would undo its retention policy
# (docs/dev/privacy.md). Takes precedence over the nginx package's weekly
# rotation of /var/log/nginx/*.log for this file.
#
# Install: sudo install -m 644 deploy/api/logrotate-auth-api /etc/logrotate.d/auth-api
# Check: sudo logrotate --debug /etc/logrotate.d/auth-api
/var/log/nginx/auth-api.access.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
create 0640 www-data adm
sharedscripts
postrotate
invoke-rc.d nginx rotate >/dev/null 2>&1 || true
endscript
}
30 changes: 30 additions & 0 deletions deploy/api/nftables-auth-api.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Only nginx (and root, for the rolling update's readiness checks) reaches the
# API instances on the API VPS. Docker publishes them on 127.0.0.1:3001-3004,
# and the application trusts X-Forwarded-For from the address those
# connections arrive with: without this rule, any other process on the host
# could forge a client address and bypass the per-address limits.
#
# Install (docs/deploy/api/deployment.md):
# sudo install -m 644 deploy/api/nftables-auth-api.conf /etc/nftables.d/auth-api.conf
# echo 'include "/etc/nftables.d/*.conf"' | sudo tee -a /etc/nftables.conf
# sudo systemctl enable --now nftables && sudo nft -f /etc/nftables.conf
#
# The instances are also reachable at their own addresses on the compose
# bridge (172.30.0.0/24), where connections arrive from the gateway
# 172.30.0.1 - the trusted proxy. The second rule closes that path too, for the
# application port and the internal listener; it also covers the published
# ports when Docker rewrites them to the bridge (userland proxy disabled).
#
# Check: `sudo -u nobody curl -s 127.0.0.1:3001/live` and
# `sudo -u nobody curl -s 172.30.0.2:3000/live` are refused, while
# `sudo curl -fsS 127.0.0.1:3001/live` and `curl -fsS https://<host>/live` answer.
# www-data is the user nginx workers run as on Debian; the Docker port proxy
# runs as root.

table inet auth_api_local {
chain output {
type filter hook output priority 0; policy accept;
oifname "lo" tcp dport 3001-3004 meta skuid != { 0, "www-data" } reject with tcp reset
ip daddr 172.30.0.0/24 tcp dport { 3000, 9464 } meta skuid != { 0, "www-data" } reject with tcp reset
}
}
62 changes: 62 additions & 0 deletions deploy/db/auth-api-grants.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
-- Privileges of the runtime role `auth_api` on a schema owned by
-- `auth_api_owner` (docs/deploy/database/deployment.md, section 2.1). Run as
-- postgres in the auth-api database, after the first migration run:
-- sudo -u postgres psql -d auth_api -f auth-api-grants.sql
-- Running it again is harmless. Later migrations run as `auth_api_owner` and
-- the default privileges below extend to the tables they create.
--
-- The runtime role reads and writes data and nothing else: it cannot alter,
-- drop or truncate a table, disable a trigger, rewrite the audit log, change
-- the permission catalog or the migration history. A compromised application
-- or an SQL injection is bounded by that.

GRANT USAGE ON SCHEMA public TO auth_api;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO auth_api;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO auth_api;
-- The functions running with the owner's privileges are granted by name: a
-- function added later runs for the application only once listed here. Every
-- other function keeps PostgreSQL's default EXECUTE for everyone.
GRANT EXECUTE ON FUNCTION
rotate_audit_log_partitions(INTEGER, INTEGER),
forget_account_traces(UUID),
purge_unverified_accounts(INTERVAL, INTEGER),
coarsen_audit_addresses(INTERVAL, INTEGER),
cleanup_published_events(INTERVAL, INTEGER),
cleanup_finished_webhook_deliveries(INTERVAL, INTEGER)
TO auth_api;

ALTER DEFAULT PRIVILEGES FOR ROLE auth_api_owner IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO auth_api;
ALTER DEFAULT PRIVILEGES FOR ROLE auth_api_owner IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO auth_api;

-- The audit log is append-only for the application: rows are rewritten only
-- by the maintenance functions of the migrations, which run as the owner, and removed only
-- with their partition.
REVOKE UPDATE, DELETE ON audit_log FROM auth_api;
DO $$
DECLARE
partition RECORD;
BEGIN
FOR partition IN
SELECT c.relname FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = 'audit_log'
LOOP
EXECUTE format('REVOKE UPDATE, DELETE ON %I FROM auth_api', partition.relname);
END LOOP;
END
$$;

-- Outgoing events and webhook deliveries leave only through the owner's
-- retention functions: the runtime role marks them sent or failed, never
-- deletes them, so an unpublished `user.deleted` cannot be made to vanish.
REVOKE DELETE ON event_outbox, webhook_deliveries FROM auth_api;

-- The permission catalog and the migration history change with migrations only.
REVOKE INSERT, UPDATE, DELETE ON permissions FROM auth_api;
REVOKE INSERT, UPDATE, DELETE ON _sqlx_migrations FROM auth_api;
-- The minimums of the maintenance functions are the owner's to change: the
-- runtime role calls the functions but cannot lower what they keep.
REVOKE INSERT, UPDATE, DELETE ON maintenance_floors FROM auth_api;
4 changes: 2 additions & 2 deletions deploy/db/auth-api-role.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
--
-- A statement or a lock wait gives up before the API's own 30-second request
-- timeout, and a connection left idle inside a transaction is closed instead of
-- holding its locks. Migrations lift the statement timeout for their own
-- session (see docs/deploy/database/deployment.md, section 2.5).
-- holding its locks. Migrations run as `auth_api_owner`, which carries no
-- such limit (see docs/deploy/database/deployment.md, section 2.5).
ALTER ROLE auth_api SET statement_timeout = '25s';
ALTER ROLE auth_api SET lock_timeout = '10s';
ALTER ROLE auth_api SET idle_in_transaction_session_timeout = '60s';
12 changes: 12 additions & 0 deletions deploy/db/pg_hba.auth-api.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Access rules of the auth-api database, appended to
# /etc/postgresql/17/main/pg_hba.conf (docs/deploy/database/deployment.md,
# section 2.2). Only the API VPS, through its WireGuard address, and only with
# a password (SCRAM): no `trust`, no wider network. Rules are read top down:
# nothing above them may grant more.
#
# Where the tunnel is not the trust boundary, write `hostssl` instead of `host`
# and add `sslmode=verify-full` to both connection URLs.

# TYPE DATABASE USER ADDRESS METHOD
host auth_api auth_api 10.0.0.1/32 scram-sha-256
host auth_api auth_api_owner 10.0.0.1/32 scram-sha-256
Loading
Loading