integration.rs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766
  1. //! Full-flow integration suite against a `wiremock` fake AS (todo 21).
  2. //!
  3. //! The fake AS implements the d-OAuth shape: RFC 8414 discovery (PAR +
  4. //! introspection + revocation endpoints, DPoP ES256, no userinfo), `/par`,
  5. //! `/token`, `/introspect`, `/revoke`. Every test drives the real router with
  6. //! `tower::ServiceExt::oneshot`: no ports are bound, no external servers
  7. //! start, no real network is touched.
  8. use axum::body::Body;
  9. use axum::http::{Request, StatusCode};
  10. use secrecy::{ExposeSecret as _, SecretString};
  11. use server::app::build_app;
  12. use server::config::ServerConfig;
  13. use server::session::SESSION_COOKIE;
  14. use server::state::AppState;
  15. use tower::ServiceExt as _;
  16. use wiremock::{
  17. Mock, MockServer, ResponseTemplate,
  18. matchers::{method, path},
  19. };
  20. type TestResult = Result<(), Box<dyn std::error::Error>>;
  21. struct Fixture {
  22. app: axum::Router,
  23. state: AppState,
  24. issuer: String,
  25. public_jwk: serde_json::Value,
  26. jkt: String,
  27. }
  28. fn empty_lookup(_: &str) -> Option<String> {
  29. None
  30. }
  31. /// Build an app whose profile points at the fake AS, with an in-memory DPoP
  32. /// key. The key's public JWK + thumbprint are kept for proof verification.
  33. fn fixture_app(fake: &MockServer) -> (Fixture, tempfile::TempDir) {
  34. let dir = tempfile::tempdir().expect("temp dir is creatable");
  35. let dist = dir.path().join("dist");
  36. std::fs::create_dir_all(&dist).expect("fixture dist is creatable");
  37. std::fs::write(
  38. dist.join("index.html"),
  39. "<html><body>spa-shell</body></html>",
  40. )
  41. .expect("fixture index is writable");
  42. let issuer = fake.uri();
  43. let config_json = format!(
  44. r#"{{
  45. "server": {{ "dist_dir": {} }},
  46. "oauth": {{
  47. "issuer": "{issuer}",
  48. "redirect_uri": "http://127.0.0.1:3000/callback",
  49. "scope": "demo.read"
  50. }}
  51. }}"#,
  52. serde_json::to_string(&dist).expect("path serializes")
  53. );
  54. std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable");
  55. std::fs::write(
  56. dir.path().join("client.json"),
  57. r#"{"client_id": "demo-client", "client_secret": "demo-secret"}"#,
  58. )
  59. .expect("client fixture is writable");
  60. let config = ServerConfig::load_from_paths_with(
  61. Some(&dir.path().join("config.json")),
  62. Some(&dir.path().join("client.json")),
  63. &empty_lookup,
  64. )
  65. .expect("fixture config loads");
  66. let key = oauth_core::dpop::key::DpopKey::generate().expect("dpop key generates");
  67. let public_jwk = key.public_jwk();
  68. let jkt = key.thumbprint().expect("thumbprint computes");
  69. let state = AppState::with_client(config, reqwest::Client::new()).with_dpop_key(key);
  70. let app = build_app(state.clone());
  71. (
  72. Fixture {
  73. app,
  74. state,
  75. issuer,
  76. public_jwk,
  77. jkt,
  78. },
  79. dir,
  80. )
  81. }
  82. /// d-OAuth-shaped discovery: PAR + introspection + revocation + DPoP ES256,
  83. /// no userinfo endpoint.
  84. async fn mount_discovery(fake: &MockServer, issuer: &str) {
  85. let doc = serde_json::json!({
  86. "issuer": issuer,
  87. "authorization_endpoint": format!("{issuer}/authorize"),
  88. "token_endpoint": format!("{issuer}/token"),
  89. "pushed_authorization_request_endpoint": format!("{issuer}/par"),
  90. "introspection_endpoint": format!("{issuer}/introspect"),
  91. "revocation_endpoint": format!("{issuer}/revoke"),
  92. "dpop_signing_alg_values_supported": ["ES256"],
  93. });
  94. Mock::given(method("GET"))
  95. .and(path("/.well-known/oauth-authorization-server"))
  96. .respond_with(ResponseTemplate::new(200).set_body_json(&doc))
  97. .mount(fake)
  98. .await;
  99. }
  100. async fn mount_par(fake: &MockServer) {
  101. Mock::given(method("POST"))
  102. .and(path("/par"))
  103. .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
  104. "request_uri": "urn:ietf:params:oauth:request_uri:par-handle-1",
  105. "expires_in": 90
  106. })))
  107. .mount(fake)
  108. .await;
  109. }
  110. fn token_ok(access: &str, refresh: &str) -> ResponseTemplate {
  111. ResponseTemplate::new(200).set_body_json(serde_json::json!({
  112. "access_token": access,
  113. "token_type": "DPoP",
  114. "expires_in": 3600,
  115. "refresh_token": refresh,
  116. "scope": "demo.read",
  117. }))
  118. }
  119. async fn mount_token(fake: &MockServer, template: ResponseTemplate) {
  120. Mock::given(method("POST"))
  121. .and(path("/token"))
  122. .respond_with(template)
  123. .mount(fake)
  124. .await;
  125. }
  126. async fn mount_introspect(fake: &MockServer, body: serde_json::Value) {
  127. Mock::given(method("POST"))
  128. .and(path("/introspect"))
  129. .respond_with(ResponseTemplate::new(200).set_body_json(&body))
  130. .mount(fake)
  131. .await;
  132. }
  133. async fn mount_revoke(fake: &MockServer) {
  134. Mock::given(method("POST"))
  135. .and(path("/revoke"))
  136. .respond_with(ResponseTemplate::new(200))
  137. .mount(fake)
  138. .await;
  139. }
  140. fn active_introspection(exp: i64, iat: i64, jkt: &str) -> serde_json::Value {
  141. serde_json::json!({
  142. "active": true,
  143. "scope": "demo.read",
  144. "client_id": "demo-client",
  145. "token_type": "Bearer",
  146. "exp": exp,
  147. "iat": iat,
  148. "sub": "demo@example.com",
  149. "cnf": { "jkt": jkt },
  150. })
  151. }
  152. fn now_secs() -> i64 {
  153. std::time::SystemTime::now()
  154. .duration_since(std::time::UNIX_EPOCH)
  155. .expect("clock available")
  156. .as_secs() as i64
  157. }
  158. async fn send(
  159. app: axum::Router,
  160. request: Request<Body>,
  161. ) -> Result<axum::http::Response<Body>, Box<dyn std::error::Error>> {
  162. Ok(app.oneshot(request).await?)
  163. }
  164. fn get_request(uri: &str, cookie: Option<&str>) -> Request<Body> {
  165. let mut builder = Request::builder().uri(uri);
  166. if let Some(cookie) = cookie {
  167. builder = builder.header("cookie", cookie);
  168. }
  169. builder.body(Body::empty()).expect("request builds")
  170. }
  171. fn post_request(uri: &str, cookie: &str, csrf: &str, origin: Option<&str>) -> Request<Body> {
  172. let mut builder = Request::builder()
  173. .uri(uri)
  174. .method("POST")
  175. .header("cookie", cookie)
  176. .header("x-csrf-token", csrf);
  177. if let Some(origin) = origin {
  178. builder = builder.header("origin", origin);
  179. }
  180. builder.body(Body::empty()).expect("request builds")
  181. }
  182. async fn body_text(
  183. response: axum::http::Response<Body>,
  184. ) -> Result<String, Box<dyn std::error::Error>> {
  185. let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
  186. Ok(std::str::from_utf8(&bytes)?.to_owned())
  187. }
  188. fn header_value(response: &axum::http::Response<Body>, name: &str) -> String {
  189. response
  190. .headers()
  191. .get(name)
  192. .and_then(|value| value.to_str().ok())
  193. .unwrap_or_default()
  194. .to_owned()
  195. }
  196. fn sid_from_set_cookie(set_cookie: &str) -> Option<String> {
  197. set_cookie.split(';').next().and_then(|pair| {
  198. let (name, value) = pair.split_once('=')?;
  199. (name.trim() == SESSION_COOKIE).then(|| value.trim().to_owned())
  200. })
  201. }
  202. fn cookie_for(sid: &str) -> String {
  203. format!("{SESSION_COOKIE}={sid}")
  204. }
  205. /// `/oauth/start` -> bound `(state, sid)`.
  206. async fn start_flow(fixture: &Fixture) -> Result<(String, String), Box<dyn std::error::Error>> {
  207. let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
  208. assert_eq!(response.status(), StatusCode::FOUND);
  209. let location = header_value(&response, "location");
  210. assert!(
  211. location.contains("/authorize"),
  212. "unexpected Location: {location}"
  213. );
  214. assert!(
  215. location.contains("request_uri"),
  216. "PAR handle missing: {location}"
  217. );
  218. let sid = sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("sid cookie");
  219. let state = fixture
  220. .state
  221. .pending()
  222. .pending_state_for_session(&sid)
  223. .expect("state bound to the session");
  224. Ok((state, sid))
  225. }
  226. /// `/callback` happy path -> rotated sid.
  227. async fn callback_flow(
  228. fixture: &Fixture,
  229. state: &str,
  230. sid: &str,
  231. ) -> Result<String, Box<dyn std::error::Error>> {
  232. let response = send(
  233. fixture.app.clone(),
  234. get_request(
  235. &format!("/callback?code=authcode-1&state={state}"),
  236. Some(&cookie_for(sid)),
  237. ),
  238. )
  239. .await?;
  240. assert_eq!(response.status(), StatusCode::FOUND);
  241. assert_eq!(header_value(&response, "location"), "/dashboard");
  242. let next = sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("rotated cookie");
  243. assert_ne!(sid, next, "session id must rotate on login");
  244. Ok(next)
  245. }
  246. async fn session_json(
  247. fixture: &Fixture,
  248. sid: &str,
  249. ) -> Result<(StatusCode, serde_json::Value), Box<dyn std::error::Error>> {
  250. let response = send(
  251. fixture.app.clone(),
  252. get_request("/api/session", Some(&cookie_for(sid))),
  253. )
  254. .await?;
  255. let status = response.status();
  256. let body = body_text(response).await?;
  257. let json: serde_json::Value =
  258. serde_json::from_str(&body).expect("session body is JSON on every status");
  259. Ok((status, json))
  260. }
  261. // ---- happy path: the whole flow end to end ----
  262. #[tokio::test]
  263. async fn full_flow_start_to_logout() -> TestResult {
  264. let fake = MockServer::start().await;
  265. let (fixture, _dir) = fixture_app(&fake);
  266. mount_discovery(&fake, &fixture.issuer).await;
  267. mount_par(&fake).await;
  268. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  269. let now = now_secs();
  270. mount_introspect(
  271. &fake,
  272. active_introspection(now + 300, now - 10, &fixture.jkt),
  273. )
  274. .await;
  275. mount_revoke(&fake).await;
  276. // `/oauth/start` -> extract `state`.
  277. let (state, sid) = start_flow(&fixture).await?;
  278. // `/callback` -> session + rotation.
  279. let sid = callback_flow(&fixture, &state, &sid).await?;
  280. // `/api/session` -> subject + scopes + DPoP display type.
  281. let (status, json) = session_json(&fixture, &sid).await?;
  282. assert_eq!(status, StatusCode::OK);
  283. assert_eq!(json["subject"], "demo@example.com");
  284. assert_eq!(json["scope"], serde_json::json!(["demo.read"]));
  285. assert_eq!(json["token_type"], "DPoP");
  286. assert_eq!(
  287. json["cnf"]["jkt"],
  288. serde_json::Value::String(fixture.jkt.clone())
  289. );
  290. let csrf = json["csrf_token"]
  291. .as_str()
  292. .expect("csrf_token served")
  293. .to_owned();
  294. // `/api/refresh` -> rotation receipt, no tokens in the body.
  295. let response = send(
  296. fixture.app.clone(),
  297. post_request("/api/refresh", &cookie_for(&sid), &csrf, None),
  298. )
  299. .await?;
  300. assert_eq!(response.status(), StatusCode::OK);
  301. let body = body_text(response).await?;
  302. assert!(
  303. body.contains("refreshed"),
  304. "unexpected refresh body: {body}"
  305. );
  306. for forbidden in ["access-1", "refresh-1", "demo-secret"] {
  307. assert!(
  308. !body.contains(forbidden),
  309. "secret leaked into refresh: {body}"
  310. );
  311. }
  312. // `/api/logout` -> cleared session + AS note.
  313. let response = send(
  314. fixture.app.clone(),
  315. post_request("/api/logout", &cookie_for(&sid), &csrf, None),
  316. )
  317. .await?;
  318. assert_eq!(response.status(), StatusCode::OK);
  319. let body = body_text(response).await?;
  320. assert!(
  321. body.contains("provider logout endpoint"),
  322. "logout note missing: {body}"
  323. );
  324. assert!(
  325. fixture.state.sessions().get(&sid).is_none(),
  326. "logout clears the session"
  327. );
  328. // Post-logout session probe is 401.
  329. let (status, _) = session_json(&fixture, &sid).await?;
  330. assert_eq!(status, StatusCode::UNAUTHORIZED);
  331. // Both tokens were revoked best-effort.
  332. let sent = fake.received_requests().await.unwrap_or_default();
  333. assert_eq!(
  334. sent
  335. .iter()
  336. .filter(|request| request.url.path() == "/revoke")
  337. .count(),
  338. 2,
  339. "refresh + access must both be revoked"
  340. );
  341. Ok(())
  342. }
  343. // ---- DPoP proofs verify against the JWK the client sent ----
  344. #[tokio::test]
  345. async fn dpop_proofs_verify_against_the_sent_jwk() -> TestResult {
  346. let fake = MockServer::start().await;
  347. let (fixture, _dir) = fixture_app(&fake);
  348. mount_discovery(&fake, &fixture.issuer).await;
  349. mount_par(&fake).await;
  350. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  351. let now = now_secs();
  352. mount_introspect(
  353. &fake,
  354. active_introspection(now + 300, now - 10, &fixture.jkt),
  355. )
  356. .await;
  357. let (state, sid) = start_flow(&fixture).await?;
  358. let sid = callback_flow(&fixture, &state, &sid).await?;
  359. let (_, json) = session_json(&fixture, &sid).await?;
  360. let csrf = json["csrf_token"]
  361. .as_str()
  362. .expect("csrf_token served")
  363. .to_owned();
  364. // Refresh so the fake AS sees two proofs (exchange + rotation).
  365. let response = send(
  366. fixture.app.clone(),
  367. post_request("/api/refresh", &cookie_for(&sid), &csrf, None),
  368. )
  369. .await?;
  370. assert_eq!(response.status(), StatusCode::OK);
  371. let sent = fake.received_requests().await.unwrap_or_default();
  372. let proofs: Vec<String> = sent
  373. .iter()
  374. .filter(|request| request.url.path() == "/token")
  375. .filter_map(|request| {
  376. request
  377. .headers
  378. .get("dpop")
  379. .and_then(|value| value.to_str().ok())
  380. .map(str::to_owned)
  381. })
  382. .collect();
  383. assert_eq!(proofs.len(), 2, "exchange + refresh each send a proof");
  384. let mut jtis = Vec::new();
  385. for proof in &proofs {
  386. let verified = oauth_core::dpop::proof::verify_proof(proof).expect("proof verifies");
  387. assert_eq!(verified.htm, "POST");
  388. assert_eq!(verified.htu, format!("{}/token", fixture.issuer));
  389. let sent_jwk = verified.header.get("jwk").expect("proof carries its JWK");
  390. assert_eq!(sent_jwk, &fixture.public_jwk, "proof key is the client key");
  391. let jkt = oauth_core::dpop::key::thumbprint_of(sent_jwk).expect("thumbprint computes");
  392. assert_eq!(
  393. jkt, fixture.jkt,
  394. "bound key matches the introspected cnf.jkt"
  395. );
  396. jtis.push(verified.jti.clone());
  397. }
  398. assert_ne!(jtis[0], jtis[1], "each proof carries a fresh jti");
  399. Ok(())
  400. }
  401. // ---- failure paths ----
  402. #[tokio::test]
  403. async fn state_mismatch_never_exchanges() -> TestResult {
  404. let fake = MockServer::start().await;
  405. let (fixture, _dir) = fixture_app(&fake);
  406. mount_discovery(&fake, &fixture.issuer).await;
  407. mount_par(&fake).await;
  408. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  409. let (_state, sid) = start_flow(&fixture).await?;
  410. let response = send(
  411. fixture.app.clone(),
  412. get_request(
  413. "/callback?code=authcode-1&state=forged-state",
  414. Some(&cookie_for(&sid)),
  415. ),
  416. )
  417. .await?;
  418. assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  419. let sent = fake.received_requests().await.unwrap_or_default();
  420. assert!(
  421. sent.iter().all(|request| request.url.path() != "/token"),
  422. "state mismatch must never trigger an exchange"
  423. );
  424. Ok(())
  425. }
  426. #[tokio::test]
  427. async fn discovery_503_yields_a_typed_error_page() -> TestResult {
  428. let fake = MockServer::start().await;
  429. let (fixture, _dir) = fixture_app(&fake);
  430. Mock::given(method("GET"))
  431. .and(path("/.well-known/oauth-authorization-server"))
  432. .respond_with(ResponseTemplate::new(503))
  433. .mount(&fake)
  434. .await;
  435. Mock::given(method("GET"))
  436. .and(path("/.well-known/openid-configuration"))
  437. .respond_with(ResponseTemplate::new(503))
  438. .mount(&fake)
  439. .await;
  440. let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
  441. assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
  442. let body = body_text(response).await?;
  443. assert!(
  444. body.contains("unavailable"),
  445. "expected a typed error page, got: {body}"
  446. );
  447. for forbidden in ["demo-secret", "code_verifier", "verifier"] {
  448. assert!(
  449. !body.contains(forbidden),
  450. "secret leaked into error page: {body}"
  451. );
  452. }
  453. Ok(())
  454. }
  455. #[tokio::test]
  456. async fn invalid_client_exchange_fails_closed_without_session() -> TestResult {
  457. let fake = MockServer::start().await;
  458. let (fixture, _dir) = fixture_app(&fake);
  459. mount_discovery(&fake, &fixture.issuer).await;
  460. mount_par(&fake).await;
  461. mount_token(
  462. &fake,
  463. ResponseTemplate::new(401).set_body_json(serde_json::json!({
  464. "error": "invalid_client",
  465. "error_description": "unknown client",
  466. })),
  467. )
  468. .await;
  469. let (state, sid) = start_flow(&fixture).await?;
  470. let response = send(
  471. fixture.app.clone(),
  472. get_request(
  473. &format!("/callback?code=authcode-1&state={state}"),
  474. Some(&cookie_for(&sid)),
  475. ),
  476. )
  477. .await?;
  478. assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
  479. let body = body_text(response).await?;
  480. assert!(
  481. !body.contains("demo-secret"),
  482. "secret leaked into error page: {body}"
  483. );
  484. let (status, _) = session_json(&fixture, &sid).await?;
  485. assert_eq!(
  486. status,
  487. StatusCode::UNAUTHORIZED,
  488. "failed exchange creates no session"
  489. );
  490. Ok(())
  491. }
  492. #[tokio::test]
  493. async fn refresh_reuse_invalid_grant_clears_the_session() -> TestResult {
  494. let fake = MockServer::start().await;
  495. let (fixture, _dir) = fixture_app(&fake);
  496. mount_discovery(&fake, &fixture.issuer).await;
  497. mount_par(&fake).await;
  498. // First `/token` call (the exchange) succeeds; the refresh reuses a revoked
  499. // family member and is rejected.
  500. Mock::given(method("POST"))
  501. .and(path("/token"))
  502. .respond_with(token_ok("access-1", "refresh-1"))
  503. .up_to_n_times(1)
  504. .mount(&fake)
  505. .await;
  506. Mock::given(method("POST"))
  507. .and(path("/token"))
  508. .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
  509. "error": "invalid_grant",
  510. "error_description": "refresh token reused",
  511. })))
  512. .mount(&fake)
  513. .await;
  514. let now = now_secs();
  515. mount_introspect(
  516. &fake,
  517. active_introspection(now + 300, now - 10, &fixture.jkt),
  518. )
  519. .await;
  520. let (state, sid) = start_flow(&fixture).await?;
  521. let sid = callback_flow(&fixture, &state, &sid).await?;
  522. let (_, json) = session_json(&fixture, &sid).await?;
  523. let csrf = json["csrf_token"]
  524. .as_str()
  525. .expect("csrf_token served")
  526. .to_owned();
  527. let response = send(
  528. fixture.app.clone(),
  529. post_request("/api/refresh", &cookie_for(&sid), &csrf, None),
  530. )
  531. .await?;
  532. assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
  533. let body = body_text(response).await?;
  534. assert!(
  535. body.contains("invalid_grant"),
  536. "expected the reuse code, got: {body}"
  537. );
  538. assert!(
  539. fixture.state.sessions().get(&sid).is_none(),
  540. "reuse must clear the session"
  541. );
  542. Ok(())
  543. }
  544. #[tokio::test]
  545. async fn csrf_and_origin_rejections_leave_the_session_intact() -> TestResult {
  546. let fake = MockServer::start().await;
  547. let (fixture, _dir) = fixture_app(&fake);
  548. mount_discovery(&fake, &fixture.issuer).await;
  549. mount_par(&fake).await;
  550. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  551. let now = now_secs();
  552. mount_introspect(
  553. &fake,
  554. active_introspection(now + 300, now - 10, &fixture.jkt),
  555. )
  556. .await;
  557. let (state, sid) = start_flow(&fixture).await?;
  558. let sid = callback_flow(&fixture, &state, &sid).await?;
  559. let (_, json) = session_json(&fixture, &sid).await?;
  560. let csrf = json["csrf_token"]
  561. .as_str()
  562. .expect("csrf_token served")
  563. .to_owned();
  564. // Foreign `Origin` is rejected.
  565. let response = send(
  566. fixture.app.clone(),
  567. post_request(
  568. "/api/refresh",
  569. &cookie_for(&sid),
  570. &csrf,
  571. Some("https://evil.example"),
  572. ),
  573. )
  574. .await?;
  575. assert_eq!(response.status(), StatusCode::FORBIDDEN);
  576. // Missing CSRF token is rejected.
  577. let response = send(
  578. fixture.app.clone(),
  579. Request::builder()
  580. .uri("/api/refresh")
  581. .method("POST")
  582. .header("cookie", cookie_for(&sid))
  583. .body(Body::empty())
  584. .expect("request builds"),
  585. )
  586. .await?;
  587. assert_eq!(response.status(), StatusCode::FORBIDDEN);
  588. // Cross-site fetch metadata is rejected.
  589. let response = send(
  590. fixture.app.clone(),
  591. Request::builder()
  592. .uri("/api/logout")
  593. .method("POST")
  594. .header("cookie", cookie_for(&sid))
  595. .header("x-csrf-token", csrf.clone())
  596. .header("sec-fetch-site", "cross-site")
  597. .body(Body::empty())
  598. .expect("request builds"),
  599. )
  600. .await?;
  601. assert_eq!(response.status(), StatusCode::FORBIDDEN);
  602. assert!(
  603. fixture.state.sessions().get(&sid).is_some(),
  604. "rejected POSTs must not touch the session"
  605. );
  606. Ok(())
  607. }
  608. #[tokio::test]
  609. async fn cookie_flags_are_hardened_through_the_flow() -> TestResult {
  610. let fake = MockServer::start().await;
  611. let (fixture, _dir) = fixture_app(&fake);
  612. mount_discovery(&fake, &fixture.issuer).await;
  613. mount_par(&fake).await;
  614. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  615. let now = now_secs();
  616. mount_introspect(
  617. &fake,
  618. active_introspection(now + 300, now - 10, &fixture.jkt),
  619. )
  620. .await;
  621. mount_revoke(&fake).await;
  622. // Start cookie: opaque id only, hardened attributes.
  623. let start = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
  624. let start_cookie = header_value(&start, "set-cookie");
  625. for expected in ["sid=", "Path=/", "HttpOnly", "SameSite=Lax"] {
  626. assert!(
  627. start_cookie.contains(expected),
  628. "start cookie misses {expected}: {start_cookie}"
  629. );
  630. }
  631. assert!(
  632. !start_cookie.contains("access-1"),
  633. "token in cookie: {start_cookie}"
  634. );
  635. let sid = sid_from_set_cookie(&start_cookie).expect("sid cookie");
  636. let state = fixture
  637. .state
  638. .pending()
  639. .pending_state_for_session(&sid)
  640. .expect("state bound to the session");
  641. // Login rotates the id with the same flags.
  642. let sid = callback_flow(&fixture, &state, &sid).await?;
  643. let (_, json) = session_json(&fixture, &sid).await?;
  644. let csrf = json["csrf_token"]
  645. .as_str()
  646. .expect("csrf_token served")
  647. .to_owned();
  648. let response = send(
  649. fixture.app.clone(),
  650. post_request("/api/logout", &cookie_for(&sid), &csrf, None),
  651. )
  652. .await?;
  653. let cleared = header_value(&response, "set-cookie");
  654. assert!(
  655. cleared.contains("Max-Age=0"),
  656. "logout must clear the cookie: {cleared}"
  657. );
  658. Ok(())
  659. }
  660. #[tokio::test]
  661. async fn no_secret_reaches_logs_or_debug_output() {
  662. let line = server::http_client::loggable_request(
  663. "POST",
  664. "https://as.example/token?code=AUTH-C0DE",
  665. true,
  666. true,
  667. );
  668. let decorated = format!(
  669. "{line} secret={} token={}",
  670. server::http_client::redact_secret("CLIENT-S3CRET"),
  671. server::http_client::redact_secret("ACCESS-T0KEN"),
  672. );
  673. for sensitive in ["CLIENT-S3CRET", "ACCESS-T0KEN", "AUTH-C0DE"] {
  674. assert!(
  675. !decorated.contains(sensitive),
  676. "secret leaked into log line: {decorated:?}"
  677. );
  678. }
  679. let store = fixture_store();
  680. let created = store.create().expect("session creates");
  681. store.store_tokens(
  682. &created.id,
  683. Some(SecretString::from("ACCESS-S3CRET")),
  684. Some(SecretString::from("REFRESH-S3CRET")),
  685. Some("DPoP".to_owned()),
  686. );
  687. let rendered = format!("{:?}", store.get(&created.id).expect("session lives"));
  688. for sensitive in ["ACCESS-S3CRET", "REFRESH-S3CRET"] {
  689. assert!(
  690. !rendered.contains(sensitive),
  691. "secret leaked into session Debug: {rendered:?}"
  692. );
  693. }
  694. let cookie =
  695. server::session::set_cookie_value(&created.id, false, std::time::Duration::from_secs(60));
  696. assert!(!cookie.contains("ACCESS-S3CRET"));
  697. let csrf = created.csrf_token.expose_secret().to_owned();
  698. assert!(store.validate_csrf(&created.id, &csrf));
  699. }
  700. fn fixture_store() -> server::session::SessionStore {
  701. server::session::SessionStore::new(std::time::Duration::from_secs(3600))
  702. }