token_exchange.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. //! Token-exchange grant with policy (RFC 8693 §2-§3).
  2. //!
  3. //! The caller is already authenticated by task 18 before this runs. The
  4. //! exchange additionally requires a freshly verified proof-of-possession on
  5. //! this request ([`SenderProof`]); when the `subject_token` is
  6. //! sender-constrained, that proof must match the SUBJECT token's `cnf`, not
  7. //! merely the caller's. A stored binding alone never mints.
  8. //!
  9. //! Scope is pinned to
  10. //! `subject.scope ∩ caller-registered ∩ subject-user-entitled ∩ enabled ∩
  11. //! allowlist`; an explicit request naming a scope the subject token does not
  12. //! hold is rejected (no widening). Chained exchanges are rejected: a subject
  13. //! token that already carries an [`ActClaim`] sidecar is not exchangeable.
  14. //! No refresh token is ever issued: the service-minted refresh half is
  15. //! revoked before returning.
  16. #[cfg(test)]
  17. mod tests;
  18. use std::time::Duration;
  19. use serde::{Deserialize, Serialize};
  20. use super::super::{ClientKind, Cnf, MintRequest, SenderProof, TokenFormat, TokenService};
  21. use crate::{
  22. config::{GrantKind, TokenExchangeRule},
  23. crypto::tokens as crypto_tokens,
  24. error::AppError,
  25. protocol::scope::{ScopeInput, ScopeSet, effective_scope},
  26. state::RedisConn,
  27. store::{Store, StoreError, durability_barrier, tokens as low},
  28. };
  29. /// Grant-type URN for token exchange (RFC 8693 §2.1).
  30. pub const GRANT_TYPE_TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
  31. /// The only supported `subject_token_type` / `actor_token_type`.
  32. pub const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token";
  33. /// Suffix for the `act` sidecar key hanging off an access-token record key.
  34. const ACT_KEY_SUFFIX: &str = ":act";
  35. /// The `act` (actor) claim bound to an exchanged token (RFC 8693 §4.1).
  36. ///
  37. /// `sub` names the party the new token acts as: the subject token's client.
  38. /// Stored as a JSON sidecar (task-29 precedent) so the frozen task-12 record
  39. /// shape is untouched; introspection (task 36) reads it via [`read_act`].
  40. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  41. pub struct ActClaim {
  42. /// The subject token's client: the party being impersonated/delegated.
  43. pub sub: String,
  44. }
  45. /// Inputs for one token-exchange call.
  46. pub struct ExchangeInput<'a> {
  47. /// Authenticated caller client id.
  48. pub caller_client_id: &'a str,
  49. /// Public or confidential: selects the `cnf` matrix on the new token.
  50. pub caller_kind: ClientKind,
  51. /// Whether a public caller must present a DPoP proof: mirrors the
  52. /// caller's `dpop_bound_access_tokens` registration.
  53. pub caller_dpop_required: bool,
  54. /// Scopes registered to the caller client.
  55. pub caller_registered: &'a ScopeSet,
  56. /// Raw subject token.
  57. pub subject_token: &'a str,
  58. /// `subject_token_type` URN (must be the access-token URN).
  59. pub subject_token_type: &'a str,
  60. /// Raw actor token, when present (validated live, never copied).
  61. pub actor_token: Option<&'a str>,
  62. /// `actor_token_type` URN, when an actor token is present.
  63. pub actor_token_type: Option<&'a str>,
  64. /// `requested_token_type` URN, when present.
  65. pub requested_token_type: Option<&'a str>,
  66. /// Raw `scope` parameter, or `None` when omitted.
  67. pub requested_scope: Option<&'a str>,
  68. /// Scopes the subject token's user is entitled to.
  69. pub subject_user_entitled: &'a ScopeSet,
  70. /// Enabled catalog rows, freshly read at issuance.
  71. pub enabled: &'a ScopeSet,
  72. /// Whether the deployment enables token exchange at all.
  73. pub allow_exchange: bool,
  74. /// Configured `(caller, subject, scopes)` policy.
  75. pub allowlist: &'a [TokenExchangeRule],
  76. /// Freshly verified proofs for this request.
  77. pub proof: &'a SenderProof,
  78. /// Current time as seconds since the Unix epoch.
  79. pub now_unix: i64,
  80. }
  81. /// An exchanged access token: raw value plus its response shaping.
  82. #[derive(Debug, Clone, PartialEq, Eq)]
  83. pub struct ExchangedTokens {
  84. /// Raw access token (return to the caller once; never stored).
  85. pub access_token: String,
  86. /// Storage identity of the access token.
  87. pub access_hash: String,
  88. /// Canonical issued scope.
  89. pub scope: String,
  90. /// Resource-owner subject carried over from the subject token.
  91. pub subject: String,
  92. /// Recorded sender constraint on the new token.
  93. pub cnf: Cnf,
  94. /// The `act` claim bound into the sidecar.
  95. pub act: ActClaim,
  96. /// `token_type` response member: `DPoP` when `jkt`-bound, else `Bearer`.
  97. pub token_type: String,
  98. /// Grant chain the new token joins (for revocation wiring).
  99. pub grant_id: String,
  100. /// Access-token expiry (Unix seconds).
  101. pub access_expires_at: i64,
  102. }
  103. /// Exchange TTL: half the service access TTL (floored at one second), so an
  104. /// exchanged token always outlives nothing it was derived from.
  105. #[must_use]
  106. pub fn exchange_ttl(service: &TokenService) -> Duration {
  107. service
  108. .access_ttl()
  109. .checked_div(2)
  110. .unwrap_or(Duration::from_secs(1))
  111. .max(Duration::from_secs(1))
  112. }
  113. /// Sidecar key holding the [`ActClaim`] JSON for an access-token hash.
  114. #[must_use]
  115. pub fn act_key(store: &Store, access_hash: &str) -> String {
  116. format!("{}{ACT_KEY_SUFFIX}", store.access_key(access_hash))
  117. }
  118. /// Read the [`ActClaim`] sidecar for an access-token hash, if any.
  119. ///
  120. /// Task 36 (introspection) reads the `act` of an exchanged token through
  121. /// this; [`exchange`] uses it to reject chained exchanges.
  122. ///
  123. /// # Errors
  124. ///
  125. /// Returns the store failure, or `server_error` when the sidecar is corrupt.
  126. pub async fn read_act(
  127. conn: &mut RedisConn,
  128. store: &Store,
  129. access_hash: &str,
  130. ) -> Result<Option<ActClaim>, AppError> {
  131. let key = act_key(store, access_hash);
  132. let raw: Option<String> = redis::cmd("GET")
  133. .arg(&key)
  134. .query_async(conn)
  135. .await
  136. .map_err(StoreError::Redis)?;
  137. raw
  138. .map(|value| serde_json::from_str(&value).map_err(|_| AppError::server_error()))
  139. .transpose()
  140. }
  141. /// Perform a token exchange: policy-check, then mint an access-only token.
  142. ///
  143. /// The refresh half minted by the service is revoked before returning, so no
  144. /// refresh token is ever issued.
  145. ///
  146. /// # Errors
  147. ///
  148. /// Returns `invalid_request` for unsupported token types, `invalid_scope`
  149. /// for scope violations, `invalid_grant` for policy/proof failures, and
  150. /// `server_error` for store corruption.
  151. pub async fn exchange(
  152. conn: &mut RedisConn,
  153. store: &Store,
  154. service: &TokenService,
  155. input: &ExchangeInput<'_>,
  156. ) -> Result<ExchangedTokens, AppError> {
  157. if !input.allow_exchange {
  158. return Err(AppError::invalid_grant("token exchange is not enabled"));
  159. }
  160. if input.subject_token_type != TOKEN_TYPE_ACCESS_TOKEN {
  161. return Err(AppError::invalid_request("unsupported subject_token_type"));
  162. }
  163. if let Some(actor_type) = input.actor_token_type
  164. && actor_type != TOKEN_TYPE_ACCESS_TOKEN
  165. {
  166. return Err(AppError::invalid_request("unsupported actor_token_type"));
  167. }
  168. if let Some(requested_type) = input.requested_token_type
  169. && requested_type != TOKEN_TYPE_ACCESS_TOKEN
  170. {
  171. return Err(AppError::invalid_request(
  172. "unsupported requested_token_type",
  173. ));
  174. }
  175. let proof_jkt = input
  176. .proof
  177. .dpop_jkt
  178. .clone()
  179. .filter(|value| !value.is_empty());
  180. let proof_x5t = input
  181. .proof
  182. .mtls_x5t_s256
  183. .clone()
  184. .filter(|value| !value.is_empty());
  185. if proof_jkt.is_none() && proof_x5t.is_none() {
  186. return Err(AppError::invalid_grant(
  187. "exchange requires a freshly verified proof of possession",
  188. ));
  189. }
  190. let subject = service
  191. .find_access(conn, store, input.subject_token)
  192. .await
  193. .map_err(AppError::from)?;
  194. let subject = subject.ok_or_else(|| AppError::invalid_grant("subject token is invalid"))?;
  195. if let Some(actor_raw) = input.actor_token {
  196. let actor = service
  197. .find_access(conn, store, actor_raw)
  198. .await
  199. .map_err(AppError::from)?;
  200. if actor.is_none() {
  201. return Err(AppError::invalid_grant("actor token is invalid"));
  202. }
  203. }
  204. if read_act(conn, store, &subject.hash).await?.is_some() {
  205. return Err(AppError::invalid_grant(
  206. "chained token exchange is not permitted",
  207. ));
  208. }
  209. let subject_cnf = Cnf::parse_storage(&subject.record.cnf).map_err(AppError::from)?;
  210. if let Some(bound) = subject_cnf.jkt.as_deref()
  211. && proof_jkt.as_deref() != Some(bound)
  212. {
  213. return Err(AppError::invalid_grant(
  214. "proof does not match the subject token binding",
  215. ));
  216. }
  217. if let Some(bound) = subject_cnf.x5t_s256.as_deref()
  218. && proof_x5t.as_deref() != Some(bound)
  219. {
  220. return Err(AppError::invalid_grant(
  221. "proof does not match the subject token binding",
  222. ));
  223. }
  224. let rule = input
  225. .allowlist
  226. .iter()
  227. .find(|rule| rule.caller == input.caller_client_id && rule.subject == subject.record.client_id)
  228. .ok_or_else(|| AppError::invalid_grant("exchange is not allowlisted for this pair"))?;
  229. let rule_scopes = ScopeSet::from_names(&rule.scopes).map_err(|_| AppError::server_error())?;
  230. let exchange_allowlist = rule_scopes.intersect(input.subject_user_entitled);
  231. let subject_scopes =
  232. ScopeSet::parse(&subject.record.scope).map_err(|_| AppError::server_error())?;
  233. if let Some(raw) = input.requested_scope {
  234. let requested = ScopeSet::parse(raw)?;
  235. if !requested.is_subset_of(&subject_scopes) {
  236. return Err(AppError::invalid_scope(
  237. "requested scope exceeds the subject token",
  238. ));
  239. }
  240. }
  241. let issued = effective_scope(&ScopeInput {
  242. requested: input.requested_scope,
  243. client_registered: input.caller_registered,
  244. user_entitled: input.subject_user_entitled,
  245. enabled: input.enabled,
  246. grant: GrantKind::TokenExchange,
  247. exchange_allowlist: &exchange_allowlist,
  248. })?;
  249. let issued = issued.intersect(&subject_scopes);
  250. let grant_id = crypto_tokens::mint_token().map_err(AppError::internal)?;
  251. let minted = service
  252. .mint(
  253. conn,
  254. store,
  255. &MintRequest {
  256. client_id: input.caller_client_id.to_owned(),
  257. client_kind: input.caller_kind,
  258. dpop_required: input.caller_dpop_required,
  259. // RFC 8707 is excluded from token exchange (Todo 9 rejects
  260. // `resource`): the minted pair carries no audience, and the subject
  261. // token's audience is never inherited.
  262. // Token exchange never mints a JWT (Todo 26): the dispatcher rejects
  263. // a `jwt`-format caller fail-closed before this runs, so `act` can
  264. // never be wrapped in an `at+jwt`.
  265. subject: subject.record.subject.clone(),
  266. scope: issued.render(),
  267. access_scope: issued.render(),
  268. grant_id,
  269. proof: input.proof.clone(),
  270. aud: Vec::new(),
  271. refresh_aud: Vec::new(),
  272. format: TokenFormat::Opaque,
  273. },
  274. input.now_unix,
  275. )
  276. .await
  277. .map_err(AppError::from)?;
  278. service
  279. .revoke_token(conn, store, &minted.refresh_token)
  280. .await
  281. .map_err(AppError::from)?;
  282. let ttl = exchange_ttl(service);
  283. let grant_id = minted_grant_of(conn, store, &minted.access_hash).await?;
  284. let record = low::AccessRecord {
  285. client_id: input.caller_client_id.to_owned(),
  286. grant_id: grant_id.clone(),
  287. scope: issued.render(),
  288. subject: subject.record.subject.clone(),
  289. cnf: minted.cnf.to_storage().map_err(AppError::from)?,
  290. aud: Vec::new(),
  291. };
  292. low::put_access(conn, store, &minted.access_hash, &record, ttl)
  293. .await
  294. .map_err(AppError::from)?;
  295. let act = ActClaim {
  296. sub: subject.record.client_id.clone(),
  297. };
  298. let act_raw = serde_json::to_string(&act).map_err(AppError::internal)?;
  299. let key = act_key(store, &minted.access_hash);
  300. let _: () = redis::cmd("SET")
  301. .arg(&key)
  302. .arg(&act_raw)
  303. .arg("PX")
  304. .arg(ttl.as_millis() as u64)
  305. .query_async(conn)
  306. .await
  307. .map_err(StoreError::Redis)?;
  308. durability_barrier(conn, store)
  309. .await
  310. .map_err(AppError::from)?;
  311. let token_type = if minted.cnf.jkt.is_some() {
  312. "DPoP".to_owned()
  313. } else {
  314. "Bearer".to_owned()
  315. };
  316. Ok(ExchangedTokens {
  317. access_token: minted.access_token,
  318. access_hash: minted.access_hash,
  319. scope: issued.render(),
  320. subject: subject.record.subject,
  321. cnf: minted.cnf,
  322. act,
  323. token_type,
  324. grant_id,
  325. access_expires_at: input.now_unix.saturating_add(ttl.as_secs() as i64),
  326. })
  327. }
  328. /// Recover the grant id the service minted into, via the stored record.
  329. async fn minted_grant_of(
  330. conn: &mut RedisConn,
  331. store: &Store,
  332. access_hash: &str,
  333. ) -> Result<String, AppError> {
  334. let record = low::get_access(conn, store, access_hash)
  335. .await
  336. .map_err(AppError::from)?;
  337. record.map_or_else(
  338. || Err(AppError::server_error()),
  339. |record| Ok(record.grant_id),
  340. )
  341. }