oauth.rs 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115
  1. //! BFF OAuth routes against a `wiremock` fake AS (todos 15-18).
  2. //!
  3. //! Drives the router with `tower::ServiceExt::oneshot`: no ports are bound,
  4. //! no servers start. Each test mounts its own fake AS (discovery, PAR,
  5. //! token, introspection, revocation) and its own [`AppState`] so discovery
  6. //! and introspection caches never leak across tests.
  7. use std::collections::HashMap;
  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::{body_string_contains, 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. }
  26. fn empty_lookup(_: &str) -> Option<String> {
  27. None
  28. }
  29. /// Build an app whose profile points at the fake AS. The DPoP key is
  30. /// generated in memory and injected (no key file is read).
  31. fn fixture_app(fake: &MockServer) -> (Fixture, tempfile::TempDir) {
  32. let dir = tempfile::tempdir().expect("temp dir is creatable");
  33. let dist = dir.path().join("dist");
  34. std::fs::create_dir_all(&dist).expect("fixture dist is creatable");
  35. std::fs::write(
  36. dist.join("index.html"),
  37. "<html><body>spa-shell</body></html>",
  38. )
  39. .expect("fixture index is writable");
  40. let issuer = fake.uri();
  41. let config_json = format!(
  42. r#"{{
  43. "server": {{ "dist_dir": {} }},
  44. "oauth": {{
  45. "issuer": "{issuer}",
  46. "redirect_uri": "http://127.0.0.1:3000/callback",
  47. "scope": "demo.read"
  48. }}
  49. }}"#,
  50. serde_json::to_string(&dist).expect("path serializes")
  51. );
  52. std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable");
  53. std::fs::write(
  54. dir.path().join("client.json"),
  55. r#"{"client_id": "demo-client", "client_secret": "demo-secret"}"#,
  56. )
  57. .expect("client fixture is writable");
  58. let config = ServerConfig::load_from_paths_with(
  59. Some(&dir.path().join("config.json")),
  60. Some(&dir.path().join("client.json")),
  61. &empty_lookup,
  62. )
  63. .expect("fixture config loads");
  64. let key = oauth_core::dpop::key::DpopKey::generate().expect("dpop key generates");
  65. let state = AppState::with_client(config, reqwest::Client::new()).with_dpop_key(key);
  66. let app = build_app(state.clone());
  67. (Fixture { app, state, issuer }, dir)
  68. }
  69. /// Mount the discovery document on the fake AS. `with_par` controls whether
  70. /// the PAR endpoint is advertised (plain-authorize fallback when absent).
  71. async fn mount_discovery(fake: &MockServer, issuer: &str, with_par: bool) {
  72. let mut doc = serde_json::json!({
  73. "issuer": issuer,
  74. "authorization_endpoint": format!("{issuer}/authorize"),
  75. "token_endpoint": format!("{issuer}/token"),
  76. "introspection_endpoint": format!("{issuer}/introspect"),
  77. "revocation_endpoint": format!("{issuer}/revoke"),
  78. "dpop_signing_alg_values_supported": ["ES256"],
  79. });
  80. if with_par {
  81. doc["pushed_authorization_request_endpoint"] = serde_json::json!(format!("{issuer}/par"));
  82. }
  83. Mock::given(method("GET"))
  84. .and(path("/.well-known/oauth-authorization-server"))
  85. .respond_with(ResponseTemplate::new(200).set_body_json(&doc))
  86. .mount(fake)
  87. .await;
  88. }
  89. async fn mount_par(fake: &MockServer) {
  90. Mock::given(method("POST"))
  91. .and(path("/par"))
  92. .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
  93. "request_uri": "urn:ietf:params:oauth:request_uri:par-handle-1",
  94. "expires_in": 90
  95. })))
  96. .mount(fake)
  97. .await;
  98. }
  99. fn token_ok(access: &str, refresh: &str) -> ResponseTemplate {
  100. ResponseTemplate::new(200).set_body_json(serde_json::json!({
  101. "access_token": access,
  102. "token_type": "DPoP",
  103. "expires_in": 3600,
  104. "refresh_token": refresh,
  105. "scope": "demo.read",
  106. }))
  107. }
  108. async fn mount_token(fake: &MockServer, template: ResponseTemplate) {
  109. Mock::given(method("POST"))
  110. .and(path("/token"))
  111. .respond_with(template)
  112. .mount(fake)
  113. .await;
  114. }
  115. async fn mount_introspect(fake: &MockServer, body: serde_json::Value) {
  116. Mock::given(method("POST"))
  117. .and(path("/introspect"))
  118. .respond_with(ResponseTemplate::new(200).set_body_json(&body))
  119. .mount(fake)
  120. .await;
  121. }
  122. /// Introspection answer for one specific token (the form body carries
  123. /// `token=<value>`), so a renewal test can answer the old and rotated access
  124. /// tokens differently.
  125. async fn mount_introspect_for(fake: &MockServer, token: &str, body: serde_json::Value) {
  126. Mock::given(method("POST"))
  127. .and(path("/introspect"))
  128. .and(body_string_contains(format!("token={token}")))
  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, with_cnf: bool) -> serde_json::Value {
  141. let mut body = 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. });
  150. if with_cnf {
  151. body["cnf"] = serde_json::json!({ "jkt": "thumbprint-of-dpop-key" });
  152. }
  153. body
  154. }
  155. async fn send(
  156. app: axum::Router,
  157. request: Request<Body>,
  158. ) -> Result<axum::http::Response<Body>, Box<dyn std::error::Error>> {
  159. Ok(app.oneshot(request).await?)
  160. }
  161. fn get_request(uri: &str, cookie: Option<&str>) -> Request<Body> {
  162. let mut builder = Request::builder().uri(uri);
  163. if let Some(cookie) = cookie {
  164. builder = builder.header("cookie", cookie);
  165. }
  166. builder.body(Body::empty()).expect("request builds")
  167. }
  168. fn post_request(uri: &str, cookie: &str, csrf: &str, origin: Option<&str>) -> Request<Body> {
  169. let mut builder = Request::builder()
  170. .uri(uri)
  171. .method("POST")
  172. .header("cookie", cookie)
  173. .header("x-csrf-token", csrf);
  174. if let Some(origin) = origin {
  175. builder = builder.header("origin", origin);
  176. }
  177. builder.body(Body::empty()).expect("request builds")
  178. }
  179. async fn body_text(
  180. response: axum::http::Response<Body>,
  181. ) -> Result<String, Box<dyn std::error::Error>> {
  182. let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
  183. Ok(std::str::from_utf8(&bytes)?.to_owned())
  184. }
  185. /// Decode an `application/x-www-form-urlencoded` body (no extra dep).
  186. fn decode_form(raw: &str) -> HashMap<String, String> {
  187. raw
  188. .split('&')
  189. .filter_map(|pair| {
  190. let (name, value) = pair.split_once('=')?;
  191. Some((percent_decode(name), percent_decode(value)))
  192. })
  193. .collect()
  194. }
  195. /// Minimal percent-decoder for ASCII form bodies.
  196. fn percent_decode(raw: &str) -> String {
  197. let mut decoded = String::with_capacity(raw.len());
  198. let mut bytes = raw.as_bytes().iter();
  199. while let Some(byte) = bytes.next() {
  200. match byte {
  201. b'%' => {
  202. let high = bytes.next().copied().unwrap_or(b'0');
  203. let low = bytes.next().copied().unwrap_or(b'0');
  204. let pair = [high, low];
  205. match std::str::from_utf8(&pair) {
  206. Ok(hex) => match u8::from_str_radix(hex, 16) {
  207. Ok(value) => decoded.push(value as char),
  208. Err(_) => decoded.push_str(&format!("%{hex}")),
  209. },
  210. Err(_) => decoded.push('%'),
  211. }
  212. }
  213. b'+' => decoded.push(' '),
  214. _ => decoded.push(*byte as char),
  215. }
  216. }
  217. decoded
  218. }
  219. fn header_value(response: &axum::http::Response<Body>, name: &str) -> String {
  220. response
  221. .headers()
  222. .get(name)
  223. .and_then(|value| value.to_str().ok())
  224. .unwrap_or_default()
  225. .to_owned()
  226. }
  227. /// `sid` from a `Set-Cookie` response header.
  228. fn sid_from_set_cookie(set_cookie: &str) -> Option<String> {
  229. set_cookie.split(';').next().and_then(|pair| {
  230. let (name, value) = pair.split_once('=')?;
  231. (name.trim() == SESSION_COOKIE).then(|| value.trim().to_owned())
  232. })
  233. }
  234. fn cookie_for(sid: &str) -> String {
  235. format!("{SESSION_COOKIE}={sid}")
  236. }
  237. // ---- todo 15: GET /oauth/start ----
  238. #[tokio::test]
  239. async fn start_redirects_with_par_request_uri_and_binds_pending() -> TestResult {
  240. let fake = MockServer::start().await;
  241. let (fixture, _dir) = fixture_app(&fake);
  242. mount_discovery(&fake, &fixture.issuer, true).await;
  243. mount_par(&fake).await;
  244. let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
  245. assert_eq!(response.status(), StatusCode::FOUND);
  246. let location = header_value(&response, "location");
  247. assert!(
  248. location.contains("/authorize"),
  249. "unexpected Location: {location}"
  250. );
  251. assert!(
  252. location.contains("request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Apar-handle-1")
  253. || location.contains("request_uri=urn:ietf:params:oauth:request_uri:par-handle-1"),
  254. "PAR handle missing from Location: {location}"
  255. );
  256. assert!(
  257. location.contains("client_id=demo-client"),
  258. "client_id missing: {location}"
  259. );
  260. for forbidden in ["code_verifier", "verifier", "demo-secret", "S3CRET"] {
  261. assert!(
  262. !location.contains(forbidden),
  263. "secret in redirect: {location}"
  264. );
  265. }
  266. let set_cookie = header_value(&response, "set-cookie");
  267. let sid = sid_from_set_cookie(&set_cookie).expect("start sets the sid cookie");
  268. assert_eq!(
  269. fixture.state.pending().len(),
  270. 1,
  271. "pending map holds the request"
  272. );
  273. let bound = fixture
  274. .state
  275. .pending()
  276. .pending_state_for_session(&sid)
  277. .expect("pending record is bound to the session");
  278. assert!(!bound.is_empty());
  279. let sent = fake.received_requests().await.unwrap_or_default();
  280. assert!(
  281. sent.iter().any(|request| request.url.path() == "/par"),
  282. "PAR was not pushed"
  283. );
  284. Ok(())
  285. }
  286. #[tokio::test]
  287. async fn start_without_par_falls_back_to_plain_authorize_redirect() -> TestResult {
  288. let fake = MockServer::start().await;
  289. let (fixture, _dir) = fixture_app(&fake);
  290. mount_discovery(&fake, &fixture.issuer, false).await;
  291. let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
  292. assert_eq!(response.status(), StatusCode::FOUND);
  293. let location = header_value(&response, "location");
  294. assert!(
  295. location.contains("/authorize"),
  296. "unexpected Location: {location}"
  297. );
  298. assert!(location.contains("response_type%3Dcode") || location.contains("response_type=code"));
  299. assert!(
  300. location.contains("code_challenge_method=S256"),
  301. "PKCE S256 missing: {location}"
  302. );
  303. assert!(
  304. !location.contains("request_uri"),
  305. "no PAR handle expected: {location}"
  306. );
  307. assert_eq!(fixture.state.pending().len(), 1);
  308. Ok(())
  309. }
  310. #[tokio::test]
  311. async fn start_with_discovery_unavailable_yields_error_page() -> TestResult {
  312. let fake = MockServer::start().await;
  313. let (fixture, _dir) = fixture_app(&fake);
  314. Mock::given(method("GET"))
  315. .and(path("/.well-known/oauth-authorization-server"))
  316. .respond_with(ResponseTemplate::new(503))
  317. .mount(&fake)
  318. .await;
  319. Mock::given(method("GET"))
  320. .and(path("/.well-known/openid-configuration"))
  321. .respond_with(ResponseTemplate::new(503))
  322. .mount(&fake)
  323. .await;
  324. let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
  325. assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
  326. let body = body_text(response).await?;
  327. assert!(
  328. body.contains("unavailable"),
  329. "expected a typed error page, got: {body}"
  330. );
  331. assert!(
  332. fixture.state.pending().is_empty(),
  333. "nothing pending after a failed start"
  334. );
  335. Ok(())
  336. }
  337. #[tokio::test]
  338. async fn oauth_redirects_are_no_store() -> TestResult {
  339. let fake = MockServer::start().await;
  340. let (fixture, _dir) = fixture_app(&fake);
  341. mount_discovery(&fake, &fixture.issuer, true).await;
  342. mount_par(&fake).await;
  343. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  344. let start = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
  345. assert_eq!(start.status(), StatusCode::FOUND);
  346. assert!(header_value(&start, "location").contains("/authorize"));
  347. assert_no_store(&start, "oauth/start");
  348. let sid = sid_from_set_cookie(&header_value(&start, "set-cookie")).expect("sid cookie");
  349. let state = fixture
  350. .state
  351. .pending()
  352. .pending_state_for_session(&sid)
  353. .expect("state bound to the session");
  354. let done = send(
  355. fixture.app.clone(),
  356. get_request(
  357. &format!("/callback?code=authcode-1&state={state}"),
  358. Some(&cookie_for(&sid)),
  359. ),
  360. )
  361. .await?;
  362. assert_eq!(done.status(), StatusCode::FOUND);
  363. assert_no_store(&done, "callback");
  364. Ok(())
  365. }
  366. fn assert_no_store(response: &axum::http::Response<Body>, route: &str) {
  367. assert_eq!(
  368. header_value(response, "cache-control"),
  369. "no-store",
  370. "{route} must not be cacheable"
  371. );
  372. assert_eq!(
  373. header_value(response, "pragma"),
  374. "no-cache",
  375. "{route} pragma"
  376. );
  377. assert_eq!(header_value(response, "expires"), "0", "{route} expiry");
  378. }
  379. // ---- todo 16: GET /callback ----
  380. /// Drive `/oauth/start` then `/callback` with the bound state (happy path).
  381. async fn start_state(fixture: &Fixture) -> Result<(String, String), Box<dyn std::error::Error>> {
  382. let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
  383. assert_eq!(response.status(), StatusCode::FOUND);
  384. let sid = sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("sid cookie");
  385. let state = fixture
  386. .state
  387. .pending()
  388. .pending_state_for_session(&sid)
  389. .expect("state bound to the session");
  390. Ok((state, sid))
  391. }
  392. #[tokio::test]
  393. async fn callback_happy_path_creates_session_rotates_and_redirects_to_dashboard() -> TestResult {
  394. let fake = MockServer::start().await;
  395. let (fixture, _dir) = fixture_app(&fake);
  396. mount_discovery(&fake, &fixture.issuer, true).await;
  397. mount_par(&fake).await;
  398. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  399. let (state, sid) = start_state(&fixture).await?;
  400. let response = send(
  401. fixture.app.clone(),
  402. get_request(
  403. &format!("/callback?code=authcode-1&state={state}"),
  404. Some(&cookie_for(&sid)),
  405. ),
  406. )
  407. .await?;
  408. assert_eq!(response.status(), StatusCode::FOUND);
  409. assert_eq!(header_value(&response, "location"), "/dashboard");
  410. let next_sid =
  411. sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("rotated cookie");
  412. assert_ne!(sid, next_sid, "session id must rotate on login");
  413. assert!(
  414. fixture.state.sessions().get(&sid).is_none(),
  415. "old id must die"
  416. );
  417. let session = fixture
  418. .state
  419. .sessions()
  420. .get(&next_sid)
  421. .expect("new session lives");
  422. assert_eq!(
  423. session
  424. .access_token()
  425. .expect("access stored")
  426. .expose_secret(),
  427. "access-1"
  428. );
  429. assert_eq!(
  430. session
  431. .refresh_token()
  432. .expect("refresh stored")
  433. .expose_secret(),
  434. "refresh-1"
  435. );
  436. assert_eq!(session.token_type(), Some("DPoP"));
  437. let sent = fake.received_requests().await.unwrap_or_default();
  438. let token_call = sent
  439. .iter()
  440. .find(|request| request.url.path() == "/token")
  441. .expect("code exchanged");
  442. assert!(
  443. token_call.headers.get("dpop").is_some(),
  444. "exchange needs a DPoP proof"
  445. );
  446. let body = decode_form(&String::from_utf8_lossy(&token_call.body));
  447. assert_eq!(
  448. body.get("redirect_uri").map(String::as_str),
  449. Some("http://127.0.0.1:3000/callback")
  450. );
  451. assert!(
  452. body
  453. .get("code_verifier")
  454. .is_some_and(|value| !value.is_empty())
  455. );
  456. Ok(())
  457. }
  458. #[tokio::test]
  459. async fn callback_unknown_state_is_400_without_exchange() -> TestResult {
  460. let fake = MockServer::start().await;
  461. let (fixture, _dir) = fixture_app(&fake);
  462. mount_discovery(&fake, &fixture.issuer, true).await;
  463. mount_par(&fake).await;
  464. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  465. let response = send(
  466. fixture.app.clone(),
  467. get_request("/callback?code=authcode-1&state=no-such-state", None),
  468. )
  469. .await?;
  470. assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  471. let sent = fake.received_requests().await.unwrap_or_default();
  472. assert!(
  473. sent.iter().all(|request| request.url.path() != "/token"),
  474. "unknown state must never trigger an exchange"
  475. );
  476. Ok(())
  477. }
  478. #[tokio::test]
  479. async fn callback_replayed_state_is_400() -> TestResult {
  480. let fake = MockServer::start().await;
  481. let (fixture, _dir) = fixture_app(&fake);
  482. mount_discovery(&fake, &fixture.issuer, true).await;
  483. mount_par(&fake).await;
  484. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  485. let (state, sid) = start_state(&fixture).await?;
  486. let first = send(
  487. fixture.app.clone(),
  488. get_request(
  489. &format!("/callback?code=authcode-1&state={state}"),
  490. Some(&cookie_for(&sid)),
  491. ),
  492. )
  493. .await?;
  494. assert_eq!(first.status(), StatusCode::FOUND);
  495. let replay = send(
  496. fixture.app.clone(),
  497. get_request(
  498. &format!("/callback?code=authcode-1&state={state}"),
  499. Some(&cookie_for(&sid)),
  500. ),
  501. )
  502. .await?;
  503. assert_eq!(replay.status(), StatusCode::BAD_REQUEST);
  504. Ok(())
  505. }
  506. #[tokio::test]
  507. async fn callback_access_denied_maps_to_user_facing_denial() -> TestResult {
  508. let fake = MockServer::start().await;
  509. let (fixture, _dir) = fixture_app(&fake);
  510. mount_discovery(&fake, &fixture.issuer, true).await;
  511. let response = send(
  512. fixture.app.clone(),
  513. get_request(
  514. "/callback?error=access_denied&error_description=the+user+denied+consent",
  515. None,
  516. ),
  517. )
  518. .await?;
  519. assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  520. let body = body_text(response).await?;
  521. assert!(
  522. body.contains("denied"),
  523. "expected a denial page, got: {body}"
  524. );
  525. let sent = fake.received_requests().await.unwrap_or_default();
  526. assert!(
  527. sent.iter().all(|request| request.url.path() != "/token"),
  528. "denial must not exchange"
  529. );
  530. Ok(())
  531. }
  532. #[tokio::test]
  533. async fn callback_invalid_scope_names_the_required_scope() -> TestResult {
  534. let fake = MockServer::start().await;
  535. let (fixture, _dir) = fixture_app(&fake);
  536. mount_discovery(&fake, &fixture.issuer, true).await;
  537. let response = send(
  538. fixture.app.clone(),
  539. get_request(
  540. "/callback?error=invalid_scope&error_description=the+requested+scope+is+unknown",
  541. None,
  542. ),
  543. )
  544. .await?;
  545. assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  546. let body = body_text(response).await?;
  547. assert!(
  548. body.contains("Scope not granted"),
  549. "expected the scope-not-granted page, got: {body}"
  550. );
  551. assert!(
  552. body.contains("demo.read"),
  553. "the page must name the configured scope, got: {body}"
  554. );
  555. let sent = fake.received_requests().await.unwrap_or_default();
  556. assert!(
  557. sent.iter().all(|request| request.url.path() != "/token"),
  558. "invalid_scope must not exchange"
  559. );
  560. Ok(())
  561. }
  562. #[tokio::test]
  563. async fn callback_iss_mismatch_is_rejected() -> TestResult {
  564. let fake = MockServer::start().await;
  565. let (fixture, _dir) = fixture_app(&fake);
  566. mount_discovery(&fake, &fixture.issuer, true).await;
  567. mount_par(&fake).await;
  568. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  569. let (state, sid) = start_state(&fixture).await?;
  570. let response = send(
  571. fixture.app.clone(),
  572. get_request(
  573. &format!("/callback?code=authcode-1&state={state}&iss=https%3A%2F%2Fevil.example"),
  574. Some(&cookie_for(&sid)),
  575. ),
  576. )
  577. .await?;
  578. assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  579. let sent = fake.received_requests().await.unwrap_or_default();
  580. assert!(
  581. sent.iter().all(|request| request.url.path() != "/token"),
  582. "iss mismatch must not exchange"
  583. );
  584. Ok(())
  585. }
  586. #[tokio::test]
  587. async fn callback_matching_iss_is_accepted() -> TestResult {
  588. let fake = MockServer::start().await;
  589. let (fixture, _dir) = fixture_app(&fake);
  590. mount_discovery(&fake, &fixture.issuer, true).await;
  591. mount_par(&fake).await;
  592. mount_token(&fake, token_ok("access-1", "refresh-1")).await;
  593. let (state, sid) = start_state(&fixture).await?;
  594. let iss = urlencoding_like(&fixture.issuer);
  595. let response = send(
  596. fixture.app.clone(),
  597. get_request(
  598. &format!("/callback?code=authcode-1&state={state}&iss={iss}"),
  599. Some(&cookie_for(&sid)),
  600. ),
  601. )
  602. .await?;
  603. assert_eq!(response.status(), StatusCode::FOUND);
  604. assert_eq!(header_value(&response, "location"), "/dashboard");
  605. Ok(())
  606. }
  607. /// Percent-encode an issuer for the `iss` query parameter without a new dep.
  608. fn urlencoding_like(raw: &str) -> String {
  609. let mut encoded = String::with_capacity(raw.len());
  610. for byte in raw.bytes() {
  611. match byte {
  612. b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
  613. encoded.push(byte as char)
  614. }
  615. _ => encoded.push_str(&format!("%{byte:02X}")),
  616. }
  617. }
  618. encoded
  619. }
  620. // ---- todo 17: GET /api/session ----
  621. /// Authenticated session holding `access-1`/`refresh-1` plus its CSRF token.
  622. fn authenticated_session(fixture: &Fixture) -> (String, String) {
  623. let created = fixture.state.sessions().create().expect("session creates");
  624. assert!(fixture.state.sessions().store_tokens(
  625. &created.id,
  626. Some(SecretString::from("access-1".to_owned())),
  627. Some(SecretString::from("refresh-1".to_owned())),
  628. Some("DPoP".to_owned()),
  629. ));
  630. let csrf = fixture
  631. .state
  632. .sessions()
  633. .get(&created.id)
  634. .expect("session lives")
  635. .csrf_token()
  636. .expose_secret()
  637. .to_owned();
  638. (created.id, csrf)
  639. }
  640. #[tokio::test]
  641. async fn session_api_returns_subject_scopes_cnf_and_csrf_token() -> TestResult {
  642. let fake = MockServer::start().await;
  643. let (fixture, _dir) = fixture_app(&fake);
  644. mount_discovery(&fake, &fixture.issuer, true).await;
  645. let now = std::time::SystemTime::now()
  646. .duration_since(std::time::UNIX_EPOCH)
  647. .expect("clock available")
  648. .as_secs() as i64;
  649. mount_introspect(&fake, active_introspection(now + 300, now - 10, true)).await;
  650. let (sid, csrf) = authenticated_session(&fixture);
  651. let response = send(
  652. fixture.app.clone(),
  653. get_request("/api/session", Some(&cookie_for(&sid))),
  654. )
  655. .await?;
  656. assert_eq!(response.status(), StatusCode::OK);
  657. let body = body_text(response).await?;
  658. let json: serde_json::Value = serde_json::from_str(&body)?;
  659. assert_eq!(json["issuer"], fixture.issuer);
  660. assert_eq!(json["client_id"], "demo-client");
  661. assert_eq!(json["subject"], "demo@example.com");
  662. assert_eq!(json["scope"], serde_json::json!(["demo.read"]));
  663. assert_eq!(
  664. json["missing_scopes"],
  665. serde_json::json!([]),
  666. "a grant covering the required scope reports no missing scope, got: {body}"
  667. );
  668. assert_eq!(
  669. json["token_type"], "DPoP",
  670. "cnf.jkt must surface as DPoP, got: {body}"
  671. );
  672. assert_eq!(json["cnf"]["jkt"], "thumbprint-of-dpop-key");
  673. assert_eq!(json["iat"], now - 10);
  674. assert_eq!(json["exp"], now + 300);
  675. let expires_in = json["expires_in"].as_i64().expect("expires_in is a number");
  676. assert!(
  677. (290..=300).contains(&expires_in),
  678. "unexpected expires_in {expires_in}"
  679. );
  680. assert_eq!(json["csrf_token"], serde_json::Value::String(csrf));
  681. for forbidden in ["access-1", "refresh-1", "demo-secret", "S3CRET"] {
  682. assert!(
  683. !body.contains(forbidden),
  684. "secret leaked into /api/session: {body}"
  685. );
  686. }
  687. Ok(())
  688. }
  689. #[tokio::test]
  690. async fn session_api_without_cnf_falls_back_to_stored_token_type() -> TestResult {
  691. let fake = MockServer::start().await;
  692. let (fixture, _dir) = fixture_app(&fake);
  693. mount_discovery(&fake, &fixture.issuer, true).await;
  694. let now = std::time::SystemTime::now()
  695. .duration_since(std::time::UNIX_EPOCH)
  696. .expect("clock available")
  697. .as_secs() as i64;
  698. mount_introspect(&fake, active_introspection(now + 60, now - 5, false)).await;
  699. let (sid, _) = authenticated_session(&fixture);
  700. let response = send(
  701. fixture.app.clone(),
  702. get_request("/api/session", Some(&cookie_for(&sid))),
  703. )
  704. .await?;
  705. assert_eq!(response.status(), StatusCode::OK);
  706. let body = body_text(response).await?;
  707. let json: serde_json::Value = serde_json::from_str(&body)?;
  708. assert_eq!(
  709. json["token_type"], "DPoP",
  710. "stored exchange type wins, got: {body}"
  711. );
  712. assert!(json.get("cnf").is_none_or(|value| value.is_null()));
  713. Ok(())
  714. }
  715. #[tokio::test]
  716. async fn session_api_inactive_with_valid_refresh_renews() -> TestResult {
  717. let fake = MockServer::start().await;
  718. let (fixture, _dir) = fixture_app(&fake);
  719. mount_discovery(&fake, &fixture.issuer, true).await;
  720. let now = std::time::SystemTime::now()
  721. .duration_since(std::time::UNIX_EPOCH)
  722. .expect("clock available")
  723. .as_secs() as i64;
  724. mount_introspect_for(&fake, "access-1", serde_json::json!({ "active": false })).await;
  725. mount_introspect_for(
  726. &fake,
  727. "access-2",
  728. active_introspection(now + 300, now - 1, true),
  729. )
  730. .await;
  731. mount_token(&fake, token_ok("access-2", "refresh-2")).await;
  732. let (sid, _) = authenticated_session(&fixture);
  733. let response = send(
  734. fixture.app.clone(),
  735. get_request("/api/session", Some(&cookie_for(&sid))),
  736. )
  737. .await?;
  738. assert_eq!(response.status(), StatusCode::OK);
  739. let body = body_text(response).await?;
  740. let json: serde_json::Value = serde_json::from_str(&body)?;
  741. assert_eq!(
  742. json["exp"],
  743. now + 300,
  744. "renewed view must carry the new expiry, got: {body}"
  745. );
  746. assert_eq!(json["cnf"]["jkt"], "thumbprint-of-dpop-key");
  747. assert_eq!(json["subject"], "demo@example.com");
  748. let session = fixture
  749. .state
  750. .sessions()
  751. .get(&sid)
  752. .expect("a valid refresh token must keep the session alive");
  753. assert_eq!(
  754. session.access_token().expect("new access").expose_secret(),
  755. "access-2"
  756. );
  757. assert_eq!(
  758. session
  759. .refresh_token()
  760. .expect("rotated refresh")
  761. .expose_secret(),
  762. "refresh-2"
  763. );
  764. for forbidden in ["access-2", "refresh-2", "demo-secret"] {
  765. assert!(!body.contains(forbidden), "token leaked into {body}");
  766. }
  767. Ok(())
  768. }
  769. #[tokio::test]
  770. async fn session_api_active_within_skew_renews() -> TestResult {
  771. let fake = MockServer::start().await;
  772. let (fixture, _dir) = fixture_app(&fake);
  773. mount_discovery(&fake, &fixture.issuer, true).await;
  774. let now = std::time::SystemTime::now()
  775. .duration_since(std::time::UNIX_EPOCH)
  776. .expect("clock available")
  777. .as_secs() as i64;
  778. mount_introspect_for(
  779. &fake,
  780. "access-1",
  781. active_introspection(now + 10, now - 300, true),
  782. )
  783. .await;
  784. mount_introspect_for(
  785. &fake,
  786. "access-2",
  787. active_introspection(now + 300, now - 1, true),
  788. )
  789. .await;
  790. mount_token(&fake, token_ok("access-2", "refresh-2")).await;
  791. let (sid, _) = authenticated_session(&fixture);
  792. let response = send(
  793. fixture.app.clone(),
  794. get_request("/api/session", Some(&cookie_for(&sid))),
  795. )
  796. .await?;
  797. assert_eq!(response.status(), StatusCode::OK);
  798. let body = body_text(response).await?;
  799. let json: serde_json::Value = serde_json::from_str(&body)?;
  800. assert_eq!(
  801. json["exp"],
  802. now + 300,
  803. "a token inside the skew window must be renewed, got: {body}"
  804. );
  805. let session = fixture
  806. .state
  807. .sessions()
  808. .get(&sid)
  809. .expect("session survives the renewal");
  810. assert_eq!(
  811. session.access_token().expect("new access").expose_secret(),
  812. "access-2"
  813. );
  814. Ok(())
  815. }
  816. #[tokio::test]
  817. async fn session_api_inactive_with_invalid_grant_clears_the_session() -> TestResult {
  818. let fake = MockServer::start().await;
  819. let (fixture, _dir) = fixture_app(&fake);
  820. mount_discovery(&fake, &fixture.issuer, true).await;
  821. mount_introspect(&fake, serde_json::json!({ "active": false })).await;
  822. mount_token(
  823. &fake,
  824. ResponseTemplate::new(400).set_body_json(serde_json::json!({
  825. "error": "invalid_grant",
  826. "error_description": "refresh token reused",
  827. })),
  828. )
  829. .await;
  830. let (sid, _) = authenticated_session(&fixture);
  831. let response = send(
  832. fixture.app.clone(),
  833. get_request("/api/session", Some(&cookie_for(&sid))),
  834. )
  835. .await?;
  836. assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
  837. assert!(
  838. header_value(&response, "set-cookie").contains("Max-Age=0"),
  839. "cookie must clear"
  840. );
  841. assert!(
  842. fixture.state.sessions().get(&sid).is_none(),
  843. "reuse detection must still end the session"
  844. );
  845. Ok(())
  846. }
  847. #[tokio::test]
  848. async fn session_api_inactive_without_refresh_token_clears_the_session() -> TestResult {
  849. let fake = MockServer::start().await;
  850. let (fixture, _dir) = fixture_app(&fake);
  851. mount_discovery(&fake, &fixture.issuer, true).await;
  852. mount_introspect(&fake, serde_json::json!({ "active": false })).await;
  853. let created = fixture.state.sessions().create().expect("session creates");
  854. assert!(fixture.state.sessions().store_tokens(
  855. &created.id,
  856. Some(SecretString::from("access-1".to_owned())),
  857. None,
  858. Some("DPoP".to_owned()),
  859. ));
  860. let response = send(
  861. fixture.app.clone(),
  862. get_request("/api/session", Some(&cookie_for(&created.id))),
  863. )
  864. .await?;
  865. assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
  866. assert!(
  867. header_value(&response, "set-cookie").contains("Max-Age=0"),
  868. "cookie must clear"
  869. );
  870. assert!(
  871. fixture.state.sessions().get(&created.id).is_none(),
  872. "a session with no refresh token cannot be renewed"
  873. );
  874. Ok(())
  875. }
  876. #[tokio::test]
  877. async fn session_api_unauthenticated_is_401() -> TestResult {
  878. let fake = MockServer::start().await;
  879. let (fixture, _dir) = fixture_app(&fake);
  880. mount_discovery(&fake, &fixture.issuer, true).await;
  881. let response = send(fixture.app.clone(), get_request("/api/session", None)).await?;
  882. assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
  883. Ok(())
  884. }
  885. // ---- todo 18: POST /api/refresh + POST /api/logout ----
  886. #[tokio::test]
  887. async fn refresh_logout_rotation_replaces_the_refresh_token() -> TestResult {
  888. let fake = MockServer::start().await;
  889. let (fixture, _dir) = fixture_app(&fake);
  890. mount_discovery(&fake, &fixture.issuer, true).await;
  891. mount_token(&fake, token_ok("access-2", "refresh-2")).await;
  892. let (sid, csrf) = authenticated_session(&fixture);
  893. let response = send(
  894. fixture.app.clone(),
  895. post_request("/api/refresh", &cookie_for(&sid), &csrf, None),
  896. )
  897. .await?;
  898. assert_eq!(response.status(), StatusCode::OK);
  899. let body = body_text(response).await?;
  900. assert!(
  901. body.contains("refreshed"),
  902. "unexpected refresh body: {body}"
  903. );
  904. for forbidden in ["access-2", "refresh-2", "demo-secret"] {
  905. assert!(
  906. !body.contains(forbidden),
  907. "token leaked into refresh answer: {body}"
  908. );
  909. }
  910. let session = fixture
  911. .state
  912. .sessions()
  913. .get(&sid)
  914. .expect("session survives rotation");
  915. assert_eq!(
  916. session.access_token().expect("new access").expose_secret(),
  917. "access-2"
  918. );
  919. assert_eq!(
  920. session
  921. .refresh_token()
  922. .expect("new refresh")
  923. .expose_secret(),
  924. "refresh-2"
  925. );
  926. let sent = fake.received_requests().await.unwrap_or_default();
  927. let refresh_call = sent
  928. .iter()
  929. .find(|request| request.url.path() == "/token")
  930. .expect("refresh posted");
  931. assert!(
  932. refresh_call.headers.get("dpop").is_some(),
  933. "refresh needs a fresh DPoP proof"
  934. );
  935. Ok(())
  936. }
  937. #[tokio::test]
  938. async fn refresh_logout_invalid_grant_clears_the_session() -> TestResult {
  939. let fake = MockServer::start().await;
  940. let (fixture, _dir) = fixture_app(&fake);
  941. mount_discovery(&fake, &fixture.issuer, true).await;
  942. mount_token(
  943. &fake,
  944. ResponseTemplate::new(400).set_body_json(serde_json::json!({
  945. "error": "invalid_grant",
  946. "error_description": "refresh token reused",
  947. })),
  948. )
  949. .await;
  950. let (sid, csrf) = authenticated_session(&fixture);
  951. let response = send(
  952. fixture.app.clone(),
  953. post_request("/api/refresh", &cookie_for(&sid), &csrf, None),
  954. )
  955. .await?;
  956. assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
  957. assert!(
  958. fixture.state.sessions().get(&sid).is_none(),
  959. "reuse must clear the session"
  960. );
  961. assert!(
  962. fixture.state.sessions().is_empty(),
  963. "no session may survive invalid_grant"
  964. );
  965. Ok(())
  966. }
  967. #[tokio::test]
  968. async fn refresh_logout_revokes_best_effort_and_clears() -> TestResult {
  969. let fake = MockServer::start().await;
  970. let (fixture, _dir) = fixture_app(&fake);
  971. mount_discovery(&fake, &fixture.issuer, true).await;
  972. mount_revoke(&fake).await;
  973. let (sid, csrf) = authenticated_session(&fixture);
  974. let response = send(
  975. fixture.app.clone(),
  976. post_request("/api/logout", &cookie_for(&sid), &csrf, None),
  977. )
  978. .await?;
  979. assert_eq!(response.status(), StatusCode::OK);
  980. let body = body_text(response).await?;
  981. let json: serde_json::Value = serde_json::from_str(&body)?;
  982. assert_eq!(json["ok"], true);
  983. assert!(
  984. json["note"]
  985. .as_str()
  986. .unwrap_or_default()
  987. .contains("provider logout endpoint"),
  988. "logout must document the AS-session limit, got: {body}"
  989. );
  990. assert!(
  991. fixture.state.sessions().get(&sid).is_none(),
  992. "logout clears the session"
  993. );
  994. let sent = fake.received_requests().await.unwrap_or_default();
  995. let revocations = sent
  996. .iter()
  997. .filter(|request| request.url.path() == "/revoke")
  998. .count();
  999. assert_eq!(
  1000. revocations, 2,
  1001. "refresh + access must both be revoked best-effort"
  1002. );
  1003. Ok(())
  1004. }
  1005. #[tokio::test]
  1006. async fn refresh_logout_foreign_origin_post_is_403() -> TestResult {
  1007. let fake = MockServer::start().await;
  1008. let (fixture, _dir) = fixture_app(&fake);
  1009. mount_discovery(&fake, &fixture.issuer, true).await;
  1010. let (sid, csrf) = authenticated_session(&fixture);
  1011. let response = send(
  1012. fixture.app.clone(),
  1013. post_request(
  1014. "/api/refresh",
  1015. &cookie_for(&sid),
  1016. &csrf,
  1017. Some("https://evil.example"),
  1018. ),
  1019. )
  1020. .await?;
  1021. assert_eq!(response.status(), StatusCode::FORBIDDEN);
  1022. assert!(
  1023. fixture.state.sessions().get(&sid).is_some(),
  1024. "rejected POST must not touch the session"
  1025. );
  1026. Ok(())
  1027. }