refresh_token.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. //! Refresh-token rotation grant (RFC 9700 §2.2.2/§4.14; RFC 9449 §5–§6).
  2. //!
  3. //! The caller authenticates the client (task 18) and verifies the sender
  4. //! proofs fresh on this request (tasks 20/21) before calling [`refresh`];
  5. //! this grant matches those proofs against the stored `cnf` binding, narrows
  6. //! the scope, mints the replacement pair, and rotates atomically through
  7. //! [`TokenService::rotate`] (task-12 Lua compare-and-set with tombstone and
  8. //! request-id mapping).
  9. //!
  10. //! Ordering is the whole contract: the new raw pair is minted server-side
  11. //! FIRST (barrier deferred to the rotation, which covers the pre-mint writes
  12. //! on the same connection), keyed by the deterministic `rotation_request_id`,
  13. //! and only then is the Lua script invoked. A barrier failure after the
  14. //! originating write applied resolves to the minted pair
  15. //! (`RotatedUnbarriered`, returned as-is); a lost post-`EXEC` reply is
  16. //! recovered by re-reading the request-id mapping (same hashes, never a
  17. //! double rotation, still rejected without cascading); only a tombstone with
  18. //! no valid mapping triggers the grant-cascade reuse path. A retry that hits
  19. //! a live mapping is rejected WITHOUT cascading: the raw tokens of the first
  20. //! rotation are unrecoverable from hashes alone, so the retry cannot be
  21. //! served, but the grant family is left intact.
  22. #[cfg(test)]
  23. mod tests;
  24. use super::super::{ClientKind, Cnf, MintRequest, MintedTokens, SenderProof, TokenService};
  25. use crate::{
  26. db::{
  27. user_repo::{self, SubjectAccountStatus},
  28. user_scope_repo,
  29. },
  30. error::AppError,
  31. keys::KeyRing,
  32. protocol::scope::{ScopeSet, narrow_on_refresh},
  33. state::RedisConn,
  34. store::{
  35. Store,
  36. grants::revoke_by_token_hash,
  37. tokens::{AccessRecord, RefreshRecord, RotateOutcome, RotationInput},
  38. },
  39. };
  40. /// `grant_type` value for refresh-token rotation (RFC 6749 §6).
  41. pub const GRANT_TYPE_REFRESH_TOKEN: &str = "refresh_token";
  42. /// Inputs for one refresh-token rotation call.
  43. pub struct RefreshInput<'a> {
  44. /// Authenticated client id (must own the presented token).
  45. pub client_id: &'a str,
  46. /// Public or confidential: selects the `cnf` matrix on the new token.
  47. pub client_kind: ClientKind,
  48. /// Raw presented refresh token.
  49. pub refresh_token: &'a str,
  50. /// Raw `scope` parameter, or `None` when omitted (keeps the grant scope).
  51. pub requested_scope: Option<&'a str>,
  52. /// Freshly verified proofs for this request (never a stored binding).
  53. pub proof: &'a SenderProof,
  54. /// Enabled catalog rows, freshly read at issuance.
  55. pub enabled: &'a ScopeSet,
  56. /// Current scopes registered to the authenticated client (fresh parse of
  57. /// the client's `scope` at issuance).
  58. pub client_registered: &'a ScopeSet,
  59. /// Current user entitlements. [`refresh_with_subject_check`] replaces this
  60. /// with a fresh Postgres read for real users; direct [`refresh`] callers
  61. /// (Redis-only tests) supply the term themselves.
  62. pub user_entitled: &'a ScopeSet,
  63. /// Current time as seconds since the Unix epoch.
  64. pub now_unix: i64,
  65. }
  66. /// A rotated token pair: raw values plus response shaping.
  67. #[derive(Debug, Clone, PartialEq, Eq)]
  68. pub struct RefreshedTokens {
  69. /// Raw access token (return to the caller once; never stored).
  70. pub access_token: String,
  71. /// Raw refresh token (return to the caller once; never stored).
  72. pub refresh_token: String,
  73. /// Space-joined effective scope actually granted.
  74. pub scope: String,
  75. /// `DPoP` for sender-bound tokens, `Bearer` otherwise.
  76. pub token_type: String,
  77. /// Access-token lifetime, seconds.
  78. pub expires_in: u64,
  79. /// Grant chain the rotated pair joins (unchanged across rotations).
  80. pub grant_id: String,
  81. }
  82. /// Datastore handles for the use-time disabled-user re-check in
  83. /// [`refresh_with_subject_check`]. `refresh` itself stays Redis-only so its
  84. /// existing callers keep compiling; the dispatcher must prefer the checked
  85. /// entry point (see the note there).
  86. pub struct RefreshUserStores<'a> {
  87. /// Postgres pool for the `users.disabled` lookup.
  88. pub pool: &'a sqlx::PgPool,
  89. /// Versioned PII keys for the email blind-index lookup.
  90. pub keys: &'a KeyRing,
  91. }
  92. /// Rotate with a fail-closed use-time disabled-user re-check and fresh
  93. /// entitlement enforcement.
  94. ///
  95. /// The presented refresh token is located first; when its non-empty subject
  96. /// resolves to a disabled user the grant is rejected with `invalid_grant`
  97. /// BEFORE anything is minted or rotated, so the presented token stays live
  98. /// for a retry after re-enable. Otherwise the current user entitlements are
  99. /// read fresh from Postgres (a subject that maps to no user — empty
  100. /// client-credentials subjects, synthetic principals — carries no user term)
  101. /// and the rotation narrows against the current `client_registered`,
  102. /// `user_entitled`, and `enabled` sets, so a revoked entitlement or a removed
  103. /// client scope drops out on refresh instead of surviving for the token's
  104. /// whole lifetime. The disabled check preserves unknown-token,
  105. /// tombstone-cascade, and proof-check behavior. A datastore failure propagates
  106. /// as an error.
  107. ///
  108. /// # Errors
  109. ///
  110. /// Returns `invalid_grant` for a disabled subject, plus every error
  111. /// [`refresh`] can return.
  112. pub async fn refresh_with_subject_check(
  113. conn: &mut RedisConn,
  114. store: &Store,
  115. service: &TokenService,
  116. users: &RefreshUserStores<'_>,
  117. input: &RefreshInput<'_>,
  118. ) -> Result<RefreshedTokens, AppError> {
  119. let raw = input.refresh_token.trim();
  120. let found = if raw.is_empty() {
  121. None
  122. } else {
  123. service
  124. .find_refresh(conn, store, raw)
  125. .await
  126. .map_err(AppError::from)?
  127. };
  128. let Some(found) = found else {
  129. return refresh(conn, store, service, input).await;
  130. };
  131. if !found.record.subject.trim().is_empty()
  132. && matches!(
  133. user_repo::subject_account_status(users.pool, users.keys, &found.record.subject).await?,
  134. SubjectAccountStatus::Disabled
  135. )
  136. {
  137. return Err(AppError::invalid_grant("account is disabled"));
  138. }
  139. let entitled =
  140. match user_repo::subject_user_id(users.pool, users.keys, &found.record.subject).await? {
  141. Some(id) => ScopeSet::from_names(&user_scope_repo::list_for_user(users.pool, id).await?)?,
  142. None => ScopeSet::parse(&found.record.scope).map_err(|_| AppError::server_error())?,
  143. };
  144. let fresh = RefreshInput {
  145. client_id: input.client_id,
  146. client_kind: input.client_kind,
  147. refresh_token: input.refresh_token,
  148. requested_scope: input.requested_scope,
  149. proof: input.proof,
  150. enabled: input.enabled,
  151. client_registered: input.client_registered,
  152. user_entitled: &entitled,
  153. now_unix: input.now_unix,
  154. };
  155. refresh(conn, store, service, &fresh).await
  156. }
  157. /// Rotate a refresh token: fresh proof-of-possession, scope narrowing, then
  158. /// mint-first atomic rotation with tombstone reuse detection.
  159. ///
  160. /// The stored `cnf` alone never mints: every bound component must equal its
  161. /// freshly verified counterpart. Scope may only narrow (`invalid_scope`
  162. /// otherwise). The replacement pair is minted BEFORE the Lua script runs
  163. /// (barrier deferred to the rotation, which gates the response), so every
  164. /// non-`Rotated`/`RotatedUnbarriered` outcome revokes the orphaned pre-mint.
  165. /// A barrier failure after the originating write applied returns the minted
  166. /// pair as tokens instead of locking out; a mapping replay still discards
  167. /// the orphans and rejects without cascading.
  168. ///
  169. /// # Errors
  170. ///
  171. /// Returns `invalid_request` for a missing client or token, `invalid_grant`
  172. /// for an unknown, foreign, already-rotated, or proof-mismatched token (reuse
  173. /// additionally cascade-revokes the grant family), `invalid_scope` for scope
  174. /// widening, and `server_error`/`temporarily_unavailable` for store failures.
  175. pub async fn refresh(
  176. conn: &mut RedisConn,
  177. store: &Store,
  178. service: &TokenService,
  179. input: &RefreshInput<'_>,
  180. ) -> Result<RefreshedTokens, AppError> {
  181. let client_id = input.client_id.trim();
  182. let raw = input.refresh_token.trim();
  183. if client_id.is_empty() || raw.is_empty() {
  184. return Err(AppError::invalid_request(
  185. "client_id and refresh_token are required",
  186. ));
  187. }
  188. let found = match service
  189. .find_refresh(conn, store, raw)
  190. .await
  191. .map_err(AppError::from)?
  192. {
  193. Some(found) => found,
  194. None => return unknown_or_tombstoned(conn, store, service, raw).await,
  195. };
  196. if found.record.client_id != client_id {
  197. return Err(AppError::invalid_grant(
  198. "refresh token was issued to another client",
  199. ));
  200. }
  201. check_proofs(input.client_kind, &found.record.cnf, input.proof)?;
  202. let previous = ScopeSet::parse(&found.record.scope).map_err(|_| AppError::server_error())?;
  203. // An empty subject is a client-credentials-style principal with no user
  204. // term; the previous grant itself stands in so only the client and catalog
  205. // sets narrow.
  206. let user_term = if found.record.subject.trim().is_empty() {
  207. &previous
  208. } else {
  209. input.user_entitled
  210. };
  211. let narrowed = narrow_on_refresh(
  212. &previous,
  213. input.requested_scope,
  214. input.enabled,
  215. input.client_registered,
  216. user_term,
  217. )?;
  218. let minted = service
  219. .mint_for_rotation(
  220. conn,
  221. store,
  222. &MintRequest {
  223. client_id: client_id.to_owned(),
  224. client_kind: input.client_kind,
  225. subject: found.record.subject.clone(),
  226. scope: narrowed.render(),
  227. grant_id: found.record.grant_id.clone(),
  228. proof: input.proof.clone(),
  229. },
  230. input.now_unix,
  231. )
  232. .await
  233. .map_err(AppError::from)?;
  234. let request_id = service
  235. .request_id_for(&found.hash)
  236. .map_err(AppError::from)?;
  237. let cnf_json = minted.cnf.to_storage().map_err(AppError::from)?;
  238. let rotation = RotationInput {
  239. old_refresh_hash: found.hash.clone(),
  240. expected_client_id: client_id.to_owned(),
  241. new_refresh_hash: minted.refresh_hash.clone(),
  242. new_access_hash: minted.access_hash.clone(),
  243. new_refresh: RefreshRecord {
  244. client_id: client_id.to_owned(),
  245. grant_id: found.record.grant_id.clone(),
  246. scope: narrowed.render(),
  247. subject: found.record.subject.clone(),
  248. cnf: cnf_json.clone(),
  249. },
  250. new_access: AccessRecord {
  251. client_id: client_id.to_owned(),
  252. grant_id: found.record.grant_id.clone(),
  253. scope: narrowed.render(),
  254. subject: found.record.subject.clone(),
  255. cnf: cnf_json,
  256. },
  257. new_refresh_ttl: service.refresh_ttl(),
  258. new_access_ttl: service.access_ttl(),
  259. request_id: Some(request_id),
  260. };
  261. match service
  262. .rotate(conn, store, &rotation)
  263. .await
  264. .map_err(AppError::from)?
  265. {
  266. RotateOutcome::Rotated { .. } | RotateOutcome::RotatedUnbarriered { .. } => Ok(response(
  267. service,
  268. &minted,
  269. &narrowed,
  270. &found.record.grant_id,
  271. )),
  272. RotateOutcome::Replay { .. } => {
  273. discard_orphan(conn, store, service, &minted).await?;
  274. Err(AppError::invalid_grant("refresh token was already rotated"))
  275. }
  276. RotateOutcome::Reused { .. } => {
  277. discard_orphan(conn, store, service, &minted).await?;
  278. Err(AppError::invalid_grant("refresh token reuse detected"))
  279. }
  280. RotateOutcome::Absent | RotateOutcome::Mismatch => {
  281. discard_orphan(conn, store, service, &minted).await?;
  282. Err(AppError::invalid_grant("refresh token is invalid"))
  283. }
  284. }
  285. }
  286. /// Reject an unknown refresh token; when the raw value resolves to a rotation
  287. /// tombstone, cascade-revoke the grant family first.
  288. async fn unknown_or_tombstoned(
  289. conn: &mut RedisConn,
  290. store: &Store,
  291. service: &TokenService,
  292. raw: &str,
  293. ) -> Result<RefreshedTokens, AppError> {
  294. if let Some(target) = service
  295. .consume_tombstone(conn, store, raw)
  296. .await
  297. .map_err(AppError::from)?
  298. {
  299. revoke_by_token_hash(conn, store, &target)
  300. .await
  301. .map_err(AppError::from)?;
  302. }
  303. Err(AppError::invalid_grant("refresh token is invalid"))
  304. }
  305. /// Match freshly verified proofs against the stored `cnf` binding.
  306. ///
  307. /// At least one proof must be present on this request, and every bound
  308. /// component of the stored claim must equal its fresh counterpart. A public
  309. /// grant always carries a `jkt` (mint-enforced); its absence here is a
  310. /// fail-closed invariant violation.
  311. fn check_proofs(kind: ClientKind, stored: &str, proof: &SenderProof) -> Result<(), AppError> {
  312. let bound = Cnf::parse_storage(stored).map_err(AppError::from)?;
  313. let jkt = proof.dpop_jkt.clone().filter(|value| !value.is_empty());
  314. let x5t = proof
  315. .mtls_x5t_s256
  316. .clone()
  317. .filter(|value| !value.is_empty());
  318. if jkt.is_none() && x5t.is_none() {
  319. return Err(AppError::invalid_grant(
  320. "refresh requires a freshly verified proof of possession",
  321. ));
  322. }
  323. if let Some(want) = bound.jkt.as_deref() {
  324. if jkt.as_deref() != Some(want) {
  325. return Err(AppError::invalid_grant(
  326. "DPoP binding does not match this grant",
  327. ));
  328. }
  329. } else if kind == ClientKind::Public {
  330. return Err(AppError::server_error());
  331. }
  332. if let Some(want) = bound.x5t_s256.as_deref()
  333. && x5t.as_deref() != Some(want)
  334. {
  335. return Err(AppError::invalid_grant(
  336. "mTLS binding does not match this grant",
  337. ));
  338. }
  339. Ok(())
  340. }
  341. /// Revoke the orphaned pre-mint pair after a non-`Rotated` outcome.
  342. ///
  343. /// The mint stored both records before the script ran; leaving them live
  344. /// would fork the grant chain. On the reuse path the cascade already wiped
  345. /// the grant, so these deletes are no-ops there.
  346. async fn discard_orphan(
  347. conn: &mut RedisConn,
  348. store: &Store,
  349. service: &TokenService,
  350. minted: &MintedTokens,
  351. ) -> Result<(), AppError> {
  352. service
  353. .revoke_token(conn, store, &minted.refresh_token)
  354. .await
  355. .map_err(AppError::from)?;
  356. service
  357. .revoke_token(conn, store, &minted.access_token)
  358. .await
  359. .map_err(AppError::from)?;
  360. Ok(())
  361. }
  362. /// Shape a successful rotation response from the minted pair.
  363. fn response(
  364. service: &TokenService,
  365. minted: &MintedTokens,
  366. scope: &ScopeSet,
  367. grant_id: &str,
  368. ) -> RefreshedTokens {
  369. RefreshedTokens {
  370. access_token: minted.access_token.clone(),
  371. refresh_token: minted.refresh_token.clone(),
  372. scope: scope.render(),
  373. token_type: if minted.cnf.jkt.is_some() {
  374. "DPoP".to_owned()
  375. } else {
  376. "Bearer".to_owned()
  377. },
  378. expires_in: service.access_ttl().as_secs(),
  379. grant_id: grant_id.to_owned(),
  380. }
  381. }