par.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. //! Pushed Authorization Requests (RFC 9126).
  2. //!
  3. //! `POST /par` authenticates the client (see [`client_auth`]), validates the
  4. //! pushed request rules, stores the parameters under a one-time
  5. //! `request_uri` with the configured PAR TTL (90 s), and returns
  6. //! `{request_uri, expires_in}`. The `/authorize` side never lives here: it
  7. //! consumes the `request_uri` through [`crate::jose::jar::resolve`], which
  8. //! rejects unknown, expired, replayed, or foreign-client ids.
  9. use std::{collections::HashMap, time::Duration};
  10. use axum::{
  11. extract::{RawForm, State},
  12. http::{HeaderMap, StatusCode},
  13. };
  14. use secrecy::SecretString;
  15. use serde::{Deserialize, Serialize};
  16. use sqlx::PgPool;
  17. use time::OffsetDateTime;
  18. use super::{
  19. authorize::CimdRequest,
  20. cimd,
  21. client_auth::{AuthContext, AuthStores, ClientAuthRequest, authenticate_client},
  22. redirect_repo::{RedirectDecision, match_redirect_uri},
  23. };
  24. use crate::{
  25. crypto::tokens::mint_token,
  26. db::{client_repo, redirect_repo},
  27. error::AppError,
  28. jose::jar::PAR_REQUEST_URI_PREFIX,
  29. middleware::{
  30. audit::AuditEvent,
  31. request_id::{REQUEST_ID_HEADER, RequestId},
  32. },
  33. state::{AppState, RedisConn},
  34. store::{Store, par},
  35. };
  36. /// Client-credential form members: authenticated, never stored.
  37. const CREDENTIAL_PARAMS: [&str; 3] = ["client_secret", "client_assertion", "client_assertion_type"];
  38. /// Datastore handles needed by [`push_authorization_request`].
  39. pub struct ParStores<'a> {
  40. /// Postgres pool for client lookup and redirect-URI matching.
  41. pub pool: &'a PgPool,
  42. /// Versioned PII keys for the encrypted client lookup.
  43. pub keys: &'a crate::keys::KeyRing,
  44. /// Redis connection for the PAR record store.
  45. pub conn: &'a mut RedisConn,
  46. /// Key layout plus retry/durability knobs for the PAR store.
  47. pub store: &'a Store,
  48. }
  49. /// Request-scoped inputs for [`push_authorization_request`].
  50. pub struct ParContext<'a> {
  51. /// Canonical token endpoint URL (`aud` must equal this exactly).
  52. pub token_endpoint: &'a str,
  53. /// Current time for secret-expiry enforcement.
  54. pub now: OffsetDateTime,
  55. /// PAR `request_uri` lifetime (90 s per the plan).
  56. pub par_ttl: Duration,
  57. /// Config `allow_localhost_redirect`: treat the exact host `localhost` as
  58. /// an RFC 8252 §7.3 loopback redirect host when matching the pushed
  59. /// `redirect_uri`.
  60. pub allow_localhost_redirect: bool,
  61. /// CIMD resolution settings; `None` when `config.cimd_enabled` is off.
  62. pub cimd: Option<CimdRequest<'a>>,
  63. /// Config `resource_indicators_enabled`: off + any `resource` present is
  64. /// `invalid_target` (fail-closed, no silent drop).
  65. pub resource_indicators_enabled: bool,
  66. }
  67. /// Successful PAR response body (RFC 9126 §2.4).
  68. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  69. pub struct ParResponse {
  70. /// One-time PAR URN the authorize request references.
  71. pub request_uri: String,
  72. /// Lifetime of the `request_uri` in seconds.
  73. pub expires_in: u64,
  74. }
  75. /// Push an authorization request (RFC 9126 §2).
  76. ///
  77. /// Authenticates the client first (unknown clients and missing credentials
  78. /// on confidential clients fail as `invalid_client`), then enforces the
  79. /// request rules: `client_id` must name the authenticated client,
  80. /// `response_type` must be `code`, `redirect_uri` must be present and
  81. /// registered for the client, and neither `request` nor `request_uri` may
  82. /// appear in a pushed body. The surviving parameters (minus credentials)
  83. /// are stored one-time under a fresh `request_uri` for `par_ttl`.
  84. ///
  85. /// # Errors
  86. ///
  87. /// Returns `invalid_client` when authentication fails, `invalid_request`
  88. /// (or `invalid_scope` for a blank scope) when a request rule is violated,
  89. /// and a masked `server_error` when storage itself fails.
  90. pub async fn push_authorization_request(
  91. stores: &mut ParStores<'_>,
  92. ctx: &ParContext<'_>,
  93. auth: &ClientAuthRequest<'_>,
  94. params: &HashMap<String, String>,
  95. resources: &[String],
  96. ) -> Result<ParResponse, AppError> {
  97. let mut auth_stores = AuthStores {
  98. pool: stores.pool,
  99. keys: stores.keys,
  100. conn: stores.conn,
  101. store: stores.store,
  102. };
  103. let auth_ctx = AuthContext {
  104. token_endpoint: ctx.token_endpoint,
  105. now: ctx.now,
  106. cimd: ctx.cimd.as_ref().map(|settings| cimd::CimdSettings {
  107. token_prefix: settings.token_prefix,
  108. max_bytes: settings.max_bytes,
  109. timeout: settings.timeout,
  110. allow_localhost: ctx.allow_localhost_redirect,
  111. }),
  112. };
  113. let authenticated = authenticate_client(&mut auth_stores, &auth_ctx, auth).await?;
  114. let aud = validate_request_rules(
  115. stores.pool,
  116. stores.keys,
  117. stores.conn,
  118. &authenticated.client.client_id,
  119. params,
  120. resources,
  121. ctx,
  122. )
  123. .await?;
  124. // The pinned audience travels in the record `aud`, never in the stored
  125. // JSON: a single-valued map cannot hold repeats, and a joined string
  126. // would collide with comma-bearing URIs.
  127. let mut stored = params.clone();
  128. stored.remove("resource");
  129. for member in CREDENTIAL_PARAMS {
  130. stored.remove(member);
  131. }
  132. let request = serde_json::to_string(&stored).map_err(AppError::internal)?;
  133. let id = mint_token().map_err(AppError::internal)?;
  134. let record = par::ParRecord {
  135. client_id: authenticated.client.client_id.clone(),
  136. request,
  137. aud,
  138. };
  139. par::put(stores.conn, stores.store, &id, &record, ctx.par_ttl).await?;
  140. Ok(ParResponse {
  141. request_uri: format!("{PAR_REQUEST_URI_PREFIX}{id}"),
  142. expires_in: ctx.par_ttl.as_secs(),
  143. })
  144. }
  145. /// Enforce the pushed-request rules (RFC 9126 §2), returning the validated
  146. /// resource audience set (RFC 8707) for the PAR record.
  147. async fn validate_request_rules(
  148. pool: &PgPool,
  149. keys: &crate::keys::KeyRing,
  150. conn: &mut RedisConn,
  151. client_id: &str,
  152. params: &HashMap<String, String>,
  153. resources: &[String],
  154. ctx: &ParContext<'_>,
  155. ) -> Result<Vec<String>, AppError> {
  156. match params.get("client_id") {
  157. Some(presented) if presented == client_id => {}
  158. _ => {
  159. return Err(AppError::invalid_request(
  160. "pushed request client_id must name the authenticated client",
  161. ));
  162. }
  163. }
  164. match params.get("response_type") {
  165. Some(response_type) if response_type == "code" => {}
  166. _ => {
  167. return Err(AppError::invalid_request(
  168. "pushed request response_type must be code",
  169. ));
  170. }
  171. }
  172. if params.contains_key("request") || params.contains_key("request_uri") {
  173. return Err(AppError::invalid_request(
  174. "pushed request must not contain request or request_uri",
  175. ));
  176. }
  177. if params
  178. .get("scope")
  179. .is_some_and(|scope| scope.trim().is_empty())
  180. {
  181. return Err(AppError::invalid_scope(
  182. "pushed request scope must not be blank",
  183. ));
  184. }
  185. let Some(redirect_uri) = params.get("redirect_uri") else {
  186. return Err(AppError::invalid_request(
  187. "pushed request redirect_uri is required",
  188. ));
  189. };
  190. let registered_uris: Vec<String> =
  191. match client_repo::find_active_by_client_id(pool, keys, client_id).await? {
  192. Some(_) => redirect_repo::effective_for_client(pool, keys, client_id)
  193. .await?
  194. .into_iter()
  195. .map(|entry| entry.uri)
  196. .collect(),
  197. // No registered row: a CIMD `client_id` uses its document redirect set
  198. // (CIMD draft §5); the catalog is never consulted.
  199. None => match ctx.cimd {
  200. Some(settings) if client_id.starts_with("https://") => {
  201. let limits = cimd::CimdLimits {
  202. max_bytes: settings.max_bytes,
  203. timeout: settings.timeout,
  204. };
  205. cimd::resolve_with_catalog(
  206. conn,
  207. settings.token_prefix,
  208. pool,
  209. client_id,
  210. ctx.allow_localhost_redirect,
  211. limits,
  212. )
  213. .await
  214. .map_err(AppError::from)?
  215. .redirect_uris
  216. }
  217. _ => Vec::new(),
  218. },
  219. };
  220. let registered: Vec<&str> = registered_uris.iter().map(String::as_str).collect();
  221. if match_redirect_uri(&registered, redirect_uri, ctx.allow_localhost_redirect)
  222. == RedirectDecision::Reject
  223. {
  224. return Err(AppError::invalid_request(
  225. "pushed request redirect_uri is not registered for this client",
  226. ));
  227. }
  228. // Resource indicators (RFC 8707) validate fail-closed: off + present is
  229. // `invalid_target`, and every value must be an absolute `https` URI with
  230. // an exact enabled-catalog match. No silent drop, no scope widening.
  231. super::params::validate_resources(pool, ctx.resource_indicators_enabled, resources).await
  232. }
  233. /// `POST /par` (RFC 9126 §2.1).
  234. ///
  235. /// Credentials come from the `Authorization` header and the form body only;
  236. /// a secret observed in the query string is never consulted. Success is
  237. /// `201 Created` with `{request_uri, expires_in}`.
  238. ///
  239. /// # Errors
  240. ///
  241. /// Returns whatever [`push_authorization_request`] returns; mTLS-bound
  242. /// clients fail closed here until the TLS accept loop injects peer
  243. /// certificates (task 21 owns that seam).
  244. pub async fn par_handler(
  245. State(state): State<AppState>,
  246. headers: HeaderMap,
  247. body: RawForm,
  248. ) -> axum::response::Response {
  249. use axum::response::IntoResponse as _;
  250. // `RawForm` gates the content type BEFORE `Bytes` buffering, so a wrong
  251. // content type is rejected 415 without reading the (up to 2 MiB) body.
  252. // The explicit exact-MIME gate below is the second check: `RawForm` uses a
  253. // case-sensitive prefix match, so it accepts e.g.
  254. // `application/x-www-form-urlencodedevil`, which the exact gate rejects.
  255. let body = body.0;
  256. // `RawForm` + a manual parse replaces `Form<HashMap>`: the extractor
  257. // consumed the raw body, hiding repeats — the repeated `resource`
  258. // indicator (RFC 8707) is captured in order by `params::values_of`
  259. // while the single-valued map keeps last-wins absorption. A malformed
  260. // body is the same 400 (deliberately stricter than the old `Form`, which
  261. // lossily accepted raw non-UTF-8 bytes).
  262. if let Err(reject) = super::params::require_form_content_type(&headers) {
  263. return reject.into_response();
  264. }
  265. let pairs = match super::params::form_pairs(&body) {
  266. Ok(pairs) => pairs,
  267. Err(reject) => return reject.into_response(),
  268. };
  269. let form = super::params::single_valued_map(&pairs);
  270. let resources = super::params::values_of(&pairs, super::params::RESOURCE_PARAM);
  271. let header_value = headers
  272. .get(axum::http::header::AUTHORIZATION)
  273. .and_then(|value| value.to_str().ok());
  274. let body_secret = form
  275. .get("client_secret")
  276. .map(|secret| SecretString::from(secret.as_str()));
  277. let auth = ClientAuthRequest {
  278. basic_header: header_value,
  279. client_id: form.get("client_id").map(String::as_str),
  280. body_secret,
  281. assertion_type: form.get("client_assertion_type").map(String::as_str),
  282. assertion: form.get("client_assertion").map(String::as_str),
  283. peer_certs: None,
  284. };
  285. let mut redis = state.redis.clone();
  286. let mut stores = ParStores {
  287. pool: &state.pool,
  288. keys: &state.keys,
  289. conn: &mut redis,
  290. store: &Store::from_config(&state.config.redis),
  291. };
  292. let ctx = ParContext {
  293. token_endpoint: &super::client_auth::token_endpoint(&state.config.issuer),
  294. now: state.clock.now(),
  295. par_ttl: state.config.ttl.par,
  296. allow_localhost_redirect: state.config.allow_localhost_redirect,
  297. cimd: state.config.cimd_enabled.then(|| CimdRequest {
  298. token_prefix: &state.config.redis.token_key_prefix,
  299. max_bytes: state.config.cimd_max_bytes,
  300. timeout: state.config.cimd_fetch_timeout,
  301. }),
  302. resource_indicators_enabled: state.config.resource_indicators_enabled,
  303. };
  304. let response = match push_authorization_request(&mut stores, &ctx, &auth, &form, &resources).await
  305. {
  306. Ok(response) => response,
  307. Err(error) => {
  308. emit_pii_decrypt(&headers, &error);
  309. return error.into_response();
  310. }
  311. };
  312. (StatusCode::CREATED, axum::Json(response)).into_response()
  313. }
  314. fn emit_pii_decrypt(headers: &HeaderMap, error: &AppError) {
  315. if matches!(error, AppError::PiiDecrypt) {
  316. let request_id = headers
  317. .get(REQUEST_ID_HEADER)
  318. .and_then(|value| value.to_str().ok())
  319. .and_then(RequestId::parse);
  320. AuditEvent::PiiDecryptFailure {
  321. request_id: request_id.as_ref().map(RequestId::as_str),
  322. }
  323. .emit();
  324. }
  325. }
  326. #[cfg(test)]
  327. mod tests;