revoke.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. //! Token revocation (RFC 7009) with family cascade.
  2. //!
  3. //! `POST /revoke` authenticates the caller (see [`super::client_auth`]),
  4. //! accepts an optional `token_type_hint`, and invalidates the presented
  5. //! token. Revoking a refresh token cascades to its grant family, so every
  6. //! token minted from the same authorization grant dies with it.
  7. //!
  8. //! # Ownership and non-oracle semantics
  9. //!
  10. //! A client may revoke only a token it owns. The lookup is performed before
  11. //! the ownership decision, and a token that is unknown, already gone, or
  12. //! **owned by another client** collapses to the same `200` no-op response —
  13. //! the endpoint never reveals whether a token exists and never cross-revokes.
  14. //!
  15. //! Well-formed requests always answer `200 OK` (RFC 7009 §2.2): unknown and
  16. //! invalid tokens are not an error. Client authentication failure is the only
  17. //! failure for an otherwise valid request (`invalid_client`); a token-type
  18. //! hint naming an unsupported type is `unsupported_token_type`.
  19. use std::collections::HashMap;
  20. use axum::{
  21. Extension, Form, Json,
  22. extract::State,
  23. http::{HeaderMap, StatusCode, header},
  24. response::{IntoResponse, Response},
  25. };
  26. use secrecy::SecretString;
  27. use serde::Serialize;
  28. use sqlx::PgPool;
  29. use time::OffsetDateTime;
  30. use super::{
  31. client_auth::{AuthContext, AuthStores, ClientAuthRequest, authenticate_client},
  32. token::TokenService,
  33. };
  34. use crate::{
  35. error::AppError,
  36. jose::jwt_access::{JwtAccessSigner, is_jwt_shape, jti_hash},
  37. middleware::{
  38. audit::AuditEvent,
  39. request_id::{REQUEST_ID_HEADER, RequestId},
  40. },
  41. state::{AppState, RedisConn},
  42. store::{Store, StoreError, durability_barrier},
  43. tls::PeerCertificates,
  44. };
  45. /// Supported `token_type_hint` value for access tokens (RFC 7009 §2.1).
  46. const HINT_ACCESS: &str = "access_token";
  47. /// Supported `token_type_hint` value for refresh tokens (RFC 7009 §2.1).
  48. const HINT_REFRESH: &str = "refresh_token";
  49. /// Client-safe description for `unsupported_token_type`.
  50. const UNSUPPORTED_TOKEN_TYPE: &str =
  51. "The authorization server does not support the revocation of the presented token type.";
  52. /// Datastore handles needed by [`revoke_tokens`].
  53. pub struct RevokeStores<'a> {
  54. /// Postgres pool for client authentication.
  55. pub pool: &'a PgPool,
  56. /// Versioned PII keys for the encrypted client lookup.
  57. pub keys: &'a crate::keys::KeyRing,
  58. /// Redis connection for the token and grant stores.
  59. pub conn: &'a mut RedisConn,
  60. /// Key layout plus retry/durability knobs.
  61. pub store: &'a Store,
  62. /// Token lookup and revocation primitives.
  63. pub service: &'a TokenService,
  64. }
  65. /// Request-scoped authentication and audit inputs for [`revoke_tokens`].
  66. pub struct RevokeContext<'a> {
  67. /// Canonical token endpoint URL used as the `private_key_jwt` audience.
  68. pub token_endpoint: &'a str,
  69. /// Current time for client-secret expiry enforcement.
  70. pub now: OffsetDateTime,
  71. /// CIMD resolution settings for a registration-free fallback; `None` when
  72. /// `config.cimd_enabled` is off (RFC 7009 §2.1: a registration-free
  73. /// client revokes its own token through this same path).
  74. pub cimd: Option<crate::protocol::cimd::CimdSettings<'a>>,
  75. /// Correlating request id for the audit event, when present.
  76. pub request_id: Option<&'a str>,
  77. }
  78. /// One revocation request: the token, its optional hint, and credentials.
  79. pub struct RevokeRequest<'a> {
  80. /// The token the client wants revoked (required, never empty).
  81. pub token: &'a str,
  82. /// Optional RFC 7009 `token_type_hint`.
  83. pub token_type_hint: Option<&'a str>,
  84. /// Client credentials from the `Authorization` header and the form body.
  85. pub auth: &'a ClientAuthRequest<'a>,
  86. }
  87. /// Outcome of a well-formed revocation request.
  88. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  89. pub enum RevokeOutcome {
  90. /// The caller owned the token; it (and its family for a refresh) is gone.
  91. Revoked,
  92. /// Unknown, already revoked, or owned by another client — no-op `200`.
  93. NoOp,
  94. /// The `token_type_hint` names no supported token type.
  95. UnsupportedHint,
  96. }
  97. /// The token to revoke plus the authenticated owner that must match its record.
  98. struct OwnedToken<'a> {
  99. /// Client that authenticated this request.
  100. owner_client_id: &'a str,
  101. /// Presented token value.
  102. token: &'a str,
  103. /// Optional type hint (used only to reject unsupported values).
  104. token_type_hint: Option<&'a str>,
  105. }
  106. /// Authenticate the caller, then revoke the token if (and only if) the caller
  107. /// owns it.
  108. ///
  109. /// A completely unauthenticated request (no `Authorization` header, body
  110. /// secret, client assertion, or `client_id`) is `401` + `WWW-Authenticate`,
  111. /// because the endpoint is a protected resource (RFC 7009 §2.1).
  112. ///
  113. /// `jwt_signer` carries the AS access-token signer for the JWT allowlist path
  114. /// (built from the config by the handler only when the presented token has
  115. /// JWT shape and the operator switch is on); `None` leaves a JWT-shaped token
  116. /// as a silent no-op. It is never consulted on the opaque path.
  117. ///
  118. /// # Errors
  119. ///
  120. /// Returns `invalid_request` for a missing token, `invalid_client` for failed
  121. /// authentication, and a masked store failure otherwise.
  122. pub async fn revoke_tokens(
  123. stores: &mut RevokeStores<'_>,
  124. ctx: &RevokeContext<'_>,
  125. request: &RevokeRequest<'_>,
  126. jwt_signer: Option<&JwtAccessSigner>,
  127. ) -> Result<RevokeOutcome, AppError> {
  128. if request.token.is_empty() {
  129. return Err(AppError::invalid_request("token is required"));
  130. }
  131. if is_unauthenticated(request.auth) {
  132. return Err(AppError::invalid_client_basic());
  133. }
  134. let authenticated = {
  135. let mut auth_stores = AuthStores {
  136. pool: stores.pool,
  137. keys: stores.keys,
  138. conn: stores.conn,
  139. store: stores.store,
  140. };
  141. let auth_ctx = AuthContext {
  142. token_endpoint: ctx.token_endpoint,
  143. now: ctx.now,
  144. cimd: ctx.cimd,
  145. };
  146. authenticate_client(&mut auth_stores, &auth_ctx, request.auth).await?
  147. };
  148. let owned = OwnedToken {
  149. owner_client_id: &authenticated.client.client_id,
  150. token: request.token,
  151. token_type_hint: request.token_type_hint,
  152. };
  153. let outcome = revoke_owned(
  154. stores.conn,
  155. stores.store,
  156. stores.service,
  157. &owned,
  158. jwt_signer,
  159. )
  160. .await?;
  161. if matches!(outcome, RevokeOutcome::Revoked) {
  162. AuditEvent::TokenRevoke {
  163. request_id: ctx.request_id,
  164. client_id: &authenticated.client.client_id,
  165. }
  166. .emit();
  167. }
  168. Ok(outcome)
  169. }
  170. /// Whether no client credential of any style was presented.
  171. fn is_unauthenticated(auth: &ClientAuthRequest<'_>) -> bool {
  172. auth.basic_header.is_none()
  173. && auth.body_secret.is_none()
  174. && auth.assertion.is_none()
  175. && auth.client_id.is_none()
  176. }
  177. /// Revoke a token owned by `owner_client_id`.
  178. ///
  179. /// Both record slots are probed before the ownership decision so the existence
  180. /// lookup is uniform; a foreign owner returns [`RevokeOutcome::NoOp`] without
  181. /// touching the store. A refresh token revokes the whole grant chain; an access
  182. /// token revokes only itself.
  183. ///
  184. /// A JWT-shaped token (RFC 9068 §4 allowlist) misses both opaque slots by
  185. /// construction and falls through to [`revoke_jwt_owned`]: signature plus
  186. /// `iss` are verified, ownership is enforced from the verified `client_id`
  187. /// (and the marker's own `client_id` field), and only then are the marker and
  188. /// the access record/member deleted. Every miss — malformed, foreign-key,
  189. /// expired, wrong-issuer, foreign-owner, or already revoked — collapses to
  190. /// [`RevokeOutcome::NoOp`] (uniform HTTP 200 per RFC 7009 §2.2); a foreign
  191. /// token is never revoked.
  192. async fn revoke_owned(
  193. conn: &mut RedisConn,
  194. store: &Store,
  195. service: &TokenService,
  196. owned: &OwnedToken<'_>,
  197. jwt_signer: Option<&JwtAccessSigner>,
  198. ) -> Result<RevokeOutcome, AppError> {
  199. match owned.token_type_hint.filter(|hint| !hint.is_empty()) {
  200. None | Some(HINT_ACCESS | HINT_REFRESH) => {}
  201. Some(_) => return Ok(RevokeOutcome::UnsupportedHint),
  202. }
  203. let access = service.find_access(conn, store, owned.token).await?;
  204. let refresh = service.find_refresh(conn, store, owned.token).await?;
  205. if let Some(found) = access {
  206. if found.record.client_id != owned.owner_client_id {
  207. return Ok(RevokeOutcome::NoOp);
  208. }
  209. service.revoke_token(conn, store, owned.token).await?;
  210. return Ok(RevokeOutcome::Revoked);
  211. }
  212. if let Some(found) = refresh {
  213. if found.record.client_id != owned.owner_client_id {
  214. return Ok(RevokeOutcome::NoOp);
  215. }
  216. service
  217. .revoke_grant(conn, store, &found.record.grant_id)
  218. .await?;
  219. return Ok(RevokeOutcome::Revoked);
  220. }
  221. if is_jwt_shape(owned.token) {
  222. return revoke_jwt_owned(conn, store, owned, jwt_signer).await;
  223. }
  224. Ok(RevokeOutcome::NoOp)
  225. }
  226. /// Revoke a JWT access token owned by `owner_client_id` (RFC 7009 §2.1).
  227. ///
  228. /// The token must verify under the AS signer (header `typ`/`alg`/`kid`,
  229. /// signature, `iss`, `exp`; `expected_aud` is `None` because revocation is
  230. /// audience-agnostic) and the verified `client_id` must equal the caller. The
  231. /// `jwt:<jti-hash>` marker is then read: an absent marker means the token is
  232. /// already revoked (`NoOp`), and a marker whose `client_id` names another
  233. /// client is never touched (`NoOp`). Otherwise the marker hash, the
  234. /// `access:jwt:<jti-hash>` record, and the grant-set member are deleted — the
  235. /// honest equivalent of [`TokenService::revoke_token`] for the member identity
  236. /// `jwt:<jti-hash>` (a `SREM` from the grant set plus `DEL` of the record
  237. /// key), barriered before returning.
  238. async fn revoke_jwt_owned(
  239. conn: &mut RedisConn,
  240. store: &Store,
  241. owned: &OwnedToken<'_>,
  242. jwt_signer: Option<&JwtAccessSigner>,
  243. ) -> Result<RevokeOutcome, AppError> {
  244. let signer = match jwt_signer {
  245. Some(signer) => signer,
  246. None => return Ok(RevokeOutcome::NoOp),
  247. };
  248. let verified = match signer.verify(owned.token, None) {
  249. Ok(verified) => verified,
  250. Err(_) => return Ok(RevokeOutcome::NoOp),
  251. };
  252. if verified.claims.client_id != owned.owner_client_id {
  253. return Ok(RevokeOutcome::NoOp);
  254. }
  255. let hash = jti_hash(&verified.claims.jti);
  256. let marker_key = store.jwt_marker_key(&hash);
  257. let fields: HashMap<String, String> = redis::cmd("HGETALL")
  258. .arg(&marker_key)
  259. .query_async(conn)
  260. .await
  261. .map_err(StoreError::Redis)?;
  262. if fields.is_empty() {
  263. return Ok(RevokeOutcome::NoOp);
  264. }
  265. if fields.get("client_id").map(String::as_str) != Some(owned.owner_client_id) {
  266. return Ok(RevokeOutcome::NoOp);
  267. }
  268. let member = format!("jwt:{hash}");
  269. let record_key = store.access_key(&member);
  270. let grant_id = fields.get("grant_id").cloned().unwrap_or_default();
  271. let set_key = store.grant_tokens_key(&grant_id);
  272. let _: () = redis::cmd("DEL")
  273. .arg(&record_key)
  274. .arg(&marker_key)
  275. .query_async(conn)
  276. .await
  277. .map_err(StoreError::Redis)?;
  278. if !grant_id.is_empty() {
  279. let _: () = redis::cmd("SREM")
  280. .arg(&set_key)
  281. .arg(&member)
  282. .query_async(conn)
  283. .await
  284. .map_err(StoreError::Redis)?;
  285. }
  286. durability_barrier(conn, store)
  287. .await
  288. .map_err(AppError::from)?;
  289. Ok(RevokeOutcome::Revoked)
  290. }
  291. /// Render a revocation outcome: `200 OK` with an empty body for success and
  292. /// no-ops, `400` with `unsupported_token_type` for a rejected hint.
  293. fn outcome_response(outcome: RevokeOutcome) -> Response {
  294. match outcome {
  295. RevokeOutcome::Revoked | RevokeOutcome::NoOp => StatusCode::OK.into_response(),
  296. RevokeOutcome::UnsupportedHint => (
  297. StatusCode::BAD_REQUEST,
  298. Json(ErrorBody {
  299. error: "unsupported_token_type",
  300. error_description: UNSUPPORTED_TOKEN_TYPE,
  301. }),
  302. )
  303. .into_response(),
  304. }
  305. }
  306. /// The JSON body for a locally-owned revocation error code.
  307. #[derive(Debug, Serialize)]
  308. struct ErrorBody {
  309. /// The wire error code.
  310. error: &'static str,
  311. /// A client-safe description.
  312. error_description: &'static str,
  313. }
  314. /// `POST /revoke` (RFC 7009 §2.1).
  315. ///
  316. /// Credentials come from the `Authorization` header and the form body only; a
  317. /// secret in the query string is never consulted. mTLS-registered clients
  318. /// (RFC 8705 §2) authenticate with `client_id` plus the verified peer chain
  319. /// injected by the TLS accept loop, exactly as the token and introspection
  320. /// endpoints do; a missing or unregistered chain fails closed as
  321. /// `invalid_client`. No forwarded header is ever trusted: only the in-process
  322. /// [`PeerCertificates`] extension counts as mTLS evidence.
  323. ///
  324. /// Certificate revocation story: client certificates are expected to be
  325. /// short-lived, so expiry bounds exposure without any revocation list; a
  326. /// CRL/OCSP check at authentication time is the hook point if deployments
  327. /// later need explicit revocation (verify freshness there, fail closed on
  328. /// outage, never cache a good verdict past the response's validity window).
  329. /// All well-formed requests answer `200 OK`, including unknown or foreign
  330. /// tokens.
  331. ///
  332. /// # Errors
  333. ///
  334. /// Returns the [`revoke_tokens`] failure: `invalid_client` for failed
  335. /// authentication and a masked `server_error` when storage fails. A rejected
  336. /// `token_type_hint` is rendered as `400 unsupported_token_type`.
  337. pub async fn revoke_handler(
  338. State(state): State<AppState>,
  339. peer_certs: Option<Extension<PeerCertificates>>,
  340. headers: HeaderMap,
  341. Form(form): Form<HashMap<String, String>>,
  342. ) -> Result<Response, AppError> {
  343. let service = TokenService::new(&state.config.ttl, &state.keys)?;
  344. let auth = ClientAuthRequest {
  345. basic_header: headers
  346. .get(header::AUTHORIZATION)
  347. .and_then(|value| value.to_str().ok()),
  348. client_id: form.get("client_id").map(String::as_str),
  349. body_secret: form
  350. .get("client_secret")
  351. .map(|secret| SecretString::from(secret.as_str())),
  352. assertion_type: form.get("client_assertion_type").map(String::as_str),
  353. assertion: form.get("client_assertion").map(String::as_str),
  354. peer_certs: peer_certs.as_deref(),
  355. };
  356. let mut redis = state.redis.clone();
  357. let store = Store::from_config(&state.config.redis);
  358. let mut stores = RevokeStores {
  359. pool: &state.pool,
  360. keys: &state.keys,
  361. conn: &mut redis,
  362. store: &store,
  363. service: &service,
  364. };
  365. let token_endpoint = super::client_auth::token_endpoint(&state.config.issuer);
  366. let request_id = headers
  367. .get(REQUEST_ID_HEADER)
  368. .and_then(|value| value.to_str().ok())
  369. .and_then(RequestId::parse);
  370. let ctx = RevokeContext {
  371. token_endpoint: &token_endpoint,
  372. now: state.clock.now(),
  373. cimd: state
  374. .config
  375. .cimd_enabled
  376. .then(|| crate::protocol::cimd::CimdSettings {
  377. token_prefix: &state.config.redis.token_key_prefix,
  378. max_bytes: state.config.cimd_max_bytes,
  379. timeout: state.config.cimd_fetch_timeout,
  380. allow_localhost: state.config.allow_localhost_redirect,
  381. }),
  382. request_id: request_id.as_ref().map(RequestId::as_str),
  383. };
  384. let request = RevokeRequest {
  385. token: form.get("token").map(String::as_str).unwrap_or_default(),
  386. token_type_hint: form.get("token_type_hint").map(String::as_str),
  387. auth: &auth,
  388. };
  389. // The signer is built only on the JWT-shaped path: opaque revocations never
  390. // touch the signing config. A missing key or a disabled switch leaves a
  391. // JWT-shaped token as a silent no-op.
  392. let jwt_signer = if is_jwt_shape(request.token) && state.config.jwt_access_tokens_enabled {
  393. JwtAccessSigner::from_config(&state.config).ok()
  394. } else {
  395. None
  396. };
  397. let outcome = revoke_tokens(&mut stores, &ctx, &request, jwt_signer.as_ref()).await?;
  398. Ok(outcome_response(outcome))
  399. }
  400. #[cfg(test)]
  401. mod tests;