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
-- "
"
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);
+}