cimd.rs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995
  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 class of a short-cached fresh-fetch validation failure. It is stored as
  280. /// a tiny marker in `cimd:invalid:<sha256>` so a retry replays the original
  281. /// client-visible error: a bad redirect set keeps `invalid_redirect_uri`;
  282. /// every other validation failure stays `invalid_client_metadata`.
  283. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  284. enum InvalidClass {
  285. /// [`CimdError::InvalidRedirectUri`].
  286. Redirect,
  287. /// Every other validation failure, replayed as [`CimdError::InvalidJson`].
  288. Metadata,
  289. }
  290. impl InvalidClass {
  291. /// The stored marker value.
  292. const fn marker(self) -> &'static str {
  293. match self {
  294. Self::Redirect => "redirect",
  295. Self::Metadata => "metadata",
  296. }
  297. }
  298. /// Classify a validation failure.
  299. const fn of(error: &CimdError) -> Self {
  300. match error {
  301. CimdError::InvalidRedirectUri => Self::Redirect,
  302. _ => Self::Metadata,
  303. }
  304. }
  305. /// Replay a stored marker. An unknown marker (a legacy `"1"` or a corrupt
  306. /// entry) fails closed as the generic metadata class.
  307. const fn from_marker(raw: &str) -> Self {
  308. match raw.as_bytes() {
  309. b"redirect" => Self::Redirect,
  310. _ => Self::Metadata,
  311. }
  312. }
  313. /// The client-visible error replayed for this class.
  314. const fn to_error(self) -> CimdError {
  315. match self {
  316. Self::Redirect => CimdError::InvalidRedirectUri,
  317. Self::Metadata => CimdError::InvalidJson,
  318. }
  319. }
  320. }
  321. /// The cache key for a validated document: `{state}`-prefixed
  322. /// `cimd:<sha256(url)>`.
  323. ///
  324. /// `token_prefix` is the configured token keyspace prefix (which carries the
  325. /// `{state}` hash tag); the key therefore never lands in the `{rl}` keyspace.
  326. #[must_use]
  327. pub fn cache_key(token_prefix: &str, client_id_url: &str) -> String {
  328. format!(
  329. "{token_prefix}cimd:{}",
  330. sha256_hex(client_id_url.as_bytes())
  331. )
  332. }
  333. /// The negative cache key for origin-level fetch failures: `{state}`-prefixed
  334. /// `cimd:neg:<sha256(url)>`, same prefix/hash conventions as [`cache_key`].
  335. /// The raw URL is never stored.
  336. #[must_use]
  337. pub fn negative_cache_key(token_prefix: &str, client_id_url: &str) -> String {
  338. format!(
  339. "{token_prefix}cimd:neg:{}",
  340. sha256_hex(client_id_url.as_bytes())
  341. )
  342. }
  343. /// The short cache key for a fresh-fetch validation failure:
  344. /// `{state}`-prefixed `cimd:invalid:<sha256(url)>`.
  345. #[must_use]
  346. pub fn invalid_cache_key(token_prefix: &str, client_id_url: &str) -> String {
  347. format!(
  348. "{token_prefix}cimd:invalid:{}",
  349. sha256_hex(client_id_url.as_bytes())
  350. )
  351. }
  352. /// Per-request CIMD resolution settings for a client-auth consumer.
  353. ///
  354. /// `None` at a call site means CIMD is disabled there (`config.cimd_enabled`
  355. /// is off): an `https://` `client_id` with no registered row keeps the
  356. /// existing unknown-client behavior. `Some` enables the registered-row-wins
  357. /// fallback for that consumer.
  358. #[derive(Debug, Clone, Copy)]
  359. pub struct CimdSettings<'a> {
  360. /// Token keyspace prefix carrying the `{state}` hash tag, for the
  361. /// `cimd:<sha256(url)>` document cache.
  362. pub token_prefix: &'a str,
  363. /// Maximum accepted document size (`config.cimd_max_bytes`).
  364. pub max_bytes: usize,
  365. /// Total fetch budget (`config.cimd_fetch_timeout`).
  366. pub timeout: Duration,
  367. /// Config `allow_localhost_redirect`: whether the host `localhost` joins
  368. /// the RFC 8252 §7.3 loopback exception.
  369. pub allow_localhost: bool,
  370. }
  371. /// The single client-principal resolution for every client-auth consumer
  372. /// (token dispatch, `client_auth`, `/revoke`, `/introspect`, authorize/JAR,
  373. /// PAR, device poll).
  374. ///
  375. /// A registered row always wins: when `find_by_client_id_any_state` names a
  376. /// row — including a row whose `client_id` happens to be an `https://` URL —
  377. /// an active row is returned and an inactive row resolves to `None` (no fetch,
  378. /// so deactivation cannot be bypassed). Otherwise, when `settings` is
  379. /// `Some` and the id parses as `https://`, the document is resolved through
  380. /// [`resolve_with_catalog`] (cache, else guarded fetch + strict validation)
  381. /// and materialized via [`ValidatedCimd::to_client`]. Any other id, or a
  382. /// disabled feature, resolves to `None` so the caller keeps its existing
  383. /// unknown-client error.
  384. ///
  385. /// Only `none` and `private_key_jwt` documents can resolve here: validation
  386. /// rejects every shared-secret and mTLS method before a principal exists
  387. /// (CIMD draft §4.1), and a `private_key_jwt` document resolves only with an
  388. /// inline `jwks` (no second fetch on the auth path).
  389. ///
  390. /// # Errors
  391. ///
  392. /// Returns the [`CimdError`] of a failed fetch or validation (mapped by the
  393. /// caller per [`From<CimdError>`]: `invalid_client_metadata`,
  394. /// `invalid_redirect_uri`, or fail-closed `503`), a pass-through
  395. /// [`CimdError::Lookup`] preserving the row lookup's own error, or a
  396. /// [`CimdError::Backend`] when the scope catalog or the cache fails.
  397. pub async fn resolve_client(
  398. pool: &PgPool,
  399. keys: &KeyRing,
  400. conn: &mut RedisConn,
  401. settings: Option<CimdSettings<'_>>,
  402. client_id: &str,
  403. ) -> Result<Option<client_repo::Client>, CimdError> {
  404. let row = client_repo::find_by_client_id_any_state(pool, keys, client_id)
  405. .await
  406. .map_err(CimdError::Lookup)?;
  407. if let Some(client) = row {
  408. if client.active {
  409. return Ok(Some(client));
  410. }
  411. // A disabled row is dead: the caller keeps its unknown-client error
  412. // and no document is fetched, so deactivation cannot be bypassed.
  413. return Ok(None);
  414. }
  415. let Some(settings) = settings else {
  416. return Ok(None);
  417. };
  418. // CIMD draft §3: only an `https://` URL names a metadata document.
  419. if !client_id.starts_with("https://") {
  420. return Ok(None);
  421. }
  422. let limits = CimdLimits {
  423. max_bytes: settings.max_bytes,
  424. timeout: settings.timeout,
  425. };
  426. let validated = resolve_with_catalog(
  427. conn,
  428. settings.token_prefix,
  429. pool,
  430. client_id,
  431. settings.allow_localhost,
  432. limits,
  433. )
  434. .await?;
  435. Ok(Some(validated.to_client()))
  436. }
  437. /// Fetch a CIMD document by URL through the guarded fetcher.
  438. ///
  439. /// Uses exactly one fetcher ([`ssrf::fetch_guarded_bytes_with`]): `https`
  440. /// only, no redirects, special-use/private addresses rejected, `limits`
  441. /// enforced. Non-success statuses and oversize bodies fail closed as
  442. /// [`CimdError::Fetch`].
  443. ///
  444. /// # Errors
  445. ///
  446. /// Returns [`CimdError::Fetch`] on any guard/fetch/status/size failure and
  447. /// [`CimdError::InvalidJson`] when the body is not a document-shaped JSON
  448. /// object.
  449. pub async fn fetch_document(
  450. client_id_url: &str,
  451. limits: CimdLimits,
  452. ) -> Result<CimdDocument, CimdError> {
  453. let bytes = ssrf::fetch_guarded_bytes_with(client_id_url, limits.max_bytes, limits.timeout)
  454. .await
  455. .map_err(|_| CimdError::Fetch)?;
  456. serde_json::from_slice(&bytes).map_err(|_| CimdError::InvalidJson)
  457. }
  458. /// Validate a document against the strict CIMD rules.
  459. ///
  460. /// # Errors
  461. ///
  462. /// Returns the specific [`CimdError`] for the first violated rule. The
  463. /// checks are ordered cheapest/most-specific first: identifier echo, auth
  464. /// method and JWKS policy, redirect set, scope subset, grant subset.
  465. pub fn validate_document(
  466. client_id_url: &str,
  467. document: &CimdDocument,
  468. validation: &CimdValidation<'_>,
  469. ) -> Result<ValidatedCimd, CimdError> {
  470. // CIMD §4.1: the document must identify itself as the URL it was fetched
  471. // from, exactly. No normalization is performed.
  472. if document.client_id != client_id_url {
  473. return Err(CimdError::ClientIdMismatch);
  474. }
  475. // CIMD §4.1: only registration-free-safe methods. Absent defaults to
  476. // `none`; shared-secret and mTLS methods are rejected.
  477. let token_endpoint_auth_method = match document.token_endpoint_auth_method.as_deref() {
  478. None | Some("none") => CimdAuthMethod::None,
  479. Some("private_key_jwt") => CimdAuthMethod::PrivateKeyJwt,
  480. Some(_) => return Err(CimdError::UnsupportedAuthMethod),
  481. };
  482. // Inline-only decision: never fetch a second URL on the auth path.
  483. if document.jwks_uri.is_some() {
  484. return Err(CimdError::JwksUriNotAllowed);
  485. }
  486. let jwks = match token_endpoint_auth_method {
  487. CimdAuthMethod::PrivateKeyJwt => {
  488. let Some(value) = document.jwks.as_ref() else {
  489. return Err(CimdError::JwksRequired);
  490. };
  491. validate_jwks_shape(value)?;
  492. Some(value.clone())
  493. }
  494. CimdAuthMethod::None => None,
  495. };
  496. validate_redirect_uris(&document.redirect_uris, validation.allow_localhost)?;
  497. let scope = validate_scope(document.scope.as_deref(), validation.enabled_scopes)?;
  498. let grant_types = validate_grants(document.grant_types.as_ref())?;
  499. Ok(ValidatedCimd {
  500. client_id: document.client_id.clone(),
  501. redirect_uris: document.redirect_uris.clone(),
  502. token_endpoint_auth_method,
  503. grant_types,
  504. scope,
  505. jwks,
  506. client_name: document.client_name.clone(),
  507. dpop_bound_access_tokens: document.dpop_bound_access_tokens.unwrap_or(false),
  508. })
  509. }
  510. /// Fetch and validate in one step (no cache).
  511. ///
  512. /// # Errors
  513. ///
  514. /// Returns a fetch, parse, or validation [`CimdError`].
  515. pub async fn fetch_and_validate(
  516. client_id_url: &str,
  517. validation: &CimdValidation<'_>,
  518. limits: CimdLimits,
  519. ) -> Result<ValidatedCimd, CimdError> {
  520. let document = fetch_document(client_id_url, limits).await?;
  521. validate_document(client_id_url, &document, validation)
  522. }
  523. /// Resolve a CIMD client: negative-cache hit fails fast, else cache hit,
  524. /// else one elected fetcher plus waiters.
  525. ///
  526. /// An origin-level fetch failure (`Fetch`/`InvalidJson`) is negatively cached
  527. /// for 30 seconds under `cimd:neg:<sha256(url)>`. A fresh-fetch validation
  528. /// failure is short-cached for [`INVALID_CACHE_TTL_SECS`] under
  529. /// `cimd:invalid:<sha256(url)>` as a single-flight completion marker tagged
  530. /// with the failure class (`redirect` or `metadata`), so a retry replays the
  531. /// original client-visible error and a corrected document is seen within that
  532. /// short window; a validation failure
  533. /// on a cached document writes no marker. A cached document is revalidated so
  534. /// catalog changes are honored during the 15-minute TTL. If the cache write
  535. /// after a successful validation fails, the validated document is still
  536. /// served (uncached).
  537. ///
  538. /// Concurrent misses for the same URL elect exactly one fetcher through a
  539. /// `{state}` Redis lock (`cimd:lock:<sha256(url)>`, TTL = the fetch budget plus
  540. /// [`LOCK_TTL_MARGIN`]). The other requests poll the positive and negative
  541. /// caches for up to the fetch budget plus [`LOCK_WAIT_MARGIN`], then try once
  542. /// more to acquire the lock; if it is still held they fail closed with
  543. /// [`CimdError::Fetch`]. A holder's `release_lock` is a compare-and-delete, and
  544. /// the lock TTL bounds a crashed holder.
  545. ///
  546. /// `token_prefix` must carry the `{state}` hash tag.
  547. ///
  548. /// # Errors
  549. ///
  550. /// Returns any fetch/parse/validation [`CimdError`], or
  551. /// [`CimdError::Backend`] when Redis cannot be read or written (fail closed).
  552. pub async fn resolve(
  553. conn: &mut RedisConn,
  554. token_prefix: &str,
  555. client_id_url: &str,
  556. validation: &CimdValidation<'_>,
  557. limits: CimdLimits,
  558. ) -> Result<ValidatedCimd, CimdError> {
  559. let key = cache_key(token_prefix, client_id_url);
  560. let neg_key = negative_cache_key(token_prefix, client_id_url);
  561. let invalid_key = invalid_cache_key(token_prefix, client_id_url);
  562. let lock = lock_key(token_prefix, client_id_url);
  563. if is_negatively_cached(conn, &neg_key).await? {
  564. return Err(CimdError::Fetch);
  565. }
  566. if let Some(document) = get_cached(conn, &key).await? {
  567. return validate_document(client_id_url, &document, validation);
  568. }
  569. if let Some(error) = is_invalid_cached(conn, &invalid_key).await? {
  570. return Err(error);
  571. }
  572. let ttl_ms = lock_ttl_ms(limits);
  573. if let Some(token) = acquire_lock(conn, &lock, ttl_ms).await? {
  574. let result = resolve_under_lock(
  575. conn,
  576. &key,
  577. &neg_key,
  578. &invalid_key,
  579. client_id_url,
  580. validation,
  581. limits,
  582. )
  583. .await;
  584. // Best effort: a missed release is bounded by the lock TTL.
  585. let _ = release_lock(conn, &lock, &token).await;
  586. return result;
  587. }
  588. let deadline = tokio::time::Instant::now()
  589. .checked_add(limits.timeout)
  590. .and_then(|instant| instant.checked_add(LOCK_WAIT_MARGIN))
  591. .unwrap_or_else(tokio::time::Instant::now);
  592. loop {
  593. if is_negatively_cached(conn, &neg_key).await? {
  594. return Err(CimdError::Fetch);
  595. }
  596. if let Some(document) = get_cached(conn, &key).await? {
  597. return validate_document(client_id_url, &document, validation);
  598. }
  599. if let Some(error) = is_invalid_cached(conn, &invalid_key).await? {
  600. return Err(error);
  601. }
  602. // A holder that failed validation releases without a cache entry; stop
  603. // waiting as soon as the lock is gone and try to elect the next fetcher.
  604. let lock_present: bool = redis::cmd("EXISTS")
  605. .arg(&lock)
  606. .query_async(conn)
  607. .await
  608. .map_err(cache_backend)?;
  609. if !lock_present {
  610. break;
  611. }
  612. let now = tokio::time::Instant::now();
  613. if now >= deadline {
  614. break;
  615. }
  616. let left = deadline.saturating_duration_since(now);
  617. tokio::time::sleep(left.min(LOCK_POLL_INTERVAL)).await;
  618. }
  619. if let Some(token) = acquire_lock(conn, &lock, ttl_ms).await? {
  620. let result = resolve_under_lock(
  621. conn,
  622. &key,
  623. &neg_key,
  624. &invalid_key,
  625. client_id_url,
  626. validation,
  627. limits,
  628. )
  629. .await;
  630. let _ = release_lock(conn, &lock, &token).await;
  631. return result;
  632. }
  633. Err(CimdError::Fetch)
  634. }
  635. /// The lock-held path: re-check the caches (another holder may have just
  636. /// published), then fetch, validate, and cache.
  637. async fn resolve_under_lock(
  638. conn: &mut RedisConn,
  639. key: &str,
  640. neg_key: &str,
  641. invalid_key: &str,
  642. client_id_url: &str,
  643. validation: &CimdValidation<'_>,
  644. limits: CimdLimits,
  645. ) -> Result<ValidatedCimd, CimdError> {
  646. if is_negatively_cached(conn, neg_key).await? {
  647. return Err(CimdError::Fetch);
  648. }
  649. if let Some(document) = get_cached(conn, key).await? {
  650. return validate_document(client_id_url, &document, validation);
  651. }
  652. if let Some(error) = is_invalid_cached(conn, invalid_key).await? {
  653. return Err(error);
  654. }
  655. let document = match fetch_document(client_id_url, limits).await {
  656. Ok(document) => document,
  657. Err(error @ (CimdError::Fetch | CimdError::InvalidJson)) => {
  658. put_negative(conn, neg_key).await?;
  659. return Err(error);
  660. }
  661. Err(error) => return Err(error),
  662. };
  663. let validated = match validate_document(client_id_url, &document, validation) {
  664. Ok(validated) => validated,
  665. Err(error) => {
  666. if put_invalid(conn, invalid_key, InvalidClass::of(&error))
  667. .await
  668. .is_err()
  669. {
  670. tracing::warn!("cimd invalid-document marker write failed");
  671. }
  672. return Err(error);
  673. }
  674. };
  675. if put_cached(conn, key, &document).await.is_err() {
  676. tracing::warn!("cimd cache population failed after validation");
  677. }
  678. Ok(validated)
  679. }
  680. /// The single-flight lock key: `{state}`-prefixed, hash of the URL, never the
  681. /// raw URL.
  682. fn lock_key(token_prefix: &str, client_id_url: &str) -> String {
  683. format!(
  684. "{token_prefix}cimd:lock:{}",
  685. sha256_hex(client_id_url.as_bytes())
  686. )
  687. }
  688. /// Lock lifetime in milliseconds: the fetch budget plus a scheduling margin.
  689. fn lock_ttl_ms(limits: CimdLimits) -> u64 {
  690. u64::try_from(limits.timeout.saturating_add(LOCK_TTL_MARGIN).as_millis()).unwrap_or(u64::MAX)
  691. }
  692. /// Acquire the single-flight lock with `SET NX PX`. The random token is the
  693. /// compare-and-delete value; `None` means another request holds it.
  694. async fn acquire_lock(
  695. conn: &mut RedisConn,
  696. key: &str,
  697. ttl_ms: u64,
  698. ) -> Result<Option<String>, CimdError> {
  699. let token = crate::crypto::tokens::mint_token()
  700. .map_err(|_| CimdError::Backend(AppError::temporarily_unavailable()))?;
  701. let acquired: Option<String> = redis::cmd("SET")
  702. .arg(key)
  703. .arg(&token)
  704. .arg("NX")
  705. .arg("PX")
  706. .arg(ttl_ms)
  707. .query_async(conn)
  708. .await
  709. .map_err(cache_backend)?;
  710. Ok(acquired.map(|_| token))
  711. }
  712. /// Compare-and-delete the single-flight lock, so a holder whose TTL expired
  713. /// cannot release a successor's lock.
  714. static RELEASE_LOCK: LazyLock<redis::Script> = LazyLock::new(|| {
  715. redis::Script::new(
  716. "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end",
  717. )
  718. });
  719. /// Release the lock only when it still holds `token`.
  720. async fn release_lock(conn: &mut RedisConn, key: &str, token: &str) -> Result<(), CimdError> {
  721. let mut invocation = RELEASE_LOCK.prepare_invoke();
  722. invocation.key(key);
  723. invocation.arg(token);
  724. let _: i64 = invocation.invoke_async(conn).await.map_err(cache_backend)?;
  725. Ok(())
  726. }
  727. /// Resolve a CIMD client while loading the enabled scope catalog from
  728. /// Postgres. Convenience wrapper for request handlers.
  729. ///
  730. /// # Errors
  731. ///
  732. /// Returns [`CimdError::Backend`] when the catalog read fails, plus every
  733. /// [`resolve`] failure.
  734. pub async fn resolve_with_catalog(
  735. conn: &mut RedisConn,
  736. token_prefix: &str,
  737. pool: &PgPool,
  738. client_id_url: &str,
  739. allow_localhost: bool,
  740. limits: CimdLimits,
  741. ) -> Result<ValidatedCimd, CimdError> {
  742. let enabled_scopes: HashSet<String> = scope_repo::list_enabled(pool)
  743. .await
  744. .map_err(CimdError::Backend)?
  745. .into_iter()
  746. .map(|record| record.name)
  747. .collect();
  748. resolve(
  749. conn,
  750. token_prefix,
  751. client_id_url,
  752. &CimdValidation {
  753. enabled_scopes: &enabled_scopes,
  754. allow_localhost,
  755. },
  756. limits,
  757. )
  758. .await
  759. }
  760. /// Read a cached document. Malformed cache entries are deleted and treated as
  761. /// a miss; a Redis failure is fail-closed [`CimdError::Backend`].
  762. async fn get_cached(conn: &mut RedisConn, key: &str) -> Result<Option<CimdDocument>, CimdError> {
  763. let raw: Option<String> = redis::cmd("GET")
  764. .arg(key)
  765. .query_async(conn)
  766. .await
  767. .map_err(cache_backend)?;
  768. let Some(raw) = raw else {
  769. return Ok(None);
  770. };
  771. match serde_json::from_str(&raw) {
  772. Ok(document) => Ok(Some(document)),
  773. Err(_) => {
  774. // A corrupt entry is not trusted: drop it and fetch afresh.
  775. let _: Result<i64, _> = redis::cmd("DEL").arg(key).query_async(conn).await;
  776. Ok(None)
  777. }
  778. }
  779. }
  780. /// Whether an origin-level fetch failure is negatively cached. A Redis
  781. /// failure is fail-closed [`CimdError::Backend`].
  782. async fn is_negatively_cached(conn: &mut RedisConn, neg_key: &str) -> Result<bool, CimdError> {
  783. let present: bool = redis::cmd("EXISTS")
  784. .arg(neg_key)
  785. .query_async(conn)
  786. .await
  787. .map_err(cache_backend)?;
  788. Ok(present)
  789. }
  790. /// Record an origin-level fetch failure for [`NEG_CACHE_TTL_SECS`]. A Redis
  791. /// failure is fail-closed [`CimdError::Backend`].
  792. async fn put_negative(conn: &mut RedisConn, neg_key: &str) -> Result<(), CimdError> {
  793. redis::cmd("SET")
  794. .arg(neg_key)
  795. .arg("1")
  796. .arg("EX")
  797. .arg(NEG_CACHE_TTL_SECS)
  798. .query_async::<()>(conn)
  799. .await
  800. .map_err(cache_backend)
  801. }
  802. /// The class of a short-cached fresh-fetch validation failure; `None` when no
  803. /// marker is present. A Redis failure is fail-closed [`CimdError::Backend`].
  804. async fn is_invalid_cached(
  805. conn: &mut RedisConn,
  806. invalid_key: &str,
  807. ) -> Result<Option<CimdError>, CimdError> {
  808. let raw: Option<String> = redis::cmd("GET")
  809. .arg(invalid_key)
  810. .query_async(conn)
  811. .await
  812. .map_err(cache_backend)?;
  813. Ok(
  814. raw
  815. .as_deref()
  816. .map(InvalidClass::from_marker)
  817. .map(InvalidClass::to_error),
  818. )
  819. }
  820. /// Record a fresh-fetch validation failure for [`INVALID_CACHE_TTL_SECS`],
  821. /// tagged with its client-visible class so a retry replays it.
  822. async fn put_invalid(
  823. conn: &mut RedisConn,
  824. invalid_key: &str,
  825. class: InvalidClass,
  826. ) -> Result<(), CimdError> {
  827. redis::cmd("SET")
  828. .arg(invalid_key)
  829. .arg(class.marker())
  830. .arg("EX")
  831. .arg(INVALID_CACHE_TTL_SECS)
  832. .query_async::<()>(conn)
  833. .await
  834. .map_err(cache_backend)
  835. }
  836. /// Cache a validated document for [`CACHE_TTL_SECS`].
  837. async fn put_cached(
  838. conn: &mut RedisConn,
  839. key: &str,
  840. document: &CimdDocument,
  841. ) -> Result<(), CimdError> {
  842. let payload = serde_json::to_string(document).map_err(|_| CimdError::InvalidJson)?;
  843. redis::cmd("SET")
  844. .arg(key)
  845. .arg(payload)
  846. .arg("EX")
  847. .arg(CACHE_TTL_SECS)
  848. .query_async::<()>(conn)
  849. .await
  850. .map_err(cache_backend)
  851. }
  852. /// Map a Redis error to a fail-closed backend error.
  853. fn cache_backend(error: redis::RedisError) -> CimdError {
  854. CimdError::Backend(StoreError::Redis(error).into())
  855. }
  856. /// Redirect URIs are `https`, or plaintext `http` only for an RFC 8252 §7.3
  857. /// loopback host. The loopback decision (including `localhost` behind
  858. /// `allow_localhost`) reuses [`redirect_repo::match_redirect_uri`] so CIMD and
  859. /// registered clients can never disagree. Wildcards are rejected outright.
  860. fn validate_redirect_uris(uris: &[String], allow_localhost: bool) -> Result<(), CimdError> {
  861. if uris.is_empty() {
  862. return Err(CimdError::InvalidRedirectUri);
  863. }
  864. for uri in uris {
  865. if uri.contains('*') {
  866. return Err(CimdError::InvalidRedirectUri);
  867. }
  868. let parsed = reqwest::Url::parse(uri).map_err(|_| CimdError::InvalidRedirectUri)?;
  869. if !matches!(parsed.scheme(), "https" | "http")
  870. || parsed.host_str().is_none_or(str::is_empty)
  871. || !parsed.username().is_empty()
  872. || parsed.password().is_some()
  873. {
  874. return Err(CimdError::InvalidRedirectUri);
  875. }
  876. // `match_redirect_uri` is the single source of truth for the loopback
  877. // exception and rejects plaintext `http` for every non-loopback host.
  878. if redirect_repo::match_redirect_uri(uris, uri, allow_localhost) == RedirectDecision::Reject {
  879. return Err(CimdError::InvalidRedirectUri);
  880. }
  881. }
  882. Ok(())
  883. }
  884. /// Check every requested scope token against the enabled catalog.
  885. fn validate_scope(
  886. scope: Option<&str>,
  887. enabled_scopes: &HashSet<String>,
  888. ) -> Result<String, CimdError> {
  889. let mut accepted = Vec::new();
  890. for token in scope.unwrap_or_default().split_whitespace() {
  891. if !enabled_scopes.contains(token) {
  892. return Err(CimdError::UnknownScope);
  893. }
  894. accepted.push(token);
  895. }
  896. Ok(accepted.join(" "))
  897. }
  898. /// Require a non-empty grant set, each member in [`ALLOWED_GRANTS`]; default
  899. /// to `["authorization_code"]` when absent. Duplicates collapse in first-seen
  900. /// order.
  901. fn validate_grants(grants: Option<&Vec<String>>) -> Result<Vec<String>, CimdError> {
  902. let requested: &[String] = grants.map_or(&[], Vec::as_slice);
  903. if grants.is_some() && requested.is_empty() {
  904. return Err(CimdError::UnsupportedGrantType);
  905. }
  906. let source: Vec<String> = if requested.is_empty() {
  907. vec!["authorization_code".to_owned()]
  908. } else {
  909. requested.to_vec()
  910. };
  911. let mut seen = HashSet::with_capacity(source.len());
  912. let mut out = Vec::with_capacity(source.len());
  913. for grant in source {
  914. if !ALLOWED_GRANTS.contains(&grant.as_str()) {
  915. return Err(CimdError::UnsupportedGrantType);
  916. }
  917. if seen.insert(grant.clone()) {
  918. out.push(grant);
  919. }
  920. }
  921. Ok(out)
  922. }
  923. /// Require an inline JWKS document to carry a non-empty `keys` array. Mirrors
  924. /// the DCR shape check; signature validation belongs to the assertion
  925. /// verifier.
  926. fn validate_jwks_shape(value: &serde_json::Value) -> Result<(), CimdError> {
  927. let has_keys = value
  928. .get("keys")
  929. .and_then(|keys| keys.as_array())
  930. .is_some_and(|keys| !keys.is_empty());
  931. if has_keys {
  932. Ok(())
  933. } else {
  934. Err(CimdError::JwksRequired)
  935. }
  936. }