e2e_flow.rs 18 KB

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