arcly-http-identity 0.9.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! DPoP (RFC 9449) sender-constrained token tests: proof verification (htm/htu,
//! freshness, replay), the cnf.jkt binding check, and that a DPoP login mints a
//! token carrying the matching `cnf.jkt`.

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;

// EC P-256 keypair (same one used across the federation tests).
const EC_PRIV_PK8: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgxPlAvt7wyrE/yb5k
1eph3a0ZYY3yD/Pv8AcgiRfyCM2hRANCAATCfueeGQ/EfPaEJPiWtYeEGVJmpTq2
rp+ce+FZVRh9hE8UINlG0eW9RgeQ1vjbS6GuAngjOyVLMR0MRIzGmwnY
-----END PRIVATE KEY-----";

// The public JWK (x/y) for the key above.
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());
    // Embed the public JWK in the header.
    // jsonwebtoken lacks a typed field for `jwk`, so we hand-build the header
    // by re-encoding: use the library for signing and inject jwk via `extras`
    // is not supported — instead craft the header JSON manually below.
    let key = EncodingKey::from_ec_pem(EC_PRIV_PK8.as_bytes()).unwrap();
    // Build header with jwk manually and sign the two segments.
    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)
}

/// Sign a JWT with a caller-supplied header JSON (needed for the `jwk` field
/// jsonwebtoken won't emit), matching its ES256 signing.
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}");
    // Reuse jsonwebtoken's crypto by signing the message directly.
    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());

    // The token would carry this jkt in cnf; binding check passes for it,
    // fails for a different thumbprint.
    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());

    // Wrong method / wrong URI must fail.
    assert!(verifier.verify(&proof, "GET", uri).await.is_err());
    assert!(verifier
        .verify(&proof, "POST", "https://api.example.com/other")
        .await
        .is_err());
    // Query string is ignored (RFC 9449 §4.3): same path with ?x=1 still matches.
    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());
    // Same proof again → replay → rejected.
    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";
    // iat far in the past (beyond the 60s window).
    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());

    // Compute the key's thumbprint by verifying once against a throwaway verifier.
    let jkt = {
        let v = DpopVerifier::new(Arc::new(MemoryDpopReplayStore::new()));
        v.verify(&make_proof("GET", uri, "jti-thumb", now()), "GET", uri)
            .await
            .unwrap()
            .jkt
    };

    // Matching cnf.jkt + valid proof → allowed.
    assert!(guard.enforce_parts(&jkt, &proof, "GET", uri).await.is_ok());

    // A proof whose key doesn't match the token's cnf.jkt → forbidden.
    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();

    // Verify a proof to obtain the jkt, then mint a bound token.
    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();

    // The access token carries cnf.jkt == the proof's thumbprint.
    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);
}