From c440e28daefb46b0fb3e999788374626851095af Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:12:41 +0200 Subject: [PATCH 01/80] chore(deps): tidy dependencies, release profile and local CI gate --- Cargo.lock | 52 +++++++++++++++++++++++++----------------- Cargo.toml | 11 ++++----- Makefile | 11 +++++++-- deny.toml | 11 +++++---- tests/db/common/db.rs | 11 ++++++++- tests/db/smoke_test.rs | 7 +----- 6 files changed, 63 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ad5b0b..4ba6940 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,7 +167,6 @@ dependencies = [ "anyhow", "argon2", "async-nats", - "async-trait", "axum", "axum-prometheus", "base64", @@ -209,9 +208,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -220,14 +219,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -446,9 +446,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -676,9 +676,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] @@ -1215,11 +1215,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1229,11 +1227,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.0", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2502,15 +2502,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -2639,6 +2640,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.0", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -2877,9 +2887,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -2942,9 +2952,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -3283,9 +3293,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] diff --git a/Cargo.toml b/Cargo.toml index 2c4f8d2..ff7664d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "auth-api" version = "0.1.0" edition = "2024" +rust-version = "1.88" publish = false [dependencies] @@ -36,7 +37,6 @@ totp-rs = { version = "5.7.1", features = ["gen_secret"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt", "json"] } uuid = { version = "1.22.0", features = ["v4", "serde"] } -async-trait = "0.1.89" tower-http = { version = "0.7.0", features = ["cors", "timeout"] } email_address = "0.2.9" maxminddb = "0.29.0" @@ -44,15 +44,14 @@ reqwest = { version = "0.13.2", default-features = false, features = ["json", "r [dev-dependencies] criterion = { version = "0.8.2", features = ["html_reports"] } -p256 = { version = "0.13", features = ["ecdsa"] } postgres = { version = "0.19.12", features = ["with-uuid-1", "with-time-0_3"] } proptest = "1.9.0" -rand_core = { version = "0.6", features = ["getrandom"] } -serde_json = "1.0" -sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "time", "derive", "json", "ipnetwork", "migrate"] } tokio = { version = "1.50.0", features = ["full"] } -uuid = { version = "1.22.0", features = ["v4"] } +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" [[test]] name = "migrations" diff --git a/Makefile b/Makefile index a3f15a7..fc9c210 100644 --- a/Makefile +++ b/Makefile @@ -67,8 +67,8 @@ fmt-check: ## Check formatting without modifying files cargo fmt --check .PHONY: clippy -clippy: ## Run Clippy linter - cargo clippy -- -D warnings +clippy: ## Run Clippy linter on every target (lib, bins, tests, benches) + cargo clippy --all-targets -- -D warnings .PHONY: deny deny: ## Enforce dependency policy and security audit (cargo-deny) @@ -94,6 +94,13 @@ test: test-infra-up ## Run all tests (starts/stops infrastructure automatically) TEST_DATABASE_URL=$(TEST_DB_URL) TEST_REDIS_URL=$(TEST_REDIS_URL) TEST_NATS_URL=$(TEST_NATS_URL) cargo nextest run; \ EXIT=$$?; $(MAKE) test-infra-down; exit $$EXIT +.PHONY: test-local +test-local: ## Run all tests against already-running infrastructure (no Docker) + TEST_DATABASE_URL=$(TEST_DB_URL) TEST_REDIS_URL=$(TEST_REDIS_URL) TEST_NATS_URL=$(TEST_NATS_URL) cargo nextest run + +.PHONY: ci +ci: quality test-local ## Full local CI gate: formatting, lints, dependency policy, all tests + .PHONY: test-verbose test-verbose: test-infra-up ## Run all tests with detailed output TEST_DATABASE_URL=$(TEST_DB_URL) TEST_REDIS_URL=$(TEST_REDIS_URL) TEST_NATS_URL=$(TEST_NATS_URL) cargo nextest run --no-capture; \ diff --git a/deny.toml b/deny.toml index 44f76a4..5e6842e 100644 --- a/deny.toml +++ b/deny.toml @@ -1,5 +1,8 @@ [graph] -all-features = true +# Only the features actually built: `all-features` would pull sqlx-mysql and +# its `rsa` dependency (RUSTSEC-2023-0071) into the graph, although neither is +# ever compiled for this service. +all-features = false [advisories] version = 2 @@ -10,9 +13,9 @@ ignore = [] multiple-versions = "warn" wildcards = "deny" highlight = "all" -# Reviewed, accepted duplicate versions. Most stem from the `postgres` -# dev-dependency (older rand/getrandom ecosystem) and the Windows platform stub -# crates (build-time only, never used on the Linux runtime target). Listed by +# Reviewed, accepted duplicate versions. They stem from sqlx, proptest and +# p256 pinning older rand/getrandom generations, and from the Windows platform +# stub crates (build-time only, never used on the Linux runtime target). Listed by # name so that any *new*, unexpected duplicate still surfaces as a warning. skip = [ { crate = "cpufeatures" }, diff --git a/tests/db/common/db.rs b/tests/db/common/db.rs index 23eb834..5d880cb 100644 --- a/tests/db/common/db.rs +++ b/tests/db/common/db.rs @@ -111,8 +111,17 @@ pub fn migration_sql(file_name: &str) -> String { .unwrap_or_else(|err| panic!("failed to read migration `{}`: {err}", path.display())) } +/// Database URL for the DB test suites. +/// +/// A missing `TEST_DATABASE_URL` is a failure, not a silent pass: otherwise +/// every database test returns early and the suite reports green without +/// having checked anything. Set `SKIP_DB_TESTS=1` to skip them on purpose. pub fn test_database_url() -> Option { - std::env::var("TEST_DATABASE_URL").ok() + match std::env::var("TEST_DATABASE_URL") { + Ok(url) => Some(url), + Err(_) if std::env::var("SKIP_DB_TESTS").as_deref() == Ok("1") => None, + Err(_) => panic!("TEST_DATABASE_URL must be set (or SKIP_DB_TESTS=1 to skip DB tests)"), + } } pub fn assert_constraint(err: &Error, expected: &str) { diff --git a/tests/db/smoke_test.rs b/tests/db/smoke_test.rs index 29fb8d8..d90d1b3 100644 --- a/tests/db/smoke_test.rs +++ b/tests/db/smoke_test.rs @@ -1,6 +1,6 @@ mod common; -use common::db::{migration_files, migration_sql, test_database_url}; +use common::db::{migration_files, migration_sql}; #[test] fn migrations_are_sorted_and_contiguous() { @@ -52,8 +52,3 @@ fn critical_migrations_contain_expected_objects() { assert!(login_attempts_sql.contains("CREATE TABLE login_attempts")); assert!(recovery_sql.contains("CREATE TABLE recovery_codes")); } - -#[test] -fn test_database_url_is_optional_for_now() { - let _ = test_database_url(); -} From f09e746b033929b91e244a41dbdfc201475f8372 Mon Sep 17 00:00:00 2001 From: SIIR3X <125802911+SIIR3X@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:47:09 +0200 Subject: [PATCH 02/80] feat(config): strict configuration, atomic attempt budgets and explicit errors --- src/config.rs | 278 ++++++++++++++++++++++++------ src/error.rs | 88 ++++++++-- src/main.rs | 4 +- src/state.rs | 70 ++------ src/utils/mod.rs | 6 +- src/utils/redis_counter.rs | 133 ++++++++++++++ tests/http/auth/mod.rs | 1 + tests/http/auth/password_reset.rs | 28 +-- tests/http/auth/redis_counter.rs | 119 +++++++++++++ tests/http/auth/security.rs | 12 +- tests/http/common/app.rs | 19 +- tests/http/user/password.rs | 5 +- 12 files changed, 608 insertions(+), 155 deletions(-) create mode 100644 src/utils/redis_counter.rs create mode 100644 tests/http/auth/redis_counter.rs diff --git a/src/config.rs b/src/config.rs index 22cf54c..db055ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -334,7 +334,10 @@ impl Config { pub fn from_env() -> Result { dotenvy::dotenv().ok(); - let env = env_parse("APP_ENV").unwrap_or(Environment::Development); + // Required: a typo such as `APP_ENV=prd` must not silently start a + // deployment with every development relaxation enabled. + let env: Environment = + env_parse("APP_ENV")?.ok_or_else(|| ConfigError::Missing("APP_ENV".into()))?; let is_production = matches!(env, Environment::Production); @@ -342,21 +345,21 @@ impl Config { env: env.clone(), server: ServerConfig { host: env_string("SERVER_HOST").unwrap_or_else(|| "0.0.0.0".into()), - port: env_parse("SERVER_PORT").unwrap_or(3000u16), + port: env_parse("SERVER_PORT")?.unwrap_or(3000u16), public_url: env_string("APP_PUBLIC_URL") .unwrap_or_else(|| "http://localhost:3000".into()), trusted_proxy_cidrs: env_ip_network_list("TRUSTED_PROXY_CIDRS")?, }, database: DatabaseConfig { url: env_require("DATABASE_URL")?, - max_connections: env_parse("DB_MAX_CONNECTIONS").unwrap_or(20), - min_connections: env_parse("DB_MIN_CONNECTIONS").unwrap_or(2), - acquire_timeout_secs: env_parse("DB_ACQUIRE_TIMEOUT_SECS").unwrap_or(30), + max_connections: env_parse("DB_MAX_CONNECTIONS")?.unwrap_or(20), + min_connections: env_parse("DB_MIN_CONNECTIONS")?.unwrap_or(2), + acquire_timeout_secs: env_parse("DB_ACQUIRE_TIMEOUT_SECS")?.unwrap_or(30), }, redis: RedisConfig { url: env_require("REDIS_URL")?, - pool_size: env_parse("REDIS_POOL_SIZE").unwrap_or(10), - wait_timeout_ms: env_parse("REDIS_WAIT_TIMEOUT_MS").unwrap_or(2000), + pool_size: env_parse("REDIS_POOL_SIZE")?.unwrap_or(10), + wait_timeout_ms: env_parse("REDIS_WAIT_TIMEOUT_MS")?.unwrap_or(2000), }, nats: NatsConfig { url: env_string("NATS_URL").unwrap_or_else(|| "nats://nats:4222".into()), @@ -366,46 +369,46 @@ impl Config { public_key: env_require("JWT_PUBLIC_KEY")?.replace("\\n", "\n"), previous_public_key: env_string("JWT_PREVIOUS_PUBLIC_KEY") .map(|s| s.replace("\\n", "\n")), - access_expiry_secs: env_parse("JWT_ACCESS_EXPIRY_SECS").unwrap_or(900), - refresh_expiry_secs: env_parse("JWT_REFRESH_EXPIRY_SECS") + access_expiry_secs: env_parse("JWT_ACCESS_EXPIRY_SECS")?.unwrap_or(900), + refresh_expiry_secs: env_parse("JWT_REFRESH_EXPIRY_SECS")? .unwrap_or(60 * 60 * 24 * 30), - short_session_expiry_secs: env_parse("JWT_SHORT_SESSION_EXPIRY_SECS") + short_session_expiry_secs: env_parse("JWT_SHORT_SESSION_EXPIRY_SECS")? .unwrap_or(60 * 60 * 24), - strict_session_binding: env_parse("JWT_STRICT_SESSION_BINDING").unwrap_or(false), - max_session_lifetime_secs: env_parse("JWT_MAX_SESSION_LIFETIME_SECS") + strict_session_binding: env_parse("JWT_STRICT_SESSION_BINDING")?.unwrap_or(false), + max_session_lifetime_secs: env_parse("JWT_MAX_SESSION_LIFETIME_SECS")? .unwrap_or(60 * 60 * 24 * 90), audience: env_csv("JWT_AUDIENCE").unwrap_or_default(), }, crypto: CryptoConfig { - argon2_memory_kib: env_parse("ARGON2_MEMORY_KIB").unwrap_or(65_536), // 64 MB - argon2_iterations: env_parse("ARGON2_ITERATIONS").unwrap_or(3), - argon2_parallelism: env_parse("ARGON2_PARALLELISM").unwrap_or(4), - argon2_max_concurrency: env_parse("ARGON2_MAX_CONCURRENCY") + argon2_memory_kib: env_parse("ARGON2_MEMORY_KIB")?.unwrap_or(65_536), // 64 MB + argon2_iterations: env_parse("ARGON2_ITERATIONS")?.unwrap_or(3), + argon2_parallelism: env_parse("ARGON2_PARALLELISM")?.unwrap_or(4), + argon2_max_concurrency: env_parse("ARGON2_MAX_CONCURRENCY")? .unwrap_or_else(default_argon2_max_concurrency), totp_issuer: env_string("TOTP_ISSUER").unwrap_or_else(|| "auth-api".into()), encryption_key: env_require("ENCRYPTION_KEY")?, previous_encryption_key: env_string("PREVIOUS_ENCRYPTION_KEY"), - totp_skew: env_parse("TOTP_SKEW").unwrap_or(1), - recovery_code_expiry_days: env_parse("RECOVERY_CODE_EXPIRY_DAYS").unwrap_or(365), // 0 = never + totp_skew: env_parse("TOTP_SKEW")?.unwrap_or(1), + recovery_code_expiry_days: env_parse("RECOVERY_CODE_EXPIRY_DAYS")?.unwrap_or(365), // 0 = never }, rate_limit: RateLimitConfig { - requests_per_minute: env_parse("RATE_LIMIT_RPM").unwrap_or(300), - auth_requests_per_minute: env_parse("RATE_LIMIT_AUTH_RPM").unwrap_or(20), - fail_open_on_redis_error: env_parse("RATE_LIMIT_FAIL_OPEN") + requests_per_minute: env_parse("RATE_LIMIT_RPM")?.unwrap_or(300), + auth_requests_per_minute: env_parse("RATE_LIMIT_AUTH_RPM")?.unwrap_or(20), + fail_open_on_redis_error: env_parse("RATE_LIMIT_FAIL_OPEN")? .unwrap_or(!is_production), - allow_requests_without_ip: env_parse("RATE_LIMIT_ALLOW_MISSING_IP") + allow_requests_without_ip: env_parse("RATE_LIMIT_ALLOW_MISSING_IP")? .unwrap_or(!is_production), }, security: SecurityConfig { - lockout_threshold: env_parse("LOCKOUT_THRESHOLD").unwrap_or(10), - lockout_duration_secs: env_parse("LOCKOUT_DURATION_SECS").unwrap_or(1800), - sensitive_action_reauth_secs: env_parse("SENSITIVE_ACTION_REAUTH_SECS") + lockout_threshold: env_parse("LOCKOUT_THRESHOLD")?.unwrap_or(10), + lockout_duration_secs: env_parse("LOCKOUT_DURATION_SECS")?.unwrap_or(1800), + sensitive_action_reauth_secs: env_parse("SENSITIVE_ACTION_REAUTH_SECS")? .unwrap_or(600), }, mail: MailConfig { smtp: SmtpConfig { host: env_require("SMTP_HOST")?, - port: env_parse("SMTP_PORT").unwrap_or(587), + port: env_parse("SMTP_PORT")?.unwrap_or(587), username: env_require("SMTP_USERNAME")?, password: env_require("SMTP_PASSWORD")?, from_name: env_string("SMTP_FROM_NAME").unwrap_or_else(|| "auth-api".into()), @@ -419,8 +422,8 @@ impl Config { secret: env_string("CAPTCHA_SECRET"), verify_url: env_string("CAPTCHA_VERIFY_URL") .unwrap_or_else(|| "https://hcaptcha.com/siteverify".into()), - request_timeout_secs: env_parse("CAPTCHA_TIMEOUT_SECS").unwrap_or(5), - fail_open_on_error: env_parse("CAPTCHA_FAIL_OPEN").unwrap_or(!is_production), + request_timeout_secs: env_parse("CAPTCHA_TIMEOUT_SECS")?.unwrap_or(5), + fail_open_on_error: env_parse("CAPTCHA_FAIL_OPEN")?.unwrap_or(!is_production), }, cors: CorsConfig { allowed_origins: env_string("CORS_ALLOWED_ORIGINS") @@ -428,45 +431,45 @@ impl Config { .split(',') .map(|s| s.trim().to_owned()) .collect(), - allow_credentials: env_parse("CORS_ALLOW_CREDENTIALS").unwrap_or(true), + allow_credentials: env_parse("CORS_ALLOW_CREDENTIALS")?.unwrap_or(true), }, cleanup: CleanupConfig { - interval_secs: env_parse("CLEANUP_INTERVAL_SECS").unwrap_or(3600), - sessions_grace_days: env_parse("CLEANUP_SESSIONS_GRACE_DAYS").unwrap_or(7), - tokens_grace_days: env_parse("CLEANUP_TOKENS_GRACE_DAYS").unwrap_or(1), - login_attempts_retention_days: env_parse("CLEANUP_LOGIN_ATTEMPTS_RETENTION_DAYS") + interval_secs: env_parse("CLEANUP_INTERVAL_SECS")?.unwrap_or(3600), + sessions_grace_days: env_parse("CLEANUP_SESSIONS_GRACE_DAYS")?.unwrap_or(7), + tokens_grace_days: env_parse("CLEANUP_TOKENS_GRACE_DAYS")?.unwrap_or(1), + login_attempts_retention_days: env_parse("CLEANUP_LOGIN_ATTEMPTS_RETENTION_DAYS")? .unwrap_or(90), - recovery_codes_grace_days: env_parse("CLEANUP_RECOVERY_CODES_GRACE_DAYS") + recovery_codes_grace_days: env_parse("CLEANUP_RECOVERY_CODES_GRACE_DAYS")? .unwrap_or(7), }, audit: AuditConfig { - retention_months: env_parse("AUDIT_LOG_RETENTION_MONTHS").unwrap_or(12), + retention_months: env_parse("AUDIT_LOG_RETENTION_MONTHS")?.unwrap_or(12), }, risk: RiskConfig { geoip_db_path: env_string("GEOIP_DB_PATH").unwrap_or_default(), - geoip_required: env_parse("GEOIP_REQUIRED").unwrap_or(false), - alert_threshold: env_parse("RISK_ALERT_THRESHOLD").unwrap_or(30), - challenge_threshold: env_parse("RISK_CHALLENGE_THRESHOLD").unwrap_or(60), - block_threshold: env_parse("RISK_BLOCK_THRESHOLD").unwrap_or(80), - history_days: env_parse("RISK_HISTORY_DAYS").unwrap_or(90), + geoip_required: env_parse("GEOIP_REQUIRED")?.unwrap_or(false), + alert_threshold: env_parse("RISK_ALERT_THRESHOLD")?.unwrap_or(30), + challenge_threshold: env_parse("RISK_CHALLENGE_THRESHOLD")?.unwrap_or(60), + block_threshold: env_parse("RISK_BLOCK_THRESHOLD")?.unwrap_or(80), + history_days: env_parse("RISK_HISTORY_DAYS")?.unwrap_or(90), }, log: LogConfig { level: env_string("LOG_LEVEL").unwrap_or_else(|| "info".into()), - format: env_parse("LOG_FORMAT").unwrap_or(LogFormat::Pretty), + format: env_parse("LOG_FORMAT")?.unwrap_or(LogFormat::Pretty), }, device_auth: DeviceAuthConfig { - ttl_secs: env_parse("DEVICE_AUTH_TTL_SECS").unwrap_or(300), - poll_interval_secs: env_parse("DEVICE_AUTH_POLL_INTERVAL_SECS").unwrap_or(5), + ttl_secs: env_parse("DEVICE_AUTH_TTL_SECS")?.unwrap_or(300), + poll_interval_secs: env_parse("DEVICE_AUTH_POLL_INTERVAL_SECS")?.unwrap_or(5), verification_uri: env_require("DEVICE_AUTH_VERIFICATION_URI")?, }, metrics: MetricsConfig { - enabled: env_parse("METRICS_ENABLED").unwrap_or(true), - port: env_parse("METRICS_PORT").unwrap_or(9464), + enabled: env_parse("METRICS_ENABLED")?.unwrap_or(true), + port: env_parse("METRICS_PORT")?.unwrap_or(9464), }, }; - config.validate()?; - + // Validation runs once, in `AppState::from_config`, after derived values + // such as the self audience are in place. Ok(config) } @@ -478,6 +481,19 @@ impl Config { self.env == Environment::Test } + /// Make sure auth-api's own `public_url` is part of the JWT audience list. + /// + /// Tokens are addressed to downstream resource servers, but auth-api also + /// consumes its own tokens for `/users/me/*` and pins `aud == public_url` + /// in the `AuthUser` extractor. Idempotent. + pub fn ensure_self_in_audience(&mut self) { + let self_url = self.server.public_url.clone(); + if self_url.is_empty() || self.jwt.audience.iter().any(|a| a == &self_url) { + return; + } + self.jwt.audience.push(self_url); + } + pub fn validate(&self) -> Result<(), ConfigError> { validate_jwt_keys(&self.jwt)?; validate_encryption_key("ENCRYPTION_KEY", &self.crypto.encryption_key)?; @@ -510,6 +526,21 @@ impl Config { validate_https_url("APP_PUBLIC_URL", &self.server.public_url)?; + // TLS terminates at a reverse proxy in production. With no trusted + // CIDR every request resolves to the proxy's address: one rate-limit + // bucket for the whole internet and one IP in every audit row. + if self.server.trusted_proxy_cidrs.is_empty() { + return Err(ConfigError::Invalid { + key: "TRUSTED_PROXY_CIDRS".into(), + reason: "must not be empty in production -- without it every client is rate-limited and audited as the reverse proxy".into(), + }); + } + + validate_production_encryption_key("ENCRYPTION_KEY", &self.crypto.encryption_key)?; + if let Some(previous) = self.crypto.previous_encryption_key.as_deref() { + validate_production_encryption_key("PREVIOUS_ENCRYPTION_KEY", previous)?; + } + if self.captcha.secret.is_some() { validate_https_url("CAPTCHA_VERIFY_URL", &self.captcha.verify_url)?; } else { @@ -566,14 +597,24 @@ impl Config { /// Used to refuse that key in production (the pair is public by definition). const DEV_JWT_PUBLIC_KEY_MARKER: &str = "MEjIGO1563lSVOpDzgW6Y9aI20lH"; +/// Symmetric keys committed in `.env.dev` or used as examples: public by +/// definition, refused in production. +const DEV_ENCRYPTION_KEYS: [&str; 2] = [ + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", +]; + // Helpers fn env_require(key: &str) -> Result { env::var(key).map_err(|_| ConfigError::Missing(key.into())) } +/// Optional string variable. A blank value counts as unset: a secret that +/// survives as `Some("")` satisfies every "must be set" check while the code +/// using it treats blank as "not configured" and skips the protection. fn env_string(key: &str) -> Option { - env::var(key).ok() + env::var(key).ok().filter(|value| !value.trim().is_empty()) } fn env_csv(key: &str) -> Option> { @@ -601,9 +642,22 @@ fn env_ip_network_list(key: &str) -> Result, ConfigError> { .collect() } -// Parse an env var into any type that implements FromStr; returns None on missing or parse failure. -fn env_parse(key: &str) -> Option { - env::var(key).ok()?.parse().ok() +/// Parse an optional variable. Absent or blank yields `None`; a value that is +/// present but does not parse is an error, never a silent fallback to the +/// default (`LOCKOUT_THRESHOLD=1O` must not quietly become 10). +fn env_parse(key: &str) -> Result, ConfigError> +where + T: FromStr, + T::Err: std::fmt::Display, +{ + env_string(key) + .map(|raw| { + raw.trim().parse::().map_err(|e| ConfigError::Invalid { + key: key.into(), + reason: format!("cannot parse '{raw}': {e}"), + }) + }) + .transpose() } fn validate_jwt_keys(jwt: &JwtConfig) -> Result<(), ConfigError> { @@ -681,6 +735,42 @@ fn validate_encryption_key(key_name: &str, value: &str) -> Result<(), ConfigErro Ok(()) } +/// Production-only checks on a symmetric key, on top of the entropy floor. +/// +/// Shannon entropy counts byte frequencies, so any 32 distinct bytes score a +/// perfect 5 bits/byte -- including the counted sequence `00 01 .. 1f` from +/// `.env.dev`. A constant stride between bytes gives such keys away. +fn validate_production_encryption_key(key_name: &str, value: &str) -> Result<(), ConfigError> { + if DEV_ENCRYPTION_KEYS.contains(&value) { + return Err(ConfigError::Invalid { + key: key_name.into(), + reason: "this is a committed development key -- it is public and must never be used in production".into(), + }); + } + + let decoded = STANDARD.decode(value).map_err(|e| ConfigError::Invalid { + key: key_name.into(), + reason: format!("must be valid base64: {e}"), + })?; + + let stride = decoded + .windows(2) + .next() + .map(|w| w[1].wrapping_sub(w[0])) + .unwrap_or_default(); + if decoded + .windows(2) + .all(|w| w[1].wrapping_sub(w[0]) == stride) + { + return Err(ConfigError::Invalid { + key: key_name.into(), + reason: "key bytes form an arithmetic sequence: use a cryptographically random key (e.g. openssl rand -base64 32)".into(), + }); + } + + Ok(()) +} + fn validate_optional_encryption_key( key_name: &str, value: Option<&str>, @@ -850,7 +940,7 @@ mod tests { host: "127.0.0.1".into(), port: 3000, public_url: "https://api.example.com".into(), - trusted_proxy_cidrs: vec![], + trusted_proxy_cidrs: vec!["10.0.0.0/8".parse().unwrap()], }, database: DatabaseConfig { url: "postgres://user:pass@localhost/db".into(), @@ -883,7 +973,7 @@ mod tests { argon2_parallelism: 1, argon2_max_concurrency: 4, totp_issuer: "test".into(), - encryption_key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), + encryption_key: "VVKGNsojoT/vVMlGypXnqcCcJIbrPKbn/8DGfEs496k=".into(), previous_encryption_key: None, totp_skew: 1, recovery_code_expiry_days: 365, @@ -1227,7 +1317,7 @@ mod tests { fn validate_accepts_valid_previous_encryption_key() { let mut config = valid_config(); config.crypto.previous_encryption_key = - Some("AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=".into()); + Some("6QoHPPjm9EnjsuRmj7OXQrYh98XIvrWYbI5KQyglMNc=".into()); assert!( config.validate().is_ok(), @@ -1450,4 +1540,84 @@ mod tests { "development config with permissive switches must be accepted" ); } + + // Production hardening added with strict configuration loading. + + #[test] + fn validate_rejects_empty_trusted_proxies_in_production() { + let mut config = valid_config(); + config.server.trusted_proxy_cidrs = vec![]; + + let err = config.validate().expect_err("empty proxies must fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "TRUSTED_PROXY_CIDRS")); + } + + #[test] + fn validate_rejects_committed_dev_encryption_key_in_production() { + let mut config = valid_config(); + config.crypto.encryption_key = DEV_ENCRYPTION_KEYS[0].into(); + + let err = config.validate().expect_err("dev AES key must fail"); + assert!(matches!(err, ConfigError::Invalid { key, .. } if key == "ENCRYPTION_KEY")); + } + + #[test] + fn validate_rejects_arithmetic_encryption_key_in_production() { + let mut config = valid_config(); + let counted: Vec = (0u8..32).map(|i| i.wrapping_mul(3)).collect(); + config.crypto.previous_encryption_key = Some(STANDARD.encode(counted)); + + let err = config.validate().expect_err("counted key must fail"); + assert!( + matches!(err, ConfigError::Invalid { key, .. } if key == "PREVIOUS_ENCRYPTION_KEY") + ); + } + + #[test] + fn validate_accepts_dev_encryption_key_outside_production() { + let mut config = valid_config(); + config.env = Environment::Development; + config.crypto.encryption_key = DEV_ENCRYPTION_KEYS[0].into(); + + assert!(config.validate().is_ok()); + } + + #[test] + fn env_string_treats_blank_as_unset() { + // SAFETY: key names are unique to this test; no other thread reads them. + unsafe { std::env::set_var("AUTH_API_TEST_BLANK_STRING", " ") }; + assert_eq!(env_string("AUTH_API_TEST_BLANK_STRING"), None); + } + + #[test] + fn env_parse_rejects_unparsable_values() { + // SAFETY: key names are unique to this test; no other thread reads them. + unsafe { std::env::set_var("AUTH_API_TEST_BAD_NUMBER", "1O") }; + let parsed: Result, _> = env_parse("AUTH_API_TEST_BAD_NUMBER"); + + assert!( + matches!(parsed, Err(ConfigError::Invalid { key, .. }) if key == "AUTH_API_TEST_BAD_NUMBER") + ); + } + + #[test] + fn env_parse_accepts_absent_and_valid_values() { + // SAFETY: key names are unique to this test; no other thread reads them. + unsafe { std::env::set_var("AUTH_API_TEST_GOOD_NUMBER", " 42 ") }; + let absent: Option = env_parse("AUTH_API_TEST_ABSENT_NUMBER").unwrap(); + let present: Option = env_parse("AUTH_API_TEST_GOOD_NUMBER").unwrap(); + + assert_eq!(absent, None); + assert_eq!(present, Some(42)); + } + + #[test] + fn ensure_self_in_audience_is_idempotent() { + let mut config = valid_config(); + config.ensure_self_in_audience(); + config.ensure_self_in_audience(); + + let own = config.server.public_url.clone(); + assert_eq!(config.jwt.audience.iter().filter(|a| **a == own).count(), 1); + } } diff --git a/src/error.rs b/src/error.rs index b9d5af0..1b41f21 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,6 +10,8 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use std::borrow::Cow; + use serde::Serialize; use tracing::error; @@ -18,66 +20,96 @@ use tracing::error; #[derive(Serialize)] struct ErrorBody { code: &'static str, - message: &'static str, + message: Cow<'static, str>, } impl ErrorBody { - fn new(code: &'static str, message: &'static str) -> Self { - Self { code, message } + fn new(code: &'static str, message: impl Into>) -> Self { + Self { + code, + message: message.into(), + } } } // AppError -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum AppError { // 401 + #[error("authentication required")] Unauthorized, + #[error("invalid credentials")] InvalidCredentials, + #[error("invalid two-factor code")] TwoFactorFailed, + #[error("token expired")] TokenExpired, + #[error("token invalid")] TokenInvalid, // 403 + #[error("forbidden")] Forbidden, + #[error("email not verified")] EmailNotVerified, + #[error("account suspended")] AccountSuspended, + #[error("account inactive")] AccountInactive, + #[error("account locked")] AccountLocked, + #[error("two-factor authentication required")] TwoFactorRequired, + #[error("login blocked")] LoginBlocked, + #[error("recent re-authentication required")] ReauthenticationRequired, // 404 + #[error("resource not found")] NotFound, // 409 + #[error("conflict: {0}")] Conflict(&'static str), // 422 + #[error("validation failed: {0}")] Validation(String), // 422 - CAPTCHA + #[error("captcha verification failed")] CaptchaFailed, // 400 - Device authorization flow (RFC 8628) + #[error("device authorization pending")] DeviceAuthPending, + #[error("device code expired")] DeviceCodeExpired, + #[error("device access denied")] DeviceAccessDenied, + #[error("device polling too fast")] DeviceSlowDown, // 403 - Device session/client restrictions + #[error("device session limit reached")] DeviceSessionLimitReached, + #[error("device client not allowed")] DeviceClientNotAllowed, + #[error("device client unknown")] DeviceClientUnknown, // 429 + #[error("rate limit exceeded")] RateLimitExceeded, // 503 + #[error("dependency unavailable: {0}")] ServiceUnavailable(&'static str), // 500 - message is logged, never sent to the caller + #[error("internal error: {0}")] Internal(anyhow::Error), } @@ -187,20 +219,23 @@ impl IntoResponse for AppError { ), // 409 - Self::Conflict(field) => { - // field is a static str like "email" or "username", safe to log - let body = ErrorBody::new("conflict", "A resource with this value already exists."); - tracing::warn!(field, "conflict on unique field"); - (StatusCode::CONFLICT, body) + Self::Conflict(code) => { + // `code` is a static, stable identifier such as "email_taken": + // clients branch on it, and it is safe to log. + tracing::warn!(code, "conflict on unique field"); + ( + StatusCode::CONFLICT, + ErrorBody::new(code, "A resource with this value already exists."), + ) } // 422 - Self::Validation(_) => ( + // Validation messages are written by the handlers for the caller + // ("password must be at least 10 characters") and never carry + // internal state, so they are returned as-is. + Self::Validation(message) => ( StatusCode::UNPROCESSABLE_ENTITY, - ErrorBody::new( - "validation_error", - "The request body contains invalid data.", - ), + ErrorBody::new("validation_error", message), ), Self::CaptchaFailed => ( StatusCode::UNPROCESSABLE_ENTITY, @@ -411,9 +446,28 @@ mod tests { // 409 #[tokio::test] - async fn conflict_is_409_with_correct_code() { - assert_eq!(status(AppError::Conflict("email")), 409); - assert_eq!(body_code(AppError::Conflict("username")).await, "conflict"); + async fn conflict_is_409_with_its_specific_code() { + assert_eq!(status(AppError::Conflict("email_taken")), 409); + assert_eq!( + body_code(AppError::Conflict("username_taken")).await, + "username_taken" + ); + } + + #[tokio::test] + async fn validation_message_is_returned_to_the_caller() { + let resp = AppError::Validation("password too short".into()).into_response(); + let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["message"], "password too short"); + } + + #[test] + fn app_error_implements_display() { + assert_eq!( + AppError::ServiceUnavailable("redis").to_string(), + "dependency unavailable: redis" + ); } // 422 diff --git a/src/main.rs b/src/main.rs index 1f602ec..9e12c92 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,9 +24,7 @@ async fn main() -> anyhow::Result<()> { // Set PREVIOUS_ENCRYPTION_KEY= ENCRYPTION_KEY=, run, then remove PREVIOUS_ENCRYPTION_KEY. if std::env::args().any(|a| a == "--rotate-totp-keys") { let state = AppState::from_config(config).await?; - let result = key_rotation::rotate_totp_encryption_key(&state) - .await - .map_err(|e| anyhow::anyhow!("{:?}", e))?; + let result = key_rotation::rotate_totp_encryption_key(&state).await?; tracing::info!( rotated = result.rotated, failed = result.failed, diff --git a/src/state.rs b/src/state.rs index 0db568a..a7e7d14 100644 --- a/src/state.rs +++ b/src/state.rs @@ -80,45 +80,9 @@ impl AppState { /// Build the application state by initializing all connection pools and services. /// Fails fast if any dependency is unreachable or misconfigured. pub async fn from_config(mut config: Config) -> Result { - // Auto-include auth-api's own public URL in the audience list so the - // tokens it mints carry it. The `AuthenticatedUser` extractor then - // pins `aud == public_url` defense-in-depth, rejecting tokens that - // were addressed only to downstream resource servers. - ensure_self_in_audience(&mut config); - config.validate()?; - + prepare_config(&mut config)?; let db = build_pg_pool(&config.database).await?; - let redis = build_redis_pool(&config.redis)?; - let nats = async_nats::connect(&config.nats.url).await?; - let mailer = build_mailer(&config.mail.smtp)?; - let http_client = build_http_client(&config.captcha)?; - let templates = Arc::new(build_templates(&config.mail)?); - let geoip = GeoIp::open(&config.risk.geoip_db_path); - - if config.risk.geoip_required && !geoip.is_available() { - return Err(AppStateError::Config(ConfigError::Invalid { - key: "GEOIP_DB_PATH".into(), - reason: "GeoIP database is required but could not be loaded".into(), - })); - } - - let jwt_keys = parse_jwt_keys(&config)?; - - Ok(Self { - db, - redis, - nats, - mailer, - http_client, - templates, - geoip, - jwt_signing_key: jwt_keys.signing_key, - jwt_verifying_key: jwt_keys.verifying_key, - jwt_previous_verifying_key: jwt_keys.previous_verifying_key, - jwt_kid: jwt_keys.kid, - jwt_jwks: jwt_keys.jwks, - config: Arc::new(config), - }) + Self::assemble(config, db).await } /// Build the application state with an existing database pool. @@ -127,9 +91,12 @@ impl AppState { mut config: Config, db: PgPool, ) -> Result { - ensure_self_in_audience(&mut config); - config.validate()?; + prepare_config(&mut config)?; + Self::assemble(config, db).await + } + /// Connect every remaining dependency around a prepared, validated config. + async fn assemble(config: Config, db: PgPool) -> Result { let redis = build_redis_pool(&config.redis)?; let nats = async_nats::connect(&config.nats.url).await?; let mailer = build_mailer(&config.mail.smtp)?; @@ -164,24 +131,13 @@ impl AppState { } } -/// Make sure auth-api's own `public_url` is part of the JWT audience list. -/// -/// auth-api emits `aud=[downstream_url, ...]` so tokens can be accepted by -/// downstream resource servers (core-api, billing-api, ...). But auth-api -/// also consumes its own tokens for `/users/me/*` routes, and we now pin -/// `aud == public_url` defense-in-depth in the `AuthenticatedUser` extractor. -/// Without this auto-injection the token wouldn't satisfy that check. +/// Derive computed values, then validate the configuration exactly once. /// -/// Idempotent: if `public_url` is already configured in `JWT_AUDIENCE`, -/// nothing changes. -fn ensure_self_in_audience(config: &mut Config) { - let self_url = config.server.public_url.clone(); - if self_url.is_empty() { - return; - } - if !config.jwt.audience.iter().any(|a| a == &self_url) { - config.jwt.audience.push(self_url); - } +/// auth-api's own public URL is added to the JWT audience before validation so +/// a production deployment without downstream audiences still boots. +fn prepare_config(config: &mut Config) -> Result<(), ConfigError> { + config.ensure_self_in_audience(); + config.validate() } // JWT key parsing diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 83a933f..ba08a5d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,11 +1,13 @@ -//! Pure utility functions with no I/O or application state. +//! Self-contained utilities shared by the services. //! -//! Each module is self-contained and can be used by any service. +//! Everything here is free of application state; only `redis_counter` performs +//! I/O, against the Redis pool it is handed. pub mod backoff; pub mod crypto; pub mod geoip; pub mod jwt; pub mod password; +pub mod redis_counter; pub mod time; pub mod totp; diff --git a/src/utils/redis_counter.rs b/src/utils/redis_counter.rs new file mode 100644 index 0000000..a138946 --- /dev/null +++ b/src/utils/redis_counter.rs @@ -0,0 +1,133 @@ +//! Atomic attempt budgets backed by Redis. +//! +//! Every brute-force guard in the service follows the same shape: count an +//! attempt, refuse once a limit is reached, reset on success. Reading the +//! counter and incrementing it in two round trips lets concurrent requests all +//! read the same value and all get through, so a budget of five becomes as many +//! guesses as an attacker can fire in parallel. +//! +//! [`consume`] reserves the attempt *before* the guarded check runs, in one Lua +//! script that increments every budget involved (per token, per user, per IP...) +//! and arms the window on first use. A request either gets its slot or is +//! refused; there is no interleaving in between. + +use std::sync::LazyLock; + +use deadpool_redis::{Pool as RedisPool, redis::Script}; + +use crate::error::AppError; + +/// INCR each key, arm its TTL on first hit, and report whether any budget is +/// now past its limit. ARGV holds `limit, window_secs` pairs, one per key. +static CONSUME: LazyLock