feat(DPoP): add API test

This commit is contained in:
Edouard Vanbelle
2026-08-08 16:21:19 +02:00
parent 5c4354bc67
commit 811c7b0f12
5 changed files with 766 additions and 0 deletions
+689
View File
@@ -0,0 +1,689 @@
//! DPoP wire-protocol test helper for the api-test suite.
//!
//! Hurl can't drive DPoP: every proof carries a fresh `jti`, a
//! current `iat`, an `htm`/`htu` matching the exact request, an
//! ES256 signature from a persistent browser-held keypair, and a
//! nonce threaded from the server's `DPoP-Nonce` response header.
//! A declarative `.hurl` template has no way to compute that per
//! request. Same problem OPAQUE has, same solution:
//! `opaque-hurl-helper.rs` (task #19) verifies OPAQUE end-to-end;
//! this binary does the same for DPoP.
//!
//! Invocation (from `tests/api/run.sh`):
//!
//! ```bash
//! OXICLOUD_DPOP_MODE=required
//! DPOP_HELPER_BASE_URL=$base_url \
//! DPOP_HELPER_USERNAME=$username \
//! DPOP_HELPER_PASSWORD=$password \
//! ./target/debug/dpop-hurl-helper
//! ```
//!
//! Exit codes:
//! * 0 — every scenario succeeded.
//! * 1 — any scenario failed; diagnostic on stderr.
//!
//! Scope: this binary covers the wire contract for the currently-
//! integrated slice — verifier + nonce + replay + middleware in
//! opportunistic mode. Scenarios requiring session-context binding
//! (bound-vs-unbound enforcement per `session.dpop_jkt`, refresh
//! continuity, thumbprint mismatch vs stored) are deferred until
//! Gate 7 threads session context through the middleware.
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE_NO_PAD as B64_URL_NO_PAD};
use opaque_ke::{ClientLogin, ClientLoginFinishParameters, CredentialResponse};
use p256::ecdsa::signature::Signer;
use p256::ecdsa::{Signature, SigningKey};
use rand_core::OsRng as OpaqueRng;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::process::ExitCode;
// Reuse the concrete ciphersuite the production `OpaqueService` uses —
// mismatching client + server suites would fail every handshake with
// a confusing error.
use oxicloud::infrastructure::services::opaque_service::OxiCloudSuite;
/// Server may emit URL_SAFE_NO_PAD *or* STANDARD base64; try both so
/// a future format flip doesn't silently break the round-trip.
fn decode_opaque_b64(input: &str) -> Result<Vec<u8>, base64::DecodeError> {
let s = input.trim();
B64_URL_NO_PAD.decode(s).or_else(|_| B64.decode(s))
}
#[derive(serde::Deserialize)]
struct OpaqueParamsResp {
enabled: bool,
#[serde(rename = "ciphersuiteVersion")]
_ciphersuite_version: i16,
ksf: OpaqueKsfParams,
}
#[derive(serde::Deserialize)]
struct OpaqueKsfParams {
#[serde(rename = "memoryKib")]
memory_kib: u32,
iterations: u32,
parallelism: u32,
}
#[derive(serde::Deserialize)]
struct OpaqueKe1Resp {
#[serde(rename = "exchangeId")]
exchange_id: String,
#[serde(rename = "loginResponse")]
login_response: String,
}
const EXIT_FAIL: u8 = 1;
fn env_or_fail(key: &str) -> String {
std::env::var(key).unwrap_or_else(|_| {
eprintln!("dpop-hurl-helper: required env var {key} unset");
std::process::exit(EXIT_FAIL as i32);
})
}
fn fail(msg: impl std::fmt::Display) -> ExitCode {
// Bordered banner so a failure stands out against the interleaved
// server audit log — the last line before this is usually the
// server-side reject that caused it, which visually blends in.
eprintln!();
eprintln!("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓");
eprintln!("┃ dpop-hurl-helper: FAIL ┃");
eprintln!("┃ {msg}");
eprintln!("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛");
ExitCode::from(EXIT_FAIL)
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn random_jti() -> String {
// 16 bytes of OS randomness, base64url'd — plenty of entropy for
// per-request uniqueness; the server's replay cache keys on this.
use p256::elliptic_curve::rand_core::{OsRng, RngCore};
let mut b = [0u8; 16];
OsRng.fill_bytes(&mut b);
B64_URL_NO_PAD.encode(b)
}
/// A persistent-across-scenarios keypair — simulates one browser
/// tab whose IndexedDB entry survives every scenario in this run.
struct KeyBundle {
signing_key: SigningKey,
jwk_x_b64: String,
jwk_y_b64: String,
}
impl KeyBundle {
fn fresh() -> Self {
// Deterministic-ish seed — this is a test tool, not a
// security surface. Skipping `rand`-dep to keep the binary
// dep footprint identical to what Gate 5 already added.
let mut bytes = [0u8; 32];
for (i, b) in bytes.iter_mut().enumerate() {
*b = ((i as u8).wrapping_mul(37)).wrapping_add(1);
}
let signing_key = SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar");
let vkey = signing_key.verifying_key();
let enc = vkey.to_encoded_point(false);
Self {
signing_key,
jwk_x_b64: B64_URL_NO_PAD.encode(enc.x().unwrap()),
jwk_y_b64: B64_URL_NO_PAD.encode(enc.y().unwrap()),
}
}
}
/// Overrides for building malformed / tampered proofs, per scenario.
#[derive(Default, Clone)]
struct ProofOverrides<'a> {
override_alg: Option<&'a str>,
override_typ: Option<&'a str>,
override_htm: Option<&'a str>,
override_htu: Option<&'a str>,
stale_iat: bool,
/// If `Some`, use this exact string for the `jti` instead of a
/// fresh random one — the replay scenario needs to reuse it.
fixed_jti: Option<String>,
/// If `Some`, include this literal `nonce` claim (even if it's
/// wrong on purpose). If `None`, use whatever the caller
/// tracked from `DPoP-Nonce` response headers.
force_nonce: Option<String>,
}
/// Build + sign a DPoP proof against the given method + URL.
/// `nonce` is the current server-issued nonce (if any); `overrides`
/// let scenario code tamper with the proof shape.
fn build_proof(
keys: &KeyBundle,
method: &str,
url: &str,
nonce: Option<&str>,
overrides: &ProofOverrides<'_>,
) -> String {
let header = json!({
"typ": overrides.override_typ.unwrap_or("dpop+jwt"),
"alg": overrides.override_alg.unwrap_or("ES256"),
"jwk": {
"crv": "P-256",
"kty": "EC",
"x": keys.jwk_x_b64,
"y": keys.jwk_y_b64,
},
});
let iat = if overrides.stale_iat {
now_secs() - 10_000
} else {
now_secs()
};
let mut claims = json!({
"htm": overrides.override_htm.unwrap_or(method),
"htu": overrides.override_htu.map(str::to_owned).unwrap_or_else(|| canonical_htu(url)),
"iat": iat,
"jti": overrides.fixed_jti.clone().unwrap_or_else(random_jti),
});
let nonce_to_use = overrides.force_nonce.as_deref().or(nonce);
if let Some(n) = nonce_to_use {
claims.as_object_mut().unwrap().insert(
"nonce".to_string(),
serde_json::Value::String(n.to_string()),
);
}
let h_b64 = B64_URL_NO_PAD.encode(header.to_string());
let p_b64 = B64_URL_NO_PAD.encode(claims.to_string());
let signing_input = format!("{h_b64}.{p_b64}");
let sig: Signature = keys.signing_key.sign(signing_input.as_bytes());
let s_b64 = B64_URL_NO_PAD.encode(sig.to_bytes());
format!("{h_b64}.{p_b64}.{s_b64}")
}
fn canonical_htu(url: &str) -> String {
let u = reqwest::Url::parse(url).expect("valid URL for htu");
format!("{}://{}{}", u.scheme(), u.authority(), u.path())
}
/// Log in via OPAQUE, return the access + refresh tokens.
///
/// `opaque-hurl-helper` runs earlier in `tests/api/run.sh` and
/// mints the OPAQUE envelope for `admin`; from that point on the
/// account is migrated and legacy `POST /api/auth/login` refuses
/// with Phase-4 `opaque_migrated_use_opaque` (403). So this
/// helper drives the OPAQUE handshake directly — same ciphersuite
/// (`OxiCloudSuite`) and same `/params` KSF-fetch pattern as
/// `opaque-hurl-helper`. Long-term this is also what OPAQUE-only
/// mode (`docs/plan/opaque-only.md`) requires: legacy login is
/// on the way out entirely.
async fn opaque_login(
http: &reqwest::Client,
base: &str,
username: &str,
password: &str,
) -> Result<(String, String), String> {
// Fetch server params so client-side Argon2 matches. Per Phase B
// the envelope's OWN KSF is authoritative for that user (via
// `/login/lookup`), but for a test admin whose envelope was
// freshly minted by the OPAQUE helper the two match, so
// `/params` is sufficient here.
let params: OpaqueParamsResp = http
.get(format!("{base}/api/auth/opaque/params"))
.send()
.await
.map_err(|e| format!("opaque /params: {e}"))?
.json()
.await
.map_err(|e| format!("parse /params: {e}"))?;
if !params.enabled {
return Err("server reports OPAQUE disabled — did opaque-hurl-helper run first?".into());
}
let ksf_params = argon2::Params::new(
params.ksf.memory_kib,
params.ksf.iterations,
params.ksf.parallelism,
None,
)
.map_err(|e| format!("build Argon2 params: {e}"))?;
let ksf = argon2::Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
ksf_params,
);
let mut rng = OpaqueRng;
// ── KE1 — public endpoint, no bearer ─────────────────────────────
let client_login = ClientLogin::<OxiCloudSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| format!("ClientLogin::start: {e}"))?;
let ke1_body = json!({
"userIdentifier": username,
"startLoginRequest": B64.encode(client_login.message.serialize()),
});
let ke1_res = http
.post(format!("{base}/api/auth/opaque/login/ke1"))
.json(&ke1_body)
.send()
.await
.map_err(|e| format!("ke1 POST: {e}"))?;
if !ke1_res.status().is_success() {
return Err(format!("ke1 returned {}", ke1_res.status()));
}
let ke1: OpaqueKe1Resp = ke1_res
.json()
.await
.map_err(|e| format!("parse ke1: {e}"))?;
let cred_bytes =
decode_opaque_b64(&ke1.login_response).map_err(|e| format!("decode loginResponse: {e}"))?;
let cred_response = CredentialResponse::<OxiCloudSuite>::deserialize(&cred_bytes)
.map_err(|e| format!("deserialize CredentialResponse: {e}"))?;
// ── KE3 — finish + submit ─────────────────────────────────────────
let login_finish = client_login
.state
.finish(
password.as_bytes(),
cred_response,
ClientLoginFinishParameters::new(None, opaque_ke::Identifiers::default(), Some(&ksf)),
)
.map_err(|e| format!("ClientLogin::finish (bad password?): {e}"))?;
// URL_SAFE_NO_PAD on the wire — matches what the SPA sends and
// what the server's handler prefers (accepts both, but this is
// the canonical form).
let ke3_body = json!({
"exchangeId": ke1.exchange_id,
"finishLoginRequest": B64_URL_NO_PAD.encode(login_finish.message.serialize()),
});
let ke3_res = http
.post(format!("{base}/api/auth/opaque/login/ke3"))
.json(&ke3_body)
.send()
.await
.map_err(|e| format!("ke3 POST: {e}"))?;
if !ke3_res.status().is_success() {
return Err(format!("ke3 returned {}", ke3_res.status()));
}
let body: serde_json::Value = ke3_res.json().await.map_err(|e| format!("ke3 body: {e}"))?;
let access = body["access_token"]
.as_str()
.ok_or("ke3 response missing access_token")?
.to_string();
let refresh = body["refresh_token"]
.as_str()
.ok_or("ke3 response missing refresh_token")?
.to_string();
Ok((access, refresh))
}
/// Send a GET request to `path` with a DPoP proof, following the
/// nonce-challenge retry loop. Returns the final response + the
/// nonce currently cached (which the caller threads into follow-up
/// requests).
async fn get_with_dpop(
http: &reqwest::Client,
base: &str,
path: &str,
access_token: &str,
keys: &KeyBundle,
cached_nonce: Option<String>,
overrides: &ProofOverrides<'_>,
) -> Result<(reqwest::Response, Option<String>), String> {
let url = format!("{base}{path}");
let proof = build_proof(keys, "GET", &url, cached_nonce.as_deref(), overrides);
let res = http
.get(&url)
.bearer_auth(access_token)
.header("DPoP", proof)
.send()
.await
.map_err(|e| format!("GET {path}: {e}"))?;
// Harvest DPoP-Nonce even on failure — the server stamps it
// regardless so the client can retry with the fresh value.
let updated_nonce = res
.headers()
.get("DPoP-Nonce")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.or(cached_nonce);
// Challenge-retry once — mirrors the SPA fetch interceptor.
if res.status() == 401
&& res
.headers()
.get("WWW-Authenticate")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("use_dpop_nonce"))
{
// Only ONE retry — a second challenge on the retry is a
// server bug and should surface as-is.
let proof2 = build_proof(keys, "GET", &url, updated_nonce.as_deref(), overrides);
let res2 = http
.get(&url)
.bearer_auth(access_token)
.header("DPoP", proof2)
.send()
.await
.map_err(|e| format!("GET {path} (retry): {e}"))?;
let updated_nonce2 = res2
.headers()
.get("DPoP-Nonce")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.or(updated_nonce);
return Ok((res2, updated_nonce2));
}
Ok((res, updated_nonce))
}
fn expect_status(scenario: &str, res: &reqwest::Response, want: u16) -> Result<(), String> {
if res.status().as_u16() == want {
Ok(())
} else {
Err(format!(
"scenario {scenario}: expected HTTP {want}, got {}",
res.status()
))
}
}
// Defensive-programming pattern: every scenario refreshes the
// cached nonce so a later scenario picks up any rotation the
// server did in between. Scenarios that DON'T re-consume the
// cached nonce (e.g. #6 which passes a bogus value on purpose)
// look like dead assignments to the linter — silence it here
// rather than sprinkle `let _ = n` calls that obscure intent.
#[allow(unused_assignments)]
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let base = env_or_fail("DPOP_HELPER_BASE_URL");
let username = env_or_fail("DPOP_HELPER_USERNAME");
let password = env_or_fail("DPOP_HELPER_PASSWORD");
let base = base.trim_end_matches('/');
let http = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
{
Ok(c) => c,
Err(e) => return fail(format!("build reqwest client: {e}")),
};
// ── 1. Log in via OPAQUE (session created unbound — Gate 3
// requires the client to send `dpop_jkt` in the login
// body; this helper doesn't, so we get the fail-open
// unbound path). Bind support gets tested via
// `POST /api/auth/dpop/bind` in a later gate.
//
// OPAQUE (not legacy) because `opaque-hurl-helper`
// migrates `admin` earlier in `run.sh`, after which
// legacy login 403s with Phase-4 refusal — and once
// OPAQUE-only mode ships (`docs/plan/opaque-only.md`)
// there IS no legacy path anyway.
let (access, _refresh) = match opaque_login(&http, base, &username, &password).await {
Ok(t) => t,
Err(e) => return fail(e),
};
let keys = KeyBundle::fresh();
let jkt = {
// Compute expected thumbprint for logging — server ignores
// at this gate but future scenarios will compare.
let canonical = format!(
r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#,
keys.jwk_x_b64, keys.jwk_y_b64
);
B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))
};
eprintln!("dpop-hurl-helper: keypair jkt={jkt}");
let mut nonce: Option<String> = None;
// ── Scenario 1: happy path — bootstrap (no nonce) → challenge
// → retry with nonce → 200. The retry loop is inside
// `get_with_dpop`.
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
nonce.clone(),
&ProofOverrides::default(),
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
if let Err(e) = expect_status("happy_path", &res, 200) {
return fail(e);
}
if n.is_none() {
return fail("happy_path: server did not stamp DPoP-Nonce on response");
}
nonce = n;
eprintln!(
"dpop-hurl-helper: scenario 1 (happy path) ✓ cached_nonce={:?}",
nonce
);
// ── Scenario 2: wrong htm — sign for POST but send GET → 401
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
nonce.clone(),
&ProofOverrides {
override_htm: Some("POST"),
..Default::default()
},
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
nonce = n;
if let Err(e) = expect_status("wrong_htm", &res, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 2 (wrong htm) ✓");
// ── Scenario 3: wrong htu — sign for /api/foo but send /api/auth/me → 401
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
nonce.clone(),
&ProofOverrides {
override_htu: Some("https://not-this-host.example/api/foo"),
..Default::default()
},
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
nonce = n;
if let Err(e) = expect_status("wrong_htu", &res, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 3 (wrong htu) ✓");
// ── Scenario 4: wrong alg (RS256) — 401
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
nonce.clone(),
&ProofOverrides {
override_alg: Some("RS256"),
..Default::default()
},
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
nonce = n;
if let Err(e) = expect_status("wrong_alg", &res, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 4 (wrong alg) ✓");
// ── Scenario 5: wrong typ — 401
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
nonce.clone(),
&ProofOverrides {
override_typ: Some("jwt"),
..Default::default()
},
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
nonce = n;
if let Err(e) = expect_status("wrong_typ", &res, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 5 (wrong typ) ✓");
// ── Scenario 6: stale nonce — simulate the client's cached
// nonce having expired server-side (server restarted, or
// pool TTL elapsed). The server issues a challenge with a
// fresh nonce, and `get_with_dpop` MUST retry once using
// that fresh value from the response header — not the
// stale one from `cached_nonce`.
//
// Pass the bogus value positionally (via `cached_nonce`)
// rather than through the `force_nonce` override — the
// override would survive the retry and cause a second
// challenge, defeating the recovery path we're testing.
let (res, n) = match get_with_dpop(
&http,
base,
"/api/auth/me",
&access,
&keys,
Some("nonce-that-server-does-not-know".to_string()),
&ProofOverrides::default(),
)
.await
{
Ok(t) => t,
Err(e) => return fail(e),
};
if let Err(e) = expect_status("stale_nonce_challenge_retry", &res, 200) {
return fail(e);
}
nonce = n;
eprintln!("dpop-hurl-helper: scenario 6 (stale nonce → challenge → retry) ✓");
// ── Scenario 7: replay — build a proof, send it TWICE with
// the same jti; second call must be replay-rejected.
// Uses a bespoke send (no retry-loop) so we control both
// submissions of the exact-same bytes.
let fixed_jti = random_jti();
let url = format!("{base}/api/auth/me");
let proof_replay = build_proof(
&keys,
"GET",
&url,
nonce.as_deref(),
&ProofOverrides {
fixed_jti: Some(fixed_jti.clone()),
..Default::default()
},
);
let res_first = match http
.get(&url)
.bearer_auth(&access)
.header("DPoP", proof_replay.clone())
.send()
.await
{
Ok(r) => r,
Err(e) => return fail(format!("replay send 1: {e}")),
};
if let Err(e) = expect_status("replay_first_send", &res_first, 200) {
return fail(e);
}
let res_second = match http
.get(&url)
.bearer_auth(&access)
.header("DPoP", proof_replay)
.send()
.await
{
Ok(r) => r,
Err(e) => return fail(format!("replay send 2: {e}")),
};
if let Err(e) = expect_status("replay_second_send", &res_second, 401) {
return fail(e);
}
// The replay 401 is an invalid_dpop_proof shape (not use_dpop_nonce)
let www_auth = res_second
.headers()
.get("WWW-Authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if www_auth.contains("use_dpop_nonce") {
return fail(format!(
"replay: WWW-Authenticate should be invalid_dpop_proof, was: {www_auth}"
));
}
eprintln!("dpop-hurl-helper: scenario 7 (replay) ✓");
// ── Scenario 8: malformed JWS — send garbage in the DPoP
// header. Server-side verifier rejects at the split step.
let res = match http
.get(&url)
.bearer_auth(&access)
.header("DPoP", "not-a-real-jws")
.send()
.await
{
Ok(r) => r,
Err(e) => return fail(format!("malformed send: {e}")),
};
if let Err(e) = expect_status("malformed", &res, 401) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 8 (malformed) ✓");
// ── Scenario 9: no proof at all on a bound-if-required path.
// In opportunistic mode this passes through; in required
// mode the session is unbound (`dpop_jkt IS NULL`) so it
// STILL passes through. Server-side gate 9 will flip this
// once session-context enforcement lands.
let res_no_proof = match http.get(&url).bearer_auth(&access).send().await {
Ok(r) => r,
Err(e) => return fail(format!("no_proof send: {e}")),
};
if let Err(e) = expect_status("no_proof_unbound_session", &res_no_proof, 200) {
return fail(e);
}
eprintln!("dpop-hurl-helper: scenario 9 (no proof, unbound session → pass) ✓");
eprintln!("dpop-hurl-helper: all scenarios passed");
ExitCode::SUCCESS
}
+33
View File
@@ -780,6 +780,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
@@ -792,6 +796,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
@@ -814,6 +822,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
@@ -851,6 +863,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
@@ -864,6 +880,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn(csrf_middleware))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
@@ -885,6 +905,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
// change-password, and logout internally so the SPA can
// complete the reset flow — see the middleware doc for the
// allowlist and its rationale.
use oxicloud::interfaces::middleware::dpop::require_dpop_layer;
use oxicloud::interfaces::middleware::user::{
require_internal_user_layer, require_no_password_change_pending_layer,
};
@@ -893,6 +914,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_internal_user_layer,
@@ -906,6 +931,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_internal_user_layer,
@@ -919,6 +948,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
app_state.clone(),
require_no_password_change_pending_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_dpop_layer,
))
.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
require_internal_user_layer,