cimd.rs 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930
  1. //! Client ID Metadata Documents (CIMD) — fetch, validate, and cache.
  2. //!
  3. //! A registration-free client identifies itself with an `https://` URL as its
  4. //! `client_id`; the authorization server fetches that URL and validates the
  5. //! returned JSON document before treating it as client metadata. This module
  6. //! owns the fetch and the strict validation rules; the request-path wiring
  7. //! (authorize/token/client auth) lives in the callers.
  8. //!
  9. //! Strict rules (CIMD draft §4.1/§5/§8.6/§8.7):
  10. //!
  11. //! - The document URL is fetched through the single guarded fetcher
  12. //! [`ssrf::fetch_guarded_bytes_with`]: `https` only, no redirects, special-
  13. //! use/private/loopback targets rejected, the configured timeout, and the
  14. //! configured byte cap.
  15. //! - The response MUST be a JSON object whose `client_id` is byte-for-byte the
  16. //! fetched URL.
  17. //! - `token_endpoint_auth_method` MUST be `none` (the default when absent) or
  18. //! `private_key_jwt`. Every shared-secret and mTLS method is rejected
  19. //! (CIMD §4.1: metadata documents describe clients that cannot hold a
  20. //! shared secret).
  21. //! - `private_key_jwt` REQUIRES an INLINE `jwks` object with a non-empty
  22. //! `keys` array. A document that supplies only `jwks_uri` is rejected: the
  23. //! decision here is inline-only so client authentication never triggers a
  24. //! second network fetch on the token path.
  25. //! - `redirect_uris` MUST be a non-empty array. Every URI must be `https`, or
  26. //! plaintext `http` to an RFC 8252 §7.3 loopback host (`127.0.0.1`, `[::1]`,
  27. //! and `localhost` only while `allow_localhost` is set). The checks delegate
  28. //! to [`redirect_repo::match_redirect_uri`] so CIMD and registered clients
  29. //! share exactly one loopback rule. Wildcards are never accepted.
  30. //! - `scope` MUST be a subset of the enabled catalog and `grant_types` a
  31. //! subset of `{authorization_code, refresh_token}`.
  32. //!
  33. //! A validated document is cached for 15 minutes under `{state}` as
  34. //! `cimd:<sha256(url)>`. The cache stores the raw document; it is revalidated
  35. //! on every read so a scope disabled during the TTL takes effect immediately.
  36. //!
  37. //! This module never writes an `/register` row and never consumes an initial
  38. //! access token; it only resolves metadata.
  39. #[cfg(test)]
  40. mod tests;
  41. #[cfg(test)]
  42. mod tests_principal;
  43. use std::{collections::HashSet, sync::LazyLock, time::Duration};
  44. use serde::{Deserialize, Serialize};
  45. use sqlx::PgPool;
  46. use crate::{
  47. db::{client_repo, scope_repo},
  48. error::AppError,
  49. keys::KeyRing,
  50. protocol::{
  51. redirect_repo::{self, RedirectDecision},
  52. ssrf,
  53. },
  54. state::RedisConn,
  55. store::{StoreError, sha256_hex},
  56. };
  57. /// How long a validated CIMD document stays cached (seconds).
  58. const CACHE_TTL_SECS: u64 = 15 * 60;
  59. /// How long a fetch failure stays negatively cached (seconds).
  60. const NEG_CACHE_TTL_SECS: u64 = 30;
  61. /// How long a fresh-fetch validation failure stays short-cached (seconds).
  62. /// This is a single-flight completion marker: it stops every waiter from
  63. /// re-fetching a deterministically invalid document, while a corrected
  64. /// document is seen within this short window.
  65. const INVALID_CACHE_TTL_SECS: u64 = 2;
  66. /// Extra lifetime on the single-flight lock beyond the fetch budget: the
  67. /// elected fetcher finishes its bounded fetch and cache write inside this.
  68. const LOCK_TTL_MARGIN: Duration = Duration::from_secs(1);
  69. /// Extra wait beyond the fetch budget before a waiter tries to acquire the
  70. /// lock itself: longer than [`LOCK_TTL_MARGIN`] so an abandoned lock expires.
  71. const LOCK_WAIT_MARGIN: Duration = Duration::from_millis(1_500);
  72. /// Waiter poll interval for the positive and negative cache.
  73. const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(25);
  74. /// Grant types a CIMD client may declare (CIMD §4.1; no device or
  75. /// client-credentials grants in a registration-free document).
  76. const ALLOWED_GRANTS: [&str; 2] = ["authorization_code", "refresh_token"];
  77. /// A CIMD document as returned by the `client_id` URL.
  78. ///
  79. /// Unknown members are ignored, so a document may carry additional metadata
  80. /// (logos, contacts, policy URIs) without failing validation. Only the members
  81. /// the server acts on are modeled here.
  82. #[derive(Debug, Clone, Deserialize, Serialize)]
  83. pub struct CimdDocument {
  84. /// The client identifier; MUST equal the fetched URL.
  85. pub client_id: String,
  86. /// Registered redirect URIs.
  87. pub redirect_uris: Vec<String>,
  88. /// Token-endpoint authentication method (`none` when absent).
  89. #[serde(default)]
  90. pub token_endpoint_auth_method: Option<String>,
  91. /// Requested grant types (default `["authorization_code"]`).
  92. #[serde(default)]
  93. pub grant_types: Option<Vec<String>>,
  94. /// Space-delimited scope bound for the client.
  95. #[serde(default)]
  96. pub scope: Option<String>,
  97. /// Inline JWKS (required for `private_key_jwt`).
  98. #[serde(default)]
  99. pub jwks: Option<serde_json::Value>,
  100. /// Remote JWKS URI. Rejected: inline-only by decision (no second fetch).
  101. #[serde(default)]
  102. pub jwks_uri: Option<String>,
  103. /// Human-readable client name.
  104. #[serde(default)]
  105. pub client_name: Option<String>,
  106. /// Whether the client opts into DPoP-bound access tokens.
  107. #[serde(default)]
  108. pub dpop_bound_access_tokens: Option<bool>,
  109. }
  110. /// Client authentication method accepted from a CIMD document.
  111. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  112. pub enum CimdAuthMethod {
  113. /// Public client (the registration-free default).
  114. None,
  115. /// `private_key_jwt` with an inline JWKS.
  116. PrivateKeyJwt,
  117. }
  118. impl CimdAuthMethod {
  119. /// The wire value stored in the synthetic client's
  120. /// `token_endpoint_auth_method`.
  121. #[must_use]
  122. pub const fn as_str(self) -> &'static str {
  123. match self {
  124. Self::None => "none",
  125. Self::PrivateKeyJwt => "private_key_jwt",
  126. }
  127. }
  128. }
  129. /// The validated subset of a CIMD document, ready for request handling.
  130. #[derive(Debug, Clone)]
  131. pub struct ValidatedCimd {
  132. /// The client identifier (equals the fetched URL).
  133. pub client_id: String,
  134. /// Redirect URIs declared by the document.
  135. pub redirect_uris: Vec<String>,
  136. /// Accepted client authentication method.
  137. pub token_endpoint_auth_method: CimdAuthMethod,
  138. /// Deduplicated grant types.
  139. pub grant_types: Vec<String>,
  140. /// Space-joined scope, already checked against the enabled catalog.
  141. pub scope: String,
  142. /// Inline JWKS for `private_key_jwt`, if any.
  143. pub jwks: Option<serde_json::Value>,
  144. /// Human-readable client name.
  145. pub client_name: Option<String>,
  146. /// Whether the client opts into DPoP-bound access tokens.
  147. pub dpop_bound_access_tokens: bool,
  148. }
  149. impl ValidatedCimd {
  150. /// Materialize the registration-free principal as a synthetic
  151. /// [`client_repo::Client`] so every existing code path works unchanged.
  152. ///
  153. /// Field mapping: `client_id` is the document URL; the auth method, grant
  154. /// allowlist, scope bound, DPoP flag, and inline `jwks` come from the
  155. /// validated document; secrets, mTLS pins, `jwks_uri`, pre-registered
  156. /// `request_uris`, contacts, and PII are empty; `access_token_format` is
  157. /// always `opaque` (a document can never opt into JWT); the row is active
  158. /// with fresh timestamps and a random surrogate id. No row is written: the
  159. /// value is in-memory only, and the next resolution revalidates the cached
  160. /// document, so a scope or catalog change takes effect immediately.
  161. #[must_use]
  162. pub fn to_client(&self) -> client_repo::Client {
  163. let now = time::OffsetDateTime::now_utc();
  164. client_repo::Client {
  165. id: uuid::Uuid::new_v4(),
  166. client_id: self.client_id.clone(),
  167. client_secret_expires_at: None,
  168. client_id_issued_at: now,
  169. client_name: self
  170. .client_name
  171. .clone()
  172. .unwrap_or_else(|| self.client_id.clone()),
  173. grant_types: self.grant_types.clone(),
  174. response_types: vec!["code".to_owned()],
  175. scope: self.scope.clone(),
  176. token_endpoint_auth_method: self.token_endpoint_auth_method.as_str().to_owned(),
  177. jwks: self.jwks.clone(),
  178. jwks_uri: None,
  179. tls_client_auth_subject_dn: None,
  180. tls_client_auth_cert_thumbprint: None,
  181. request_uris: None,
  182. contacts: None,
  183. software_id: None,
  184. dpop_bound_access_tokens: self.dpop_bound_access_tokens,
  185. access_token_format: "opaque".to_owned(),
  186. active: true,
  187. created_at: now,
  188. updated_at: now,
  189. }
  190. }
  191. }
  192. /// Per-fetch limits from the validated configuration.
  193. #[derive(Debug, Clone, Copy)]
  194. pub struct CimdLimits {
  195. /// Maximum accepted document size in bytes (`cimd_max_bytes`).
  196. pub max_bytes: usize,
  197. /// Total fetch budget (`cimd_fetch_timeout`).
  198. pub timeout: Duration,
  199. }
  200. /// Validation inputs resolved by the caller (keeps this module pure).
  201. pub struct CimdValidation<'a> {
  202. /// Every enabled scope name (`scope_repo::list_enabled`).
  203. pub enabled_scopes: &'a HashSet<String>,
  204. /// Config `allow_localhost_redirect`: whether the host `localhost` joins
  205. /// the RFC 8252 §7.3 loopback exception.
  206. pub allow_localhost: bool,
  207. }
  208. /// Fail-closed CIMD failures.
  209. ///
  210. /// Validation failures map to `invalid_client_metadata` (or
  211. /// `invalid_redirect_uri` for a bad redirect set) via [`From<CimdError>`].
  212. #[derive(Debug, thiserror::Error)]
  213. pub enum CimdError {
  214. /// The guarded fetch failed (policy, DNS, status, timeout, or size cap).
  215. #[error("client metadata document fetch failed")]
  216. Fetch,
  217. /// The fetched body was not JSON or not the expected document shape.
  218. #[error("client metadata document is not valid JSON")]
  219. InvalidJson,
  220. /// The document `client_id` does not equal the fetched URL.
  221. #[error("client metadata document client_id does not match its URL")]
  222. ClientIdMismatch,
  223. /// `token_endpoint_auth_method` was absent-with-invalid-value or a
  224. /// shared-secret/mTLS method.
  225. #[error("unsupported token_endpoint_auth_method")]
  226. UnsupportedAuthMethod,
  227. /// `private_key_jwt` was declared without an inline JWKS.
  228. #[error("private_key_jwt requires an inline jwks")]
  229. JwksRequired,
  230. /// The document supplied `jwks_uri`; inline-only by decision.
  231. #[error("jwks_uri is not allowed in a client metadata document")]
  232. JwksUriNotAllowed,
  233. /// A redirect URI was missing, wildcarded, non-https, or non-loopback
  234. /// plaintext `http`.
  235. #[error("invalid redirect_uris")]
  236. InvalidRedirectUri,
  237. /// A grant type outside `{authorization_code, refresh_token}`.
  238. #[error("unsupported grant_type")]
  239. UnsupportedGrantType,
  240. /// A scope token is not in the enabled catalog.
  241. #[error("scope is not a subset of the enabled catalog")]
  242. UnknownScope,
  243. /// The cache or scope catalog could not be read (fail closed, `503`).
  244. #[error("client metadata backend failure")]
  245. Backend(#[source] AppError),
  246. /// The registered-row lookup failed. Pass-through: the inner error is
  247. /// returned unchanged so typed row failures (e.g. a corrupt sealed PII
  248. /// field surfacing as `PiiDecrypt`) are never flattened into a `503`.
  249. /// A database outage still surfaces as `temporarily_unavailable` through
  250. /// the existing `From<sqlx::Error> for AppError`.
  251. #[error("client row lookup failed")]
  252. Lookup(#[source] AppError),
  253. }
  254. impl From<CimdError> for AppError {
  255. /// Map a CIMD failure to a client-safe OAuth error. Bad redirects use
  256. /// `invalid_redirect_uri`; every other validation failure uses
  257. /// `invalid_client_metadata`; a dependency failure is a fail-closed `503`.
  258. /// No document contents or fetch internals are included.
  259. fn from(error: CimdError) -> Self {
  260. match error {
  261. CimdError::InvalidRedirectUri => {
  262. Self::invalid_redirect_uri("invalid redirect_uri in client metadata document")
  263. }
  264. CimdError::Backend(_) => Self::temporarily_unavailable(),
  265. CimdError::Lookup(inner) => inner,
  266. CimdError::Fetch
  267. | CimdError::InvalidJson
  268. | CimdError::ClientIdMismatch
  269. | CimdError::UnsupportedAuthMethod
  270. | CimdError::JwksRequired
  271. | CimdError::JwksUriNotAllowed
  272. | CimdError::UnsupportedGrantType
  273. | CimdError::UnknownScope => {
  274. Self::invalid_client_metadata("invalid client metadata document")
  275. }
  276. }
  277. }
  278. }
  279. /// The cache key for a validated document: `{state}`-prefixed
  280. /// `cimd:<sha256(url)>`.
  281. ///
  282. /// `token_prefix` is the configured token keyspace prefix (which carries the
  283. /// `{state}` hash tag); the key therefore never lands in the `{rl}` keyspace.
  284. #[must_use]
  285. pub fn cache_key(token_prefix: &str, client_id_url: &str) -> String {
  286. format!(
  287. "{token_prefix}cimd:{}",
  288. sha256_hex(client_id_url.as_bytes())
  289. )
  290. }
  291. /// The negative cache key for origin-level fetch failures: `{state}`-prefixed
  292. /// `cimd:neg:<sha256(url)>`, same prefix/hash conventions as [`cache_key`].
  293. /// The raw URL is never stored.
  294. #[must_use]
  295. pub fn negative_cache_key(token_prefix: &str, client_id_url: &str) -> String {
  296. format!(
  297. "{token_prefix}cimd:neg:{}",
  298. sha256_hex(client_id_url.as_bytes())
  299. )
  300. }
  301. /// The short cache key for a fresh-fetch validation failure:
  302. /// `{state}`-prefixed `cimd:invalid:<sha256(url)>`.
  303. #[must_use]
  304. pub fn invalid_cache_key(token_prefix: &str, client_id_url: &str) -> String {
  305. format!(
  306. "{token_prefix}cimd:invalid:{}",
  307. sha256_hex(client_id_url.as_bytes())
  308. )
  309. }
  310. /// Per-request CIMD resolution settings for a client-auth consumer.
  311. ///
  312. /// `None` at a call site means CIMD is disabled there (`config.cimd_enabled`
  313. /// is off): an `https://` `client_id` with no registered row keeps the
  314. /// existing unknown-client behavior. `Some` enables the registered-row-wins
  315. /// fallback for that consumer.
  316. #[derive(Debug, Clone, Copy)]
  317. pub struct CimdSettings<'a> {
  318. /// Token keyspace prefix carrying the `{state}` hash tag, for the
  319. /// `cimd:<sha256(url)>` document cache.
  320. pub token_prefix: &'a str,
  321. /// Maximum accepted document size (`config.cimd_max_bytes`).
  322. pub max_bytes: usize,
  323. /// Total fetch budget (`config.cimd_fetch_timeout`).
  324. pub timeout: Duration,
  325. /// Config `allow_localhost_redirect`: whether the host `localhost` joins
  326. /// the RFC 8252 §7.3 loopback exception.
  327. pub allow_localhost: bool,
  328. }
  329. /// The single client-principal resolution for every client-auth consumer
  330. /// (token dispatch, `client_auth`, `/revoke`, `/introspect`, authorize/JAR,
  331. /// PAR, device poll).
  332. ///
  333. /// A registered row always wins: when `find_by_client_id_any_state` names a
  334. /// row — including a row whose `client_id` happens to be an `https://` URL —
  335. /// an active row is returned and an inactive row resolves to `None` (no fetch,
  336. /// so deactivation cannot be bypassed). Otherwise, when `settings` is
  337. /// `Some` and the id parses as `https://`, the document is resolved through
  338. /// [`resolve_with_catalog`] (cache, else guarded fetch + strict validation)
  339. /// and materialized via [`ValidatedCimd::to_client`]. Any other id, or a
  340. /// disabled feature, resolves to `None` so the caller keeps its existing
  341. /// unknown-client error.
  342. ///
  343. /// Only `none` and `private_key_jwt` documents can resolve here: validation
  344. /// rejects every shared-secret and mTLS method before a principal exists
  345. /// (CIMD draft §4.1), and a `private_key_jwt` document resolves only with an
  346. /// inline `jwks` (no second fetch on the auth path).
  347. ///
  348. /// # Errors
  349. ///
  350. /// Returns the [`CimdError`] of a failed fetch or validation (mapped by the
  351. /// caller per [`From<CimdError>`]: `invalid_client_metadata`,
  352. /// `invalid_redirect_uri`, or fail-closed `503`), a pass-through
  353. /// [`CimdError::Lookup`] preserving the row lookup's own error, or a
  354. /// [`CimdError::Backend`] when the scope catalog or the cache fails.
  355. pub async fn resolve_client(
  356. pool: &PgPool,
  357. keys: &KeyRing,
  358. conn: &mut RedisConn,
  359. settings: Option<CimdSettings<'_>>,
  360. client_id: &str,
  361. ) -> Result<Option<client_repo::Client>, CimdError> {
  362. let row = client_repo::find_by_client_id_any_state(pool, keys, client_id)
  363. .await
  364. .map_err(CimdError::Lookup)?;
  365. if let Some(client) = row {
  366. if client.active {
  367. return Ok(Some(client));
  368. }
  369. // A disabled row is dead: the caller keeps its unknown-client error
  370. // and no document is fetched, so deactivation cannot be bypassed.
  371. return Ok(None);
  372. }
  373. let Some(settings) = settings else {
  374. return Ok(None);
  375. };
  376. // CIMD draft §3: only an `https://` URL names a metadata document.
  377. if !client_id.starts_with("https://") {
  378. return Ok(None);
  379. }
  380. let limits = CimdLimits {
  381. max_bytes: settings.max_bytes,
  382. timeout: settings.timeout,
  383. };
  384. let validated = resolve_with_catalog(
  385. conn,
  386. settings.token_prefix,
  387. pool,
  388. client_id,
  389. settings.allow_localhost,
  390. limits,
  391. )
  392. .await?;
  393. Ok(Some(validated.to_client()))
  394. }
  395. /// Fetch a CIMD document by URL through the guarded fetcher.
  396. ///
  397. /// Uses exactly one fetcher ([`ssrf::fetch_guarded_bytes_with`]): `https`
  398. /// only, no redirects, special-use/private addresses rejected, `limits`
  399. /// enforced. Non-success statuses and oversize bodies fail closed as
  400. /// [`CimdError::Fetch`].
  401. ///
  402. /// # Errors
  403. ///
  404. /// Returns [`CimdError::Fetch`] on any guard/fetch/status/size failure and
  405. /// [`CimdError::InvalidJson`] when the body is not a document-shaped JSON
  406. /// object.
  407. pub async fn fetch_document(
  408. client_id_url: &str,
  409. limits: CimdLimits,
  410. ) -> Result<CimdDocument, CimdError> {
  411. let bytes = ssrf::fetch_guarded_bytes_with(client_id_url, limits.max_bytes, limits.timeout)
  412. .await
  413. .map_err(|_| CimdError::Fetch)?;
  414. serde_json::from_slice(&bytes).map_err(|_| CimdError::InvalidJson)
  415. }
  416. /// Validate a document against the strict CIMD rules.
  417. ///
  418. /// # Errors
  419. ///
  420. /// Returns the specific [`CimdError`] for the first violated rule. The
  421. /// checks are ordered cheapest/most-specific first: identifier echo, auth
  422. /// method and JWKS policy, redirect set, scope subset, grant subset.
  423. pub fn validate_document(
  424. client_id_url: &str,
  425. document: &CimdDocument,
  426. validation: &CimdValidation<'_>,
  427. ) -> Result<ValidatedCimd, CimdError> {
  428. // CIMD §4.1: the document must identify itself as the URL it was fetched
  429. // from, exactly. No normalization is performed.
  430. if document.client_id != client_id_url {
  431. return Err(CimdError::ClientIdMismatch);
  432. }
  433. // CIMD §4.1: only registration-free-safe methods. Absent defaults to
  434. // `none`; shared-secret and mTLS methods are rejected.
  435. let token_endpoint_auth_method = match document.token_endpoint_auth_method.as_deref() {
  436. None | Some("none") => CimdAuthMethod::None,
  437. Some("private_key_jwt") => CimdAuthMethod::PrivateKeyJwt,
  438. Some(_) => return Err(CimdError::UnsupportedAuthMethod),
  439. };
  440. // Inline-only decision: never fetch a second URL on the auth path.
  441. if document.jwks_uri.is_some() {
  442. return Err(CimdError::JwksUriNotAllowed);
  443. }
  444. let jwks = match token_endpoint_auth_method {
  445. CimdAuthMethod::PrivateKeyJwt => {
  446. let Some(value) = document.jwks.as_ref() else {
  447. return Err(CimdError::JwksRequired);
  448. };
  449. validate_jwks_shape(value)?;
  450. Some(value.clone())
  451. }
  452. CimdAuthMethod::None => None,
  453. };
  454. validate_redirect_uris(&document.redirect_uris, validation.allow_localhost)?;
  455. let scope = validate_scope(document.scope.as_deref(), validation.enabled_scopes)?;
  456. let grant_types = validate_grants(document.grant_types.as_ref())?;
  457. Ok(ValidatedCimd {
  458. client_id: document.client_id.clone(),
  459. redirect_uris: document.redirect_uris.clone(),
  460. token_endpoint_auth_method,
  461. grant_types,
  462. scope,
  463. jwks,
  464. client_name: document.client_name.clone(),
  465. dpop_bound_access_tokens: document.dpop_bound_access_tokens.unwrap_or(false),
  466. })
  467. }
  468. /// Fetch and validate in one step (no cache).
  469. ///
  470. /// # Errors
  471. ///
  472. /// Returns a fetch, parse, or validation [`CimdError`].
  473. pub async fn fetch_and_validate(
  474. client_id_url: &str,
  475. validation: &CimdValidation<'_>,
  476. limits: CimdLimits,
  477. ) -> Result<ValidatedCimd, CimdError> {
  478. let document = fetch_document(client_id_url, limits).await?;
  479. validate_document(client_id_url, &document, validation)
  480. }
  481. /// Resolve a CIMD client: negative-cache hit fails fast, else cache hit,
  482. /// else one elected fetcher plus waiters.
  483. ///
  484. /// An origin-level fetch failure (`Fetch`/`InvalidJson`) is negatively cached
  485. /// for 30 seconds under `cimd:neg:<sha256(url)>`. A fresh-fetch validation
  486. /// failure is short-cached for [`INVALID_CACHE_TTL_SECS`] under
  487. /// `cimd:invalid:<sha256(url)>` as a single-flight completion marker, so a
  488. /// corrected document is seen within that short window; a validation failure
  489. /// on a cached document writes no marker. A cached document is revalidated so
  490. /// catalog changes are honored during the 15-minute TTL. If the cache write
  491. /// after a successful validation fails, the validated document is still
  492. /// served (uncached).
  493. ///
  494. /// Concurrent misses for the same URL elect exactly one fetcher through a
  495. /// `{state}` Redis lock (`cimd:lock:<sha256(url)>`, TTL = the fetch budget plus
  496. /// [`LOCK_TTL_MARGIN`]). The other requests poll the positive and negative
  497. /// caches for up to the fetch budget plus [`LOCK_WAIT_MARGIN`], then try once
  498. /// more to acquire the lock; if it is still held they fail closed with
  499. /// [`CimdError::Fetch`]. A holder's `release_lock` is a compare-and-delete, and
  500. /// the lock TTL bounds a crashed holder.
  501. ///
  502. /// `token_prefix` must carry the `{state}` hash tag.
  503. ///
  504. /// # Errors
  505. ///
  506. /// Returns any fetch/parse/validation [`CimdError`], or
  507. /// [`CimdError::Backend`] when Redis cannot be read or written (fail closed).
  508. pub async fn resolve(
  509. conn: &mut RedisConn,
  510. token_prefix: &str,
  511. client_id_url: &str,
  512. validation: &CimdValidation<'_>,
  513. limits: CimdLimits,
  514. ) -> Result<ValidatedCimd, CimdError> {
  515. let key = cache_key(token_prefix, client_id_url);
  516. let neg_key = negative_cache_key(token_prefix, client_id_url);
  517. let invalid_key = invalid_cache_key(token_prefix, client_id_url);
  518. let lock = lock_key(token_prefix, client_id_url);
  519. if is_negatively_cached(conn, &neg_key).await? {
  520. return Err(CimdError::Fetch);
  521. }
  522. if let Some(document) = get_cached(conn, &key).await? {
  523. return validate_document(client_id_url, &document, validation);
  524. }
  525. if is_invalid_cached(conn, &invalid_key).await? {
  526. return Err(CimdError::Fetch);
  527. }
  528. let ttl_ms = lock_ttl_ms(limits);
  529. if let Some(token) = acquire_lock(conn, &lock, ttl_ms).await? {
  530. let result = resolve_under_lock(
  531. conn,
  532. &key,
  533. &neg_key,
  534. &invalid_key,
  535. client_id_url,
  536. validation,
  537. limits,
  538. )
  539. .await;
  540. // Best effort: a missed release is bounded by the lock TTL.
  541. let _ = release_lock(conn, &lock, &token).await;
  542. return result;
  543. }
  544. let deadline = tokio::time::Instant::now()
  545. .checked_add(limits.timeout)
  546. .and_then(|instant| instant.checked_add(LOCK_WAIT_MARGIN))
  547. .unwrap_or_else(tokio::time::Instant::now);
  548. loop {
  549. if is_negatively_cached(conn, &neg_key).await? {
  550. return Err(CimdError::Fetch);
  551. }
  552. if let Some(document) = get_cached(conn, &key).await? {
  553. return validate_document(client_id_url, &document, validation);
  554. }
  555. if is_invalid_cached(conn, &invalid_key).await? {
  556. return Err(CimdError::Fetch);
  557. }
  558. // A holder that failed validation releases without a cache entry; stop
  559. // waiting as soon as the lock is gone and try to elect the next fetcher.
  560. let lock_present: bool = redis::cmd("EXISTS")
  561. .arg(&lock)
  562. .query_async(conn)
  563. .await
  564. .map_err(cache_backend)?;
  565. if !lock_present {
  566. break;
  567. }
  568. let now = tokio::time::Instant::now();
  569. if now >= deadline {
  570. break;
  571. }
  572. let left = deadline.saturating_duration_since(now);
  573. tokio::time::sleep(left.min(LOCK_POLL_INTERVAL)).await;
  574. }
  575. if let Some(token) = acquire_lock(conn, &lock, ttl_ms).await? {
  576. let result = resolve_under_lock(
  577. conn,
  578. &key,
  579. &neg_key,
  580. &invalid_key,
  581. client_id_url,
  582. validation,
  583. limits,
  584. )
  585. .await;
  586. let _ = release_lock(conn, &lock, &token).await;
  587. return result;
  588. }
  589. Err(CimdError::Fetch)
  590. }
  591. /// The lock-held path: re-check the caches (another holder may have just
  592. /// published), then fetch, validate, and cache.
  593. async fn resolve_under_lock(
  594. conn: &mut RedisConn,
  595. key: &str,
  596. neg_key: &str,
  597. invalid_key: &str,
  598. client_id_url: &str,
  599. validation: &CimdValidation<'_>,
  600. limits: CimdLimits,
  601. ) -> Result<ValidatedCimd, CimdError> {
  602. if is_negatively_cached(conn, neg_key).await? {
  603. return Err(CimdError::Fetch);
  604. }
  605. if let Some(document) = get_cached(conn, key).await? {
  606. return validate_document(client_id_url, &document, validation);
  607. }
  608. if is_invalid_cached(conn, invalid_key).await? {
  609. return Err(CimdError::Fetch);
  610. }
  611. let document = match fetch_document(client_id_url, limits).await {
  612. Ok(document) => document,
  613. Err(error @ (CimdError::Fetch | CimdError::InvalidJson)) => {
  614. put_negative(conn, neg_key).await?;
  615. return Err(error);
  616. }
  617. Err(error) => return Err(error),
  618. };
  619. let validated = match validate_document(client_id_url, &document, validation) {
  620. Ok(validated) => validated,
  621. Err(error) => {
  622. if put_invalid(conn, invalid_key).await.is_err() {
  623. tracing::warn!("cimd invalid-document marker write failed");
  624. }
  625. return Err(error);
  626. }
  627. };
  628. if put_cached(conn, key, &document).await.is_err() {
  629. tracing::warn!("cimd cache population failed after validation");
  630. }
  631. Ok(validated)
  632. }
  633. /// The single-flight lock key: `{state}`-prefixed, hash of the URL, never the
  634. /// raw URL.
  635. fn lock_key(token_prefix: &str, client_id_url: &str) -> String {
  636. format!(
  637. "{token_prefix}cimd:lock:{}",
  638. sha256_hex(client_id_url.as_bytes())
  639. )
  640. }
  641. /// Lock lifetime in milliseconds: the fetch budget plus a scheduling margin.
  642. fn lock_ttl_ms(limits: CimdLimits) -> u64 {
  643. u64::try_from(limits.timeout.saturating_add(LOCK_TTL_MARGIN).as_millis()).unwrap_or(u64::MAX)
  644. }
  645. /// Acquire the single-flight lock with `SET NX PX`. The random token is the
  646. /// compare-and-delete value; `None` means another request holds it.
  647. async fn acquire_lock(
  648. conn: &mut RedisConn,
  649. key: &str,
  650. ttl_ms: u64,
  651. ) -> Result<Option<String>, CimdError> {
  652. let token = crate::crypto::tokens::mint_token()
  653. .map_err(|_| CimdError::Backend(AppError::temporarily_unavailable()))?;
  654. let acquired: Option<String> = redis::cmd("SET")
  655. .arg(key)
  656. .arg(&token)
  657. .arg("NX")
  658. .arg("PX")
  659. .arg(ttl_ms)
  660. .query_async(conn)
  661. .await
  662. .map_err(cache_backend)?;
  663. Ok(acquired.map(|_| token))
  664. }
  665. /// Compare-and-delete the single-flight lock, so a holder whose TTL expired
  666. /// cannot release a successor's lock.
  667. static RELEASE_LOCK: LazyLock<redis::Script> = LazyLock::new(|| {
  668. redis::Script::new(
  669. "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end",
  670. )
  671. });
  672. /// Release the lock only when it still holds `token`.
  673. async fn release_lock(conn: &mut RedisConn, key: &str, token: &str) -> Result<(), CimdError> {
  674. let mut invocation = RELEASE_LOCK.prepare_invoke();
  675. invocation.key(key);
  676. invocation.arg(token);
  677. let _: i64 = invocation.invoke_async(conn).await.map_err(cache_backend)?;
  678. Ok(())
  679. }
  680. /// Resolve a CIMD client while loading the enabled scope catalog from
  681. /// Postgres. Convenience wrapper for request handlers.
  682. ///
  683. /// # Errors
  684. ///
  685. /// Returns [`CimdError::Backend`] when the catalog read fails, plus every
  686. /// [`resolve`] failure.
  687. pub async fn resolve_with_catalog(
  688. conn: &mut RedisConn,
  689. token_prefix: &str,
  690. pool: &PgPool,
  691. client_id_url: &str,
  692. allow_localhost: bool,
  693. limits: CimdLimits,
  694. ) -> Result<ValidatedCimd, CimdError> {
  695. let enabled_scopes: HashSet<String> = scope_repo::list_enabled(pool)
  696. .await
  697. .map_err(CimdError::Backend)?
  698. .into_iter()
  699. .map(|record| record.name)
  700. .collect();
  701. resolve(
  702. conn,
  703. token_prefix,
  704. client_id_url,
  705. &CimdValidation {
  706. enabled_scopes: &enabled_scopes,
  707. allow_localhost,
  708. },
  709. limits,
  710. )
  711. .await
  712. }
  713. /// Read a cached document. Malformed cache entries are deleted and treated as
  714. /// a miss; a Redis failure is fail-closed [`CimdError::Backend`].
  715. async fn get_cached(conn: &mut RedisConn, key: &str) -> Result<Option<CimdDocument>, CimdError> {
  716. let raw: Option<String> = redis::cmd("GET")
  717. .arg(key)
  718. .query_async(conn)
  719. .await
  720. .map_err(cache_backend)?;
  721. let Some(raw) = raw else {
  722. return Ok(None);
  723. };
  724. match serde_json::from_str(&raw) {
  725. Ok(document) => Ok(Some(document)),
  726. Err(_) => {
  727. // A corrupt entry is not trusted: drop it and fetch afresh.
  728. let _: Result<i64, _> = redis::cmd("DEL").arg(key).query_async(conn).await;
  729. Ok(None)
  730. }
  731. }
  732. }
  733. /// Whether an origin-level fetch failure is negatively cached. A Redis
  734. /// failure is fail-closed [`CimdError::Backend`].
  735. async fn is_negatively_cached(conn: &mut RedisConn, neg_key: &str) -> Result<bool, CimdError> {
  736. let present: bool = redis::cmd("EXISTS")
  737. .arg(neg_key)
  738. .query_async(conn)
  739. .await
  740. .map_err(cache_backend)?;
  741. Ok(present)
  742. }
  743. /// Record an origin-level fetch failure for [`NEG_CACHE_TTL_SECS`]. A Redis
  744. /// failure is fail-closed [`CimdError::Backend`].
  745. async fn put_negative(conn: &mut RedisConn, neg_key: &str) -> Result<(), CimdError> {
  746. redis::cmd("SET")
  747. .arg(neg_key)
  748. .arg("1")
  749. .arg("EX")
  750. .arg(NEG_CACHE_TTL_SECS)
  751. .query_async::<()>(conn)
  752. .await
  753. .map_err(cache_backend)
  754. }
  755. /// Whether a fresh-fetch validation failure is short-cached. A Redis failure
  756. /// is fail-closed [`CimdError::Backend`].
  757. async fn is_invalid_cached(conn: &mut RedisConn, invalid_key: &str) -> Result<bool, CimdError> {
  758. let present: bool = redis::cmd("EXISTS")
  759. .arg(invalid_key)
  760. .query_async(conn)
  761. .await
  762. .map_err(cache_backend)?;
  763. Ok(present)
  764. }
  765. /// Record a fresh-fetch validation failure for [`INVALID_CACHE_TTL_SECS`].
  766. async fn put_invalid(conn: &mut RedisConn, invalid_key: &str) -> Result<(), CimdError> {
  767. redis::cmd("SET")
  768. .arg(invalid_key)
  769. .arg("1")
  770. .arg("EX")
  771. .arg(INVALID_CACHE_TTL_SECS)
  772. .query_async::<()>(conn)
  773. .await
  774. .map_err(cache_backend)
  775. }
  776. /// Cache a validated document for [`CACHE_TTL_SECS`].
  777. async fn put_cached(
  778. conn: &mut RedisConn,
  779. key: &str,
  780. document: &CimdDocument,
  781. ) -> Result<(), CimdError> {
  782. let payload = serde_json::to_string(document).map_err(|_| CimdError::InvalidJson)?;
  783. redis::cmd("SET")
  784. .arg(key)
  785. .arg(payload)
  786. .arg("EX")
  787. .arg(CACHE_TTL_SECS)
  788. .query_async::<()>(conn)
  789. .await
  790. .map_err(cache_backend)
  791. }
  792. /// Map a Redis error to a fail-closed backend error.
  793. fn cache_backend(error: redis::RedisError) -> CimdError {
  794. CimdError::Backend(StoreError::Redis(error).into())
  795. }
  796. /// Redirect URIs are `https`, or plaintext `http` only for an RFC 8252 §7.3
  797. /// loopback host. The loopback decision (including `localhost` behind
  798. /// `allow_localhost`) reuses [`redirect_repo::match_redirect_uri`] so CIMD and
  799. /// registered clients can never disagree. Wildcards are rejected outright.
  800. fn validate_redirect_uris(uris: &[String], allow_localhost: bool) -> Result<(), CimdError> {
  801. if uris.is_empty() {
  802. return Err(CimdError::InvalidRedirectUri);
  803. }
  804. for uri in uris {
  805. if uri.contains('*') {
  806. return Err(CimdError::InvalidRedirectUri);
  807. }
  808. let parsed = reqwest::Url::parse(uri).map_err(|_| CimdError::InvalidRedirectUri)?;
  809. if !matches!(parsed.scheme(), "https" | "http")
  810. || parsed.host_str().is_none_or(str::is_empty)
  811. || !parsed.username().is_empty()
  812. || parsed.password().is_some()
  813. {
  814. return Err(CimdError::InvalidRedirectUri);
  815. }
  816. // `match_redirect_uri` is the single source of truth for the loopback
  817. // exception and rejects plaintext `http` for every non-loopback host.
  818. if redirect_repo::match_redirect_uri(uris, uri, allow_localhost) == RedirectDecision::Reject {
  819. return Err(CimdError::InvalidRedirectUri);
  820. }
  821. }
  822. Ok(())
  823. }
  824. /// Check every requested scope token against the enabled catalog.
  825. fn validate_scope(
  826. scope: Option<&str>,
  827. enabled_scopes: &HashSet<String>,
  828. ) -> Result<String, CimdError> {
  829. let mut accepted = Vec::new();
  830. for token in scope.unwrap_or_default().split_whitespace() {
  831. if !enabled_scopes.contains(token) {
  832. return Err(CimdError::UnknownScope);
  833. }
  834. accepted.push(token);
  835. }
  836. Ok(accepted.join(" "))
  837. }
  838. /// Require a non-empty grant set, each member in [`ALLOWED_GRANTS`]; default
  839. /// to `["authorization_code"]` when absent. Duplicates collapse in first-seen
  840. /// order.
  841. fn validate_grants(grants: Option<&Vec<String>>) -> Result<Vec<String>, CimdError> {
  842. let requested: &[String] = grants.map_or(&[], Vec::as_slice);
  843. if grants.is_some() && requested.is_empty() {
  844. return Err(CimdError::UnsupportedGrantType);
  845. }
  846. let source: Vec<String> = if requested.is_empty() {
  847. vec!["authorization_code".to_owned()]
  848. } else {
  849. requested.to_vec()
  850. };
  851. let mut seen = HashSet::with_capacity(source.len());
  852. let mut out = Vec::with_capacity(source.len());
  853. for grant in source {
  854. if !ALLOWED_GRANTS.contains(&grant.as_str()) {
  855. return Err(CimdError::UnsupportedGrantType);
  856. }
  857. if seen.insert(grant.clone()) {
  858. out.push(grant);
  859. }
  860. }
  861. Ok(out)
  862. }
  863. /// Require an inline JWKS document to carry a non-empty `keys` array. Mirrors
  864. /// the DCR shape check; signature validation belongs to the assertion
  865. /// verifier.
  866. fn validate_jwks_shape(value: &serde_json::Value) -> Result<(), CimdError> {
  867. let has_keys = value
  868. .get("keys")
  869. .and_then(|keys| keys.as_array())
  870. .is_some_and(|keys| !keys.is_empty());
  871. if has_keys {
  872. Ok(())
  873. } else {
  874. Err(CimdError::JwksRequired)
  875. }
  876. }