| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358 |
- //! Push-to-consume PAR tests: one-time records, authorize-side client
- //! binding, replay rejection, and TTL expiry.
- use std::{collections::HashMap, time::Duration};
- use super::TestError;
- use super::{
- super::push_authorization_request, attach_redirect, bare_auth, ctx_of, expect_code, harness,
- insert_client, push_params, stores_of,
- };
- use crate::{
- db::{TokenEndpointAuthMethod, client_repo},
- error::OAuthErrorCode,
- jose::jar::{JarRequest, PAR_REQUEST_URI_PREFIX, resolve},
- store::par,
- };
- /// Push via a public client and return the issued `request_uri`.
- async fn push_public(
- h: &mut super::Harness,
- endpoint: &str,
- client_id: &str,
- redirect_uri: &str,
- ) -> Result<String, TestError> {
- let params = push_params(client_id, redirect_uri);
- let auth = bare_auth(client_id);
- let ctx = ctx_of(endpoint);
- let mut stores = stores_of(h);
- let response = push_authorization_request(&mut stores, &ctx, &auth, ¶ms, &[])
- .await
- .map_err(|error| TestError::Unexpected(format!("push failed: {}", error.code())))?;
- if response.expires_in != 90 {
- return Err(TestError::Unexpected(format!(
- "wrong expires_in: {}",
- response.expires_in
- )));
- }
- if !response.request_uri.starts_with(PAR_REQUEST_URI_PREFIX) {
- return Err(TestError::Unexpected(format!(
- "bad request_uri: {}",
- response.request_uri
- )));
- }
- Ok(response.request_uri)
- }
- #[tokio::test]
- async fn par_push_stores_one_time_record() -> Result<(), TestError> {
- let mut h = harness().await?;
- let client_id = "par-public-client";
- let redirect_uri = "https://client.example.test/cb";
- insert_client(
- &h.pool,
- &h.keys,
- client_id,
- TokenEndpointAuthMethod::None,
- None,
- )
- .await?;
- attach_redirect(&h.pool, &h.keys, client_id, redirect_uri).await?;
- let endpoint = h.endpoint.clone();
- let request_uri = push_public(&mut h, &endpoint, client_id, redirect_uri).await?;
- let Some(id) = request_uri.strip_prefix(PAR_REQUEST_URI_PREFIX) else {
- return Err(TestError::Unexpected("missing PAR prefix".to_owned()));
- };
- let record = par::consume(&mut h.conn, &h.store, id)
- .await
- .map_err(|error| TestError::Unexpected(format!("{error}")))?;
- let Some(record) = record else {
- return Err(TestError::Unexpected("PAR record missing".to_owned()));
- };
- if record.client_id != client_id {
- return Err(TestError::Unexpected("PAR record wrong client".to_owned()));
- }
- let stored: serde_json::Value = serde_json::from_str(&record.request)
- .map_err(|error| TestError::Unexpected(format!("{error}")))?;
- if stored.get("redirect_uri").and_then(|value| value.as_str()) != Some(redirect_uri) {
- return Err(TestError::Unexpected("stored redirect_uri lost".to_owned()));
- }
- if stored.get("client_secret").is_some() {
- return Err(TestError::Unexpected(
- "credential leaked into store".to_owned(),
- ));
- }
- let replayed = par::consume(&mut h.conn, &h.store, id)
- .await
- .map_err(|error| TestError::Unexpected(format!("{error}")))?;
- if replayed.is_some() {
- return Err(TestError::Unexpected("PAR record reusable".to_owned()));
- }
- Ok(())
- }
- #[tokio::test]
- async fn par_authorize_binds_client_and_rejects_replay() -> Result<(), TestError> {
- let mut h = harness().await?;
- let client_a = "par-client-a";
- let client_b = "par-client-b";
- let redirect_uri = "https://client.example.test/cb";
- insert_client(
- &h.pool,
- &h.keys,
- client_a,
- TokenEndpointAuthMethod::None,
- None,
- )
- .await?;
- insert_client(
- &h.pool,
- &h.keys,
- client_b,
- TokenEndpointAuthMethod::None,
- None,
- )
- .await?;
- attach_redirect(&h.pool, &h.keys, client_a, redirect_uri).await?;
- attach_redirect(&h.pool, &h.keys, client_b, redirect_uri).await?;
- let endpoint = h.endpoint.clone();
- let request_uri = push_public(&mut h, &endpoint, client_a, redirect_uri).await?;
- let issuer = "https://auth.example.test";
- let client_row_b = client_repo::find_active_by_client_id(&h.pool, &h.keys, client_b)
- .await?
- .ok_or_else(|| TestError::Unexpected("client B missing".to_owned()))?;
- let query_b = HashMap::from([
- ("client_id".to_owned(), client_b.to_owned()),
- ("request_uri".to_owned(), request_uri.clone()),
- ]);
- let foreign = JarRequest {
- client: &client_row_b,
- issuer,
- query: &query_b,
- request: None,
- request_uri: Some(request_uri.as_str()),
- };
- match resolve(&mut h.conn, &h.store, &foreign).await {
- Ok(_) => {
- return Err(TestError::Unexpected(
- "cross-client request_uri accepted".to_owned(),
- ));
- }
- Err(error) => expect_code(&error, OAuthErrorCode::InvalidRequest)?,
- }
- // The foreign attempt must not burn the victim record: the legitimate
- // first use resolves the same `request_uri`.
- let client_row_a = client_repo::find_active_by_client_id(&h.pool, &h.keys, client_a)
- .await?
- .ok_or_else(|| TestError::Unexpected("client A missing".to_owned()))?;
- let query_a = HashMap::from([
- ("client_id".to_owned(), client_a.to_owned()),
- ("request_uri".to_owned(), request_uri.clone()),
- ]);
- let first = JarRequest {
- client: &client_row_a,
- issuer,
- query: &query_a,
- request: None,
- request_uri: Some(request_uri.as_str()),
- };
- let merged = resolve(&mut h.conn, &h.store, &first)
- .await
- .map_err(|error| TestError::Unexpected(format!("first use failed: {}", error.code())))?;
- if merged.params.get("redirect_uri").map(String::as_str) != Some(redirect_uri) {
- return Err(TestError::Unexpected("merged params lost".to_owned()));
- }
- let replay = JarRequest {
- client: &client_row_a,
- issuer,
- query: &query_a,
- request: None,
- request_uri: Some(request_uri.as_str()),
- };
- match resolve(&mut h.conn, &h.store, &replay).await {
- Ok(_) => {
- return Err(TestError::Unexpected(
- "replayed request_uri accepted".to_owned(),
- ));
- }
- Err(error) => expect_code(&error, OAuthErrorCode::InvalidRequest)?,
- }
- Ok(())
- }
- #[tokio::test]
- async fn par_foreign_presentation_does_not_consume_victim_record() -> Result<(), TestError> {
- let mut h = harness().await?;
- let client_a = "par-victim-client";
- let client_b = "par-foreign-client";
- let redirect_uri = "https://client.example.test/cb";
- insert_client(
- &h.pool,
- &h.keys,
- client_a,
- TokenEndpointAuthMethod::None,
- None,
- )
- .await?;
- insert_client(
- &h.pool,
- &h.keys,
- client_b,
- TokenEndpointAuthMethod::None,
- None,
- )
- .await?;
- attach_redirect(&h.pool, &h.keys, client_a, redirect_uri).await?;
- attach_redirect(&h.pool, &h.keys, client_b, redirect_uri).await?;
- let endpoint = h.endpoint.clone();
- let request_uri = push_public(&mut h, &endpoint, client_a, redirect_uri).await?;
- let issuer = "https://auth.example.test";
- let client_row_b = client_repo::find_active_by_client_id(&h.pool, &h.keys, client_b)
- .await?
- .ok_or_else(|| TestError::Unexpected("client B missing".to_owned()))?;
- let query_b = HashMap::from([
- ("client_id".to_owned(), client_b.to_owned()),
- ("request_uri".to_owned(), request_uri.clone()),
- ]);
- let foreign = JarRequest {
- client: &client_row_b,
- issuer,
- query: &query_b,
- request: None,
- request_uri: Some(request_uri.as_str()),
- };
- match resolve(&mut h.conn, &h.store, &foreign).await {
- Ok(_) => {
- return Err(TestError::Unexpected(
- "cross-client request_uri accepted".to_owned(),
- ));
- }
- Err(error) => expect_code(&error, OAuthErrorCode::InvalidRequest)?,
- }
- let client_row_a = client_repo::find_active_by_client_id(&h.pool, &h.keys, client_a)
- .await?
- .ok_or_else(|| TestError::Unexpected("client A missing".to_owned()))?;
- let query_a = HashMap::from([
- ("client_id".to_owned(), client_a.to_owned()),
- ("request_uri".to_owned(), request_uri.clone()),
- ]);
- let legitimate = JarRequest {
- client: &client_row_a,
- issuer,
- query: &query_a,
- request: None,
- request_uri: Some(request_uri.as_str()),
- };
- resolve(&mut h.conn, &h.store, &legitimate)
- .await
- .map_err(|error| {
- TestError::Unexpected(format!(
- "victim record burned by foreign use: {}",
- error.code()
- ))
- })?;
- Ok(())
- }
- #[tokio::test]
- async fn par_consume_bound_transport_error_recovery() -> Result<(), TestError> {
- let mut h = harness().await?;
- let client_id = "par-recovery-client";
- let record = par::ParRecord {
- client_id: client_id.to_owned(),
- request: "{\"response_type\":\"code\"}".to_owned(),
- aud: Vec::new(),
- };
- par::put(
- &mut h.conn,
- &h.store,
- "par-recovery-id",
- &record,
- Duration::from_secs(300),
- )
- .await
- .map_err(|error| TestError::Unexpected(format!("{error}")))?;
- // Poison the key type through the public key layout: the guarded `HGETALL`
- // script fails indeterminately on a string key, the recovery re-read sees
- // the surviving key, and the consume fails closed without deleting it.
- let key = h.store.par_key("par-recovery-id");
- let _: () = redis::cmd("DEL")
- .arg(&key)
- .query_async(&mut h.conn)
- .await
- .map_err(TestError::Redis)?;
- let _: () = redis::cmd("SET")
- .arg(&key)
- .arg("poison")
- .query_async(&mut h.conn)
- .await
- .map_err(TestError::Redis)?;
- match par::consume_bound(&mut h.conn, &h.store, "par-recovery-id", client_id).await {
- Err(_) => {}
- Ok(_) => {
- return Err(TestError::Unexpected(
- "indeterminate consume with a surviving key must fail closed".to_owned(),
- ));
- }
- }
- let present: i64 = redis::cmd("EXISTS")
- .arg(&key)
- .query_async(&mut h.conn)
- .await
- .map_err(TestError::Redis)?;
- if present != 1 {
- return Err(TestError::Unexpected(
- "failed consume must leave the surviving key intact".to_owned(),
- ));
- }
- // An absent key reports `Absent` through the same public API: no record,
- // no error, never a replay.
- match par::consume_bound(&mut h.conn, &h.store, "par-recovery-absent", client_id).await {
- Ok(par::ConsumeOutcome::Absent) => Ok(()),
- Ok(_) => Err(TestError::Unexpected(
- "absent PAR record must report Absent".to_owned(),
- )),
- Err(error) => Err(TestError::Unexpected(format!(
- "absent consume error: {error}"
- ))),
- }
- }
- #[tokio::test]
- async fn par_expired_request_uri_is_rejected() -> Result<(), TestError> {
- let mut h = harness().await?;
- let record = par::ParRecord {
- client_id: "par-expiry-client".to_owned(),
- request: "{\"response_type\":\"code\"}".to_owned(),
- aud: Vec::new(),
- };
- par::put(
- &mut h.conn,
- &h.store,
- "par-expiry-id",
- &record,
- Duration::from_millis(50),
- )
- .await
- .map_err(|error| TestError::Unexpected(format!("{error}")))?;
- tokio::time::sleep(Duration::from_millis(300)).await;
- let expired = par::consume(&mut h.conn, &h.store, "par-expiry-id")
- .await
- .map_err(|error| TestError::Unexpected(format!("{error}")))?;
- if expired.is_some() {
- return Err(TestError::Unexpected(
- "expired PAR record survived".to_owned(),
- ));
- }
- Ok(())
- }
|