issue.rs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. //! Approved-grant redemption: binding continuity, scope, and mint.
  2. //!
  3. //! The stored sender binding must be matched by the fresh proofs from this
  4. //! request, the grant is consumed atomically (single redemption), and the
  5. //! scope is recomputed against the approving user's live entitlements
  6. //! before anything is minted.
  7. use uuid::Uuid;
  8. use super::super::super::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService};
  9. use super::{DevicePollError, DeviceTokenResponse, PollState};
  10. use crate::jose::jwt_access::JwtAccessSigner;
  11. use crate::{
  12. config::GrantKind,
  13. db::{scope_repo, user_repo, user_scope_repo},
  14. error::AppError,
  15. protocol::{
  16. device::{binding_key, read_binding},
  17. scope::{ScopeInput, ScopeSet, effective_scope},
  18. },
  19. store::{self, device as device_store},
  20. };
  21. /// The approved grant being redeemed: record identity plus fresh proofs.
  22. pub(super) struct Redemption<'x> {
  23. /// Keyed hash of the device code being redeemed.
  24. pub(super) device_hash: &'x str,
  25. /// The polling client row.
  26. pub(super) client: &'x crate::db::Client,
  27. /// Public or confidential: selects the constraint matrix.
  28. pub(super) kind: ClientKind,
  29. /// Whether a public client must present a DPoP proof: mirrors the
  30. /// client's `dpop_bound_access_tokens` registration.
  31. pub(super) dpop_required: bool,
  32. /// Freshly verified proofs from this poll request.
  33. pub(super) proofs: SenderProof,
  34. /// Validated resource audiences (RFC 8707 §2) presented with this poll;
  35. /// empty means inherit the set pinned at authorization time (§2.2).
  36. pub(super) aud: &'x [String],
  37. /// Access-token representation for the minted pair.
  38. pub(super) token_format: TokenFormat,
  39. /// JWT signer for `TokenFormat::Jwt` mints.
  40. pub(super) jwt_signer: Option<JwtAccessSigner>,
  41. }
  42. /// Consume an approved grant exactly once and mint its token pair.
  43. ///
  44. /// The stored sender binding must be matched by the fresh proofs from this
  45. /// request, and the scope is recomputed against the approving user's live
  46. /// entitlements before anything is minted.
  47. pub(super) async fn issue_approved(
  48. state: &mut PollState<'_>,
  49. redemption: &Redemption<'_>,
  50. ) -> Result<DeviceTokenResponse, DevicePollError> {
  51. let stored = read_binding(state.conn, state.store, redemption.device_hash)
  52. .await?
  53. .ok_or_else(AppError::server_error)?;
  54. if let Some(want) = stored.jkt.as_deref() {
  55. if redemption.proofs.dpop_jkt.as_deref().unwrap_or("") != want {
  56. return Err(AppError::invalid_grant("DPoP binding does not match this grant").into());
  57. }
  58. } else if redemption.kind == ClientKind::Public && redemption.dpop_required {
  59. return Err(AppError::server_error().into());
  60. }
  61. if let Some(want) = stored.x5t_s256.as_deref()
  62. && redemption.proofs.mtls_x5t_s256.as_deref().unwrap_or("") != want
  63. {
  64. return Err(AppError::invalid_grant("mTLS binding does not match this grant").into());
  65. }
  66. let consumed = device_store::consume(state.conn, state.store, redemption.device_hash)
  67. .await
  68. .map_err(AppError::from)?
  69. .ok_or(DevicePollError::ExpiredToken)?;
  70. match consumed.status {
  71. device_store::DeviceStatus::Approved => {}
  72. device_store::DeviceStatus::Denied => return Err(DevicePollError::AccessDenied),
  73. device_store::DeviceStatus::Pending => {
  74. return Err(DevicePollError::AuthorizationPending);
  75. }
  76. }
  77. // RFC 8707 §2: the poll's resource set must equal the set pinned at
  78. // authorization time. RFC 8707 §2.2 / RFC 8628 §3.4: a device client never
  79. // re-sends `resource`, so an empty presentation inherits `consumed.aud`;
  80. // an empty pinned set keeps the unchanged no-audience behavior. A mismatch
  81. // is a grant-binding failure (`invalid_grant`), never `invalid_target`: the
  82. // request values were already catalog-validated after authentication.
  83. if !redemption.aud.is_empty()
  84. && !crate::protocol::params::audience_binds(&consumed.aud, redemption.aud)
  85. {
  86. return Err(
  87. AppError::invalid_grant("resource indicators do not match this device authorization").into(),
  88. );
  89. }
  90. // The pinned audience is re-validated against the CURRENT catalog before
  91. // minting: a resource server disabled or deleted after authorization (or a
  92. // flag turned off since) fails `invalid_target` instead of minting a token
  93. // for a dead audience. The device record above is already consumed (single
  94. // redemption), so a retry observes `expired_token` and the client must
  95. // re-authorize; no token is minted on this path.
  96. if !consumed.aud.is_empty() {
  97. crate::protocol::params::validate_resources(
  98. state.pool,
  99. state.resource_indicators_enabled,
  100. &consumed.aud,
  101. )
  102. .await?;
  103. }
  104. // The device record above is already consumed (single redemption): a JWT
  105. // redemption with an unbound audience fails here, before the mint, and the
  106. // client must re-authorize with a `resource`.
  107. if redemption.token_format == TokenFormat::Jwt && consumed.aud.is_empty() {
  108. return Err(AppError::invalid_target("jwt access tokens require a resource audience").into());
  109. }
  110. if consumed.grant_id.is_empty() || consumed.subject.is_empty() {
  111. return Err(AppError::server_error().into());
  112. }
  113. let effective = approved_scope(state, redemption.client, &consumed).await?;
  114. let mut tokens = TokenService::new(state.ttl, state.keys).map_err(AppError::internal)?;
  115. if redemption.token_format == TokenFormat::Jwt {
  116. let signer = redemption
  117. .jwt_signer
  118. .clone()
  119. .ok_or_else(AppError::server_error)?;
  120. tokens = tokens.with_jwt_signer(signer);
  121. }
  122. let minted = tokens
  123. .mint(
  124. state.conn,
  125. state.store,
  126. &MintRequest {
  127. client_id: redemption.client.client_id.clone(),
  128. client_kind: redemption.kind,
  129. dpop_required: redemption.dpop_required,
  130. subject: consumed.subject.clone(),
  131. scope: effective.clone(),
  132. access_scope: effective.clone(),
  133. grant_id: consumed.grant_id.clone(),
  134. proof: redemption.proofs.clone(),
  135. aud: consumed.aud.clone(),
  136. refresh_aud: consumed.aud.clone(),
  137. format: redemption.token_format,
  138. },
  139. state.now_unix,
  140. )
  141. .await
  142. .map_err(AppError::from)?;
  143. let stale = [
  144. device_store::poll_key(state.store, redemption.device_hash),
  145. binding_key(state.store, redemption.device_hash),
  146. ];
  147. redis::cmd("DEL")
  148. .arg(&stale)
  149. .query_async::<()>(state.conn)
  150. .await
  151. .map_err(store::StoreError::Redis)
  152. .map_err(AppError::from)?;
  153. Ok(DeviceTokenResponse {
  154. token_type: if minted.cnf.jkt.is_some() {
  155. "DPoP".to_owned()
  156. } else {
  157. "Bearer".to_owned()
  158. },
  159. expires_in: state.ttl.access.as_secs(),
  160. access_token: minted.access_token,
  161. refresh_token: minted.refresh_token,
  162. scope: effective,
  163. })
  164. }
  165. /// Recompute the effective scope against the approving user's entitlements.
  166. ///
  167. /// An explicit request must name only enabled, client-registered, and
  168. /// user-entitled scopes (`invalid_scope` otherwise); an empty request
  169. /// defaults to the client's set narrowed by the catalog and the user.
  170. async fn approved_scope(
  171. state: &mut PollState<'_>,
  172. client: &crate::db::Client,
  173. consumed: &device_store::DeviceRecord,
  174. ) -> Result<String, DevicePollError> {
  175. // The UI records the authenticated session subject, which is the user's
  176. // email; older records and fixtures carry the user UUID. Resolve either to
  177. // the entitlement owner and fail closed when the account is not live.
  178. let user_id = match user_repo::find_by_email(state.pool, state.keys, &consumed.subject).await? {
  179. Some(user) if user.disabled => {
  180. return Err(AppError::invalid_grant("the approving user is disabled").into());
  181. }
  182. Some(user) => user.id,
  183. None => Uuid::parse_str(&consumed.subject).map_err(|_| AppError::server_error())?,
  184. };
  185. let client_registered =
  186. ScopeSet::parse(client.scope.trim()).map_err(|_| AppError::server_error())?;
  187. let enabled_names = scope_repo::list_enabled(state.pool)
  188. .await?
  189. .into_iter()
  190. .map(|record| record.name)
  191. .collect::<Vec<_>>();
  192. let enabled = ScopeSet::from_names(&enabled_names).map_err(|_| AppError::server_error())?;
  193. let entitled = ScopeSet::from_names(&user_scope_repo::list_for_user(state.pool, user_id).await?)
  194. .map_err(|_| AppError::server_error())?;
  195. let requested = if consumed.scope.trim().is_empty() {
  196. None
  197. } else {
  198. Some(consumed.scope.as_str())
  199. };
  200. let effective = effective_scope(&ScopeInput {
  201. requested,
  202. client_registered: &client_registered,
  203. user_entitled: &entitled,
  204. enabled: &enabled,
  205. grant: GrantKind::DeviceCode,
  206. exchange_allowlist: &ScopeSet::empty(),
  207. })?;
  208. Ok(effective.render())
  209. }