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
//! H-tier hardening tests: password policy + breach rejection at registration,
//! and the consent gate at the single mint funnel (applies to every login path).

use std::sync::Arc;

use async_trait::async_trait;

use arcly_http_core::auth::{JwtConfig, JwtService};
use arcly_http_identity::consent::{ConsentGrant, ConsentStore, MemoryConsentStore};
use arcly_http_identity::error::{IdentityError, Result as IdResult};
use arcly_http_identity::identity::{IdentityConfig, IdentityService};
use arcly_http_identity::model::{LoginOutcome, RegisterRequest};
use arcly_http_identity::password::{Argon2idHasher, BreachChecker, PasswordPolicy};
use arcly_http_identity::session::{MemoryChallengeStore, MemoryRefreshStore};
use arcly_http_identity::store::MemoryStore;

fn jwt() -> Arc<JwtService> {
    Arc::new(JwtService::new(JwtConfig {
        secret: "htier-test-secret".into(),
        ..Default::default()
    }))
}

fn builder(store: Arc<MemoryStore>) -> arcly_http_identity::identity::IdentityServiceBuilder {
    IdentityService::builder(
        store.clone(),
        store.clone(),
        Arc::new(Argon2idHasher::new()),
        jwt(),
        Arc::new(MemoryRefreshStore::new()),
        Arc::new(MemoryChallengeStore::new()),
    )
    .config(IdentityConfig {
        bind_tenant: false,
        ..Default::default()
    })
}

/// A breach checker that flags one specific password.
struct StubBreach(&'static str);
#[async_trait]
impl BreachChecker for StubBreach {
    async fn is_breached(&self, password: &str) -> IdResult<bool> {
        Ok(password == self.0)
    }
}

#[tokio::test]
async fn register_enforces_password_policy() {
    let store = Arc::new(MemoryStore::new());
    let svc = builder(store)
        .password_policy(PasswordPolicy {
            min_len: 12,
            ..PasswordPolicy::default()
        })
        .build();

    // Too short → PasswordRejected (surfaced, since it's registration).
    let err = svc
        .register(RegisterRequest {
            email: "a@corp.com".into(),
            password: Some("short".into()),
            tenant: None,
            attributes: Default::default(),
        })
        .await
        .unwrap_err();
    assert!(matches!(err, IdentityError::PasswordRejected(_)));

    // Long enough → ok.
    assert!(svc
        .register(RegisterRequest {
            email: "a@corp.com".into(),
            password: Some("a-good-long-passphrase".into()),
            tenant: None,
            attributes: Default::default(),
        })
        .await
        .is_ok());
}

#[tokio::test]
async fn register_rejects_breached_password() {
    let store = Arc::new(MemoryStore::new());
    let svc = builder(store)
        .breach_checker(Arc::new(StubBreach("hunter2-hunter2")))
        .build();

    let err = svc
        .register(RegisterRequest {
            email: "b@corp.com".into(),
            password: Some("hunter2-hunter2".into()),
            tenant: None,
            attributes: Default::default(),
        })
        .await
        .unwrap_err();
    assert!(matches!(err, IdentityError::PasswordRejected(_)));
}

#[tokio::test]
async fn consent_gate_blocks_mint_until_granted() {
    let store = Arc::new(MemoryStore::new());
    let consent = Arc::new(MemoryConsentStore::new());
    let svc = builder(store)
        .require_consents(consent.clone(), vec!["tos".into()])
        .build();

    let user = svc
        .register(RegisterRequest {
            email: "c@corp.com".into(),
            password: Some("a-good-long-passphrase".into()),
            tenant: None,
            attributes: Default::default(),
        })
        .await
        .unwrap();

    // Login without consent → ConsentRequired, no tokens.
    let err = svc
        .login(None, "c@corp.com", "a-good-long-passphrase")
        .await
        .unwrap_err();
    match err {
        IdentityError::ConsentRequired(purposes) => assert_eq!(purposes, vec!["tos".to_string()]),
        other => panic!("expected ConsentRequired, got {other:?}"),
    }

    // Grant consent, then login succeeds.
    consent
        .record(
            &user.id,
            ConsentGrant {
                purpose: "tos".into(),
                version: "1".into(),
                granted: true,
                timestamp: 0,
            },
        )
        .await
        .unwrap();
    let out = svc
        .login(None, "c@corp.com", "a-good-long-passphrase")
        .await
        .unwrap();
    assert!(matches!(out, LoginOutcome::Authenticated(_)));
}