| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- //! `test(e2e_logout)` RP-initiated logout over the live router.
- //!
- //! `GET /logout` is a read-only confirmation, and only `POST /logout` with the
- //! session-bound form token destroys the session. The return target is never
- //! caller-supplied: `/authorize` freezes the client's registered post-logout
- //! URI in session meta, and `POST /logout` honors it only while the client and
- //! that exact URI are still registered.
- #[allow(dead_code)]
- #[path = "e2e/harness.rs"]
- mod harness;
- #[path = "e2e/dpop.rs"]
- mod dpop;
- #[allow(dead_code)]
- #[path = "e2e/browser.rs"]
- mod browser;
- use browser::{
- SESSION_COOKIE, begin_browser, begin_browser_with_post_logout, cookie_value, get_consent,
- hidden_input, location_of, post_login, totp_now,
- };
- use harness::{TestError, World, ensure, open_rate_limit};
- /// Registered client the logout flow drives.
- const CLIENT_ID: &str = "e2e-logout-app";
- /// Registered authorize redirect (RFC 8252 loopback exception applies).
- const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
- /// Registered post-logout return target.
- const POST_LOGOUT_URI: &str = "https://client.example.test/signed-out";
- /// Catalog scope the client and the user share.
- const SCOPE: &str = "read";
- /// A valid `POST /logout` destroys the session server-side, clears the cookie,
- /// and redirects to the exact post-logout URI the authorization flow froze.
- #[tokio::test]
- async fn e2e_logout_post_destroys_session_and_redirects() -> Result<(), TestError> {
- let world = World::start(open_rate_limit()).await?;
- let browser = begin_browser_with_post_logout(
- world,
- CLIENT_ID,
- REDIRECT_URI,
- SCOPE,
- "logout-post@example.test",
- "correct-horse-28-logout-post",
- POST_LOGOUT_URI,
- )
- .await?;
- let (session, cookie) = login(&browser).await?;
- let (status, _, body) = browser
- .world
- .get("/logout", &[("cookie", cookie.as_str())])
- .await?;
- ensure(
- status == reqwest::StatusCode::OK,
- format!("GET /logout must render the confirmation: {status} {body}"),
- )?;
- let logout_csrf = hidden_input(&body, "csrf")?;
- let (status, headers, body) = browser
- .world
- .post_form(
- "/logout",
- &[("csrf", logout_csrf.as_str())],
- None,
- &[("cookie", cookie.as_str())],
- )
- .await?;
- ensure(
- status == reqwest::StatusCode::SEE_OTHER,
- format!("POST /logout must 303 to the bound target: {status} {body}"),
- )?;
- let location = location_of(&headers)?;
- ensure(
- location == POST_LOGOUT_URI,
- format!("logout must return to the registered post-logout URI, got {location}"),
- )?;
- let prefix = format!("{SESSION_COOKIE}=");
- let cleared = headers.get_all("set-cookie").iter().any(|value| {
- value
- .to_str()
- .ok()
- .is_some_and(|text| text.starts_with(prefix.as_str()) && text.contains("Max-Age=0"))
- });
- ensure(cleared, "POST /logout must expire the session cookie")?;
- let (status, headers, _) = get_consent(&browser, &session).await?;
- ensure(
- status == reqwest::StatusCode::SEE_OTHER,
- format!("the destroyed session must not reach consent: {status}"),
- )?;
- ensure(
- location_of(&headers)? == "/login",
- "the destroyed session must bounce consent to login",
- )?;
- Ok(())
- }
- /// A caller-supplied return target is ignored: only the URI frozen at
- /// authorization time is honored, even when the request presents another.
- #[tokio::test]
- async fn e2e_logout_ignores_caller_supplied_return_target() -> Result<(), TestError> {
- let world = World::start(open_rate_limit()).await?;
- let browser = begin_browser_with_post_logout(
- world,
- CLIENT_ID,
- REDIRECT_URI,
- SCOPE,
- "logout-evil@example.test",
- "correct-horse-28-logout-evil",
- POST_LOGOUT_URI,
- )
- .await?;
- let (_session, cookie) = login(&browser).await?;
- let (status, _, body) = browser
- .world
- .get("/logout", &[("cookie", cookie.as_str())])
- .await?;
- ensure(
- status == reqwest::StatusCode::OK,
- format!("GET /logout must render the confirmation: {status} {body}"),
- )?;
- let logout_csrf = hidden_input(&body, "csrf")?;
- let (status, headers, body) = browser
- .world
- .post_form(
- "/logout",
- &[
- ("csrf", logout_csrf.as_str()),
- ("post_logout_redirect_uri", "https://evil.example/steal"),
- ("redirect_uri", "https://evil.example/steal"),
- ],
- None,
- &[("cookie", cookie.as_str())],
- )
- .await?;
- ensure(
- status == reqwest::StatusCode::SEE_OTHER,
- format!("POST /logout must 303 to the bound target: {status} {body}"),
- )?;
- let location = location_of(&headers)?;
- ensure(
- location == POST_LOGOUT_URI,
- format!("logout must ignore a caller-supplied return target, got {location}"),
- )?;
- Ok(())
- }
- /// `GET /logout` only renders the confirmation form; the session survives and
- /// still serves the bound consent page.
- #[tokio::test]
- async fn e2e_logout_get_is_read_only() -> Result<(), TestError> {
- let world = World::start(open_rate_limit()).await?;
- let browser = begin_browser(
- world,
- "logout-get@example.test",
- "correct-horse-28-logout-get",
- )
- .await?;
- let (session, cookie) = login(&browser).await?;
- let (status, _, body) = browser
- .world
- .get("/logout", &[("cookie", cookie.as_str())])
- .await?;
- ensure(
- status == reqwest::StatusCode::OK,
- format!("GET /logout must render the confirmation: {status} {body}"),
- )?;
- ensure(
- body.contains("Sign out"),
- "GET /logout must offer the sign-out form",
- )?;
- let logout_csrf = hidden_input(&body, "csrf")?;
- ensure(
- !logout_csrf.is_empty(),
- "the confirmation form must carry a session-bound token",
- )?;
- let (status, _, body) = get_consent(&browser, &session).await?;
- ensure(
- status == reqwest::StatusCode::OK,
- format!("a read-only GET must leave the session usable: {status} {body}"),
- )?;
- Ok(())
- }
- /// A POST without a valid session-bound token is rejected with
- /// `invalid_request`, touches no cookie, and leaves the session usable.
- #[tokio::test]
- async fn e2e_logout_csrf_mismatch_keeps_session() -> Result<(), TestError> {
- let world = World::start(open_rate_limit()).await?;
- let browser = begin_browser(
- world,
- "logout-csrf@example.test",
- "correct-horse-28-logout-csrf",
- )
- .await?;
- let (session, cookie) = login(&browser).await?;
- for presented in [None, Some("forged-token")] {
- let params: Vec<(&str, &str)> = presented.map_or_else(Vec::new, |csrf| vec![("csrf", csrf)]);
- let (status, headers, body) = browser
- .world
- .post_form("/logout", ¶ms, None, &[("cookie", cookie.as_str())])
- .await?;
- ensure(
- status == reqwest::StatusCode::BAD_REQUEST,
- format!("a bad logout token must be 400: {status} {body}"),
- )?;
- ensure(
- body.contains("invalid_request"),
- "a bad logout token must be invalid_request",
- )?;
- ensure(
- !headers.contains_key("set-cookie"),
- "a rejected logout must not touch cookies",
- )?;
- }
- let (status, _, body) = get_consent(&browser, &session).await?;
- ensure(
- status == reqwest::StatusCode::OK,
- format!("a rejected logout must leave the session usable: {status} {body}"),
- )?;
- Ok(())
- }
- /// Log in through the driver and return the authenticated session id plus its
- /// cookie header value.
- async fn login(browser: &browser::Browser) -> Result<(String, String), TestError> {
- let code_2fa = totp_now(&browser.totp_secret).await?;
- let (status, headers, body) = post_login(browser, &browser.password.clone(), &code_2fa).await?;
- ensure(
- status == reqwest::StatusCode::SEE_OTHER,
- format!("login must 303 to consent: {status} {body}"),
- )?;
- let session = cookie_value(&headers, SESSION_COOKIE)?;
- let cookie = format!("{SESSION_COOKIE}={session}");
- Ok((session, cookie))
- }
|