device.rs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. //! Device-grant wire mechanics shared by `tests/e2e_device.rs`.
  2. //!
  3. //! The suite owns the scenario assertions; this module owns the RFC 8628
  4. //! request shapes (authorization issue, review page + CSRF, decision, poll)
  5. //! and the fixture client definitions.
  6. use d_oauth::db::TokenEndpointAuthMethod;
  7. use reqwest::StatusCode;
  8. use super::{
  9. browser,
  10. dpop::DpopKey,
  11. harness::{SeedClientOpts, TestError, World, ensure, fail, json_body, seed_client_bound},
  12. };
  13. /// Grant-type URN for the device-code grant (RFC 8628 §3.4).
  14. pub const DEVICE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
  15. /// Catalog scope the device requests and the user is entitled to.
  16. pub const SCOPE: &str = "read";
  17. /// DPoP-bound confidential client used for the approve/poll/introspect case.
  18. pub const DEVICE_CLIENT: &str = "e2e-device-owner";
  19. /// Owner secret (also the leak probe).
  20. pub const DEVICE_SECRET: &str = "e2e-device-secret-1a2b3c4d5e6f708192a0";
  21. /// Public DPoP-bound client used for the deny and normalization cases.
  22. pub const PUBLIC_CLIENT: &str = "e2e-device-public";
  23. /// A client that may request a device authorization.
  24. #[derive(Clone, Copy)]
  25. pub struct Client<'a> {
  26. /// Registered client id.
  27. pub id: &'a str,
  28. /// Owner secret; `None` for a public client.
  29. pub secret: Option<&'a str>,
  30. /// Requested/registered scope.
  31. pub scope: &'a str,
  32. }
  33. impl Client<'_> {
  34. /// Basic-auth pair for a confidential client, `None` for a public one.
  35. fn basic(&self) -> Option<(&str, &str)> {
  36. self.secret.map(|secret| (self.id, secret))
  37. }
  38. }
  39. /// One `/token` device-code poll.
  40. #[derive(Clone, Copy)]
  41. pub struct Poll<'a> {
  42. /// Polling client.
  43. pub client: Client<'a>,
  44. /// Opaque device code returned by `/device_authorization`.
  45. pub device_code: &'a str,
  46. }
  47. /// One approve/deny submission from the review page.
  48. #[derive(Clone, Copy)]
  49. pub struct Decision<'a> {
  50. /// Authenticated session id.
  51. pub session: &'a str,
  52. /// User code echoed by the review form.
  53. pub user_code: &'a str,
  54. /// Stateless CSRF token minted for this decision.
  55. pub csrf: &'a str,
  56. /// `approve` or `deny`.
  57. pub action: &'a str,
  58. }
  59. /// Seed the public DPoP-bound client used by the deny and normalization rows.
  60. pub async fn seed_public_client(world: &World) -> Result<(), TestError> {
  61. seed_client_bound(
  62. &world.pool,
  63. &world.state.keys,
  64. PUBLIC_CLIENT,
  65. None,
  66. &SeedClientOpts {
  67. grants: &[DEVICE_GRANT],
  68. scope: SCOPE,
  69. method: TokenEndpointAuthMethod::None,
  70. dpop_bound: true,
  71. },
  72. )
  73. .await
  74. }
  75. /// `POST /device_authorization` with a fresh proof; returns
  76. /// `(device_code, user_code)`.
  77. pub async fn issue(
  78. world: &World,
  79. client: Client<'_>,
  80. key: &DpopKey,
  81. ) -> Result<(String, String), TestError> {
  82. let htu = format!("{}/device_authorization", world.state.config.issuer);
  83. let proof = key.proof("POST", &htu)?;
  84. let (status, _, body) = world
  85. .post_form(
  86. "/device_authorization",
  87. &[("client_id", client.id), ("scope", client.scope)],
  88. client.basic(),
  89. &[("dpop", proof.as_str())],
  90. )
  91. .await?;
  92. ensure(
  93. status == StatusCode::OK,
  94. format!("device authorization must 200: {status} {body}"),
  95. )?;
  96. let payload = json_body(&body)?;
  97. let device_code = payload["device_code"]
  98. .as_str()
  99. .ok_or_else(|| fail("device authorization must return device_code"))?
  100. .to_owned();
  101. let user_code = payload["user_code"]
  102. .as_str()
  103. .ok_or_else(|| fail("device authorization must return user_code"))?
  104. .to_owned();
  105. Ok((device_code, user_code))
  106. }
  107. /// `POST /token` for the device grant with a fresh proof per attempt.
  108. pub async fn poll(
  109. world: &World,
  110. request: Poll<'_>,
  111. key: &DpopKey,
  112. ) -> Result<(StatusCode, String), TestError> {
  113. let htu = format!("{}/token", world.state.config.issuer);
  114. let proof = key.proof("POST", &htu)?;
  115. let (status, _, body) = world
  116. .post_form(
  117. "/token",
  118. &[
  119. ("grant_type", DEVICE_GRANT),
  120. ("device_code", request.device_code),
  121. ("client_id", request.client.id),
  122. ],
  123. request.client.basic(),
  124. &[("dpop", proof.as_str())],
  125. )
  126. .await?;
  127. Ok((status, body))
  128. }
  129. /// Session cookie header for the device UI.
  130. pub fn session_cookie(session: &str) -> String {
  131. format!("{}={session}", browser::SESSION_COOKIE)
  132. }
  133. /// `POST /device` with a code and no `action`: the review page (with CSRF).
  134. pub async fn review(world: &World, session: &str, user_code: &str) -> Result<String, TestError> {
  135. let cookie = session_cookie(session);
  136. let (status, _, body) = world
  137. .post_form(
  138. "/device",
  139. &[("user_code", user_code)],
  140. None,
  141. &[("cookie", cookie.as_str())],
  142. )
  143. .await?;
  144. ensure(
  145. status == StatusCode::OK,
  146. format!("device review page must render: {status} {body}"),
  147. )?;
  148. Ok(body)
  149. }
  150. /// Approve or deny the pending device request through the real form.
  151. pub async fn decide(world: &World, decision: Decision<'_>) -> Result<(), TestError> {
  152. let cookie = session_cookie(decision.session);
  153. let (status, _, body) = world
  154. .post_form(
  155. "/device",
  156. &[
  157. ("user_code", decision.user_code),
  158. ("action", decision.action),
  159. ("csrf", decision.csrf),
  160. ],
  161. None,
  162. &[("cookie", cookie.as_str())],
  163. )
  164. .await?;
  165. ensure(
  166. status == StatusCode::OK,
  167. format!(
  168. "device {} must render the outcome page: {status} {body}",
  169. decision.action
  170. ),
  171. )?;
  172. Ok(())
  173. }