use base64::Engine as _;
use issuerd_cluster::cache_keys;
use issuerd_core::RealmId;
use crate::config::DpopNonceMode;
use crate::state::ServerState;
pub(crate) const DPOP_PROOF_MAX_AGE_SECS: i64 = 300;
const DPOP_PROOF_LEEWAY_SECS: i64 = 60;
fn jti_cache_key(realm_id: &RealmId, jti: &str) -> String {
format!("dpop_jti:{}:{jti}", realm_id.0)
}
const MAX_NONCE_CLAIM_LEN: usize = 256;
pub(crate) const DPOP_NONCE_HEADER: &str = "dpop-nonce";
pub(crate) struct DpopVerification {
pub proof: Option<issuerd_token::VerifiedDpopProof>,
pub response_nonce: Option<String>,
}
#[derive(Debug)]
pub(crate) enum DpopRejection {
InvalidProof,
UseDpopNonce(String),
}
fn accepted_htus(
state: &ServerState,
realm_id: &RealmId,
realm_name: Option<&str>,
endpoint: &str,
) -> Vec<String> {
let base = state.config.issuer_url.trim_end_matches('/');
let primary = realm_name.unwrap_or(realm_id.0.as_str());
let mut urls = vec![format!(
"{base}/realms/{primary}/protocol/openid-connect/{endpoint}"
)];
if realm_name.is_some_and(|name| name != realm_id.0) {
urls.push(format!("{base}/realms/{}/protocol/openid-connect/{endpoint}", realm_id.0));
}
urls
}
pub(crate) async fn verify_proof_header(
state: &ServerState,
realm_id: &RealmId,
realm_name: Option<&str>,
headers: &axum::http::HeaderMap,
htm: &str,
endpoint: &str,
access_token: Option<&str>,
) -> Result<DpopVerification, DpopRejection> {
let Some(value) = headers.get("dpop") else {
return Ok(DpopVerification {
proof: None,
response_nonce: None,
});
};
let proof = value.to_str().map_err(|_| {
tracing::debug!(realm = %realm_id, "DPoP header is not valid ASCII");
DpopRejection::InvalidProof
})?;
let nonce_mode = state.config.dpop.nonce.mode;
let expected_nonce: Option<String> = if nonce_mode == DpopNonceMode::Disabled {
None
} else {
match unverified_nonce_claim(proof) {
Some(claim) if claim.len() <= MAX_NONCE_CLAIM_LEN => {
let key = cache_keys::dpop_nonce(&realm_id.0, &claim);
match state.cache.get_and_delete(&key).await {
Ok(Some(_)) => Some(claim),
Ok(None) => {
tracing::debug!(
realm = %realm_id,
"DPoP proof carries an unknown, stale, or already-used nonce"
);
return Err(nonce_challenge(state, realm_id).await);
}
Err(e) => {
tracing::warn!(realm = %realm_id, error = %e, "DPoP nonce cache unavailable");
return Err(DpopRejection::InvalidProof);
}
}
}
claim if nonce_mode == DpopNonceMode::Required || claim.is_some() => {
tracing::debug!(realm = %realm_id, "DPoP proof without a usable server nonce");
return Err(nonce_challenge(state, realm_id).await);
}
_ => None,
}
};
let requirements = issuerd_token::DpopProofRequirements {
expected_htm: htm,
accepted_htu: &accepted_htus(state, realm_id, realm_name, endpoint),
expected_ath: access_token,
max_age_secs: DPOP_PROOF_MAX_AGE_SECS,
leeway_secs: DPOP_PROOF_LEEWAY_SECS,
now: issuerd_core::utils::now_secs() as i64,
expected_nonce: expected_nonce.as_deref(),
};
let verified = issuerd_token::validate_dpop_proof(proof, &requirements).map_err(|e| {
if ath_mismatch(proof, access_token) {
tracing::warn!(realm = %realm_id, error = %e, "DPoP proof ath mismatch for presented access token");
} else {
tracing::debug!(realm = %realm_id, error = %e, "DPoP proof validation failed");
}
DpopRejection::InvalidProof
})?;
let ttl =
(verified.iat + DPOP_PROOF_MAX_AGE_SECS).saturating_sub(requirements.now).max(1) as u64;
let uses = state
.cache
.increment(
&jti_cache_key(realm_id, &verified.jti),
Some(std::time::Duration::from_secs(ttl)),
)
.await
.map_err(|e| {
tracing::warn!(realm = %realm_id, error = %e, "DPoP replay cache unavailable");
DpopRejection::InvalidProof
})?;
if uses != 1 {
tracing::warn!(realm = %realm_id, "DPoP proof jti replayed");
return Err(DpopRejection::InvalidProof);
}
let response_nonce = if nonce_mode == DpopNonceMode::Disabled {
None
} else {
issue_nonce(state, realm_id).await
};
Ok(DpopVerification {
proof: Some(verified),
response_nonce,
})
}
fn unverified_nonce_claim(proof: &str) -> Option<String> {
let payload = proof.split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?;
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
claims.get("nonce").and_then(serde_json::Value::as_str).map(str::to_string)
}
async fn issue_nonce(state: &ServerState, realm_id: &RealmId) -> Option<String> {
use ring::rand::SecureRandom as _;
let mut bytes = [0u8; 32];
if let Err(e) = ring::rand::SystemRandom::new().fill(&mut bytes) {
tracing::warn!(realm = %realm_id, error = ?e, "DPoP nonce generation failed");
return None;
}
let nonce = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let key = cache_keys::dpop_nonce(&realm_id.0, &nonce);
let ttl = std::time::Duration::from_secs(state.config.dpop.nonce.lifetime_secs);
match state.cache.set(&key, vec![1], Some(ttl)).await {
Ok(()) => Some(nonce),
Err(e) => {
tracing::warn!(realm = %realm_id, error = %e, "DPoP nonce issuance failed");
None
}
}
}
async fn nonce_challenge(state: &ServerState, realm_id: &RealmId) -> DpopRejection {
match issue_nonce(state, realm_id).await {
Some(nonce) => DpopRejection::UseDpopNonce(nonce),
None => DpopRejection::InvalidProof,
}
}
fn ath_mismatch(proof: &str, access_token: Option<&str>) -> bool {
let Some(access_token) = access_token else {
return false;
};
let Some(payload) = proof.split('.').nth(1) else {
return false;
};
let Ok(bytes) =
base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload)
else {
return false;
};
let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return false;
};
let expected = issuerd_token::access_token_hash(access_token);
claims.get("ath").and_then(serde_json::Value::as_str) != Some(expected.as_str())
}
pub(crate) fn bind_cnf_overlay(
overlay: Option<serde_json::Map<String, serde_json::Value>>,
dpop_jkt: Option<&str>,
) -> Option<serde_json::Map<String, serde_json::Value>> {
match (overlay, dpop_jkt) {
(overlay, None) => overlay,
(overlay, Some(jkt)) => {
let mut map = overlay.unwrap_or_default();
map.insert("cnf".to_string(), serde_json::json!({ "jkt": jkt }));
Some(map)
}
}
}
pub(crate) fn token_type(dpop_bound: bool) -> String {
if dpop_bound { "DPoP" } else { "Bearer" }.to_string()
}
pub(crate) fn with_nonce_header(
mut response: axum::response::Response,
nonce: Option<&str>,
) -> axum::response::Response {
if let Some(nonce) = nonce {
if let Ok(value) = axum::http::HeaderValue::from_str(nonce) {
response.headers_mut().insert(DPOP_NONCE_HEADER, value);
}
}
response
}
pub(crate) fn use_dpop_nonce_response(nonce: &str) -> axum::response::Response {
use axum::response::IntoResponse as _;
let response = (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({
"error": "use_dpop_nonce",
"error_description": "Authorization server requires nonce in DPoP proof",
})),
)
.into_response();
with_nonce_header(response, Some(nonce))
}
pub(crate) fn challenge_response(
error: &str,
description: &str,
nonce: Option<&str>,
) -> axum::response::Response {
use axum::response::IntoResponse as _;
let mut response = (
axum::http::StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": error})),
)
.into_response();
response.headers_mut().insert(
axum::http::header::WWW_AUTHENTICATE,
format!("DPoP error=\"{error}\", error_description=\"{description}\"")
.parse()
.expect("DPoP challenge header values are static-safe"),
);
with_nonce_header(response, nonce)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ServerConfig;
use ring::signature::KeyPair as _;
use std::sync::Arc;
const MASTER: &str = "master";
struct EcKey {
pair: ring::signature::EcdsaKeyPair,
jwk: serde_json::Value,
}
fn gen_key() -> EcKey {
use base64::Engine as _;
let rng = ring::rand::SystemRandom::new();
let doc = ring::signature::EcdsaKeyPair::generate_pkcs8(
&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
&rng,
)
.unwrap();
let pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
doc.as_ref(),
&rng,
)
.unwrap();
let public = pair.public_key().as_ref();
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
let jwk = serde_json::json!({
"kty": "EC", "crv": "P-256", "x": b64(&public[1..33]), "y": b64(&public[33..65]),
});
EcKey { pair, jwk }
}
impl EcKey {
fn proof(&self, htm: &str, htu: &str, jti: &str, ath: Option<&str>) -> String {
self.proof_with_nonce(htm, htu, jti, ath, None)
}
fn proof_with_nonce(
&self,
htm: &str,
htu: &str,
jti: &str,
ath: Option<&str>,
nonce: Option<&str>,
) -> String {
use base64::Engine as _;
let header = serde_json::json!({
"alg": "ES256", "typ": "dpop+jwt", "jwk": self.jwk,
});
let mut claims = serde_json::json!({
"jti": jti,
"htm": htm,
"htu": htu,
"iat": issuerd_core::utils::now_secs() as i64,
});
if let Some(ath) = ath {
claims["ath"] = serde_json::json!(issuerd_token::access_token_hash(ath));
}
if let Some(nonce) = nonce {
claims["nonce"] = serde_json::json!(nonce);
}
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
let input = format!(
"{}.{}",
b64(&serde_json::to_vec(&header).unwrap()),
b64(&serde_json::to_vec(&claims).unwrap())
);
let rng = ring::rand::SystemRandom::new();
let sig = self.pair.sign(&rng, input.as_bytes()).unwrap();
format!("{input}.{}", b64(sig.as_ref()))
}
fn jkt(&self) -> String {
issuerd_token::jwk_thumbprint(&self.jwk).unwrap()
}
}
async fn test_state() -> Arc<ServerState> {
test_state_with_mode(DpopNonceMode::Disabled).await
}
async fn test_state_with_mode(mode: DpopNonceMode) -> Arc<ServerState> {
let mut config = ServerConfig::default();
config.dpop.nonce.mode = mode;
Arc::new(ServerState::from_config(&config).await.unwrap())
}
fn headers_with(proof: Option<&str>) -> axum::http::HeaderMap {
let mut headers = axum::http::HeaderMap::new();
if let Some(proof) = proof {
headers.insert("dpop", proof.parse().unwrap());
}
headers
}
fn token_htu(state: &ServerState) -> String {
format!("{}/realms/{MASTER}/protocol/openid-connect/token", state.config.issuer_url)
}
#[tokio::test]
async fn absent_header_is_bearer_flow() {
let state = test_state().await;
let realm_id = RealmId::new(MASTER).unwrap();
let result = verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers_with(None),
"POST",
"token",
None,
)
.await
.unwrap();
assert!(result.proof.is_none());
assert!(result.response_nonce.is_none());
}
#[tokio::test]
async fn valid_proof_verifies_and_binds() {
let state = test_state().await;
let realm_id = RealmId::new(MASTER).unwrap();
let key = gen_key();
let proof = key.proof("POST", &token_htu(&state), "jti-ok-1", None);
let result = verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers_with(Some(&proof)),
"POST",
"token",
None,
)
.await
.unwrap();
let verified = result.proof.unwrap();
assert_eq!(verified.jkt, key.jkt());
assert!(result.response_nonce.is_none());
}
#[tokio::test]
async fn jti_replay_rejected() {
let state = test_state().await;
let realm_id = RealmId::new(MASTER).unwrap();
let key = gen_key();
let proof = key.proof("POST", &token_htu(&state), "jti-replay-1", None);
let headers = headers_with(Some(&proof));
assert!(verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers,
"POST",
"token",
None
)
.await
.is_ok());
assert!(matches!(
verify_proof_header(&state, &realm_id, Some(MASTER), &headers, "POST", "token", None)
.await,
Err(DpopRejection::InvalidProof)
));
}
#[tokio::test]
async fn wrong_endpoint_or_method_rejected() {
let state = test_state().await;
let realm_id = RealmId::new(MASTER).unwrap();
let key = gen_key();
let proof = key.proof("GET", &token_htu(&state), "jti-htm", None);
assert!(verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers_with(Some(&proof)),
"POST",
"token",
None,
)
.await
.is_err());
let htu =
format!("{}/realms/{MASTER}/protocol/openid-connect/userinfo", state.config.issuer_url);
let proof = key.proof("POST", &htu, "jti-htu", None);
assert!(verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers_with(Some(&proof)),
"POST",
"token",
None,
)
.await
.is_err());
}
#[tokio::test]
async fn ath_enforced_when_token_present() {
let state = test_state().await;
let realm_id = RealmId::new(MASTER).unwrap();
let key = gen_key();
let htu = token_htu(&state);
let proof = key.proof("POST", &htu, "jti-ath-missing", None);
assert!(verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers_with(Some(&proof)),
"POST",
"token",
Some("the-access-token"),
)
.await
.is_err());
let proof = key.proof("POST", &htu, "jti-ath-ok", Some("the-access-token"));
assert!(verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers_with(Some(&proof)),
"POST",
"token",
Some("the-access-token"),
)
.await
.is_ok());
}
#[test]
fn bind_cnf_overlay_merges() {
assert_eq!(bind_cnf_overlay(None, None), None);
let mut existing = serde_json::Map::new();
existing.insert("x".to_string(), serde_json::json!(1));
assert_eq!(bind_cnf_overlay(Some(existing.clone()), None), Some(existing.clone()));
let bound = bind_cnf_overlay(None, Some("thumb")).unwrap();
assert_eq!(bound["cnf"], serde_json::json!({"jkt": "thumb"}));
let bound = bind_cnf_overlay(Some(existing), Some("thumb")).unwrap();
assert_eq!(bound["cnf"], serde_json::json!({"jkt": "thumb"}));
assert_eq!(bound["x"], serde_json::json!(1));
}
#[test]
fn token_type_switch() {
assert_eq!(token_type(true), "DPoP");
assert_eq!(token_type(false), "Bearer");
}
#[tokio::test]
async fn accepted_htu_lists_name_then_id() {
let state = test_state().await;
let base = state.config.issuer_url.clone();
let realm_id = RealmId::new("uuid-id").unwrap();
let htus = accepted_htus(&state, &RealmId::new(MASTER).unwrap(), Some(MASTER), "token");
assert_eq!(
htus,
vec![format!(
"{base}/realms/{MASTER}/protocol/openid-connect/token"
)]
);
let htus = accepted_htus(&state, &realm_id, Some("human-name"), "token");
assert_eq!(
htus,
vec![
format!("{base}/realms/human-name/protocol/openid-connect/token"),
format!("{base}/realms/uuid-id/protocol/openid-connect/token"),
]
);
}
async fn expect_nonce_challenge(
state: &Arc<ServerState>,
realm_id: &RealmId,
proof: &str,
) -> String {
match verify_proof_header(
state,
realm_id,
Some(MASTER),
&headers_with(Some(proof)),
"POST",
"token",
None,
)
.await
{
Err(DpopRejection::UseDpopNonce(nonce)) => nonce,
other => panic!("expected use_dpop_nonce challenge, got {}", outcome_kind(&other)),
}
}
fn outcome_kind(outcome: &Result<DpopVerification, DpopRejection>) -> &'static str {
match outcome {
Ok(_) => "Ok",
Err(DpopRejection::InvalidProof) => "Err(InvalidProof)",
Err(DpopRejection::UseDpopNonce(_)) => "Err(UseDpopNonce)",
}
}
#[tokio::test]
async fn required_mode_challenge_retry_and_single_use() {
let state = test_state_with_mode(DpopNonceMode::Required).await;
let realm_id = RealmId::new(MASTER).unwrap();
let key = gen_key();
let proof = key.proof("POST", &token_htu(&state), "jti-req-1", None);
let nonce = expect_nonce_challenge(&state, &realm_id, &proof).await;
let proof =
key.proof_with_nonce("POST", &token_htu(&state), "jti-req-2", None, Some(&nonce));
let outcome = verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers_with(Some(&proof)),
"POST",
"token",
None,
)
.await
.unwrap();
assert!(outcome.proof.is_some());
let next = outcome.response_nonce.expect("success issues a fresh nonce");
assert_ne!(next, nonce);
let proof =
key.proof_with_nonce("POST", &token_htu(&state), "jti-req-3", None, Some(&nonce));
let renewed = expect_nonce_challenge(&state, &realm_id, &proof).await;
assert_ne!(renewed, nonce);
let proof = key.proof_with_nonce(
"POST",
&token_htu(&state),
"jti-req-4",
None,
Some("attacker-guess"),
);
expect_nonce_challenge(&state, &realm_id, &proof).await;
}
#[tokio::test]
async fn required_mode_oversized_nonce_claim_challenged_without_cache_lookup() {
let state = test_state_with_mode(DpopNonceMode::Required).await;
let realm_id = RealmId::new(MASTER).unwrap();
let key = gen_key();
let huge = "x".repeat(MAX_NONCE_CLAIM_LEN + 1);
let proof =
key.proof_with_nonce("POST", &token_htu(&state), "jti-req-big", None, Some(&huge));
expect_nonce_challenge(&state, &realm_id, &proof).await;
}
#[tokio::test]
async fn supported_mode_accepts_absent_nonce_and_issues() {
let state = test_state_with_mode(DpopNonceMode::Supported).await;
let realm_id = RealmId::new(MASTER).unwrap();
let key = gen_key();
let proof = key.proof("POST", &token_htu(&state), "jti-sup-1", None);
let outcome = verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers_with(Some(&proof)),
"POST",
"token",
None,
)
.await
.unwrap();
assert!(outcome.proof.is_some());
let nonce = outcome.response_nonce.expect("supported mode always issues");
let proof =
key.proof_with_nonce("POST", &token_htu(&state), "jti-sup-2", None, Some(&nonce));
let outcome = verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers_with(Some(&proof)),
"POST",
"token",
None,
)
.await
.unwrap();
assert!(outcome.proof.is_some());
assert!(outcome.response_nonce.is_some());
}
#[tokio::test]
async fn supported_mode_challenges_present_but_unknown_nonce() {
let state = test_state_with_mode(DpopNonceMode::Supported).await;
let realm_id = RealmId::new(MASTER).unwrap();
let key = gen_key();
let proof =
key.proof_with_nonce("POST", &token_htu(&state), "jti-sup-x", None, Some("made-up"));
expect_nonce_challenge(&state, &realm_id, &proof).await;
}
#[tokio::test]
async fn disabled_mode_ignores_nonce_claims() {
let state = test_state().await;
let realm_id = RealmId::new(MASTER).unwrap();
let key = gen_key();
let proof =
key.proof_with_nonce("POST", &token_htu(&state), "jti-dis-1", None, Some("anything"));
let outcome = verify_proof_header(
&state,
&realm_id,
Some(MASTER),
&headers_with(Some(&proof)),
"POST",
"token",
None,
)
.await
.unwrap();
assert!(outcome.proof.is_some());
assert!(outcome.response_nonce.is_none());
}
#[tokio::test]
async fn nonce_challenge_response_shapes() {
let response = use_dpop_nonce_response("fresh-nonce");
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
assert_eq!(response.headers().get(DPOP_NONCE_HEADER).unwrap(), "fresh-nonce");
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], "use_dpop_nonce");
let response = challenge_response(
"use_dpop_nonce",
"Resource server requires nonce in DPoP proof",
Some("rs-nonce"),
);
assert_eq!(response.status(), axum::http::StatusCode::UNAUTHORIZED);
let www = response
.headers()
.get(axum::http::header::WWW_AUTHENTICATE)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(www.starts_with("DPoP error=\"use_dpop_nonce\""), "got: {www}");
assert_eq!(response.headers().get(DPOP_NONCE_HEADER).unwrap(), "rs-nonce");
let response = challenge_response("invalid_dpop_proof", "the DPoP proof is invalid", None);
assert!(response.headers().get(DPOP_NONCE_HEADER).is_none());
}
}