routes.rs 64 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681
  1. //! Router assembly: public listener routes plus the middleware chain.
  2. //!
  3. //! [`build_router`] mounts every endpoint behind one [`tower::ServiceBuilder`]
  4. //! whose execution order (outer-to-inner) is exactly {request-id → security
  5. //! headers → CORS (when configured) → resilience (load-shed →
  6. //! timeout → concurrency-limit) → rate-limit → body limit}:
  7. //!
  8. //! 1. [`middleware::request_id::RequestIdLayer`] mints/propagates
  9. //! `X-Request-Id` and opens the redacted `http_request` tracing span
  10. //! (tracing and redaction live in this layer).
  11. //! 2. [`middleware::security_headers::SecurityHeadersLayer`] decorates
  12. //! responses with `Referrer-Policy`, `X-Content-Type-Options`, a CSP with
  13. //! `frame-ancestors 'none'` (plus a strict script policy on the
  14. //! login/consent/device pages), and HSTS on the TLS listener. Every response
  15. //! except `/.well-known/…` metadata and the public JWK Set is also
  16. //! `Cache-Control: no-store`; `/jwks.json` carries its own shorter window.
  17. //! 3. [`middleware::security_headers::cors_layer`] answers CORS preflights
  18. //! from the explicit allowlist before the limiter can bill them; mounted
  19. //! only when `cors_allowed_origins` is non-empty (default empty: no CORS
  20. //! headers, browser clients unsupported).
  21. //! 4. [`ResilienceLayer`] sheds excess load, then times out slow handlers,
  22. //! then caps concurrency, so overload returns a `503` before it can queue
  23. //! into `PoolTimedOut`; it sits outside the rate limiter and inside the
  24. //! request-id span.
  25. //! 5. [`middleware::ratelimit::RateLimitLayer`] enforces per-IP/per-client
  26. //! budgets on every endpoint except health and metadata (those paths have
  27. //! no rule and pass through to handlers that fail closed on their own).
  28. //! The IP bucket uses the verified TCP peer unless the peer is a
  29. //! configured `rate_limit.trusted_proxies` entry, in which case the
  30. //! rightmost-untrusted `X-Forwarded-For` address is attributed instead
  31. //! (rate-limit bucket selection only; never client authentication).
  32. //! 6. [`tower_http::limit::RequestBodyLimitLayer`] caps form bodies at
  33. //! [`MAX_BODY_BYTES`].
  34. //!
  35. //! Endpoint inventory: `GET /.well-known/oauth-authorization-server`
  36. //! ([`protocol::metadata`]), `GET /authorize` ([`authorize_entry`] —
  37. //! validates via [`protocol::authorize::validate`], verifies any DPoP proof
  38. //! into the pending `jkt` binding, stashes the normalized request in
  39. //! [`store::pending`], and 303-redirects to `/login`; never 307, so the
  40. //! follow-up is always a credential-free `GET`), `POST /par`
  41. //! ([`protocol::par`]), `POST /token` ([`protocol::token::handler`], the
  42. //! five-grant dispatcher), `POST /device_authorization`
  43. //! ([`protocol::device`]), the `/device` verification page ([`ui::device`]),
  44. //! the `/login` family ([`ui::login`]), `/consent` ([`ui::consent`]),
  45. //! `POST /register` plus `GET/PUT/DELETE /register/{client_id}`
  46. //! ([`protocol::dcr`] and [`protocol::dcr::manage`]), `POST /introspect`
  47. //! ([`protocol::introspect`]), `POST /revoke` ([`protocol::revoke`]), and
  48. //! `/health/live` + `/health/ready` (merged from
  49. //! [`state::health_router`]; the plaintext listener serves that router alone
  50. //! in task 41).
  51. use std::net::SocketAddr;
  52. use std::time::Duration;
  53. use axum::extract::{ConnectInfo, RawQuery, State};
  54. use axum::http::{HeaderMap, StatusCode, header};
  55. use axum::response::{IntoResponse, Response};
  56. use axum::routing::{get, post};
  57. use axum::{Extension, Form};
  58. use axum_extra::extract::CookieJar;
  59. use rustls::ServerConfig;
  60. use tower_http::limit::RequestBodyLimitLayer;
  61. use crate::error::AppError;
  62. use crate::jose::dpop::{DpopRequest, verify_dpop};
  63. use crate::middleware::audit::AuditEvent;
  64. use crate::middleware::ratelimit::RateLimitLayer;
  65. use crate::middleware::request_id::{REQUEST_ID_HEADER, RequestId, RequestIdLayer};
  66. use crate::middleware::security_headers::{SecurityHeadersLayer, cors_layer};
  67. use crate::protocol::mtls::{self, MtlsError};
  68. use crate::protocol::{authorize, dcr, device, introspect, jwks, metadata, par, revoke, token};
  69. use crate::resilience::{ResilienceConfig, ResilienceLayer};
  70. use crate::state::{AppState, health_router};
  71. use crate::store::pending::PendingAuth;
  72. use crate::store::{Store, pending};
  73. use crate::ui::device::DeviceForm;
  74. use crate::ui::login::{ConfirmForm, LoginForm};
  75. use crate::ui::{assets, consent, device as device_page, login};
  76. /// Largest accepted request body (2 MiB; DCR may carry an inline `jwks`).
  77. pub const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
  78. /// How long a validated authorize request waits for login/consent (300 s).
  79. pub const PENDING_TTL: Duration = Duration::from_secs(300);
  80. /// Router tuning owned by the listener bootstrap (task 41).
  81. #[derive(Debug, Clone, Copy)]
  82. pub struct RouterOptions {
  83. /// Emit `Strict-Transport-Security` (true on the TLS public listener).
  84. pub hsts: bool,
  85. /// Request-body cap applied by the body-limit layer.
  86. pub max_body_bytes: usize,
  87. }
  88. impl Default for RouterOptions {
  89. fn default() -> Self {
  90. Self {
  91. hsts: true,
  92. max_body_bytes: MAX_BODY_BYTES,
  93. }
  94. }
  95. }
  96. /// Build the public-listener router with the default options (HSTS on).
  97. pub fn build_router(state: AppState) -> axum::Router {
  98. build_router_with_options(state, RouterOptions::default())
  99. }
  100. /// Build the public-listener router: all endpoints plus the middleware chain.
  101. ///
  102. /// Layers below run outer-to-inner in declaration order here because each
  103. /// [`axum::Router::layer`] call wraps everything added before it; the calls
  104. /// are therefore issued innermost-first. The conditional CORS layer slots
  105. /// into its spec position only when configured.
  106. pub fn build_router_with_options(state: AppState, options: RouterOptions) -> axum::Router {
  107. let cors = cors_layer(&state.config.cors_allowed_origins);
  108. // Task 44 resilience insertion: timeout + concurrency-limit + load-shed sit
  109. // outside the rate limiter (so overload sheds 503 before it can queue into
  110. // `PoolTimedOut`) and inside request-id (so the span still covers them).
  111. // The security-headers layer sits just inside request-id (outside every
  112. // short-circuiting layer: CORS preflights, resilience 503s, limiter
  113. // 429/503s, body-limit 413s), so every rejection still carries the
  114. // hardening headers. Execution order outer-to-inner: request-id →
  115. // security-headers → load-shed → concurrency-limit → timeout → rate-limit
  116. // → body-limit; the `.layer` calls below read innermost-first.
  117. let resilience = ResilienceConfig::from_policy(&state.config.failure_policy);
  118. let router = axum::Router::new()
  119. .route("/assets/app.css", get(assets::stylesheet))
  120. .route(
  121. "/.well-known/oauth-authorization-server",
  122. get(metadata::metadata),
  123. )
  124. .route("/jwks.json", get(jwks::handler))
  125. .route("/authorize", get(authorize_entry))
  126. .route("/par", post(par::par_handler))
  127. .route("/token", post(token::handler))
  128. .route("/device_authorization", post(device::handler))
  129. .route(
  130. "/device",
  131. get(device_page::get_device).post(post_device_entry),
  132. )
  133. .route("/login", get(login::get_login).post(post_login_entry))
  134. .route("/login/success", get(login::get_login_success))
  135. .route(
  136. "/login/change-password",
  137. get(login::get_change_password).post(login::post_change_password),
  138. )
  139. .route("/login/totp/setup", get(login::get_totp_setup))
  140. .route(
  141. "/login/totp/confirm",
  142. get(login::get_totp_confirm).post(post_totp_confirm_entry),
  143. )
  144. .route("/logout", get(login::get_logout).post(login::post_logout))
  145. .route(
  146. "/consent",
  147. get(consent::get_consent).post(consent::post_consent),
  148. )
  149. .route("/register", post(dcr::register_handler))
  150. .route(
  151. "/register/{client_id}",
  152. get(dcr::manage::read_handler)
  153. .put(dcr::manage::update_handler)
  154. .delete(dcr::manage::delete_handler),
  155. )
  156. .route("/introspect", post(introspect::handler))
  157. .route("/revoke", post(revoke::revoke_handler))
  158. .with_state(state.clone())
  159. .merge(health_router(state.clone()))
  160. .layer(RequestBodyLimitLayer::new(options.max_body_bytes))
  161. .layer(RateLimitLayer::new(state))
  162. .layer(ResilienceLayer::from_config(&resilience));
  163. let router = match cors {
  164. Some(layer) => router.layer(layer),
  165. None => router,
  166. };
  167. router
  168. .layer(SecurityHeadersLayer::new(options.hsts))
  169. .layer(RequestIdLayer)
  170. }
  171. /// Build the rustls server config for the public TLS listener.
  172. ///
  173. /// The server identity comes from the configured PEM chain/key. Client
  174. /// certificates are accepted but never required at the TLS layer: with a
  175. /// client-CA bundle the PKI verifier (`tls_client_auth`) is used, otherwise
  176. /// the signature-checking self-signed verifier
  177. /// (`self_signed_tls_client_auth`); per-client enforcement happens in
  178. /// [`mtls`](crate::protocol::mtls) at the application layer.
  179. ///
  180. /// # Errors
  181. ///
  182. /// Returns [`MtlsError::TrustBundle`] when the chain, key, or CA bundle is
  183. /// not valid PEM or the key does not match the chain.
  184. pub fn build_server_config(
  185. cert_chain_pem: &[u8],
  186. key_pem: &[u8],
  187. client_ca_pem: Option<&[u8]>,
  188. ) -> Result<ServerConfig, MtlsError> {
  189. let verifier = match client_ca_pem {
  190. Some(bundle) => mtls::pki_verifier(bundle)?,
  191. None => mtls::self_signed_verifier(),
  192. };
  193. mtls::server_config(cert_chain_pem, key_pem, verifier)
  194. }
  195. /// `GET /authorize`: validate, stash the normalized request, 303 to login.
  196. ///
  197. /// [`authorize::validate`] resolves PAR/JAR input and enforces the
  198. /// client/redirect/response-type/scope/PKCE matrix. Failures render through
  199. /// [`authorize::AuthorizeFailure`] (400 page or 302 error redirect per the
  200. /// task-23 matrix — both carry no credentials). Success mints an opaque
  201. /// `request_id`, stores the [`PendingAuth`] record server-side (the request
  202. /// itself never travels in query or cookie), and answers `303 See Other` to
  203. /// `/login?request_id=…` so the follow-up is always a credential-free `GET`.
  204. /// A 307 would preserve the request method and body across the redirect and
  205. /// is therefore never used for credential-carrying redirects.
  206. async fn authorize_entry(
  207. State(state): State<AppState>,
  208. headers: HeaderMap,
  209. RawQuery(raw): RawQuery,
  210. ) -> Response {
  211. // `RawQuery` + a manual parse replaces `Query<HashMap>`: the extractor
  212. // consumed the raw query, hiding repeats — the repeated `resource`
  213. // indicator (RFC 8707) is captured in order by `params::values_of`
  214. // while the single-valued map keeps last-wins absorption. A malformed
  215. // query is the same 400 `Query` rendered.
  216. let raw = raw.as_deref().unwrap_or("");
  217. let pairs = match crate::protocol::params::query_pairs(Some(raw)) {
  218. Ok(pairs) => pairs,
  219. Err(reject) => return reject.into_response(),
  220. };
  221. let query = crate::protocol::params::single_valued_map(&pairs);
  222. let resources =
  223. crate::protocol::params::values_of(&pairs, crate::protocol::params::RESOURCE_PARAM);
  224. let mut conn = state.redis.clone();
  225. let store = Store::from_config(&state.config.redis);
  226. let input = authorize::AuthorizeInput {
  227. pool: &state.pool,
  228. keys: &state.keys,
  229. conn: &mut conn,
  230. store: &store,
  231. issuer: &state.config.issuer,
  232. allow_localhost_redirect: state.config.allow_localhost_redirect,
  233. cimd: state.config.cimd_enabled.then(|| authorize::CimdRequest {
  234. token_prefix: &state.config.redis.token_key_prefix,
  235. max_bytes: state.config.cimd_max_bytes,
  236. timeout: state.config.cimd_fetch_timeout,
  237. }),
  238. query: &query,
  239. resources,
  240. resource_indicators_enabled: state.config.resource_indicators_enabled,
  241. };
  242. let validated = match authorize::validate(input).await {
  243. Ok(validated) => validated,
  244. Err(failure) => {
  245. if failure.is_pii_decrypt() {
  246. let request_id = headers
  247. .get(REQUEST_ID_HEADER)
  248. .and_then(|value| value.to_str().ok())
  249. .and_then(RequestId::parse);
  250. AuditEvent::PiiDecryptFailure {
  251. request_id: request_id.as_ref().map(RequestId::as_str),
  252. }
  253. .emit();
  254. }
  255. return failure.into_response();
  256. }
  257. };
  258. let dpop_jkt = match headers.get("dpop").and_then(|value| value.to_str().ok()) {
  259. Some(proof) => {
  260. let htu = format!("{}/authorize", state.config.issuer);
  261. let request = DpopRequest {
  262. proof: Some(proof),
  263. htm: "GET",
  264. htu: &htu,
  265. access_token: None,
  266. now_unix: state.clock.now().unix_timestamp(),
  267. };
  268. match verify_dpop(&mut conn, &store, &request).await {
  269. Ok(verified) => Some(verified.jkt),
  270. Err(error) => return error.into_response(),
  271. }
  272. }
  273. None => None,
  274. };
  275. let request_id = match pending::new_request_id() {
  276. Ok(id) => id,
  277. Err(_) => return AppError::server_error().into_response(),
  278. };
  279. let record = PendingAuth {
  280. client_id: validated.client_id,
  281. redirect_uri: validated.redirect_uri,
  282. scope: validated.scope.render(),
  283. code_challenge: validated.code_challenge,
  284. state: validated.state,
  285. dpop_jkt,
  286. iss: state.config.issuer.clone(),
  287. post_logout_uri: validated.post_logout_uri,
  288. aud: validated.aud,
  289. };
  290. if let Err(error) = pending::put(&mut conn, &store, &request_id, &record, PENDING_TTL).await {
  291. return AppError::from(error).into_response();
  292. }
  293. (
  294. StatusCode::SEE_OTHER,
  295. [(header::LOCATION, format!("/login?request_id={request_id}"))],
  296. )
  297. .into_response()
  298. }
  299. /// `POST /device` adapter: [`ui::device::post_device`] takes
  300. /// `Option<ConnectInfo<_>>`, which axum cannot use as an extractor
  301. /// (`ConnectInfo` is a required extractor with no optional form), so this
  302. /// thin entry takes the required extractor and forwards it as `Some`.
  303. /// Served with `into_make_service_with_connect_info` (task 41) the peer is
  304. /// always present; without it the handler fails closed via its `None` arm.
  305. async fn post_device_entry(
  306. State(state): State<AppState>,
  307. jar: CookieJar,
  308. peer: ConnectInfo<SocketAddr>,
  309. Form(form): Form<DeviceForm>,
  310. ) -> Response {
  311. device_page::post_device(State(state), jar, Some(peer), Form(form)).await
  312. }
  313. /// `POST /login` adapter: [`ui::login::post_login`] takes
  314. /// `Option<ConnectInfo<_>>` (not a handler extractor), so this thin entry
  315. /// takes the required extractor and forwards it as `Some` for composite
  316. /// lockout keying. Served with `into_make_service_with_connect_info` the
  317. /// peer is always present; without it the request is rejected before the
  318. /// handler runs.
  319. async fn post_login_entry(
  320. State(state): State<AppState>,
  321. Extension(request_id): Extension<RequestId>,
  322. peer: ConnectInfo<SocketAddr>,
  323. jar: CookieJar,
  324. Form(form): Form<LoginForm>,
  325. ) -> Response {
  326. login::post_login(
  327. State(state),
  328. Extension(request_id),
  329. jar,
  330. Form(form),
  331. Some(peer),
  332. )
  333. .await
  334. }
  335. /// `POST /login/totp/confirm` adapter: mirrors [`post_login_entry`] for the
  336. /// enrollment-confirmation lockout path.
  337. async fn post_totp_confirm_entry(
  338. State(state): State<AppState>,
  339. Extension(request_id): Extension<RequestId>,
  340. peer: ConnectInfo<SocketAddr>,
  341. jar: CookieJar,
  342. Form(form): Form<ConfirmForm>,
  343. ) -> Response {
  344. login::post_totp_confirm(
  345. State(state),
  346. Extension(request_id),
  347. jar,
  348. Form(form),
  349. Some(peer),
  350. )
  351. .await
  352. }
  353. #[cfg(test)]
  354. mod router_tests {
  355. use std::net::SocketAddr;
  356. use std::sync::Arc;
  357. use std::time::Duration;
  358. use axum::body::Body;
  359. use axum::extract::connect_info::MockConnectInfo;
  360. use axum::http::{HeaderMap, Request, StatusCode, header};
  361. use tower::ServiceExt as _;
  362. use super::{RouterOptions, build_router, build_router_with_options, build_server_config};
  363. use crate::config::Config;
  364. use crate::middleware::security_headers::{JWKS_CACHE_VALUE, METADATA_CACHE_VALUE, cors_layer};
  365. use crate::state::{AppState, Clock, RedisConn};
  366. /// Test body result (no `unwrap`/`expect` under the deny lints).
  367. type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
  368. /// One mounted route under test: method, URI, and extra headers.
  369. type Route<'a> = (&'a str, &'a str, &'a [(&'a str, &'a str)]);
  370. /// Self-signed `localhost` server fixture (ECDSA P-256, end-entity with
  371. /// SAN `localhost` + `127.0.0.1`, valid 2026-10-04 through 2036-10-01).
  372. /// Generated once with `openssl req -x509` and embedded so the TLS test
  373. /// needs no new dependencies and no network.
  374. const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
  375. MIIBvDCCAWOgAwIBAgIUImK99ANKJsyjtrs8BAozgQGrnj0wCgYIKoZIzj0EAwIw\n\
  376. FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAwNDAzNTEwMloXDTM2MTAwMTAz\n\
  377. NTEwMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D\n\
  378. AQcDQgAE62U9JpY6jrYLcoAX+WJ8zGxDSWpK9pTbqKX+FT/TB1MKjmYcBgo4G/tV\n\
  379. lpNb4WjFVoMYsEAytUBTBRa3dT7Pw6OBkjCBjzAdBgNVHQ4EFgQUAVS9S+6tF+en\n\
  380. s4SwbomL4rPgYOwwHwYDVR0jBBgwFoAUAVS9S+6tF+ens4SwbomL4rPgYOwwGgYD\n\
  381. VR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/\n\
  382. BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAoGCCqGSM49BAMCA0cAMEQCIHbY\n\
  383. KXR+2+TuMzeYzh1C2GuxXuvSzdRIwwVnpAU/0rOqAiAOpeArygErayxHAl27s7R+\n\
  384. Re/ypkNPjDUTtjCyvGmVQg==\n\
  385. -----END CERTIFICATE-----\n";
  386. /// Private key matching [`TEST_CERT_PEM`] (test-only material).
  387. const TEST_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
  388. MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgWaGpohPjJPYOs+92\n\
  389. Eyf8hawQCvdtD6tRjVXY0MlB/FihRANCAATrZT0mljqOtgtygBf5YnzMbENJakr2\n\
  390. lNuopf4VP9MHUwqOZhwGCjgb+1WWk1vhaMVWgxiwQDK1QFMFFrd1Ps/D\n\
  391. -----END PRIVATE KEY-----\n";
  392. /// Self-signed `CA:TRUE` fixture for the client-CA acceptance case.
  393. const TEST_CA_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
  394. MIIBljCCAT2gAwIBAgIUHmpGGg0eZayO4xWoWQ4ARhsgUz8wCgYIKoZIzj0EAwIw\n\
  395. GTEXMBUGA1UEAwwOcm91dGVyLXRlc3QtY2EwHhcNMjYxMDA0MDM1MTA4WhcNMzYx\n\
  396. MDAxMDM1MTA4WjAZMRcwFQYDVQQDDA5yb3V0ZXItdGVzdC1jYTBZMBMGByqGSM49\n\
  397. AgEGCCqGSM49AwEHA0IABMj+xkhvvlk4LNRAEBMVoKUVa9UQDY3pdhEqkRBrs7Pm\n\
  398. NyAw9Nfws/OjuLSGzCZ8XFlhlQaTdM+Gs0uhFJ5owwSjYzBhMB0GA1UdDgQWBBTi\n\
  399. PrALL/zQO+Rmt6uLlA6Vmp7qxzAfBgNVHSMEGDAWgBTiPrALL/zQO+Rmt6uLlA6V\n\
  400. mp7qxzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAKBggqhkjOPQQD\n\
  401. AgNHADBEAiBzZ5xiYL9YVq0KQrHTV47RuuX3S+lASyBdJO8pOo4HRgIgcTisWcyG\n\
  402. jDv1sqQJQfa/sEESxFLyAw9BYwH7hOGgZTg=\n\
  403. -----END CERTIFICATE-----\n";
  404. /// The limiter's fail-closed body marker (distinct from every `AppError`
  405. /// description, so the test can tell "limiter engaged" apart from
  406. /// "handler failed closed on its own").
  407. const LIMITER_MARKER: &str = "temporarily unable to handle the request";
  408. /// Shared raw fixture over a dead Postgres URL and the given Redis URL.
  409. fn config_fixture(
  410. redis_url: &str,
  411. rate_limit: serde_json::Value,
  412. cors_origins: Vec<&str>,
  413. ) -> serde_json::Value {
  414. serde_json::json!({
  415. "issuer": "https://auth.example.test",
  416. "environment": "development",
  417. "tls": {"cert_path": "/tmp/router-test-cert.pem", "key_path": "/tmp/router-test-key.pem"},
  418. "database": {"url": "postgres://127.0.0.1:1/router?sslmode=disable"},
  419. "redis": {"url": redis_url, "durability": "waitaof"},
  420. "token_pepper": "router-test-pepper-0123456789abcdef",
  421. "totp_encryption_key": "router-test-totp-key-0123456789abcdef",
  422. "pii_encryption_key": "router-test-pii-key-0123456789abcdef",
  423. "rate_limit": rate_limit,
  424. "cors_allowed_origins": cors_origins,
  425. })
  426. }
  427. /// Validated config over a dead Postgres URL and the given Redis URL.
  428. fn test_config(
  429. redis_url: &str,
  430. rate_limit: serde_json::Value,
  431. cors_origins: Vec<&str>,
  432. ) -> Result<Config, crate::config::ConfigError> {
  433. let fixture = config_fixture(redis_url, rate_limit, cors_origins);
  434. Config::from_source(::config::Config::try_from(&fixture)?)
  435. }
  436. /// 32-byte ASCII JWT signing keys (valid non-zero P-256 scalars).
  437. const JWT_TEST_KEY: &str = "bcdefghijklmnopqrstuvwxyzabcdefg";
  438. /// Rotated-out verify-only key, distinct from [`JWT_TEST_KEY`].
  439. const JWT_PREVIOUS_KEY: &str = "cdefghijklmnopqrstuvwxyzabcdefgh";
  440. /// Validated config with the JWT switch on and a current + retained key.
  441. fn jwt_config(redis_url: &str) -> Result<Config, crate::config::ConfigError> {
  442. let mut fixture = config_fixture(redis_url, serde_json::json!({"enabled": false}), Vec::new());
  443. fixture["jwt_access_tokens_enabled"] = serde_json::Value::Bool(true);
  444. fixture["jwt_signing_key"] = serde_json::Value::String(JWT_TEST_KEY.to_owned());
  445. fixture["jwt_signing_kid"] = serde_json::Value::String("v2".to_owned());
  446. fixture["jwt_signing_previous_keys"] = serde_json::json!([
  447. {"kid": "v1", "key": JWT_PREVIOUS_KEY}
  448. ]);
  449. Config::from_source(::config::Config::try_from(&fixture)?)
  450. }
  451. /// State with a lazy Postgres pool (never connected; short acquire
  452. /// budget so fail-closed paths return fast) and a Redis manager pointed
  453. /// at `redis_url`.
  454. fn lazy_state(config: Config, redis_url: &str) -> TestResult<AppState> {
  455. let pool = sqlx::postgres::PgPoolOptions::new()
  456. .max_connections(1)
  457. .acquire_timeout(Duration::from_millis(300))
  458. .connect_lazy("postgres://127.0.0.1:1/router")?;
  459. let client = redis::Client::open(redis_url)?;
  460. let manager = redis::aio::ConnectionManager::new_lazy_with_config(
  461. client,
  462. redis::aio::ConnectionManagerConfig::new()
  463. .set_number_of_retries(0)
  464. .set_response_timeout(Some(Duration::from_millis(200)))
  465. .set_connection_timeout(Some(Duration::from_millis(200))),
  466. )?;
  467. let keys = Arc::new(config.keys.clone());
  468. Ok(AppState {
  469. pool,
  470. redis: RedisConn::Single(manager),
  471. config: Arc::new(config),
  472. keys,
  473. transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
  474. clock: Clock::system(),
  475. })
  476. }
  477. /// Router whose limiter passes through (disabled) and whose backends are
  478. /// dead: handlers fail closed, but every route is reachable.
  479. fn passthrough_state() -> TestResult<AppState> {
  480. let config = test_config(
  481. "redis://127.0.0.1:1",
  482. serde_json::json!({"enabled": false}),
  483. Vec::new(),
  484. )?;
  485. lazy_state(config, "redis://127.0.0.1:1")
  486. }
  487. /// Router whose limiter is engaged against a refused Redis port: every
  488. /// protected endpoint fails closed with the limiter's 503 marker.
  489. fn limited_state(cors_origins: Vec<&str>) -> TestResult<AppState> {
  490. let config = test_config(
  491. "redis://127.0.0.1:1",
  492. serde_json::json!({"enabled": true}),
  493. cors_origins,
  494. )?;
  495. lazy_state(config, "redis://127.0.0.1:1")
  496. }
  497. /// Router whose limiter is engaged but every budget is zero: a request
  498. /// WITH a peer identity passes through to its handler; a request WITHOUT
  499. /// one still fails closed at the peer check.
  500. fn zero_limit_state() -> TestResult<AppState> {
  501. let config = test_config(
  502. "redis://127.0.0.1:1",
  503. serde_json::json!({
  504. "enabled": true,
  505. "per_ip": 0,
  506. "per_client": 0,
  507. "login_per_ip": 0,
  508. "token_per_client": 0,
  509. }),
  510. Vec::new(),
  511. )?;
  512. lazy_state(config, "redis://127.0.0.1:1")
  513. }
  514. /// Drive one request through the router with both peer-identity extensions
  515. /// the stack reads (`ConnectInfo` for the limiter, `MockConnectInfo` for
  516. /// the `ConnectInfo` extractor).
  517. async fn drive(
  518. router: axum::Router,
  519. method: &str,
  520. uri: &str,
  521. headers: &[(&str, &str)],
  522. body: impl Into<Body>,
  523. ) -> TestResult<(StatusCode, HeaderMap, String)> {
  524. let peer = SocketAddr::from(([127, 0, 0, 1], 40_001));
  525. let mut builder = Request::builder().method(method).uri(uri);
  526. for (name, value) in headers {
  527. builder = builder.header(*name, *value);
  528. }
  529. let mut request = builder.body(body.into())?;
  530. request
  531. .extensions_mut()
  532. .insert(axum::extract::ConnectInfo(peer));
  533. request.extensions_mut().insert(MockConnectInfo(peer));
  534. let response = router.oneshot(request).await.map_err(|_| "router error")?;
  535. let status = response.status();
  536. let response_headers = response.headers().clone();
  537. let bytes = http_body_util::BodyExt::collect(response.into_body())
  538. .await?
  539. .to_bytes();
  540. Ok((
  541. status,
  542. response_headers,
  543. String::from_utf8_lossy(&bytes).into_owned(),
  544. ))
  545. }
  546. /// Every mounted route answers something other than 404.
  547. #[tokio::test]
  548. async fn router_routes_are_reachable() -> TestResult {
  549. let router = build_router(passthrough_state()?);
  550. let form = [("content-type", "application/x-www-form-urlencoded")];
  551. let routes: &[Route<'_>] = &[
  552. ("GET", "/assets/app.css", &[]),
  553. ("GET", "/.well-known/oauth-authorization-server", &[]),
  554. ("GET", "/authorize?client_id=nobody", &[]),
  555. ("POST", "/par", &form),
  556. ("POST", "/token", &form),
  557. ("POST", "/device_authorization", &form),
  558. ("GET", "/device", &[]),
  559. ("POST", "/device", &form),
  560. ("GET", "/login", &[]),
  561. ("POST", "/login", &form),
  562. ("GET", "/login/success", &[]),
  563. ("GET", "/login/change-password", &[]),
  564. ("POST", "/login/change-password", &form),
  565. ("GET", "/login/totp/setup", &[]),
  566. ("GET", "/login/totp/confirm", &[]),
  567. ("POST", "/login/totp/confirm", &form),
  568. ("GET", "/logout", &[]),
  569. ("POST", "/logout", &[]),
  570. ("GET", "/consent", &[]),
  571. ("POST", "/consent", &form),
  572. ("POST", "/register", &form),
  573. ("GET", "/register/some-client", &[]),
  574. ("PUT", "/register/some-client", &form),
  575. ("DELETE", "/register/some-client", &[]),
  576. ("POST", "/introspect", &form),
  577. ("POST", "/revoke", &form),
  578. ("GET", "/health/live", &[]),
  579. ("GET", "/health/ready", &[]),
  580. ];
  581. for (method, uri, headers) in routes {
  582. let (status, _, _) = drive(router.clone(), method, uri, headers, "").await?;
  583. if status == StatusCode::NOT_FOUND {
  584. return Err(format!("route {method} {uri} is not mounted").into());
  585. }
  586. }
  587. // The liveness probe is dependency-free and always 200.
  588. let (status, _, _) = drive(router, "GET", "/health/live", &[], "").await?;
  589. if status != StatusCode::OK {
  590. return Err(format!("GET /health/live must be 200, got {status}").into());
  591. }
  592. Ok(())
  593. }
  594. /// Security headers ride on a dependency-free page response.
  595. #[tokio::test]
  596. async fn router_security_headers_present_on_page() -> TestResult {
  597. let (status, headers, _) = drive(
  598. build_router(passthrough_state()?),
  599. "GET",
  600. "/device",
  601. &[],
  602. "",
  603. )
  604. .await?;
  605. if status != StatusCode::OK {
  606. return Err(format!("GET /device must be 200, got {status}").into());
  607. }
  608. let value = |name: header::HeaderName| {
  609. headers
  610. .get(name)
  611. .and_then(|value| value.to_str().ok())
  612. .unwrap_or_default()
  613. .to_owned()
  614. };
  615. if value(header::X_CONTENT_TYPE_OPTIONS) != "nosniff" {
  616. return Err("X-Content-Type-Options: nosniff is required".into());
  617. }
  618. if value(header::REFERRER_POLICY) != "no-referrer" {
  619. return Err("Referrer-Policy: no-referrer is required".into());
  620. }
  621. let csp = value(header::CONTENT_SECURITY_POLICY);
  622. if !csp.contains("frame-ancestors 'none'") {
  623. return Err(format!("page CSP must deny framing, got {csp}").into());
  624. }
  625. if !csp.contains("script-src 'none'") {
  626. return Err(format!("page CSP must forbid scripts, got {csp}").into());
  627. }
  628. if !value(header::STRICT_TRANSPORT_SECURITY).starts_with("max-age=") {
  629. return Err("HSTS is required on the TLS public router".into());
  630. }
  631. if headers.get("x-request-id").is_none() {
  632. return Err("X-Request-Id must be echoed".into());
  633. }
  634. Ok(())
  635. }
  636. /// API responses get the locked-down CSP; error responses keep headers too.
  637. #[tokio::test]
  638. async fn router_security_headers_present_on_api_error() -> TestResult {
  639. let form = [("content-type", "application/x-www-form-urlencoded")];
  640. let (_, headers, _) = drive(
  641. build_router(passthrough_state()?),
  642. "POST",
  643. "/introspect",
  644. &form,
  645. "",
  646. )
  647. .await?;
  648. let csp = headers
  649. .get(header::CONTENT_SECURITY_POLICY)
  650. .and_then(|value| value.to_str().ok())
  651. .unwrap_or_default();
  652. if !csp.contains("default-src 'none'") || !csp.contains("frame-ancestors 'none'") {
  653. return Err(format!("API CSP must lock down the resource, got {csp}").into());
  654. }
  655. if headers.get("x-request-id").is_none() {
  656. return Err("X-Request-Id must be echoed on API errors".into());
  657. }
  658. Ok(())
  659. }
  660. /// The login error page (dead backends) still carries the strict page CSP.
  661. #[tokio::test]
  662. async fn router_login_page_carries_strict_script_policy() -> TestResult {
  663. let (_, headers, _) =
  664. drive(build_router(passthrough_state()?), "GET", "/login", &[], "").await?;
  665. let csp = headers
  666. .get(header::CONTENT_SECURITY_POLICY)
  667. .and_then(|value| value.to_str().ok())
  668. .unwrap_or_default();
  669. if !csp.contains("script-src 'none'") {
  670. return Err(format!("login CSP must forbid scripts, got {csp}").into());
  671. }
  672. Ok(())
  673. }
  674. /// Every credential- or authorization-bearing response forbids caching.
  675. #[tokio::test]
  676. async fn router_cache_control_no_store_on_oauth_surface() -> TestResult {
  677. let router = build_router(passthrough_state()?);
  678. let form = [("content-type", "application/x-www-form-urlencoded")];
  679. let sensitive: &[Route<'_>] = &[
  680. ("GET", "/login", &[]),
  681. ("GET", "/consent", &[]),
  682. ("GET", "/device", &[]),
  683. ("GET", "/authorize?client_id=nobody", &[]),
  684. ("POST", "/token", &form),
  685. ("POST", "/par", &form),
  686. ("POST", "/device_authorization", &form),
  687. ];
  688. for (method, uri, headers) in sensitive {
  689. let (_, response_headers, _) = drive(router.clone(), method, uri, headers, "").await?;
  690. let value = response_headers
  691. .get(header::CACHE_CONTROL)
  692. .and_then(|value| value.to_str().ok())
  693. .unwrap_or_default();
  694. if value != "no-store" {
  695. return Err(format!("{method} {uri} must be no-store, got {value:?}").into());
  696. }
  697. }
  698. Ok(())
  699. }
  700. /// Metadata discovery is cacheable by design: an explicit positive
  701. /// `Cache-Control`, not browser heuristic freshness.
  702. #[tokio::test]
  703. async fn router_metadata_carries_explicit_positive_cache_control() -> TestResult {
  704. let (_, headers, _) = drive(
  705. build_router(passthrough_state()?),
  706. "GET",
  707. "/.well-known/oauth-authorization-server",
  708. &[],
  709. "",
  710. )
  711. .await?;
  712. let value = headers
  713. .get(header::CACHE_CONTROL)
  714. .and_then(|value| value.to_str().ok())
  715. .unwrap_or_default();
  716. if !value.starts_with("public") || !value.contains("max-age=") {
  717. return Err(format!("metadata must carry a positive public max-age, got {value:?}").into());
  718. }
  719. if value.contains("no-store") {
  720. return Err(format!("metadata must stay cacheable, got {value:?}").into());
  721. }
  722. Ok(())
  723. }
  724. /// The JWK Set advertises the current key first, then every retained key,
  725. /// with public members only (RFC 7517 §4/§5, RFC 7518 §6.2.1, RFC 8037 §2).
  726. #[tokio::test]
  727. async fn jwks_serves_current_and_retained() -> TestResult {
  728. let redis_url = "redis://127.0.0.1:1";
  729. let router = build_router(lazy_state(jwt_config(redis_url)?, redis_url)?);
  730. let (status, headers, body) = drive(router, "GET", "/jwks.json", &[], "").await?;
  731. if status != StatusCode::OK {
  732. return Err(format!("GET /jwks.json must be 200, got {status}: {body}").into());
  733. }
  734. let content_type = headers
  735. .get(header::CONTENT_TYPE)
  736. .and_then(|value| value.to_str().ok())
  737. .unwrap_or_default();
  738. if !content_type.starts_with("application/jwk-set+json") {
  739. return Err(format!("JWK Set media type must be pinned, got {content_type:?}").into());
  740. }
  741. let document: serde_json::Value = serde_json::from_str(&body)?;
  742. let keys = document
  743. .get("keys")
  744. .and_then(serde_json::Value::as_array)
  745. .ok_or("JWK Set must carry a keys array")?;
  746. if keys.len() != 2 {
  747. return Err(format!("expected current + retained key, got {}", keys.len()).into());
  748. }
  749. let current_kid = keys[0].get("kid").and_then(serde_json::Value::as_str);
  750. let retained_kid = keys[1].get("kid").and_then(serde_json::Value::as_str);
  751. if current_kid != Some("v2") || retained_kid != Some("v1") {
  752. return Err(
  753. format!("current key must come first, got {current_kid:?} then {retained_kid:?}").into(),
  754. );
  755. }
  756. for key in keys {
  757. if key.get("use").and_then(serde_json::Value::as_str) != Some("sig") {
  758. return Err("every JWK must declare use=sig".into());
  759. }
  760. if key.get("alg").and_then(serde_json::Value::as_str) != Some("ES256") {
  761. return Err("every JWK must carry the configured algorithm".into());
  762. }
  763. if key.get("kty").and_then(serde_json::Value::as_str) != Some("EC") {
  764. return Err("ES256 JWKs must be kty=EC".into());
  765. }
  766. if key.get("crv").and_then(serde_json::Value::as_str) != Some("P-256") {
  767. return Err("ES256 JWKs must be crv=P-256".into());
  768. }
  769. for coordinate in ["x", "y"] {
  770. if key
  771. .get(coordinate)
  772. .and_then(serde_json::Value::as_str)
  773. .is_none_or(str::is_empty)
  774. {
  775. return Err(format!("EC JWK must carry a non-empty {coordinate}").into());
  776. }
  777. }
  778. for private in ["d", "p", "q", "dp", "dq", "qi", "oth", "k"] {
  779. if key.get(private).is_some() {
  780. return Err(format!("JWK must never carry private member {private}").into());
  781. }
  782. }
  783. }
  784. Ok(())
  785. }
  786. /// `/jwks.json` gets its own short cache window (rotation/revocation bound),
  787. /// distinct from the one-hour metadata value and from the no-store default.
  788. #[tokio::test]
  789. async fn jwks_cache_control_value() -> TestResult {
  790. let redis_url = "redis://127.0.0.1:1";
  791. let router = build_router(lazy_state(jwt_config(redis_url)?, redis_url)?);
  792. let (_, jwks_headers, _) = drive(router.clone(), "GET", "/jwks.json", &[], "").await?;
  793. let jwks_cache = jwks_headers
  794. .get(header::CACHE_CONTROL)
  795. .and_then(|value| value.to_str().ok())
  796. .unwrap_or_default();
  797. if jwks_cache != JWKS_CACHE_VALUE {
  798. return Err(format!("expected pinned {JWKS_CACHE_VALUE:?}, got {jwks_cache:?}").into());
  799. }
  800. if jwks_cache == "no-store" || jwks_cache == METADATA_CACHE_VALUE {
  801. return Err(format!("JWKS cache must be its own value, got {jwks_cache:?}").into());
  802. }
  803. let (_, metadata_headers, _) = drive(
  804. router,
  805. "GET",
  806. "/.well-known/oauth-authorization-server",
  807. &[],
  808. "",
  809. )
  810. .await?;
  811. let metadata_cache = metadata_headers
  812. .get(header::CACHE_CONTROL)
  813. .and_then(|value| value.to_str().ok())
  814. .unwrap_or_default();
  815. if metadata_cache != METADATA_CACHE_VALUE {
  816. return Err(
  817. format!("metadata cache must stay {METADATA_CACHE_VALUE:?}, got {metadata_cache:?}").into(),
  818. );
  819. }
  820. Ok(())
  821. }
  822. /// With the switch off the endpoint still exists deterministically: an
  823. /// empty JWK Set, never a 404 (which would be cached for the short window)
  824. /// and never a 500.
  825. #[tokio::test]
  826. async fn jwks_empty_when_feature_disabled() -> TestResult {
  827. let (status, _, body) = drive(
  828. build_router(passthrough_state()?),
  829. "GET",
  830. "/jwks.json",
  831. &[],
  832. "",
  833. )
  834. .await?;
  835. if status != StatusCode::OK {
  836. return Err(format!("feature-off JWKS must be 200, got {status}: {body}").into());
  837. }
  838. let document: serde_json::Value = serde_json::from_str(&body)?;
  839. let keys = document
  840. .get("keys")
  841. .and_then(serde_json::Value::as_array)
  842. .ok_or("JWK Set must carry a keys array")?;
  843. if !keys.is_empty() {
  844. return Err(format!("feature-off JWK Set must be empty, got {} keys", keys.len()).into());
  845. }
  846. Ok(())
  847. }
  848. /// HSTS is emitted only when the TLS option is on (never plaintext).
  849. #[tokio::test]
  850. async fn router_hsts_disabled_when_plaintext() -> TestResult {
  851. let options = RouterOptions {
  852. hsts: false,
  853. ..RouterOptions::default()
  854. };
  855. let (_, headers, _) = drive(
  856. build_router_with_options(passthrough_state()?, options),
  857. "GET",
  858. "/device",
  859. &[],
  860. "",
  861. )
  862. .await?;
  863. if headers.contains_key(header::STRICT_TRANSPORT_SECURITY) {
  864. return Err("plaintext router must not emit HSTS".into());
  865. }
  866. if headers.get(header::X_CONTENT_TYPE_OPTIONS).is_none() {
  867. return Err("other security headers must still apply".into());
  868. }
  869. Ok(())
  870. }
  871. /// The engaged limiter fails every classified OAuth route closed (503 +
  872. /// limiter marker); metadata and liveness pass through untouched.
  873. #[tokio::test]
  874. async fn router_rate_limit_engaged_on_every_oauth_route() -> TestResult {
  875. let router = build_router(limited_state(Vec::new())?);
  876. let form = [("content-type", "application/x-www-form-urlencoded")];
  877. let protected: &[Route<'_>] = &[
  878. ("GET", "/authorize?client_id=nobody", &[]),
  879. ("POST", "/par", &form),
  880. ("POST", "/token", &form),
  881. ("POST", "/device_authorization", &form),
  882. ("GET", "/device", &[]),
  883. ("POST", "/device", &form),
  884. ("GET", "/login", &[]),
  885. ("POST", "/login", &form),
  886. ("GET", "/login/success", &[]),
  887. ("GET", "/login/change-password", &[]),
  888. ("GET", "/login/totp/setup", &[]),
  889. ("GET", "/login/totp/confirm", &[]),
  890. ("GET", "/logout", &[]),
  891. ("GET", "/consent", &[]),
  892. ("POST", "/consent", &form),
  893. ("POST", "/register", &form),
  894. ("GET", "/register/some-client", &[]),
  895. ("POST", "/introspect", &form),
  896. ("POST", "/revoke", &form),
  897. ];
  898. for (method, uri, headers) in protected {
  899. let (status, _, body) = drive(router.clone(), method, uri, headers, "").await?;
  900. if status != StatusCode::SERVICE_UNAVAILABLE || !body.contains(LIMITER_MARKER) {
  901. return Err(
  902. format!("route {method} {uri} must fail closed via the limiter, got {status}: {body}")
  903. .into(),
  904. );
  905. }
  906. }
  907. // Metadata has no limiter rule: it reaches its handler (which fails
  908. // closed on its own with a different body), proving the exemption.
  909. let (_, _, meta_body) = drive(
  910. router.clone(),
  911. "GET",
  912. "/.well-known/oauth-authorization-server",
  913. &[],
  914. "",
  915. )
  916. .await?;
  917. if meta_body.contains(LIMITER_MARKER) {
  918. return Err("metadata must not be limiter-gated".into());
  919. }
  920. // Liveness never touches the limiter or any backend.
  921. let (live_status, _, _) = drive(router, "GET", "/health/live", &[], "").await?;
  922. if live_status != StatusCode::OK {
  923. return Err(format!("GET /health/live must be 200, got {live_status}").into());
  924. }
  925. Ok(())
  926. }
  927. /// Short-circuit rejections (limiter, CORS preflight, body limit,
  928. /// resilience shed) return before any handler, so they must still carry
  929. /// the hardening headers: `Cache-Control: no-store` +
  930. /// `X-Content-Type-Options: nosniff`.
  931. fn assert_hardening_headers(headers: &HeaderMap) -> TestResult {
  932. let no_store = headers
  933. .get(header::CACHE_CONTROL)
  934. .and_then(|value| value.to_str().ok())
  935. .unwrap_or_default();
  936. if no_store != "no-store" {
  937. return Err(format!("short-circuit must be no-store, got {no_store:?}").into());
  938. }
  939. let nosniff = headers
  940. .get(header::X_CONTENT_TYPE_OPTIONS)
  941. .and_then(|value| value.to_str().ok())
  942. .unwrap_or_default();
  943. if nosniff != "nosniff" {
  944. return Err("short-circuit must carry X-Content-Type-Options: nosniff".into());
  945. }
  946. Ok(())
  947. }
  948. /// Limiter short-circuits (429 over budget, 503 fail-closed) must still
  949. /// carry the hardening headers: `Cache-Control: no-store` +
  950. /// `X-Content-Type-Options: nosniff`. Both `too_many_requests` and
  951. /// `service_unavailable` return before the inner security layer, so this
  952. /// drives the deterministic fail-closed 503 path (same bypass as 429).
  953. #[tokio::test]
  954. async fn router_limiter_short_circuit_carries_security_headers() -> TestResult {
  955. let form = [("content-type", "application/x-www-form-urlencoded")];
  956. let (_, headers, body) = drive(
  957. build_router(limited_state(Vec::new())?),
  958. "POST",
  959. "/token",
  960. &form,
  961. "",
  962. )
  963. .await?;
  964. if !body.contains(LIMITER_MARKER) {
  965. return Err("expected the limiter fail-closed marker".into());
  966. }
  967. assert_hardening_headers(&headers)
  968. }
  969. /// CORS preflights short-circuit at the CORS layer (outside the limiter),
  970. /// but inside the security-headers layer, so an allowlisted preflight must
  971. /// echo the origin and still carry the hardening headers.
  972. #[tokio::test]
  973. async fn router_cors_preflight_carries_security_headers() -> TestResult {
  974. let router = build_router(limited_state(vec!["https://app.example.test"])?);
  975. let preflight = [
  976. ("origin", "https://app.example.test"),
  977. ("access-control-request-method", "POST"),
  978. ];
  979. let (_, headers, _) = drive(router, "OPTIONS", "/token", &preflight, "").await?;
  980. let allowed = headers
  981. .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
  982. .and_then(|value| value.to_str().ok())
  983. .unwrap_or_default();
  984. if allowed != "https://app.example.test" {
  985. return Err(format!("allowlisted origin must be echoed, got {allowed}").into());
  986. }
  987. assert_hardening_headers(&headers)
  988. }
  989. /// Body-limit 413s return before any handler, inside the security-headers
  990. /// layer, so an oversized body must be rejected with the hardening headers.
  991. #[tokio::test]
  992. async fn router_body_limit_413_carries_security_headers() -> TestResult {
  993. let options = RouterOptions {
  994. max_body_bytes: 8,
  995. ..RouterOptions::default()
  996. };
  997. let form = [
  998. ("content-type", "application/x-www-form-urlencoded"),
  999. ("content-length", "24"),
  1000. ];
  1001. let (status, headers, _) = drive(
  1002. build_router_with_options(passthrough_state()?, options),
  1003. "POST",
  1004. "/token",
  1005. &form,
  1006. "body-exceeds-eight-bytes",
  1007. )
  1008. .await?;
  1009. if status != StatusCode::PAYLOAD_TOO_LARGE {
  1010. return Err(format!("oversized body must be 413, got {status}").into());
  1011. }
  1012. assert_hardening_headers(&headers)
  1013. }
  1014. /// Resilience sheds (timeout/overload 503) return before any handler,
  1015. /// inside the security-headers layer, so a timed-out request must fail
  1016. /// closed with the hardening headers.
  1017. #[tokio::test]
  1018. async fn router_resilience_timeout_carries_security_headers() -> TestResult {
  1019. let hanging = axum::Router::new().route(
  1020. "/hang",
  1021. axum::routing::get(|| async {
  1022. tokio::time::sleep(Duration::from_secs(30)).await;
  1023. "never"
  1024. }),
  1025. );
  1026. let router = hanging
  1027. .layer(crate::resilience::ResilienceLayer::new(
  1028. Duration::from_millis(1),
  1029. 1024,
  1030. ))
  1031. .layer(crate::middleware::security_headers::SecurityHeadersLayer::new(false));
  1032. let (status, headers, _) = drive(router, "GET", "/hang", &[], "").await?;
  1033. if status != StatusCode::SERVICE_UNAVAILABLE {
  1034. return Err(format!("resilience timeout must fail closed 503, got {status}").into());
  1035. }
  1036. assert_hardening_headers(&headers)
  1037. }
  1038. /// Every mounted sensitive route is classified by `Rule::for_path`, and the
  1039. /// exempt routes are not. The list mirrors the mount table in
  1040. /// `build_router_with_options`; a newly mounted sensitive endpoint that is
  1041. /// not classified fails here.
  1042. #[test]
  1043. fn router_every_sensitive_route_has_a_rate_limit_rule() -> TestResult {
  1044. let config = test_config(
  1045. "redis://127.0.0.1:1",
  1046. serde_json::json!({"enabled": true}),
  1047. Vec::new(),
  1048. )?;
  1049. for path in [
  1050. "/authorize",
  1051. "/par",
  1052. "/token",
  1053. "/device_authorization",
  1054. "/device",
  1055. "/login",
  1056. "/login/success",
  1057. "/login/change-password",
  1058. "/login/totp/setup",
  1059. "/login/totp/confirm",
  1060. "/logout",
  1061. "/consent",
  1062. "/register",
  1063. "/register/some-client",
  1064. "/introspect",
  1065. "/revoke",
  1066. ] {
  1067. if crate::middleware::ratelimit::Rule::for_path(&config.rate_limit, path).is_none() {
  1068. return Err(format!("sensitive route {path} must have a rate-limit rule").into());
  1069. }
  1070. }
  1071. for path in [
  1072. "/.well-known/oauth-authorization-server",
  1073. "/health/live",
  1074. "/health/ready",
  1075. "/assets/app.css",
  1076. ] {
  1077. if crate::middleware::ratelimit::Rule::for_path(&config.rate_limit, path).is_some() {
  1078. return Err(format!("exempt route {path} must not have a rate-limit rule").into());
  1079. }
  1080. }
  1081. Ok(())
  1082. }
  1083. /// CORS default: a browser `Origin` gets no CORS headers back.
  1084. #[tokio::test]
  1085. async fn router_cors_default_deny() -> TestResult {
  1086. let origin = [("origin", "https://evil.test")];
  1087. let (_, headers, _) = drive(
  1088. build_router(limited_state(Vec::new())?),
  1089. "POST",
  1090. "/token",
  1091. &origin,
  1092. "",
  1093. )
  1094. .await?;
  1095. if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
  1096. return Err("default router must not emit CORS headers".into());
  1097. }
  1098. let preflight = [
  1099. ("origin", "https://evil.test"),
  1100. ("access-control-request-method", "POST"),
  1101. ];
  1102. let (_, pre_headers, _) = drive(
  1103. build_router(limited_state(Vec::new())?),
  1104. "OPTIONS",
  1105. "/token",
  1106. &preflight,
  1107. "",
  1108. )
  1109. .await?;
  1110. if pre_headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
  1111. return Err("default router must not answer CORS preflights".into());
  1112. }
  1113. Ok(())
  1114. }
  1115. /// An explicitly allowlisted origin is echoed on preflight and absent for
  1116. /// unlisted origins.
  1117. #[tokio::test]
  1118. async fn router_cors_allowlist_permits_configured_origin() -> TestResult {
  1119. let router = build_router(limited_state(vec!["https://app.example.test"])?);
  1120. let preflight = [
  1121. ("origin", "https://app.example.test"),
  1122. ("access-control-request-method", "POST"),
  1123. ];
  1124. let (_, headers, _) = drive(router.clone(), "OPTIONS", "/token", &preflight, "").await?;
  1125. let allowed = headers
  1126. .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
  1127. .and_then(|value| value.to_str().ok())
  1128. .unwrap_or_default();
  1129. if allowed != "https://app.example.test" {
  1130. return Err(format!("allowlisted origin must be echoed, got {allowed}").into());
  1131. }
  1132. let foreign = [
  1133. ("origin", "https://evil.test"),
  1134. ("access-control-request-method", "POST"),
  1135. ];
  1136. let (_, foreign_headers, _) = drive(router, "OPTIONS", "/token", &foreign, "").await?;
  1137. if foreign_headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
  1138. return Err("unlisted origins must not be echoed".into());
  1139. }
  1140. Ok(())
  1141. }
  1142. /// The wildcard is never honored: `*` alone means "no CORS layer".
  1143. #[test]
  1144. fn router_no_wildcard_cors() -> TestResult {
  1145. if cors_layer(&["*".to_owned()]).is_some() {
  1146. return Err("wildcard CORS must be refused".into());
  1147. }
  1148. if cors_layer(&[]).is_some() {
  1149. return Err("empty allowlist must yield no CORS layer".into());
  1150. }
  1151. Ok(())
  1152. }
  1153. /// `GET /authorize` with a lossy-encoded query value (`%FF` is a valid
  1154. /// URI but decodes to U+FFFD, never a parse failure) reaches downstream
  1155. /// validation: with no `client_id` the 400 page renders, proving the
  1156. /// `RawQuery` switch neither silently accepts nor parse-rejects
  1157. /// misencoded input. (A query `&str` is always valid UTF-8 and
  1158. /// percent-decoding is infallible, so the query layer has no
  1159. /// encoding-failure path left.)
  1160. #[tokio::test]
  1161. async fn router_authorize_malformed_query_is_400() -> TestResult {
  1162. let (status, _, body) = drive(
  1163. build_router(passthrough_state()?),
  1164. "GET",
  1165. "/authorize?state=%FF",
  1166. &[],
  1167. "",
  1168. )
  1169. .await?;
  1170. if status != StatusCode::BAD_REQUEST || !body.contains("invalid_request") {
  1171. return Err(
  1172. format!("lossy authorize query must be invalid_request 400, got {status}: {body}").into(),
  1173. );
  1174. }
  1175. Ok(())
  1176. }
  1177. /// `POST /par` preserves the transport contract after the `RawForm`
  1178. /// switch: a structurally unreadable body (raw non-UTF-8 bytes) is 400
  1179. /// (deliberately stricter than the old `Form`, which lossily accepted raw
  1180. /// non-UTF-8 bytes), a lossy-encoded body (`%FF` decodes to
  1181. /// U+FFFD) reaches downstream validation (`invalid_client`: no
  1182. /// credentials name a client), and a non-urlencoded body is 415.
  1183. #[tokio::test]
  1184. async fn router_par_transport_contract_preserved() -> TestResult {
  1185. let urlencoded = [("content-type", "application/x-www-form-urlencoded")];
  1186. let (bad_status, _, bad_body) = drive(
  1187. build_router(passthrough_state()?),
  1188. "POST",
  1189. "/par",
  1190. &urlencoded,
  1191. vec![0xFFu8],
  1192. )
  1193. .await?;
  1194. if bad_status != StatusCode::BAD_REQUEST || !bad_body.contains("Failed to deserialize form") {
  1195. return Err(format!("non-UTF-8 PAR body must be 400, got {bad_status}: {bad_body}").into());
  1196. }
  1197. let (lossy_status, _, lossy_body) = drive(
  1198. build_router(passthrough_state()?),
  1199. "POST",
  1200. "/par",
  1201. &urlencoded,
  1202. "scope=%FF",
  1203. )
  1204. .await?;
  1205. if lossy_status != StatusCode::BAD_REQUEST || !lossy_body.contains("invalid_client") {
  1206. return Err(
  1207. format!("lossy PAR body must be invalid_client 400, got {lossy_status}: {lossy_body}")
  1208. .into(),
  1209. );
  1210. }
  1211. let json = [("content-type", "application/json")];
  1212. let (ctype_status, _, _) = drive(
  1213. build_router(passthrough_state()?),
  1214. "POST",
  1215. "/par",
  1216. &json,
  1217. "client_id=nobody",
  1218. )
  1219. .await?;
  1220. if ctype_status != StatusCode::UNSUPPORTED_MEDIA_TYPE {
  1221. return Err(format!("non-form PAR body must be 415, got {ctype_status}").into());
  1222. }
  1223. Ok(())
  1224. }
  1225. /// `POST /token` preserves the documented duplicate-parameter mapping
  1226. /// after the `RawForm` switch: a repeated known field is `invalid_request`
  1227. /// (RFC 6749 §5.2), and a non-urlencoded body is 415.
  1228. #[tokio::test]
  1229. async fn router_token_duplicate_param_preserved() -> TestResult {
  1230. let urlencoded = [("content-type", "application/x-www-form-urlencoded")];
  1231. let (status, _, body) = drive(
  1232. build_router(passthrough_state()?),
  1233. "POST",
  1234. "/token",
  1235. &urlencoded,
  1236. "grant_type=authorization_code&grant_type=refresh_token",
  1237. )
  1238. .await?;
  1239. if status != StatusCode::BAD_REQUEST || !body.contains("invalid_request") {
  1240. return Err(
  1241. format!("duplicate token param must be invalid_request, got {status}: {body}").into(),
  1242. );
  1243. }
  1244. let json = [("content-type", "application/json")];
  1245. let (ctype_status, _, _) = drive(
  1246. build_router(passthrough_state()?),
  1247. "POST",
  1248. "/token",
  1249. &json,
  1250. "grant_type=refresh_token",
  1251. )
  1252. .await?;
  1253. if ctype_status != StatusCode::UNSUPPORTED_MEDIA_TYPE {
  1254. return Err(format!("non-form token body must be 415, got {ctype_status}").into());
  1255. }
  1256. Ok(())
  1257. }
  1258. /// `POST /introspect` and `POST /device_authorization` preserve the
  1259. /// transport contract after the `RawForm` switch: a structurally
  1260. /// unreadable body (raw non-UTF-8 bytes) is 400 plain text (deliberately
  1261. /// stricter than the old `Form`, which lossily accepted raw non-UTF-8
  1262. /// bytes), a lossy-encoded body (`%FF` decodes to U+FFFD) reaches
  1263. /// downstream validation, and non-urlencoded bodies are 415. A repeated
  1264. /// known field is JSON `invalid_request` (the old `Form` returned 422
  1265. /// `Failed to deserialize form body`, so the new JSON 400 is the intended
  1266. /// contract, not parity). The
  1267. /// downstream expectation differs per path: introspect has no
  1268. /// credentials, so `invalid_client` (401); device has an empty
  1269. /// `client_id`, so `invalid_request` (400).
  1270. #[tokio::test]
  1271. async fn router_introspect_and_device_transport_contract_preserved() -> TestResult {
  1272. let urlencoded = [("content-type", "application/x-www-form-urlencoded")];
  1273. let json = [("content-type", "application/json")];
  1274. for path in ["/introspect", "/device_authorization"] {
  1275. let (bad_status, _, bad_body) = drive(
  1276. build_router(passthrough_state()?),
  1277. "POST",
  1278. path,
  1279. &urlencoded,
  1280. vec![0xFFu8],
  1281. )
  1282. .await?;
  1283. if bad_status != StatusCode::BAD_REQUEST || !bad_body.contains("Failed to deserialize form") {
  1284. return Err(
  1285. format!("non-UTF-8 {path} body must be 400, got {bad_status}: {bad_body}").into(),
  1286. );
  1287. }
  1288. let (lossy_body, expected_status, expected_code) = match path {
  1289. "/introspect" => ("token=%FF", StatusCode::UNAUTHORIZED, "invalid_client"),
  1290. _ => (
  1291. "client_id=&scope=%FF",
  1292. StatusCode::BAD_REQUEST,
  1293. "invalid_request",
  1294. ),
  1295. };
  1296. let (lossy_status, _, lossy_response) = drive(
  1297. build_router(passthrough_state()?),
  1298. "POST",
  1299. path,
  1300. &urlencoded,
  1301. lossy_body,
  1302. )
  1303. .await?;
  1304. if lossy_status != expected_status || !lossy_response.contains(expected_code) {
  1305. return Err(
  1306. format!(
  1307. "lossy {path} body must be {expected_code}, got {lossy_status}: {lossy_response}"
  1308. )
  1309. .into(),
  1310. );
  1311. }
  1312. let (ctype_status, _, _) = drive(
  1313. build_router(passthrough_state()?),
  1314. "POST",
  1315. path,
  1316. &json,
  1317. "token=abc",
  1318. )
  1319. .await?;
  1320. if ctype_status != StatusCode::UNSUPPORTED_MEDIA_TYPE {
  1321. return Err(format!("non-form {path} body must be 415, got {ctype_status}").into());
  1322. }
  1323. }
  1324. Ok(())
  1325. }
  1326. /// A repeated known field on `POST /introspect` is JSON `invalid_request`
  1327. /// (RFC 6749 §5.2), never the old `Form` 422 `Failed to deserialize form
  1328. /// body` and never plain text.
  1329. #[tokio::test]
  1330. async fn router_introspect_duplicate_param_is_json_invalid_request() -> TestResult {
  1331. let urlencoded = [("content-type", "application/x-www-form-urlencoded")];
  1332. let (status, _, body) = drive(
  1333. build_router(passthrough_state()?),
  1334. "POST",
  1335. "/introspect",
  1336. &urlencoded,
  1337. "token=abc&token=def",
  1338. )
  1339. .await?;
  1340. if status != StatusCode::BAD_REQUEST || !body.contains("invalid_request") {
  1341. return Err(
  1342. format!("duplicate introspect param must be invalid_request 400, got {status}: {body}")
  1343. .into(),
  1344. );
  1345. }
  1346. if body.contains("Failed to deserialize form") {
  1347. return Err("duplicate introspect param must be JSON, not the plain-text form error".into());
  1348. }
  1349. Ok(())
  1350. }
  1351. /// A repeated known field on `POST /device_authorization` is JSON
  1352. /// `invalid_request` (RFC 6749 §5.2), never the old `Form` 422 `Failed to
  1353. /// deserialize form body` and never plain text.
  1354. #[tokio::test]
  1355. async fn router_device_duplicate_param_is_json_invalid_request() -> TestResult {
  1356. let urlencoded = [("content-type", "application/x-www-form-urlencoded")];
  1357. let (status, _, body) = drive(
  1358. build_router(passthrough_state()?),
  1359. "POST",
  1360. "/device_authorization",
  1361. &urlencoded,
  1362. "client_id=nobody&client_id=somebody",
  1363. )
  1364. .await?;
  1365. if status != StatusCode::BAD_REQUEST || !body.contains("invalid_request") {
  1366. return Err(
  1367. format!("duplicate device param must be invalid_request 400, got {status}: {body}").into(),
  1368. );
  1369. }
  1370. if body.contains("Failed to deserialize form") {
  1371. return Err("duplicate device param must be JSON, not the plain-text form error".into());
  1372. }
  1373. Ok(())
  1374. }
  1375. /// `POST /revoke` preserves the transport contract after the `RawForm`
  1376. /// switch: a repeated known field is JSON `invalid_request`, a
  1377. /// non-urlencoded body is 415, and a well-formed unauthenticated body
  1378. /// still parses and reaches authentication (`invalid_client` 401),
  1379. /// proving the field mapping survived the `Form<HashMap>` migration.
  1380. #[tokio::test]
  1381. async fn router_revoke_transport_contract_preserved() -> TestResult {
  1382. let urlencoded = [("content-type", "application/x-www-form-urlencoded")];
  1383. let (dup_status, _, dup_body) = drive(
  1384. build_router(passthrough_state()?),
  1385. "POST",
  1386. "/revoke",
  1387. &urlencoded,
  1388. "token=abc&token=def",
  1389. )
  1390. .await?;
  1391. if dup_status != StatusCode::BAD_REQUEST || !dup_body.contains("invalid_request") {
  1392. return Err(
  1393. format!("duplicate revoke param must be invalid_request 400, got {dup_status}: {dup_body}")
  1394. .into(),
  1395. );
  1396. }
  1397. if dup_body.contains("Failed to deserialize form") {
  1398. return Err("duplicate revoke param must be JSON, not the plain-text form error".into());
  1399. }
  1400. let json = [("content-type", "application/json")];
  1401. let (ctype_status, _, _) = drive(
  1402. build_router(passthrough_state()?),
  1403. "POST",
  1404. "/revoke",
  1405. &json,
  1406. "token=abc",
  1407. )
  1408. .await?;
  1409. if ctype_status != StatusCode::UNSUPPORTED_MEDIA_TYPE {
  1410. return Err(format!("non-form revoke body must be 415, got {ctype_status}").into());
  1411. }
  1412. let (auth_status, _, auth_body) = drive(
  1413. build_router(passthrough_state()?),
  1414. "POST",
  1415. "/revoke",
  1416. &urlencoded,
  1417. "token=abc",
  1418. )
  1419. .await?;
  1420. if auth_status != StatusCode::UNAUTHORIZED || !auth_body.contains("invalid_client") {
  1421. return Err(
  1422. format!("well-formed unauthenticated revoke must be invalid_client 401, got {auth_status}: {auth_body}")
  1423. .into(),
  1424. );
  1425. }
  1426. Ok(())
  1427. }
  1428. /// `GET /authorize` with no usable client never redirects with 307/308.
  1429. #[tokio::test]
  1430. async fn router_authorize_entry_never_307() -> TestResult {
  1431. let (status, headers, _) = drive(
  1432. build_router(passthrough_state()?),
  1433. "GET",
  1434. "/authorize?client_id=nobody",
  1435. &[],
  1436. "",
  1437. )
  1438. .await?;
  1439. if status == StatusCode::TEMPORARY_REDIRECT || status == StatusCode::PERMANENT_REDIRECT {
  1440. return Err(format!("authorize must never 307/308, got {status}").into());
  1441. }
  1442. if headers.contains_key(header::LOCATION)
  1443. && (status == StatusCode::TEMPORARY_REDIRECT || status == StatusCode::PERMANENT_REDIRECT)
  1444. {
  1445. return Err("credential-carrying redirect must be 303, not 307".into());
  1446. }
  1447. Ok(())
  1448. }
  1449. /// A rate-protected route served over the production TLS accept loop
  1450. /// reaches its handler (200) instead of failing closed with the limiter's
  1451. /// 503: [`crate::tls::serve_tls`] must inject the peer address.
  1452. #[tokio::test]
  1453. async fn router_tls_listener_injects_peer_for_rate_limiting() -> TestResult {
  1454. use rustls::pki_types::pem::PemObject as _;
  1455. use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
  1456. let router = build_router(zero_limit_state()?);
  1457. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
  1458. let addr = listener.local_addr()?;
  1459. let server_config =
  1460. build_server_config(TEST_CERT_PEM.as_bytes(), TEST_KEY_PEM.as_bytes(), None)?;
  1461. let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config));
  1462. let server = tokio::spawn(crate::tls::serve_tls(listener, acceptor, router));
  1463. let chain = rustls::pki_types::CertificateDer::pem_slice_iter(TEST_CERT_PEM.as_bytes())
  1464. .collect::<Result<Vec<_>, _>>()?;
  1465. let Some(leaf) = chain.into_iter().next() else {
  1466. return Err("test certificate must parse".into());
  1467. };
  1468. let mut roots = rustls::RootCertStore::empty();
  1469. roots.add(leaf)?;
  1470. let client_config = rustls::ClientConfig::builder()
  1471. .with_root_certificates(roots)
  1472. .with_no_client_auth();
  1473. let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
  1474. let stream = tokio::net::TcpStream::connect(addr).await?;
  1475. let server_name = rustls::pki_types::ServerName::try_from("localhost")?.to_owned();
  1476. let mut tls = connector.connect(server_name, stream).await?;
  1477. tls
  1478. .write_all(b"GET /device HTTP/1.1\r\nhost: localhost\r\nconnection: close\r\n\r\n")
  1479. .await?;
  1480. let mut raw = Vec::new();
  1481. tls.read_to_end(&mut raw).await?;
  1482. server.abort();
  1483. let response = String::from_utf8_lossy(&raw);
  1484. let status_line = response.lines().next().unwrap_or_default();
  1485. if !status_line.contains("200") {
  1486. return Err(format!("TLS /device must reach its handler, got: {status_line}").into());
  1487. }
  1488. if !response.contains("Enter the code shown on your device") {
  1489. return Err("TLS /device must return the device page".into());
  1490. }
  1491. Ok(())
  1492. }
  1493. /// The server config completes a TLS handshake with the test certificate.
  1494. #[tokio::test]
  1495. async fn router_tls_handshake_with_test_cert() -> TestResult {
  1496. use rustls::pki_types::pem::PemObject as _;
  1497. let server_config =
  1498. build_server_config(TEST_CERT_PEM.as_bytes(), TEST_KEY_PEM.as_bytes(), None)?;
  1499. let chain = rustls::pki_types::CertificateDer::pem_slice_iter(TEST_CERT_PEM.as_bytes())
  1500. .collect::<Result<Vec<_>, _>>()?;
  1501. let Some(leaf) = chain.into_iter().next() else {
  1502. return Err("test certificate must parse".into());
  1503. };
  1504. let mut roots = rustls::RootCertStore::empty();
  1505. roots.add(leaf)?;
  1506. let client_config = rustls::ClientConfig::builder()
  1507. .with_root_certificates(roots)
  1508. .with_no_client_auth();
  1509. let (client_io, server_io) = tokio::io::duplex(65_536);
  1510. let server = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)).accept(server_io);
  1511. let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
  1512. let server_name = rustls::pki_types::ServerName::try_from("localhost")?.to_owned();
  1513. let client = connector.connect(server_name, client_io);
  1514. let (server_out, client_out) = tokio::join!(server, client);
  1515. if server_out.is_err() || client_out.is_err() {
  1516. return Err(format!("server: {server_out:?} client: {client_out:?}").into());
  1517. }
  1518. Ok(())
  1519. }
  1520. /// Bad TLS material fails closed: garbage chain, key, or CA bundle.
  1521. #[tokio::test]
  1522. async fn router_tls_rejects_bad_material() -> TestResult {
  1523. if build_server_config(b"not pem", TEST_KEY_PEM.as_bytes(), None).is_ok() {
  1524. return Err("garbage chain must be rejected".into());
  1525. }
  1526. if build_server_config(TEST_CERT_PEM.as_bytes(), b"not pem", None).is_ok() {
  1527. return Err("garbage key must be rejected".into());
  1528. }
  1529. if build_server_config(
  1530. TEST_CERT_PEM.as_bytes(),
  1531. TEST_KEY_PEM.as_bytes(),
  1532. Some(b"not pem"),
  1533. )
  1534. .is_ok()
  1535. {
  1536. return Err("garbage client-CA bundle must be rejected".into());
  1537. }
  1538. // A `CA:TRUE` bundle is accepted as the client-CA trust input.
  1539. if build_server_config(
  1540. TEST_CERT_PEM.as_bytes(),
  1541. TEST_KEY_PEM.as_bytes(),
  1542. Some(TEST_CA_PEM.as_bytes()),
  1543. )
  1544. .is_err()
  1545. {
  1546. return Err("valid client-CA bundle must be accepted".into());
  1547. }
  1548. Ok(())
  1549. }
  1550. /// draft-ietf-oauth-v2-1-16 §1.5 + §7.14: the public listener pins a
  1551. /// TLS 1.2 minimum explicitly (not rustls defaults). The pinned set is
  1552. /// exactly TLS 1.2 + TLS 1.3, so nothing older can be negotiated.
  1553. #[test]
  1554. fn tls_version_minimum_is_tls12() -> TestResult {
  1555. use rustls::ProtocolVersion;
  1556. let versions: Vec<ProtocolVersion> = crate::protocol::mtls::TLS_PROTOCOL_VERSIONS
  1557. .iter()
  1558. .map(|supported| supported.version)
  1559. .collect();
  1560. if !versions.contains(&ProtocolVersion::TLSv1_2) {
  1561. return Err("pinned TLS versions must include TLS 1.2".into());
  1562. }
  1563. if !versions.contains(&ProtocolVersion::TLSv1_3) {
  1564. return Err("pinned TLS versions must include TLS 1.3".into());
  1565. }
  1566. for version in &versions {
  1567. if *version != ProtocolVersion::TLSv1_2 && *version != ProtocolVersion::TLSv1_3 {
  1568. return Err("no protocol version below TLS 1.2 may be enabled".into());
  1569. }
  1570. }
  1571. Ok(())
  1572. }
  1573. /// The pinned server config accepts TLS 1.3-only and TLS 1.2-only
  1574. /// clients, negotiating the offered version each time.
  1575. #[tokio::test]
  1576. async fn tls_version_accepts_tls12_and_tls13() -> TestResult {
  1577. use rustls::ProtocolVersion;
  1578. use rustls::pki_types::pem::PemObject as _;
  1579. for (pinned, expected) in [
  1580. (&rustls::version::TLS13, ProtocolVersion::TLSv1_3),
  1581. (&rustls::version::TLS12, ProtocolVersion::TLSv1_2),
  1582. ] {
  1583. let server_config =
  1584. build_server_config(TEST_CERT_PEM.as_bytes(), TEST_KEY_PEM.as_bytes(), None)?;
  1585. let chain = rustls::pki_types::CertificateDer::pem_slice_iter(TEST_CERT_PEM.as_bytes())
  1586. .collect::<Result<Vec<_>, _>>()?;
  1587. let Some(leaf) = chain.into_iter().next() else {
  1588. return Err("test certificate must parse".into());
  1589. };
  1590. let mut roots = rustls::RootCertStore::empty();
  1591. roots.add(leaf)?;
  1592. let client_config = rustls::ClientConfig::builder_with_protocol_versions(&[pinned])
  1593. .with_root_certificates(roots)
  1594. .with_no_client_auth();
  1595. let (client_io, server_io) = tokio::io::duplex(65_536);
  1596. let server = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)).accept(server_io);
  1597. let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
  1598. let server_name = rustls::pki_types::ServerName::try_from("localhost")?.to_owned();
  1599. let client = connector.connect(server_name, client_io);
  1600. let (server_out, client_out) = tokio::join!(server, client);
  1601. let server_tls = server_out.map_err(|err| format!("server handshake failed: {err:?}"))?;
  1602. client_out.map_err(|err| format!("client handshake failed: {err:?}"))?;
  1603. if server_tls.get_ref().1.protocol_version() != Some(expected) {
  1604. return Err(
  1605. format!(
  1606. "expected negotiated {expected:?}, got {:?}",
  1607. server_tls.get_ref().1.protocol_version()
  1608. )
  1609. .into(),
  1610. );
  1611. }
  1612. }
  1613. Ok(())
  1614. }
  1615. }