use std::sync::Arc;
use arcly_http_core::auth::{JwtConfig, JwtService};
use arcly_http_identity::dpop::{DpopVerifier, MemoryDpopReplayStore};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde::Serialize;
use serde_json::json;
const EC_PRIV_PK8: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgxPlAvt7wyrE/yb5k
1eph3a0ZYY3yD/Pv8AcgiRfyCM2hRANCAATCfueeGQ/EfPaEJPiWtYeEGVJmpTq2
rp+ce+FZVRh9hE8UINlG0eW9RgeQ1vjbS6GuAngjOyVLMR0MRIzGmwnY
-----END PRIVATE KEY-----";
const JWK_X: &str = "wn7nnhkPxHz2hCT4lrWHhBlSZqU6tq6fnHvhWVUYfYQ";
const JWK_Y: &str = "TxQg2UbR5b1GB5DW-NtLoa4CeCM7JUsxHQxEjMabCdg";
#[derive(Serialize)]
struct Proof {
htm: String,
htu: String,
jti: String,
iat: u64,
}
fn make_proof(method: &str, uri: &str, jti: &str, iat: u64) -> String {
let mut header = Header::new(Algorithm::ES256);
header.typ = Some("dpop+jwt".into());
let key = EncodingKey::from_ec_pem(EC_PRIV_PK8.as_bytes()).unwrap();
let header_json = json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { "kty": "EC", "crv": "P-256", "x": JWK_X, "y": JWK_Y }
});
let claims = Proof {
htm: method.into(),
htu: uri.into(),
jti: jti.into(),
iat,
};
sign_es256(&header_json, &claims, &key)
}
fn sign_es256<T: Serialize>(header: &serde_json::Value, claims: &T, key: &EncodingKey) -> String {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(header).unwrap());
let c = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap());
let signing_input = format!("{h}.{c}");
let sig = jsonwebtoken::crypto::sign(signing_input.as_bytes(), key, Algorithm::ES256).unwrap();
format!("{signing_input}.{sig}")
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[tokio::test]
async fn valid_proof_verifies_and_binds() {
let verifier = DpopVerifier::new(Arc::new(MemoryDpopReplayStore::new()));
let uri = "https://api.example.com/ciam/login";
let proof = make_proof("POST", uri, "jti-1", now());
let verified = verifier.verify(&proof, "POST", uri).await.unwrap();
assert!(!verified.jkt.is_empty());
assert!(DpopVerifier::confirm_binding(&verified, &verified.jkt).is_ok());
assert!(DpopVerifier::confirm_binding(&verified, "someone-elses-jkt").is_err());
}
#[tokio::test]
async fn proof_bound_to_method_and_uri() {
let verifier = DpopVerifier::new(Arc::new(MemoryDpopReplayStore::new()));
let uri = "https://api.example.com/ciam/login";
let proof = make_proof("POST", uri, "jti-2", now());
assert!(verifier.verify(&proof, "GET", uri).await.is_err());
assert!(verifier
.verify(&proof, "POST", "https://api.example.com/other")
.await
.is_err());
assert!(verifier
.verify(&proof, "POST", &format!("{uri}?x=1"))
.await
.is_ok());
}
#[tokio::test]
async fn replayed_jti_is_rejected() {
let verifier = DpopVerifier::new(Arc::new(MemoryDpopReplayStore::new()));
let uri = "https://api.example.com/ciam/login";
let proof = make_proof("POST", uri, "jti-replay", now());
assert!(verifier.verify(&proof, "POST", uri).await.is_ok());
assert!(verifier.verify(&proof, "POST", uri).await.is_err());
}
#[tokio::test]
async fn stale_proof_is_rejected() {
let verifier = DpopVerifier::new(Arc::new(MemoryDpopReplayStore::new()));
let uri = "https://api.example.com/ciam/login";
let proof = make_proof("POST", uri, "jti-stale", now() - 3600);
assert!(verifier.verify(&proof, "POST", uri).await.is_err());
}
#[tokio::test]
async fn resource_guard_enforce_parts() {
use arcly_http_identity::dpop::DpopResourceGuard;
let verifier = Arc::new(DpopVerifier::new(Arc::new(MemoryDpopReplayStore::new())));
let guard = DpopResourceGuard::new(verifier);
let uri = "https://api.example.com/ciam/me";
let proof = make_proof("GET", uri, "jti-guard", now());
let jkt = {
let v = DpopVerifier::new(Arc::new(MemoryDpopReplayStore::new()));
v.verify(&make_proof("GET", uri, "jti-thumb", now()), "GET", uri)
.await
.unwrap()
.jkt
};
assert!(guard.enforce_parts(&jkt, &proof, "GET", uri).await.is_ok());
let proof2 = make_proof("GET", uri, "jti-guard-2", now());
assert!(guard
.enforce_parts("a-different-thumbprint", &proof2, "GET", uri)
.await
.is_err());
}
#[tokio::test]
async fn dpop_login_mints_sender_constrained_token() {
use arcly_http_identity::identity::{IdentityConfig, IdentityService};
use arcly_http_identity::model::RegisterRequest;
use arcly_http_identity::password::Argon2idHasher;
use arcly_http_identity::session::{MemoryChallengeStore, MemoryRefreshStore};
use arcly_http_identity::store::MemoryStore;
let store = Arc::new(MemoryStore::new());
let jwt = Arc::new(JwtService::new(JwtConfig {
secret: "dpop-mint-secret".into(),
..Default::default()
}));
let svc = IdentityService::builder(
store.clone(),
store.clone(),
Arc::new(Argon2idHasher::new()),
jwt.clone(),
Arc::new(MemoryRefreshStore::new()),
Arc::new(MemoryChallengeStore::new()),
)
.config(IdentityConfig {
bind_tenant: false,
..Default::default()
})
.build();
let user = svc
.register(RegisterRequest {
email: "dpop@corp.com".into(),
password: Some("a-good-long-passphrase".into()),
tenant: None,
attributes: Default::default(),
})
.await
.unwrap();
let verifier = DpopVerifier::new(Arc::new(MemoryDpopReplayStore::new()));
let uri = "https://api.example.com/ciam/login";
let proof = make_proof("POST", uri, "jti-mint", now());
let verified = verifier.verify(&proof, "POST", uri).await.unwrap();
let tokens = svc.mint_bound(&user, Some(&verified.jkt)).await.unwrap();
let claims = jwt.decode_access(&tokens.access_token).unwrap();
let cnf_jkt = claims
.get("cnf")
.and_then(|c| c.get("jkt"))
.and_then(|v| v.as_str())
.unwrap();
assert_eq!(cnf_jkt, verified.jkt);
}