authorize.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. //! `/authorize` validation pipeline and error-delivery matrix.
  2. //!
  3. //! Validation order (RFC 6749 §4.1.2.1, RFC 9700 §4.1.3, RFC 9101 §4,
  4. //! RFC 9126 §4): resolve PAR/JAR input, `client_id` → client, the JAR/PAR
  5. //! mandatory rule, `redirect_uri`, `response_type` = `code`, scope, PKCE
  6. //! (`S256`, required), then `state` passthrough. No code is issued here;
  7. //! a [`ValidatedRequest`] only authorizes the login/consent stages to run.
  8. //!
  9. //! Error-delivery matrix: [`AuthorizeFailure::Page`] (HTTP 400, no redirect)
  10. //! only for an absent/unknown `client_id`, an invalid or unregistered
  11. //! `redirect_uri`, or a malformed request (including every PAR/JAR
  12. //! resolution failure, which precedes `redirect_uri` validation and therefore
  13. //! cannot redirect safely). Every later failure redirects to the validated
  14. //! `redirect_uri` with `error` (+ `state`).
  15. use std::collections::HashMap;
  16. use axum::{
  17. body::Body,
  18. http::{StatusCode, header},
  19. response::{Html, IntoResponse, Response},
  20. };
  21. use sqlx::PgPool;
  22. use crate::{
  23. config::GrantKind,
  24. crypto::pkce::{CHALLENGE_LEN, S256_METHOD, verify_method},
  25. db::{client_repo, redirect_repo, scope_repo},
  26. error::{AppError, OAuthErrorCode},
  27. jose::jar::{self, JarRequest},
  28. protocol::{redirect_repo as redirect_match, scope},
  29. state::RedisConn,
  30. store::Store,
  31. };
  32. /// Inputs to [`validate`]: live stores plus the raw authorize query.
  33. pub struct AuthorizeInput<'a> {
  34. /// Postgres pool for the client, redirect, and scope catalog reads.
  35. pub pool: &'a PgPool,
  36. /// Versioned PII keys for the encrypted client lookup.
  37. pub keys: &'a crate::keys::KeyRing,
  38. /// Redis connection for PAR `request_uri` consumption.
  39. pub conn: &'a mut RedisConn,
  40. /// Redis store handle backing the PAR lookup.
  41. pub store: &'a Store,
  42. /// Server issuer; the `aud` of JAR request objects.
  43. pub issuer: &'a str,
  44. /// Raw authorize query parameters as received.
  45. pub query: &'a HashMap<String, String>,
  46. }
  47. /// An authorize request that passed every validation step.
  48. ///
  49. /// This carries no code and no session: it is the normalized record the
  50. /// pending-auth store (task 24) persists before login/consent runs.
  51. #[derive(Debug, Clone, PartialEq, Eq)]
  52. pub struct ValidatedRequest {
  53. /// The validated `client_id`.
  54. pub client_id: String,
  55. /// The validated `redirect_uri`, exactly as requested.
  56. pub redirect_uri: String,
  57. /// The effective scope (explicit request checked; omission defaults to the
  58. /// client set narrowed by the enabled catalog; user entitlement is enforced
  59. /// at consent, not here).
  60. pub scope: scope::ScopeSet,
  61. /// The S256 `code_challenge` the later code grant is bound to.
  62. pub code_challenge: String,
  63. /// The `state` value to echo in the authorization response, if supplied.
  64. pub state: Option<String>,
  65. /// The client's single registered post-logout return URI, when it has one.
  66. /// Resolved during validation so a later logout can return the browser to
  67. /// the client without accepting any redirect target from a request.
  68. pub post_logout_uri: Option<String>,
  69. }
  70. /// How an authorize failure reaches the user agent.
  71. ///
  72. /// A [`AuthorizeFailure::Page`] renders HTTP 400 with no `Location`; a
  73. /// [`AuthorizeFailure::Redirect`] renders HTTP 302 to the validated
  74. /// `redirect_uri` carrying `error` (+ `error_description`, + `state`).
  75. #[derive(Debug)]
  76. pub enum AuthorizeFailure {
  77. /// Direct error page: unknown client, bad redirect, or malformed request.
  78. Page(AppError),
  79. /// Redirect to the validated `redirect_uri` with an OAuth error.
  80. Redirect {
  81. /// The validated `redirect_uri` the user agent returns to.
  82. target: String,
  83. /// The OAuth error to encode in the redirect query.
  84. error: AppError,
  85. /// The `state` to echo, if the request supplied one.
  86. state: Option<String>,
  87. },
  88. }
  89. impl AuthorizeFailure {
  90. /// The OAuth error code carried by this failure.
  91. #[must_use]
  92. pub fn code(&self) -> OAuthErrorCode {
  93. match self {
  94. Self::Page(error) | Self::Redirect { error, .. } => error.code(),
  95. }
  96. }
  97. /// Whether this failure carries a stored-value decrypt failure: the
  98. /// handler audits it as `pii.decrypt_failure` (the repository never emits).
  99. #[must_use]
  100. pub fn is_pii_decrypt(&self) -> bool {
  101. matches!(
  102. self,
  103. Self::Page(AppError::PiiDecrypt)
  104. | Self::Redirect {
  105. error: AppError::PiiDecrypt,
  106. ..
  107. }
  108. )
  109. }
  110. /// Render the failure as an HTTP response (page or redirect).
  111. #[must_use]
  112. pub fn into_response(self) -> Response {
  113. match self {
  114. Self::Page(error) => error_page(&error),
  115. Self::Redirect {
  116. target,
  117. error,
  118. state,
  119. } => error_redirect(&target, &error, state.as_deref()),
  120. }
  121. }
  122. }
  123. /// Validate an authorize request following the mandated order.
  124. ///
  125. /// Returns the normalized request when every check passes. Returns
  126. /// [`AuthorizeFailure::Page`] before `redirect_uri` is validated and
  127. /// [`AuthorizeFailure::Redirect`] after; no code is issued on any path.
  128. ///
  129. /// # Errors
  130. ///
  131. /// Returns [`AuthorizeFailure`] as described by the error-delivery matrix.
  132. pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, AuthorizeFailure> {
  133. let client_id = present(input.query.get("client_id"))
  134. .ok_or_else(|| page(AppError::invalid_request("client_id is required")))?;
  135. let client = client_repo::find_active_by_client_id(input.pool, input.keys, client_id)
  136. .await
  137. .map_err(AuthorizeFailure::Page)?
  138. .ok_or_else(|| page(AppError::invalid_client_non_basic()))?;
  139. let jar_input = JarRequest {
  140. client: &client,
  141. issuer: input.issuer,
  142. query: input.query,
  143. request: present(input.query.get("request")).map(String::as_str),
  144. request_uri: present(input.query.get("request_uri")).map(String::as_str),
  145. };
  146. let merged = jar::resolve(input.conn, input.store, &jar_input)
  147. .await
  148. .map_err(AuthorizeFailure::Page)?;
  149. if merged
  150. .get("client_id")
  151. .is_some_and(|seen| seen != &client.client_id)
  152. {
  153. return Err(page(AppError::invalid_request(
  154. "client_id binding mismatch",
  155. )));
  156. }
  157. let redirect_uri = present(merged.get("redirect_uri"))
  158. .ok_or_else(|| page(AppError::invalid_request("redirect_uri is required")))?;
  159. let effective = redirect_repo::effective_for_client(input.pool, input.keys, &client.client_id)
  160. .await
  161. .map_err(AuthorizeFailure::Page)?;
  162. let registered: Vec<&str> = effective.iter().map(|entry| entry.uri.as_str()).collect();
  163. match redirect_match::match_redirect_uri(&registered, redirect_uri) {
  164. redirect_match::RedirectDecision::Exact | redirect_match::RedirectDecision::Loopback => {}
  165. redirect_match::RedirectDecision::Reject => {
  166. return Err(page(AppError::invalid_request(
  167. "redirect_uri is not registered for this client",
  168. )));
  169. }
  170. }
  171. let redirect = redirect_uri.clone();
  172. // From here the redirect target is validated: every failure redirects.
  173. let redirect_err = |error: AppError| AuthorizeFailure::Redirect {
  174. target: redirect.clone(),
  175. error,
  176. state: merged.get("state").cloned(),
  177. };
  178. match present(merged.get("response_type")) {
  179. Some(response_type) if response_type == "code" => {}
  180. Some(_) => {
  181. return Err(redirect_err(AppError::unsupported_response_type(
  182. "only response_type=code is supported",
  183. )));
  184. }
  185. None => {
  186. return Err(page(AppError::invalid_request("response_type is required")));
  187. }
  188. }
  189. let enabled = scope_repo::list_enabled(input.pool)
  190. .await
  191. .map_err(redirect_err)?;
  192. let enabled_names: Vec<String> = enabled.iter().map(|record| record.name.clone()).collect();
  193. let enabled_set = scope_set_of(&enabled_names, &redirect, &merged)?;
  194. let client_registered =
  195. scope::ScopeSet::parse(&client.scope).map_err(|_| redirect_err(AppError::server_error()))?;
  196. let scope_input = scope::ScopeInput {
  197. requested: present(merged.get("scope")).map(String::as_str),
  198. client_registered: &client_registered,
  199. user_entitled: &enabled_set,
  200. enabled: &enabled_set,
  201. grant: GrantKind::AuthorizationCode,
  202. exchange_allowlist: &scope::ScopeSet::empty(),
  203. };
  204. let effective_scope = scope::effective_scope(&scope_input).map_err(redirect_err)?;
  205. let challenge = present(merged.get("code_challenge"))
  206. .ok_or_else(|| redirect_err(AppError::invalid_request("code_challenge is required")))?;
  207. match present(merged.get("code_challenge_method")) {
  208. Some(method) if method == S256_METHOD => {}
  209. Some(_) => {
  210. return Err(redirect_err(AppError::invalid_request(
  211. "only code_challenge_method=S256 is accepted",
  212. )));
  213. }
  214. None => {
  215. return Err(redirect_err(AppError::invalid_request(
  216. "code_challenge_method=S256 is required",
  217. )));
  218. }
  219. }
  220. verify_method(S256_METHOD).map_err(|_| redirect_err(AppError::server_error()))?;
  221. check_challenge_shape(challenge).map_err(redirect_err)?;
  222. let post_logout_uri =
  223. redirect_repo::post_logout_for_client(input.pool, input.keys, &client.client_id)
  224. .await
  225. .map_err(AuthorizeFailure::Page)?;
  226. Ok(ValidatedRequest {
  227. client_id: client.client_id,
  228. redirect_uri: redirect,
  229. scope: effective_scope,
  230. code_challenge: challenge.clone(),
  231. state: merged.get("state").cloned(),
  232. post_logout_uri,
  233. })
  234. }
  235. /// Treat `None` and `""` alike: an absent parameter.
  236. fn present(value: Option<&String>) -> Option<&String> {
  237. value.filter(|text| !text.is_empty())
  238. }
  239. /// Wrap an [`AppError`] as a direct (no-redirect) failure.
  240. fn page(error: AppError) -> AuthorizeFailure {
  241. AuthorizeFailure::Page(error)
  242. }
  243. /// Build an enabled-catalog [`scope::ScopeSet`]; a corrupt catalog row is a
  244. /// `server_error` redirect (the target is already validated here).
  245. fn scope_set_of(
  246. names: &[String],
  247. redirect: &str,
  248. merged: &HashMap<String, String>,
  249. ) -> Result<scope::ScopeSet, AuthorizeFailure> {
  250. scope::ScopeSet::from_names(names).map_err(|_| AuthorizeFailure::Redirect {
  251. target: redirect.to_owned(),
  252. error: AppError::server_error(),
  253. state: merged.get("state").cloned(),
  254. })
  255. }
  256. /// Check the S256 challenge shape (43 base64url characters, no verifier yet).
  257. fn check_challenge_shape(challenge: &str) -> Result<(), AppError> {
  258. let well_formed = challenge.len() == CHALLENGE_LEN
  259. && challenge
  260. .bytes()
  261. .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
  262. if well_formed {
  263. Ok(())
  264. } else {
  265. Err(AppError::invalid_request("malformed code_challenge"))
  266. }
  267. }
  268. /// Render a direct error page: HTTP 400 HTML, no `Location`.
  269. fn error_page(error: &AppError) -> Response {
  270. let body = format!(
  271. "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
  272. <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
  273. <link rel=\"stylesheet\" href=\"/assets/app.css\">\
  274. <title>{code}</title></head><body><h1>{code}</h1><p>{description}</p></body></html>",
  275. code = escape_html(error.code().as_str()),
  276. description = escape_html(error.description()),
  277. );
  278. let mut response = Html(body).into_response();
  279. *response.status_mut() = StatusCode::BAD_REQUEST;
  280. response
  281. }
  282. /// Render an authorize error redirect: HTTP 302 with `error` (+ `state`).
  283. fn error_redirect(target: &str, error: &AppError, state: Option<&str>) -> Response {
  284. let separator = if target.contains('?') { '&' } else { '?' };
  285. let mut location = String::with_capacity(target.len() + 128);
  286. location.push_str(target);
  287. location.push(separator);
  288. location.push_str("error=");
  289. location.push_str(error.code().as_str());
  290. location.push_str("&error_description=");
  291. push_percent_encoded(&mut location, error.description());
  292. if let Some(state) = state {
  293. location.push_str("&state=");
  294. push_percent_encoded(&mut location, state);
  295. }
  296. match header::HeaderValue::from_str(&location) {
  297. Ok(value) => Response::builder()
  298. .status(StatusCode::FOUND)
  299. .header(header::LOCATION, value)
  300. .body(Body::empty())
  301. .unwrap_or_else(|_| error_page(&AppError::server_error())),
  302. Err(_) => error_page(&AppError::server_error()),
  303. }
  304. }
  305. /// Escape the five HTML-significant characters.
  306. fn escape_html(text: &str) -> String {
  307. let mut out = String::with_capacity(text.len());
  308. for char in text.chars() {
  309. match char {
  310. '&' => out.push_str("&amp;"),
  311. '<' => out.push_str("&lt;"),
  312. '>' => out.push_str("&gt;"),
  313. '"' => out.push_str("&quot;"),
  314. '\'' => out.push_str("&#39;"),
  315. _ => out.push(char),
  316. }
  317. }
  318. out
  319. }
  320. /// Append `text` percent-encoded (unreserved characters pass through).
  321. fn push_percent_encoded(out: &mut String, text: &str) {
  322. const HEX: &[u8; 16] = b"0123456789ABCDEF";
  323. for byte in text.bytes() {
  324. if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
  325. out.push(byte as char);
  326. } else {
  327. out.push('%');
  328. out.push(HEX[usize::from(byte >> 4)] as char);
  329. out.push(HEX[usize::from(byte & 0x0F)] as char);
  330. }
  331. }
  332. }
  333. #[cfg(test)]
  334. mod tests;