error.rs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852
  1. //! RFC error model and HTTP responses.
  2. //!
  3. //! `AppError` maps OAuth 2.1, DPoP (RFC 9449 §9), and JAR (RFC 9101 §5)
  4. //! error codes onto the correct HTTP status and a JSON body of the shape
  5. //! `{"error": "...", "error_description": "..."}`.
  6. //!
  7. //! `invalid_client` renders `401 Unauthorized` with a `WWW-Authenticate`
  8. //! challenge when the client used HTTP Basic, and `400 Bad Request`
  9. //! otherwise (RFC 6749 §5.2). Internal failures collapse to `server_error`
  10. //! with a generic description, so database text, backtraces, or secrets can
  11. //! never reach a client.
  12. use std::borrow::Cow;
  13. use std::error::Error as StdError;
  14. use std::fmt;
  15. use axum::Json;
  16. use axum::http::{HeaderValue, StatusCode, header};
  17. use axum::response::{IntoResponse, Response};
  18. use serde::Serialize;
  19. /// `WWW-Authenticate` challenge advertised for a failed HTTP Basic attempt.
  20. const BASIC_CHALLENGE: &str = "Basic realm=\"oauth\"";
  21. /// `WWW-Authenticate` challenge advertised for a failed Bearer token attempt
  22. /// (RFC 6750 §3, DCR initial/registration access tokens).
  23. const BEARER_CHALLENGE: &str = "Bearer realm=\"oauth\"";
  24. /// `Retry-After` value (seconds) sent with `temporarily_unavailable`.
  25. const RETRY_AFTER_SECS: &str = "1";
  26. /// An RFC OAuth error code understood by this authorization server.
  27. ///
  28. /// Codes are grouped by the endpoint that can emit them: authorization
  29. /// endpoint (`invalid_request`, `unauthorized_client`, `access_denied`,
  30. /// `unsupported_response_type`, `invalid_scope`, `server_error`,
  31. /// `temporarily_unavailable`), token endpoint (the authorization set plus
  32. /// `invalid_client`, `invalid_grant`, `unsupported_grant_type`,
  33. /// `invalid_target`), DPoP (RFC 9449: `invalid_dpop_proof`), JAR
  34. /// (RFC 9101: `invalid_request_object`), and dynamic client registration
  35. /// (RFC 7591: `invalid_redirect_uri`, `invalid_client_metadata`).
  36. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
  37. pub enum OAuthErrorCode {
  38. /// The request is malformed or missing a required parameter.
  39. InvalidRequest,
  40. /// The client may not use this grant or response type.
  41. UnauthorizedClient,
  42. /// The resource owner or authorization server denied the request.
  43. AccessDenied,
  44. /// The authorization server does not support this response type.
  45. UnsupportedResponseType,
  46. /// The requested scope is invalid, unknown, or malformed.
  47. InvalidScope,
  48. /// An unexpected server-side condition prevented fulfillment.
  49. ServerError,
  50. /// The server is temporarily overloaded or under maintenance.
  51. TemporarilyUnavailable,
  52. /// Client authentication failed.
  53. InvalidClient,
  54. /// The supplied grant or refresh token is invalid.
  55. InvalidGrant,
  56. /// The grant type is not supported by the server.
  57. UnsupportedGrantType,
  58. /// The requested resource target is invalid or unsupported.
  59. InvalidTarget,
  60. /// The DPoP proof is invalid (RFC 9449 §9).
  61. InvalidDpopProof,
  62. /// The JAR request object is invalid (RFC 9101 §5).
  63. InvalidRequestObject,
  64. /// A requested redirect URI is invalid (RFC 7591 §3.2.2).
  65. InvalidRedirectUri,
  66. /// Client metadata is invalid (RFC 7591 §3.2.2).
  67. InvalidClientMetadata,
  68. }
  69. impl OAuthErrorCode {
  70. /// The wire value of this error code (the `error` member).
  71. #[must_use]
  72. pub fn as_str(self) -> &'static str {
  73. match self {
  74. Self::InvalidRequest => "invalid_request",
  75. Self::UnauthorizedClient => "unauthorized_client",
  76. Self::AccessDenied => "access_denied",
  77. Self::UnsupportedResponseType => "unsupported_response_type",
  78. Self::InvalidScope => "invalid_scope",
  79. Self::ServerError => "server_error",
  80. Self::TemporarilyUnavailable => "temporarily_unavailable",
  81. Self::InvalidClient => "invalid_client",
  82. Self::InvalidGrant => "invalid_grant",
  83. Self::UnsupportedGrantType => "unsupported_grant_type",
  84. Self::InvalidTarget => "invalid_target",
  85. Self::InvalidDpopProof => "invalid_dpop_proof",
  86. Self::InvalidRequestObject => "invalid_request_object",
  87. Self::InvalidRedirectUri => "invalid_redirect_uri",
  88. Self::InvalidClientMetadata => "invalid_client_metadata",
  89. }
  90. }
  91. /// The standard, client-safe description for this code.
  92. ///
  93. /// These strings are fixed and contain no request-specific data.
  94. #[must_use]
  95. pub fn default_description(self) -> &'static str {
  96. match self {
  97. Self::InvalidRequest => {
  98. "The request is missing a required parameter, includes an invalid parameter value, \
  99. includes a parameter more than once, or is otherwise malformed."
  100. }
  101. Self::UnauthorizedClient => {
  102. "The client is not authorized to request an authorization code using this method."
  103. }
  104. Self::AccessDenied => "The resource owner or authorization server denied the request.",
  105. Self::UnsupportedResponseType => {
  106. "The authorization server does not support obtaining an authorization code using this \
  107. method."
  108. }
  109. Self::InvalidScope => "The requested scope is invalid, unknown, or malformed.",
  110. Self::ServerError => {
  111. "The authorization server encountered an unexpected condition that prevented it from \
  112. fulfilling the request."
  113. }
  114. Self::TemporarilyUnavailable => {
  115. "The authorization server is currently unable to handle the request due to a temporary \
  116. overloading or maintenance of the server."
  117. }
  118. Self::InvalidClient => {
  119. "Client authentication failed (for example, unknown client, no client authentication \
  120. included, or unsupported authentication method)."
  121. }
  122. Self::InvalidGrant => {
  123. "The provided authorization grant or refresh token is invalid, expired, revoked, does \
  124. not match the redirection URI used in the authorization request, or was issued to \
  125. another client."
  126. }
  127. Self::UnsupportedGrantType => {
  128. "The authorization grant type is not supported by the authorization server."
  129. }
  130. Self::InvalidTarget => {
  131. "The requested resource is invalid, unknown, malformed, or otherwise not supported by \
  132. the authorization server."
  133. }
  134. Self::InvalidDpopProof => "The DPoP proof is invalid.",
  135. Self::InvalidRequestObject => "The request object is invalid.",
  136. Self::InvalidRedirectUri => "The requested redirect URI is invalid.",
  137. Self::InvalidClientMetadata => "The client metadata is invalid.",
  138. }
  139. }
  140. }
  141. impl fmt::Display for OAuthErrorCode {
  142. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  143. f.write_str(self.as_str())
  144. }
  145. }
  146. /// How a client presented its credentials, which selects the HTTP status
  147. /// used for an `invalid_client` response.
  148. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
  149. pub enum ClientAuthMethod {
  150. /// Credentials came from the request body, or none were presented.
  151. #[default]
  152. NonBasic,
  153. /// Credentials came from the `Authorization: Basic` header.
  154. Basic,
  155. /// A Bearer token (DCR initial or registration access token) was presented.
  156. Bearer,
  157. }
  158. /// An error that can be rendered as an OAuth error response or masked as an
  159. /// internal server failure.
  160. #[derive(Debug, thiserror::Error)]
  161. pub enum AppError {
  162. /// A protocol error that is safe to render to the client.
  163. #[error("{code}: {description}")]
  164. OAuth {
  165. /// The RFC error code.
  166. code: OAuthErrorCode,
  167. /// A client-safe human-readable description.
  168. description: Cow<'static, str>,
  169. /// How the client authenticated; selects the `invalid_client` status.
  170. client_auth: ClientAuthMethod,
  171. },
  172. /// An internal failure; the cause is never rendered to the client.
  173. #[error("internal server error")]
  174. Internal {
  175. /// Underlying cause, retained for server-side logging only.
  176. #[source]
  177. source: Box<dyn StdError + Send + Sync>,
  178. },
  179. /// A unique email collided (pre-check or `ux_users_email[_index]` 23505).
  180. ///
  181. /// Payload-free by construction (unit variant): no email ever reaches a
  182. /// log, a metric label, or an HTTP body through this variant.
  183. #[error("duplicate email")]
  184. DuplicateEmail,
  185. /// A stored PII ciphertext failed to open (tampered or corrupt).
  186. ///
  187. /// Fail-closed `temporarily_unavailable` (503), distinguishable from a
  188. /// database-outage 503 by the variant. Payload-free by construction.
  189. #[error("PII field cannot be decrypted")]
  190. PiiDecrypt,
  191. /// A client mTLS identity collided (pre-check or `ux_oauth_clients_tls_*`
  192. /// 23505).
  193. ///
  194. /// Payload-free by construction (unit variant): no DN or thumbprint value
  195. /// ever reaches a log, a metric label, or an HTTP body through this variant.
  196. #[error("duplicate client identity")]
  197. DuplicateClientIdentity,
  198. }
  199. impl AppError {
  200. /// Build a protocol error from a code and an explicit, client-safe
  201. /// description.
  202. ///
  203. /// Callers MUST NOT pass tokens, secrets, or raw dependency errors here.
  204. #[must_use]
  205. pub fn new(code: OAuthErrorCode, description: impl Into<Cow<'static, str>>) -> Self {
  206. Self::OAuth {
  207. code,
  208. description: description.into(),
  209. client_auth: ClientAuthMethod::NonBasic,
  210. }
  211. }
  212. /// Build a protocol error using the code's standard RFC description.
  213. #[must_use]
  214. pub fn from_code(code: OAuthErrorCode) -> Self {
  215. Self::new(code, code.default_description())
  216. }
  217. /// `invalid_request` (authorize and token endpoints).
  218. #[must_use]
  219. pub fn invalid_request(description: impl Into<Cow<'static, str>>) -> Self {
  220. Self::new(OAuthErrorCode::InvalidRequest, description)
  221. }
  222. /// `unauthorized_client` (authorize and token endpoints).
  223. #[must_use]
  224. pub fn unauthorized_client(description: impl Into<Cow<'static, str>>) -> Self {
  225. Self::new(OAuthErrorCode::UnauthorizedClient, description)
  226. }
  227. /// `access_denied` (authorize endpoint).
  228. #[must_use]
  229. pub fn access_denied(description: impl Into<Cow<'static, str>>) -> Self {
  230. Self::new(OAuthErrorCode::AccessDenied, description)
  231. }
  232. /// `unsupported_response_type` (authorize endpoint).
  233. #[must_use]
  234. pub fn unsupported_response_type(description: impl Into<Cow<'static, str>>) -> Self {
  235. Self::new(OAuthErrorCode::UnsupportedResponseType, description)
  236. }
  237. /// `invalid_scope` (authorize and token endpoints).
  238. #[must_use]
  239. pub fn invalid_scope(description: impl Into<Cow<'static, str>>) -> Self {
  240. Self::new(OAuthErrorCode::InvalidScope, description)
  241. }
  242. /// `server_error`, rendered as `500 Internal Server Error`.
  243. #[must_use]
  244. pub fn server_error() -> Self {
  245. Self::from_code(OAuthErrorCode::ServerError)
  246. }
  247. /// `temporarily_unavailable`, rendered as `503 Service Unavailable` with a
  248. /// `Retry-After` header (fail-closed dependency policy).
  249. #[must_use]
  250. pub fn temporarily_unavailable() -> Self {
  251. Self::from_code(OAuthErrorCode::TemporarilyUnavailable)
  252. }
  253. /// `invalid_client`, with the status selected by how the client
  254. /// authenticated.
  255. #[must_use]
  256. pub fn invalid_client(client_auth: ClientAuthMethod) -> Self {
  257. Self::OAuth {
  258. code: OAuthErrorCode::InvalidClient,
  259. description: Cow::Borrowed(OAuthErrorCode::InvalidClient.default_description()),
  260. client_auth,
  261. }
  262. }
  263. /// `invalid_client` after HTTP Basic authentication (`401` +
  264. /// `WWW-Authenticate`).
  265. #[must_use]
  266. pub fn invalid_client_basic() -> Self {
  267. Self::invalid_client(ClientAuthMethod::Basic)
  268. }
  269. /// `invalid_client` without HTTP Basic authentication (`400`).
  270. #[must_use]
  271. pub fn invalid_client_non_basic() -> Self {
  272. Self::invalid_client(ClientAuthMethod::NonBasic)
  273. }
  274. /// `invalid_client` after a Bearer token authentication attempt (`401` +
  275. /// `WWW-Authenticate: Bearer`).
  276. #[must_use]
  277. pub fn invalid_client_bearer() -> Self {
  278. Self::invalid_client(ClientAuthMethod::Bearer)
  279. }
  280. /// `invalid_redirect_uri` (dynamic client registration, RFC 7591 §3.2.2).
  281. #[must_use]
  282. pub fn invalid_redirect_uri(description: impl Into<Cow<'static, str>>) -> Self {
  283. Self::new(OAuthErrorCode::InvalidRedirectUri, description)
  284. }
  285. /// `invalid_client_metadata` (dynamic client registration, RFC 7591 §3.2.2).
  286. #[must_use]
  287. pub fn invalid_client_metadata(description: impl Into<Cow<'static, str>>) -> Self {
  288. Self::new(OAuthErrorCode::InvalidClientMetadata, description)
  289. }
  290. /// `invalid_grant` (token endpoint).
  291. #[must_use]
  292. pub fn invalid_grant(description: impl Into<Cow<'static, str>>) -> Self {
  293. Self::new(OAuthErrorCode::InvalidGrant, description)
  294. }
  295. /// `unsupported_grant_type` (token endpoint).
  296. #[must_use]
  297. pub fn unsupported_grant_type(description: impl Into<Cow<'static, str>>) -> Self {
  298. Self::new(OAuthErrorCode::UnsupportedGrantType, description)
  299. }
  300. /// `invalid_target` (token endpoint, resource indicators).
  301. #[must_use]
  302. pub fn invalid_target(description: impl Into<Cow<'static, str>>) -> Self {
  303. Self::new(OAuthErrorCode::InvalidTarget, description)
  304. }
  305. /// `invalid_dpop_proof` (RFC 9449 §9).
  306. #[must_use]
  307. pub fn invalid_dpop_proof(description: impl Into<Cow<'static, str>>) -> Self {
  308. Self::new(OAuthErrorCode::InvalidDpopProof, description)
  309. }
  310. /// `invalid_request_object` (RFC 9101 §5).
  311. #[must_use]
  312. pub fn invalid_request_object(description: impl Into<Cow<'static, str>>) -> Self {
  313. Self::new(OAuthErrorCode::InvalidRequestObject, description)
  314. }
  315. /// Wrap an internal failure. The source is kept for logging but is never
  316. /// serialized; the client only sees `server_error` with a generic
  317. /// description.
  318. #[must_use]
  319. pub fn internal<E>(source: E) -> Self
  320. where
  321. E: StdError + Send + Sync + 'static,
  322. {
  323. Self::Internal {
  324. source: Box::new(source),
  325. }
  326. }
  327. /// The RFC error code this error renders as. Internal failures map to
  328. /// `server_error`; a duplicate email maps to `invalid_request`, a duplicate
  329. /// client mTLS identity maps to `invalid_client_metadata`, and a PII
  330. /// decrypt failure maps to `temporarily_unavailable`.
  331. #[must_use]
  332. pub fn code(&self) -> OAuthErrorCode {
  333. match self {
  334. Self::OAuth { code, .. } => *code,
  335. Self::DuplicateEmail => OAuthErrorCode::InvalidRequest,
  336. Self::DuplicateClientIdentity => OAuthErrorCode::InvalidClientMetadata,
  337. Self::PiiDecrypt => OAuthErrorCode::TemporarilyUnavailable,
  338. Self::Internal { .. } => OAuthErrorCode::ServerError,
  339. }
  340. }
  341. /// The HTTP status this error renders as.
  342. #[must_use]
  343. pub fn status(&self) -> StatusCode {
  344. match self {
  345. Self::OAuth {
  346. code, client_auth, ..
  347. } => status_for(*code, *client_auth),
  348. Self::DuplicateEmail => {
  349. status_for(OAuthErrorCode::InvalidRequest, ClientAuthMethod::NonBasic)
  350. }
  351. Self::DuplicateClientIdentity => status_for(
  352. OAuthErrorCode::InvalidClientMetadata,
  353. ClientAuthMethod::NonBasic,
  354. ),
  355. Self::PiiDecrypt => status_for(
  356. OAuthErrorCode::TemporarilyUnavailable,
  357. ClientAuthMethod::NonBasic,
  358. ),
  359. Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
  360. }
  361. }
  362. /// The client-safe description this error renders as.
  363. #[must_use]
  364. pub fn description(&self) -> &str {
  365. match self {
  366. Self::OAuth { description, .. } => description,
  367. Self::DuplicateEmail => "The email address is already in use.",
  368. Self::DuplicateClientIdentity => OAuthErrorCode::InvalidClientMetadata.default_description(),
  369. Self::PiiDecrypt => OAuthErrorCode::TemporarilyUnavailable.default_description(),
  370. Self::Internal { .. } => OAuthErrorCode::ServerError.default_description(),
  371. }
  372. }
  373. }
  374. /// Map an error code (and, for `invalid_client`, the auth method) to an HTTP
  375. /// status.
  376. fn status_for(code: OAuthErrorCode, client_auth: ClientAuthMethod) -> StatusCode {
  377. match (code, client_auth) {
  378. (OAuthErrorCode::InvalidClient, ClientAuthMethod::Basic)
  379. | (OAuthErrorCode::InvalidClient, ClientAuthMethod::Bearer) => StatusCode::UNAUTHORIZED,
  380. (OAuthErrorCode::ServerError, _) => StatusCode::INTERNAL_SERVER_ERROR,
  381. (OAuthErrorCode::TemporarilyUnavailable, _) => StatusCode::SERVICE_UNAVAILABLE,
  382. _ => StatusCode::BAD_REQUEST,
  383. }
  384. }
  385. /// The JSON error body: `{error, error_description}`.
  386. #[derive(Debug, Serialize)]
  387. struct OAuthErrorBody<'a> {
  388. error: &'a str,
  389. error_description: &'a str,
  390. }
  391. impl IntoResponse for AppError {
  392. fn into_response(self) -> Response {
  393. let (code, description, client_auth) = match self {
  394. Self::OAuth {
  395. code,
  396. description,
  397. client_auth,
  398. } => (code, description, client_auth),
  399. // Payload-free typed errors render fixed codes and descriptions.
  400. Self::DuplicateEmail => (
  401. OAuthErrorCode::InvalidRequest,
  402. Cow::Borrowed("The email address is already in use."),
  403. ClientAuthMethod::NonBasic,
  404. ),
  405. Self::DuplicateClientIdentity => (
  406. OAuthErrorCode::InvalidClientMetadata,
  407. Cow::Borrowed(OAuthErrorCode::InvalidClientMetadata.default_description()),
  408. ClientAuthMethod::NonBasic,
  409. ),
  410. Self::PiiDecrypt => (
  411. OAuthErrorCode::TemporarilyUnavailable,
  412. Cow::Borrowed(OAuthErrorCode::TemporarilyUnavailable.default_description()),
  413. ClientAuthMethod::NonBasic,
  414. ),
  415. // Mask every internal failure: generic code + generic description.
  416. Self::Internal { .. } => (
  417. OAuthErrorCode::ServerError,
  418. Cow::Borrowed(OAuthErrorCode::ServerError.default_description()),
  419. ClientAuthMethod::NonBasic,
  420. ),
  421. };
  422. let body = OAuthErrorBody {
  423. error: code.as_str(),
  424. error_description: description.as_ref(),
  425. };
  426. let mut response = (status_for(code, client_auth), Json(body)).into_response();
  427. response
  428. .headers_mut()
  429. .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
  430. response
  431. .headers_mut()
  432. .insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
  433. if code == OAuthErrorCode::InvalidClient && client_auth == ClientAuthMethod::Basic {
  434. response.headers_mut().insert(
  435. header::WWW_AUTHENTICATE,
  436. HeaderValue::from_static(BASIC_CHALLENGE),
  437. );
  438. }
  439. if code == OAuthErrorCode::InvalidClient && client_auth == ClientAuthMethod::Bearer {
  440. response.headers_mut().insert(
  441. header::WWW_AUTHENTICATE,
  442. HeaderValue::from_static(BEARER_CHALLENGE),
  443. );
  444. }
  445. if code == OAuthErrorCode::TemporarilyUnavailable {
  446. response.headers_mut().insert(
  447. header::RETRY_AFTER,
  448. HeaderValue::from_static(RETRY_AFTER_SECS),
  449. );
  450. }
  451. response
  452. }
  453. }
  454. impl From<sqlx::Error> for AppError {
  455. /// Map a database error to a client-safe error: connectivity failures
  456. /// (pool timeout/close, transport I/O, and an outage SQLSTATE such as a
  457. /// server shutdown mid-query) render `503 temporarily_unavailable` (fail
  458. /// closed, matching the Redis mapping); a unique violation on the
  459. /// user-email constraints renders the payload-free [`AppError::DuplicateEmail`];
  460. /// a unique violation on any client mTLS identity constraint (legacy
  461. /// plaintext or blind-index) renders the payload-free
  462. /// [`AppError::DuplicateClientIdentity`]; every other genuine query or
  463. /// logic failure stays a masked `server_error` that never leaks database text.
  464. fn from(source: sqlx::Error) -> Self {
  465. match source {
  466. sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed | sqlx::Error::Io(_) => {
  467. Self::temporarily_unavailable()
  468. }
  469. sqlx::Error::Database(db) if is_dependency_outage(db.code().as_deref()) => {
  470. Self::temporarily_unavailable()
  471. }
  472. sqlx::Error::Database(db)
  473. if db.code().as_deref() == Some("23505")
  474. && matches!(
  475. db.constraint(),
  476. Some("ux_users_email") | Some("ux_users_email_index")
  477. ) =>
  478. {
  479. Self::DuplicateEmail
  480. }
  481. sqlx::Error::Database(db)
  482. if db.code().as_deref() == Some("23505")
  483. && matches!(
  484. db.constraint(),
  485. Some("ux_oauth_clients_tls_subject_dn")
  486. | Some("ux_oauth_clients_tls_cert_thumbprint")
  487. | Some("ux_oauth_clients_tls_subject_dn_index")
  488. | Some("ux_oauth_clients_tls_cert_thumbprint_index")
  489. ) =>
  490. {
  491. Self::DuplicateClientIdentity
  492. }
  493. other => Self::internal(other),
  494. }
  495. }
  496. }
  497. /// SQLSTATE codes proving the Postgres dependency is transiently unavailable
  498. /// (a failover, restart, or shutdown) rather than the statement being wrong.
  499. /// These must fail closed as `503 temporarily_unavailable`, never a masked
  500. /// `500`, matching the connectivity arms above and the Redis transient class.
  501. ///
  502. /// - class `08` — Connection Exception (`08000`, `08003`, `08006`, `08P01`, …)
  503. /// - `57P01` admin_shutdown, `57P02` crash_shutdown, `57P03` cannot_connect_now,
  504. /// `57P04` database_dropped
  505. /// - `53300` too_many_connections
  506. fn is_dependency_outage(code: Option<&str>) -> bool {
  507. let Some(code) = code else {
  508. return false;
  509. };
  510. code.starts_with("08") || matches!(code, "57P01" | "57P02" | "57P03" | "57P04" | "53300")
  511. }
  512. impl From<crate::crypto::field::FieldError> for AppError {
  513. /// Map a PII field failure fail-closed: a blob that fails to open becomes
  514. /// the distinguishable [`AppError::PiiDecrypt`] (503); a seal or RNG
  515. /// failure becomes generic `temporarily_unavailable` (503). Neither carries
  516. /// plaintext or key material.
  517. fn from(error: crate::crypto::field::FieldError) -> Self {
  518. match error {
  519. crate::crypto::field::FieldError::Decrypt => Self::PiiDecrypt,
  520. crate::crypto::field::FieldError::Encrypt
  521. | crate::crypto::field::FieldError::UnavailableRandomness => Self::temporarily_unavailable(),
  522. }
  523. }
  524. }
  525. #[cfg(test)]
  526. mod tests {
  527. use super::*;
  528. use axum::body::Bytes;
  529. use axum::http::{HeaderMap, header};
  530. use http_body_util::BodyExt as _;
  531. use serde_json::Value;
  532. /// Drive an `AppError` through `IntoResponse` and collect its status,
  533. /// headers, and parsed JSON body.
  534. async fn render(err: AppError) -> (StatusCode, HeaderMap, Value) {
  535. let response = err.into_response();
  536. let status = response.status();
  537. let headers = response.headers().clone();
  538. let bytes = match response.into_body().collect().await {
  539. Ok(collected) => collected.to_bytes(),
  540. Err(_) => Bytes::new(),
  541. };
  542. let body = serde_json::from_slice::<Value>(&bytes).unwrap_or(Value::Null);
  543. (status, headers, body)
  544. }
  545. /// Every error body forbids caching (RFC 6749 §5.1, draft-16 §3.2.3).
  546. #[tokio::test]
  547. async fn error_responses_are_not_cacheable() {
  548. let (_, headers, _) = render(AppError::invalid_request("nope")).await;
  549. let value = |name: header::HeaderName| {
  550. headers
  551. .get(name)
  552. .and_then(|value| value.to_str().ok())
  553. .unwrap_or_default()
  554. };
  555. assert_eq!(value(header::CACHE_CONTROL), "no-store");
  556. assert_eq!(value(header::PRAGMA), "no-cache");
  557. }
  558. #[tokio::test]
  559. async fn error_response_codes_and_statuses() {
  560. let cases: [(AppError, OAuthErrorCode, StatusCode); 14] = [
  561. (
  562. AppError::invalid_request("bad request"),
  563. OAuthErrorCode::InvalidRequest,
  564. StatusCode::BAD_REQUEST,
  565. ),
  566. (
  567. AppError::unauthorized_client("not allowed"),
  568. OAuthErrorCode::UnauthorizedClient,
  569. StatusCode::BAD_REQUEST,
  570. ),
  571. (
  572. AppError::access_denied("denied"),
  573. OAuthErrorCode::AccessDenied,
  574. StatusCode::BAD_REQUEST,
  575. ),
  576. (
  577. AppError::unsupported_response_type("unsupported"),
  578. OAuthErrorCode::UnsupportedResponseType,
  579. StatusCode::BAD_REQUEST,
  580. ),
  581. (
  582. AppError::invalid_scope("bad scope"),
  583. OAuthErrorCode::InvalidScope,
  584. StatusCode::BAD_REQUEST,
  585. ),
  586. (
  587. AppError::server_error(),
  588. OAuthErrorCode::ServerError,
  589. StatusCode::INTERNAL_SERVER_ERROR,
  590. ),
  591. (
  592. AppError::temporarily_unavailable(),
  593. OAuthErrorCode::TemporarilyUnavailable,
  594. StatusCode::SERVICE_UNAVAILABLE,
  595. ),
  596. (
  597. AppError::invalid_client_non_basic(),
  598. OAuthErrorCode::InvalidClient,
  599. StatusCode::BAD_REQUEST,
  600. ),
  601. (
  602. AppError::invalid_client_basic(),
  603. OAuthErrorCode::InvalidClient,
  604. StatusCode::UNAUTHORIZED,
  605. ),
  606. (
  607. AppError::invalid_grant("bad grant"),
  608. OAuthErrorCode::InvalidGrant,
  609. StatusCode::BAD_REQUEST,
  610. ),
  611. (
  612. AppError::unsupported_grant_type("bad grant type"),
  613. OAuthErrorCode::UnsupportedGrantType,
  614. StatusCode::BAD_REQUEST,
  615. ),
  616. (
  617. AppError::invalid_target("bad target"),
  618. OAuthErrorCode::InvalidTarget,
  619. StatusCode::BAD_REQUEST,
  620. ),
  621. (
  622. AppError::invalid_dpop_proof("bad proof"),
  623. OAuthErrorCode::InvalidDpopProof,
  624. StatusCode::BAD_REQUEST,
  625. ),
  626. (
  627. AppError::invalid_request_object("bad object"),
  628. OAuthErrorCode::InvalidRequestObject,
  629. StatusCode::BAD_REQUEST,
  630. ),
  631. ];
  632. for (err, code, status) in cases {
  633. assert_eq!(err.code(), code, "code mismatch (expected {code})");
  634. assert_eq!(
  635. err.status(),
  636. status,
  637. "in-memory status mismatch (code {code})"
  638. );
  639. let (rendered, _headers, body) = render(err).await;
  640. assert_eq!(rendered, status, "rendered status mismatch (code {code})");
  641. assert_eq!(
  642. body.get("error").and_then(|value| value.as_str()),
  643. Some(code.as_str())
  644. );
  645. assert!(
  646. body
  647. .get("error_description")
  648. .and_then(|value| value.as_str())
  649. .is_some(),
  650. "error_description missing (code {code})"
  651. );
  652. }
  653. }
  654. #[tokio::test]
  655. async fn error_response_invalid_client_basic_sets_www_authenticate() {
  656. let (status, headers, body) = render(AppError::invalid_client_basic()).await;
  657. assert_eq!(status, StatusCode::UNAUTHORIZED);
  658. assert_eq!(
  659. headers
  660. .get(header::WWW_AUTHENTICATE)
  661. .and_then(|value| value.to_str().ok()),
  662. Some(BASIC_CHALLENGE)
  663. );
  664. assert_eq!(
  665. body.get("error").and_then(|value| value.as_str()),
  666. Some("invalid_client")
  667. );
  668. }
  669. #[tokio::test]
  670. async fn error_response_invalid_client_non_basic_is_400() {
  671. let (status, headers, body) = render(AppError::invalid_client_non_basic()).await;
  672. assert_eq!(status, StatusCode::BAD_REQUEST);
  673. assert!(headers.get(header::WWW_AUTHENTICATE).is_none());
  674. assert_eq!(
  675. body.get("error").and_then(|value| value.as_str()),
  676. Some("invalid_client")
  677. );
  678. }
  679. #[tokio::test]
  680. async fn error_response_temporarily_unavailable_sets_retry_after() {
  681. let (status, headers, body) = render(AppError::temporarily_unavailable()).await;
  682. assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
  683. assert_eq!(
  684. headers
  685. .get(header::RETRY_AFTER)
  686. .and_then(|value| value.to_str().ok()),
  687. Some(RETRY_AFTER_SECS)
  688. );
  689. assert_eq!(
  690. body.get("error").and_then(|value| value.as_str()),
  691. Some("temporarily_unavailable")
  692. );
  693. }
  694. #[tokio::test]
  695. async fn error_response_internal_sqlx_masks_db_detail() {
  696. let marker = "pg-secret-marked-detail-7d3a";
  697. let err = AppError::from(sqlx::Error::Protocol(marker.to_owned()));
  698. assert_eq!(err.code(), OAuthErrorCode::ServerError);
  699. assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
  700. let (status, _headers, body) = render(err).await;
  701. assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
  702. assert_eq!(
  703. body.get("error").and_then(|value| value.as_str()),
  704. Some("server_error")
  705. );
  706. assert!(
  707. body
  708. .get("error_description")
  709. .and_then(|value| value.as_str())
  710. .is_some()
  711. );
  712. let rendered = body.to_string();
  713. assert!(!rendered.contains(marker), "DB detail leaked: {rendered}");
  714. assert!(
  715. !rendered.to_ascii_lowercase().contains("protocol"),
  716. "dependency error text leaked: {rendered}"
  717. );
  718. }
  719. #[tokio::test]
  720. async fn error_sqlx_connectivity_maps_to_503() {
  721. let cases = [
  722. sqlx::Error::PoolTimedOut,
  723. sqlx::Error::PoolClosed,
  724. sqlx::Error::Io(std::io::Error::new(
  725. std::io::ErrorKind::ConnectionRefused,
  726. "refused",
  727. )),
  728. ];
  729. for source in cases {
  730. let err = AppError::from(source);
  731. assert_eq!(err.code(), OAuthErrorCode::TemporarilyUnavailable);
  732. assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
  733. let (status, headers, body) = render(err).await;
  734. assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
  735. assert_eq!(
  736. headers
  737. .get(header::RETRY_AFTER)
  738. .and_then(|value| value.to_str().ok()),
  739. Some(RETRY_AFTER_SECS)
  740. );
  741. assert_eq!(
  742. body.get("error").and_then(|value| value.as_str()),
  743. Some("temporarily_unavailable")
  744. );
  745. }
  746. }
  747. #[test]
  748. fn sqlx_outage_sqlstate_classification() {
  749. for code in [
  750. "08000", "08003", "08006", "08P01", "57P01", "57P02", "57P03", "57P04", "53300",
  751. ] {
  752. assert!(is_dependency_outage(Some(code)), "{code} must be an outage");
  753. }
  754. for code in ["23505", "42P01", "42601", "28P01", "57014", "40001"] {
  755. assert!(
  756. !is_dependency_outage(Some(code)),
  757. "{code} must not be an outage"
  758. );
  759. }
  760. assert!(!is_dependency_outage(None));
  761. }
  762. #[tokio::test]
  763. async fn error_response_invalid_client_snapshot() {
  764. let (status, headers, body) = render(AppError::invalid_client_basic()).await;
  765. let challenge = headers
  766. .get(header::WWW_AUTHENTICATE)
  767. .and_then(|value| value.to_str().ok())
  768. .unwrap_or_default();
  769. let snapshot = serde_json::json!({
  770. "status": status.as_u16(),
  771. "www_authenticate": challenge,
  772. "body": body,
  773. });
  774. // The rendered response must never carry secret material.
  775. assert!(!snapshot.to_string().to_ascii_lowercase().contains("secret"));
  776. // `insta`'s `json` feature is not enabled in this workspace (and
  777. // `Cargo.toml` is out of scope for this task), so the snapshot is stored
  778. // as pretty-printed JSON via the always-available `assert_snapshot!`.
  779. let rendered_snapshot = serde_json::to_string_pretty(&snapshot).unwrap_or_default();
  780. insta::with_settings!({ prepend_module_to_snapshot => false }, {
  781. insta::assert_snapshot!("error_invalid_client", rendered_snapshot);
  782. });
  783. }
  784. }