issue.rs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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 by the token handler.
  83. //
  84. // Todo 11 binds the enforced set (`consumed.aud`) through `MintRequest.aud`;
  85. // nothing is written onto the token record here.
  86. if !redemption.aud.is_empty()
  87. && !crate::protocol::params::audience_binds(&consumed.aud, redemption.aud)
  88. {
  89. return Err(
  90. AppError::invalid_grant("resource indicators do not match this device authorization").into(),
  91. );
  92. }
  93. // The device record above is already consumed (single redemption): a JWT
  94. // redemption with an unbound audience fails here, before the mint, and the
  95. // client must re-authorize with a `resource`.
  96. if redemption.token_format == TokenFormat::Jwt && consumed.aud.is_empty() {
  97. return Err(AppError::invalid_target("jwt access tokens require a resource audience").into());
  98. }
  99. if consumed.grant_id.is_empty() || consumed.subject.is_empty() {
  100. return Err(AppError::server_error().into());
  101. }
  102. let effective = approved_scope(state, redemption.client, &consumed).await?;
  103. let mut tokens = TokenService::new(state.ttl, state.keys).map_err(AppError::internal)?;
  104. if redemption.token_format == TokenFormat::Jwt {
  105. let signer = redemption
  106. .jwt_signer
  107. .clone()
  108. .ok_or_else(AppError::server_error)?;
  109. tokens = tokens.with_jwt_signer(signer);
  110. }
  111. let minted = tokens
  112. .mint(
  113. state.conn,
  114. state.store,
  115. &MintRequest {
  116. client_id: redemption.client.client_id.clone(),
  117. client_kind: redemption.kind,
  118. dpop_required: redemption.dpop_required,
  119. subject: consumed.subject.clone(),
  120. scope: effective.clone(),
  121. access_scope: effective.clone(),
  122. grant_id: consumed.grant_id.clone(),
  123. proof: redemption.proofs.clone(),
  124. aud: consumed.aud.clone(),
  125. refresh_aud: consumed.aud.clone(),
  126. format: redemption.token_format,
  127. },
  128. state.now_unix,
  129. )
  130. .await
  131. .map_err(AppError::from)?;
  132. let stale = [
  133. device_store::poll_key(state.store, redemption.device_hash),
  134. binding_key(state.store, redemption.device_hash),
  135. ];
  136. redis::cmd("DEL")
  137. .arg(&stale)
  138. .query_async::<()>(state.conn)
  139. .await
  140. .map_err(store::StoreError::Redis)
  141. .map_err(AppError::from)?;
  142. Ok(DeviceTokenResponse {
  143. token_type: if minted.cnf.jkt.is_some() {
  144. "DPoP".to_owned()
  145. } else {
  146. "Bearer".to_owned()
  147. },
  148. expires_in: state.ttl.access.as_secs(),
  149. access_token: minted.access_token,
  150. refresh_token: minted.refresh_token,
  151. scope: effective,
  152. })
  153. }
  154. /// Recompute the effective scope against the approving user's entitlements.
  155. ///
  156. /// An explicit request must name only enabled, client-registered, and
  157. /// user-entitled scopes (`invalid_scope` otherwise); an empty request
  158. /// defaults to the client's set narrowed by the catalog and the user.
  159. async fn approved_scope(
  160. state: &mut PollState<'_>,
  161. client: &crate::db::Client,
  162. consumed: &device_store::DeviceRecord,
  163. ) -> Result<String, DevicePollError> {
  164. // The UI records the authenticated session subject, which is the user's
  165. // email; older records and fixtures carry the user UUID. Resolve either to
  166. // the entitlement owner and fail closed when the account is not live.
  167. let user_id = match user_repo::find_by_email(state.pool, state.keys, &consumed.subject).await? {
  168. Some(user) if user.disabled => {
  169. return Err(AppError::invalid_grant("the approving user is disabled").into());
  170. }
  171. Some(user) => user.id,
  172. None => Uuid::parse_str(&consumed.subject).map_err(|_| AppError::server_error())?,
  173. };
  174. let client_registered =
  175. ScopeSet::parse(client.scope.trim()).map_err(|_| AppError::server_error())?;
  176. let enabled_names = scope_repo::list_enabled(state.pool)
  177. .await?
  178. .into_iter()
  179. .map(|record| record.name)
  180. .collect::<Vec<_>>();
  181. let enabled = ScopeSet::from_names(&enabled_names).map_err(|_| AppError::server_error())?;
  182. let entitled = ScopeSet::from_names(&user_scope_repo::list_for_user(state.pool, user_id).await?)
  183. .map_err(|_| AppError::server_error())?;
  184. let requested = if consumed.scope.trim().is_empty() {
  185. None
  186. } else {
  187. Some(consumed.scope.as_str())
  188. };
  189. let effective = effective_scope(&ScopeInput {
  190. requested,
  191. client_registered: &client_registered,
  192. user_entitled: &entitled,
  193. enabled: &enabled,
  194. grant: GrantKind::DeviceCode,
  195. exchange_allowlist: &ScopeSet::empty(),
  196. })?;
  197. Ok(effective.render())
  198. }