browser.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. //! Shared browser-flow driver for the end-to-end suites.
  2. //!
  3. //! One [`Browser`] walks the real authorization-code UI over the live router:
  4. //! `GET /authorize` (RFC 6749 §4.1.1 + OAuth 2.1 draft §4.1.3) → `GET /login` →
  5. //! `POST /login` (password + TOTP) → `GET /consent` → `POST /consent` → code on
  6. //! the registered redirect (RFC 9700 §4.5.3, `code` + `state` + RFC 9207
  7. //! `iss`), and the caller redeems it with [`Browser::verifier`].
  8. //!
  9. //! Suites include this file with `#[path = "e2e/browser.rs"] mod browser;` and
  10. //! must also include `e2e/harness.rs` and `e2e/dpop.rs` as siblings: the driver
  11. //! borrows their [`World`] and [`DpopKey`]. The world is supplied by the caller
  12. //! so a suite can pin its own config (CIMD fetcher, rate budgets, feature
  13. //! gates) before the driver touches it.
  14. //!
  15. //! A public client is used so the flow needs no PAR/JAR (a confidential client
  16. //! with only plain query params is rejected with `invalid_request`). Public
  17. //! clients are DPoP-bound end to end: the SAME [`DpopKey`] proves
  18. //! `GET /authorize` (its `jkt` is stashed in the pending-auth record) and
  19. //! `POST /token` (the redemption checks the proof `jkt` against the code's
  20. //! `cnf` binding), so one key spans the whole flow.
  21. // allow: SIZE_OK — the plan fixes one shared driver surface (four constructors
  22. // plus the exported HTTP helpers) at this single path; splitting it would
  23. // scatter the contract the suite modules import.
  24. use std::time::Duration;
  25. use d_oauth::db::{
  26. TokenEndpointAuthMethod,
  27. user_repo::{self, NewUser},
  28. user_scope_repo,
  29. };
  30. pub use super::dpop::DpopKey;
  31. use super::harness::{
  32. TestError, World, attach_post_logout, attach_redirect, ensure, fail, seed_client, seed_scope,
  33. };
  34. /// Fixture public client used by [`begin_browser`] (no secret: DPoP is its
  35. /// sender constraint).
  36. pub const CLIENT_ID: &str = "e2e-flow-public";
  37. /// Registered loopback redirect for [`begin_browser`] (RFC 8252 loopback
  38. /// exception applies).
  39. pub const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
  40. /// Catalog scope exercised by [`begin_browser`].
  41. pub const SCOPE: &str = "read";
  42. /// `state` echoed from authorize to the code redirect (unreserved chars only).
  43. pub const STATE: &str = "e2e-flow-state";
  44. /// Session cookie name (production `src/ui/login.rs`: `SESSION_COOKIE`).
  45. pub const SESSION_COOKIE: &str = "__Host-session_id";
  46. /// Uniform credential-rejection page text (`INVALID_CREDENTIALS`).
  47. pub const INVALID_CREDENTIALS: &str = "Invalid email, password, or code.";
  48. /// One browser driving the flow: live world, PKCE pair, DPoP key, login
  49. /// session material, and the provisioned user's credentials.
  50. pub struct Browser {
  51. /// Live router world the flow runs against.
  52. pub world: World,
  53. /// Sender-constraint key proving both `/authorize` and `/token`.
  54. pub dpop: DpopKey,
  55. /// PKCE `code_verifier` matching the challenge sent to `/authorize`.
  56. pub verifier: String,
  57. /// Pending-auth id the authorize redirect handed to `/login`.
  58. pub request_id: String,
  59. /// Pre-login session id (`GET /login`).
  60. pub presession: String,
  61. /// Authenticated session id, when a constructor logged in already.
  62. pub session: Option<String>,
  63. /// Login-form CSRF token (`GET /login`).
  64. pub login_csrf: String,
  65. /// Provisioned user's email.
  66. pub email: String,
  67. /// Provisioned user's password.
  68. pub password: String,
  69. /// Provisioned user's TOTP secret.
  70. pub totp_secret: String,
  71. }
  72. /// Client metadata a browser run drives authorize for.
  73. struct Flow<'a> {
  74. /// Client id the authorize request presents.
  75. client_id: &'a str,
  76. /// Callback the authorize request presents.
  77. redirect_uri: &'a str,
  78. /// Requested scope.
  79. scope: &'a str,
  80. /// Provisioned user's email.
  81. email: &'a str,
  82. /// Provisioned user's password.
  83. password: &'a str,
  84. }
  85. /// Seed the fixed e2e-flow client (public, code + refresh) and one
  86. /// TOTP-enrolled user entitled to [`SCOPE`]; drive `GET /authorize` →
  87. /// `GET /login` against the caller's world.
  88. ///
  89. /// # Errors
  90. ///
  91. /// Returns any seeding, routing, or assertion failure of the first two legs.
  92. pub async fn begin_browser(
  93. world: World,
  94. email: &str,
  95. password: &str,
  96. ) -> Result<Browser, TestError> {
  97. begin_registered(
  98. world,
  99. Flow {
  100. client_id: CLIENT_ID,
  101. redirect_uri: REDIRECT_URI,
  102. scope: SCOPE,
  103. email,
  104. password,
  105. },
  106. )
  107. .await
  108. }
  109. /// Drive the first two legs for a client the world resolves another way
  110. /// (a CIMD document), so no client row or redirect is seeded: only the scope
  111. /// catalog entry and the entitled user. `GET /authorize` performs the fetch.
  112. ///
  113. /// # Errors
  114. ///
  115. /// Returns any seeding, routing, or assertion failure of the first two legs.
  116. pub async fn begin_browser_for(
  117. world: World,
  118. client_id: &str,
  119. redirect_uri: &str,
  120. scope: &str,
  121. email: &str,
  122. password: &str,
  123. ) -> Result<Browser, TestError> {
  124. let flow = Flow {
  125. client_id,
  126. redirect_uri,
  127. scope,
  128. email,
  129. password,
  130. };
  131. seed_scope(&world.pool, flow.scope).await?;
  132. let totp_secret = seed_user(&world, flow.email, flow.password, flow.scope).await?;
  133. authorize_login(world, &flow, totp_secret).await
  134. }
  135. /// Seed a registered public client with both an authorize and a post-logout
  136. /// redirect, then drive the first two legs; `/authorize` freezes the logout
  137. /// binding in the session meta.
  138. ///
  139. /// # Errors
  140. ///
  141. /// Returns any seeding, routing, or assertion failure of the first two legs.
  142. pub async fn begin_browser_with_post_logout(
  143. world: World,
  144. client_id: &str,
  145. redirect_uri: &str,
  146. scope: &str,
  147. email: &str,
  148. password: &str,
  149. post_logout_uri: &str,
  150. ) -> Result<Browser, TestError> {
  151. let flow = Flow {
  152. client_id,
  153. redirect_uri,
  154. scope,
  155. email,
  156. password,
  157. };
  158. let totp_secret = seed_registered(&world, &flow, Some(post_logout_uri)).await?;
  159. authorize_login(world, &flow, totp_secret).await
  160. }
  161. /// Seed one entitled user and sign in through `GET /login` → `POST /login`
  162. /// with no pending authorization request (the device-approval entry point).
  163. /// The returned [`Browser::session`] is the authenticated session id.
  164. ///
  165. /// # Errors
  166. ///
  167. /// Returns any seeding, routing, or assertion failure of the login legs.
  168. pub async fn login_session(
  169. world: World,
  170. email: &str,
  171. password: &str,
  172. ) -> Result<Browser, TestError> {
  173. seed_scope(&world.pool, SCOPE).await?;
  174. let totp_secret = seed_user(&world, email, password, SCOPE).await?;
  175. let (status, headers, body) = world.get("/login", &[]).await?;
  176. ensure(
  177. status == reqwest::StatusCode::OK,
  178. format!("standalone login page must render: {status} {body}"),
  179. )?;
  180. let presession = cookie_value(&headers, SESSION_COOKIE)?;
  181. let login_csrf = hidden_input(&body, "csrf")?;
  182. let code_2fa = totp_now(&totp_secret).await?;
  183. let cookie = format!("{SESSION_COOKIE}={presession}");
  184. let (status, headers, body) = world
  185. .post_form(
  186. "/login",
  187. &[
  188. ("email", email),
  189. ("password", password),
  190. ("code", code_2fa.as_str()),
  191. ("csrf", login_csrf.as_str()),
  192. ],
  193. None,
  194. &[("cookie", cookie.as_str())],
  195. )
  196. .await?;
  197. ensure(
  198. status == reqwest::StatusCode::SEE_OTHER,
  199. format!("standalone login must 303: {status} {body}"),
  200. )?;
  201. let session = cookie_value(&headers, SESSION_COOKIE)?;
  202. Ok(Browser {
  203. world,
  204. dpop: DpopKey::generate()?,
  205. verifier: String::new(),
  206. request_id: String::new(),
  207. presession,
  208. session: Some(session),
  209. login_csrf,
  210. email: email.to_owned(),
  211. password: password.to_owned(),
  212. totp_secret,
  213. })
  214. }
  215. /// Seed a fixed-client flow and drive the first two legs.
  216. async fn begin_registered(world: World, flow: Flow<'_>) -> Result<Browser, TestError> {
  217. let totp_secret = seed_registered(&world, &flow, None).await?;
  218. authorize_login(world, &flow, totp_secret).await
  219. }
  220. /// Seed the client row, its catalog scope, its authorize redirect (plus an
  221. /// optional post-logout redirect), and one entitled user.
  222. async fn seed_registered(
  223. world: &World,
  224. flow: &Flow<'_>,
  225. post_logout_uri: Option<&str>,
  226. ) -> Result<String, TestError> {
  227. seed_client(
  228. &world.pool,
  229. &world.state.keys,
  230. flow.client_id,
  231. None,
  232. &["authorization_code", "refresh_token"],
  233. flow.scope,
  234. TokenEndpointAuthMethod::None,
  235. )
  236. .await?;
  237. seed_scope(&world.pool, flow.scope).await?;
  238. attach_redirect(
  239. &world.pool,
  240. &world.state.keys,
  241. flow.client_id,
  242. flow.redirect_uri,
  243. )
  244. .await?;
  245. if let Some(uri) = post_logout_uri {
  246. attach_post_logout(&world.pool, &world.state.keys, flow.client_id, uri).await?;
  247. }
  248. seed_user(world, flow.email, flow.password, flow.scope).await
  249. }
  250. /// Drive `GET /authorize` → pending-record binding check → `GET /login`.
  251. async fn authorize_login(
  252. world: World,
  253. flow: &Flow<'_>,
  254. totp_secret: String,
  255. ) -> Result<Browser, TestError> {
  256. let (challenge, verifier) = oauth2_pkce();
  257. let dpop = DpopKey::generate()?;
  258. let proof = dpop.proof("GET", &format!("{}/authorize", world.state.config.issuer))?;
  259. let (status, headers, body) = world
  260. .get(
  261. &format!(
  262. "/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}\
  263. &code_challenge={challenge}&code_challenge_method=S256",
  264. encoded(flow.client_id),
  265. encoded(flow.redirect_uri),
  266. encoded(flow.scope),
  267. encoded(STATE)
  268. ),
  269. &[("dpop", proof.as_str())],
  270. )
  271. .await?;
  272. ensure(
  273. status == reqwest::StatusCode::SEE_OTHER,
  274. format!("authorize must 303 to login: {status} {body}"),
  275. )?;
  276. let request_id = query_value(&location_of(&headers)?, "request_id")?;
  277. // The authorize layer stashes the verified DPoP thumbprint in the
  278. // pending-auth record the later code issuance binds (RFC 9700 §4.5.3).
  279. let mut conn = world.state.redis.clone();
  280. let record = d_oauth::store::pending::get(&mut conn, &world.store, &request_id)
  281. .await
  282. .map_err(|error| fail(format!("pending read failed: {error}")))?
  283. .ok_or_else(|| fail("pending record must exist"))?;
  284. let expected = d_oauth::jose::jwk::thumbprint(&dpop.jwk())
  285. .map_err(|error| fail(format!("jwk thumbprint: {error}")))?;
  286. ensure(
  287. record.dpop_jkt.as_deref() == Some(expected.as_str()),
  288. "pending record must carry the verified DPoP thumbprint",
  289. )?;
  290. let (status, headers, body) = world
  291. .get(&format!("/login?request_id={request_id}"), &[])
  292. .await?;
  293. ensure(
  294. status == reqwest::StatusCode::OK,
  295. format!("login page must render: {status} {body}"),
  296. )?;
  297. let presession = cookie_value(&headers, SESSION_COOKIE)?;
  298. let login_csrf = hidden_input(&body, "csrf")?;
  299. Ok(Browser {
  300. world,
  301. dpop,
  302. verifier,
  303. request_id,
  304. presession,
  305. session: None,
  306. login_csrf,
  307. email: flow.email.to_owned(),
  308. password: flow.password.to_owned(),
  309. totp_secret,
  310. })
  311. }
  312. /// Provision one enabled user (known password + TOTP secret, entitled to
  313. /// `scope`) through the REAL repository path, using the server's own Argon2
  314. /// parameters so verification matches production exactly.
  315. ///
  316. /// # Errors
  317. ///
  318. /// Returns a hashing, repository, or TOTP-sealing failure.
  319. pub async fn seed_user(
  320. world: &World,
  321. email: &str,
  322. password: &str,
  323. scope: &str,
  324. ) -> Result<String, TestError> {
  325. let raw = &world.state.config.argon2;
  326. let params =
  327. d_oauth::crypto::secret::Argon2Params::new(raw.memory_kib, raw.iterations, raw.parallelism, 32)
  328. .map_err(|error| fail(format!("argon2 params: {error}")))?;
  329. let hash = d_oauth::crypto::secret::hash_secret(password, &params)
  330. .map_err(|error| fail(format!("hash password: {error}")))?;
  331. let created = user_repo::create_user(
  332. &world.pool,
  333. &world.state.keys,
  334. world.state.config.pii_dual_write,
  335. &NewUser {
  336. email: email.to_owned(),
  337. password_hash: hash,
  338. is_admin: false,
  339. must_change_password: false,
  340. },
  341. )
  342. .await?;
  343. let secret = d_oauth::crypto::totp::generate_secret()
  344. .map_err(|error| fail(format!("totp secret: {error}")))?;
  345. let blob = d_oauth::crypto::totp::encrypt_secret(
  346. &world.state.keys,
  347. &secret,
  348. !world.state.config.pii_dual_write,
  349. )
  350. .map_err(|error| fail(format!("totp seal: {error}")))?;
  351. ensure(
  352. user_repo::set_totp_secret(&world.pool, created.id, &blob).await?,
  353. "totp enroll must update the user row",
  354. )?;
  355. user_scope_repo::entitle(&world.pool, created.id, scope, None).await?;
  356. Ok(secret)
  357. }
  358. /// Current TOTP code from the wall clock, computed the way the server
  359. /// verifies it (RFC 6238, ±1-step window). Steps back from the step edge so
  360. /// the code cannot roll over between generation and the `POST /login`.
  361. ///
  362. /// # Errors
  363. ///
  364. /// Returns a TOTP-constructor failure.
  365. pub async fn totp_now(secret: &str) -> Result<String, TestError> {
  366. let step = d_oauth::crypto::totp::TOTP_STEP_SECS;
  367. let now = time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64;
  368. if now % step >= step - 2 {
  369. tokio::time::sleep(Duration::from_secs(3)).await;
  370. }
  371. let now = time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64;
  372. let verifier = d_oauth::crypto::totp::build_totp(secret)
  373. .map_err(|error| fail(format!("totp verifier: {error}")))?;
  374. let counter = now / step;
  375. Ok(format!("{:06}", verifier.generate(counter * step)))
  376. }
  377. /// `POST /login` as the browser: presession cookie + form credentials.
  378. ///
  379. /// # Errors
  380. ///
  381. /// Returns the HTTP transport failure.
  382. pub async fn post_login(
  383. browser: &Browser,
  384. password: &str,
  385. code: &str,
  386. ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
  387. let cookie = format!("{SESSION_COOKIE}={}", browser.presession);
  388. browser
  389. .world
  390. .post_form(
  391. "/login",
  392. &[
  393. ("email", browser.email.as_str()),
  394. ("password", password),
  395. ("code", code),
  396. ("csrf", browser.login_csrf.as_str()),
  397. ],
  398. None,
  399. &[("cookie", cookie.as_str())],
  400. )
  401. .await
  402. }
  403. /// `GET /consent` as the browser holding `session`.
  404. ///
  405. /// # Errors
  406. ///
  407. /// Returns the HTTP transport failure.
  408. pub async fn get_consent(
  409. browser: &Browser,
  410. session: &str,
  411. ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
  412. let cookie = format!("{SESSION_COOKIE}={session}");
  413. browser
  414. .world
  415. .get("/consent", &[("cookie", cookie.as_str())])
  416. .await
  417. }
  418. /// `POST /consent` with the bound `request_id` and the session form token.
  419. ///
  420. /// # Errors
  421. ///
  422. /// Returns the HTTP transport failure.
  423. pub async fn post_consent(
  424. browser: &Browser,
  425. session: &str,
  426. request_id: &str,
  427. csrf: &str,
  428. decision: &str,
  429. ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
  430. let cookie = format!("{SESSION_COOKIE}={session}");
  431. browser
  432. .world
  433. .post_form(
  434. "/consent",
  435. &[
  436. ("request_id", request_id),
  437. ("csrf", csrf),
  438. ("decision", decision),
  439. ],
  440. None,
  441. &[("cookie", cookie.as_str())],
  442. )
  443. .await
  444. }
  445. /// PKCE pair from the `oauth2` crate.
  446. #[must_use]
  447. pub fn oauth2_pkce() -> (String, String) {
  448. let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
  449. (challenge.as_str().to_owned(), verifier.secret().to_owned())
  450. }
  451. /// Percent-encode a value for query embedding.
  452. #[must_use]
  453. pub fn encoded(value: &str) -> String {
  454. let mut out = String::with_capacity(value.len());
  455. for byte in value.bytes() {
  456. if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
  457. out.push(byte as char);
  458. } else {
  459. out.push_str(&format!("%{byte:02X}"));
  460. }
  461. }
  462. out
  463. }
  464. /// Read the `location` header or fail the test.
  465. ///
  466. /// # Errors
  467. ///
  468. /// Returns [`TestError::Failed`] when the header is absent or not ASCII.
  469. pub fn location_of(headers: &reqwest::header::HeaderMap) -> Result<String, TestError> {
  470. headers
  471. .get("location")
  472. .and_then(|value| value.to_str().ok())
  473. .map(str::to_owned)
  474. .ok_or_else(|| fail("response lacks location header"))
  475. }
  476. /// Read a cookie value from the `set-cookie` headers or fail the test.
  477. ///
  478. /// # Errors
  479. ///
  480. /// Returns [`TestError::Failed`] when the cookie is absent or not ASCII.
  481. pub fn cookie_value(headers: &reqwest::header::HeaderMap, name: &str) -> Result<String, TestError> {
  482. let prefix = format!("{name}=");
  483. let mut found = None;
  484. for value in headers.get_all("set-cookie").iter() {
  485. let text = value
  486. .to_str()
  487. .map_err(|_| fail("set-cookie is not visible ascii"))?;
  488. if let Some(rest) = text.strip_prefix(prefix.as_str()) {
  489. let end = rest.find(';').unwrap_or(rest.len());
  490. found = Some(rest[..end].to_owned());
  491. }
  492. }
  493. found.ok_or_else(|| fail(format!("response lacks {name} cookie")))
  494. }
  495. /// Read a hidden form field (`name=".." value=".."`) from a page or fail.
  496. ///
  497. /// # Errors
  498. ///
  499. /// Returns [`TestError::Failed`] when the field is absent or unterminated.
  500. pub fn hidden_input(body: &str, name: &str) -> Result<String, TestError> {
  501. let needle = format!("name=\"{name}\" value=\"");
  502. let start = body
  503. .find(needle.as_str())
  504. .ok_or_else(|| fail(format!("page lacks hidden {name}")))?;
  505. let rest = &body[start + needle.len()..];
  506. let end = rest
  507. .find('"')
  508. .ok_or_else(|| fail(format!("hidden {name} is unterminated")))?;
  509. Ok(rest[..end].to_owned())
  510. }
  511. /// Read one query value from a redirect location or fail the test.
  512. ///
  513. /// # Errors
  514. ///
  515. /// Returns [`TestError::Failed`] when the query or key is absent.
  516. pub fn query_value(location: &str, key: &str) -> Result<String, TestError> {
  517. let query = location
  518. .split_once('?')
  519. .map(|pair| pair.1)
  520. .ok_or_else(|| fail(format!("location carries no query: {location}")))?;
  521. let prefix = format!("{key}=");
  522. for pair in query.split('&') {
  523. if let Some(value) = pair.strip_prefix(prefix.as_str()) {
  524. return Ok(value.to_owned());
  525. }
  526. }
  527. Err(fail(format!("location lacks {key}: {location}")))
  528. }
  529. /// Whether a redirect location carries a query key.
  530. #[must_use]
  531. pub fn has_query_key(location: &str, key: &str) -> bool {
  532. let Some(query) = location.split_once('?').map(|pair| pair.1) else {
  533. return false;
  534. };
  535. let prefix = format!("{key}=");
  536. query
  537. .split('&')
  538. .any(|pair| pair.strip_prefix(prefix.as_str()).is_some())
  539. }