diff --git a/src/tests/snapshots/idcoop__tests__test_oidc_auth_flow__4__token_conflict.snap b/src/tests/snapshots/idcoop__tests__test_oidc_auth_flow__4__token_conflict.snap new file mode 100644 index 0000000..6b479d4 --- /dev/null +++ b/src/tests/snapshots/idcoop__tests__test_oidc_auth_flow__4__token_conflict.snap @@ -0,0 +1,9 @@ +--- +source: src/tests/test_oidc_auth_flow.rs +expression: "(headers, text)" +--- +- access-control-allow-origin: "*" + access-control-expose-headers: "*" + content-length: "124" + content-type: application/json +- "{\"error\":\"invalid_grant\",\"error_description\":\"Auth code has been redeemed multiple times! This could mean something nasty.\"}" diff --git a/src/tests/snapshots/idcoop__tests__test_oidc_auth_flow__todo.snap b/src/tests/snapshots/idcoop__tests__test_oidc_auth_flow__todo.snap deleted file mode 100644 index a4f9e71..0000000 --- a/src/tests/snapshots/idcoop__tests__test_oidc_auth_flow__todo.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: src/tests/test_oidc_auth_flow.rs -expression: "(headers, text)" ---- -- content-length: "238" - content-type: text/html; charset=utf-8 - set-cookie: __Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=43200000 - x-frame-options: DENY -- "
UN PW (temporary form)
" diff --git a/src/tests/test_oidc_auth_flow.rs b/src/tests/test_oidc_auth_flow.rs index 082c708..91f979b 100644 --- a/src/tests/test_oidc_auth_flow.rs +++ b/src/tests/test_oidc_auth_flow.rs @@ -220,9 +220,6 @@ async fn test_userinfo_bad_auth() { assert_yaml_snapshot!("3. wrong auth token", (headers, text)); } -// Tests TODO: -// /token conflict — including checking that the old access token gets revoked - /// Tests error conditions in the /token endpoint #[tokio::test] async fn test_token_errors() { @@ -357,3 +354,119 @@ async fn test_token_errors() { let json: BTreeMap = serde_json::from_str(&text).unwrap(); assert_yaml_snapshot!("6/token_wrong_verifier", (headers, json)); } + +/// Tests double-requesting a /token (auth code conflict) +#[tokio::test] +async fn test_token_conflict() { + let sys = basic_system().await; + + let uuid = Uuid::nil(); + let pwhash = create_password_hash("secret", &sys.config.password_hashing).unwrap(); + let _: () = sys + .store + .txn(|txn| { + Box::pin(async move { + sqlx::query( + "INSERT INTO users (user_name, user_id, created_at_utc, password_hash, locked) VALUES ($1, $2, NOW(), $3, $4) RETURNING user_id", + ).bind("robert").bind(uuid).bind(pwhash).bind(false) + .fetch_one(&mut **txn.txn) + .await.unwrap(); + Ok(()) + }) + }) + .await + .unwrap(); + + let client = TestClient::new(sys.web); + + ///// These requests are on behalf of the user's browser ///// + + const CODE_VERIFIER: &str = "verifying"; + // base64(sha256("verifying")) + const CODE_CHALLENGE: &str = "LeU9Sprdh-i2mzasKGh8-hmbnmzk48l3Siw390dKY3M"; + + // 1. /login request with credentials + let resp = client + .post("/login") + .form(&btreemap! { + "username" => "robert", + "password" => "secret", + "xsrf" => "HL4qRFKUlBqkrPTvAQ6z-w", + }) + .header("Cookie", "__Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w") + // /login is rate-limited by IP source and needs an IP + .header("X-Forwarded-For", "0.0.0.0") + .send() + .await; + let (status, _headers, _text) = + dump_resp_text("1. /login request with credentials", resp).await; + assert_eq!(status, 302); + + let auth_loc = format!("/oidc/auth?scope=openid&client_id=aclient&response_type=code&state=wombat&redirect_uri=http:%2F%2Faclient.example.com%2Fredirect&code_challenge={CODE_CHALLENGE}&code_challenge_method=S256&nonce=noncey"); + + // 2. /auth request with confirmation + let resp = client + .post(&auth_loc) + .header( + "Cookie", + "__Host-LoginSession=HL4qRFKUlBqkrPTvAQ6z-xpYf2uo9sbO68miVnnz7KE", + ) + .form(&btreemap! { + "action" => "accept", + "xsrf" => "0.48qkqIorf3dyk1LgVQwyNT82yDHyqHbXge09Rvfsz8Y", + }) + .send() + .await; + let (status, _headers, _text) = dump_resp_text("2. POST /auth after confirmation", resp).await; + assert_eq!(status, 302); + eprintln!("{:?}", _text); + + ///// At this point, we make requests on behalf of the client ///// + + // 3. /token request (successful) + let resp = client + .post("/oidc/token") + .header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB") + .form(&btreemap! { + "code" => "LRtIBH5rO3O7hwWaF_UkuFJy0v2xqtGQ", + "code_verifier" => CODE_VERIFIER, + "grant_type" => "authorization_code", + "redirect_uri" => "http://aclient.example.com/redirect", + }) + .send() + .await; + let (status, _headers, text) = dump_resp_text("3. POST /token", resp).await; + assert_eq!(status, 200); + let json: BTreeMap = serde_json::from_str(&text).unwrap(); + let access_token = json + .get("access_token") + .unwrap() + .as_str() + .unwrap() + .to_owned(); + + // 4. /token request (conflicting) + let resp = client + .post("/oidc/token") + .header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB") + .form(&btreemap! { + "code" => "LRtIBH5rO3O7hwWaF_UkuFJy0v2xqtGQ", + "code_verifier" => CODE_VERIFIER, + "grant_type" => "authorization_code", + "redirect_uri" => "http://aclient.example.com/redirect", + }) + .send() + .await; + let (status, headers, text) = dump_resp_text("4. POST /token (conflict)", resp).await; + assert_eq!(status, 400); + assert_yaml_snapshot!("4/token_conflict", (headers, text)); + + // 5. /userinfo (using the access token that should now have expired) + let resp = client + .get("/oidc/userinfo") + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await; + let (status, _headers, _text) = dump_resp_text("7. /userinfo", resp).await; + assert_eq!(status, 401); +}