refresh_token.rs 13 KB

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