arcly-http-identity 0.7.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
//! End-to-end flow tests over the in-memory stores — proves the public API
//! wires together: register → login → refresh, TOTP MFA step-up, and the OIDC
//! authorization-code + PKCE exchange.

use std::sync::Arc;

use arcly_http_core::auth::{JwtConfig, JwtService};
use arcly_http_identity::identity::{IdentityConfig, IdentityService};
use arcly_http_identity::mfa::totp::{MfaSecretStore, TotpService};
use arcly_http_identity::model::{LoginOutcome, RegisterRequest};
use arcly_http_identity::password::Argon2idHasher;
use arcly_http_identity::session::{MemoryChallengeStore, MemoryRefreshStore};
use arcly_http_identity::store::{MemoryStore, UserStore};

fn jwt() -> Arc<JwtService> {
    Arc::new(JwtService::new(JwtConfig {
        secret: "test-secret-please-change".into(),
        access_ttl_secs: 900,
        refresh_ttl_secs: 86_400,
        ..Default::default()
    }))
}

fn build_service(store: Arc<MemoryStore>, jwt: Arc<JwtService>) -> IdentityService {
    IdentityService::builder(
        store.clone(),
        store.clone(),
        Arc::new(Argon2idHasher::new()),
        jwt,
        Arc::new(MemoryRefreshStore::new()),
        Arc::new(MemoryChallengeStore::new()),
    )
    .config(IdentityConfig::default())
    .build()
}

#[tokio::test]
async fn register_login_refresh() {
    let store = Arc::new(MemoryStore::new());
    let svc = build_service(store.clone(), jwt());

    let user = svc
        .register(RegisterRequest {
            email: "alice@example.com".into(),
            password: Some("correct horse battery".into()),
            tenant: None,
            attributes: Default::default(),
        })
        .await
        .unwrap();
    assert_eq!(user.email.as_deref(), Some("alice@example.com"));

    // Wrong password → 401-shaped error.
    assert!(svc.login(None, "alice@example.com", "nope").await.is_err());

    // Correct password → tokens.
    let outcome = svc
        .login(None, "alice@example.com", "correct horse battery")
        .await
        .unwrap();
    let tokens = match outcome {
        LoginOutcome::Authenticated(t) => t,
        _ => panic!("expected authenticated"),
    };
    assert!(!tokens.access_token.is_empty());

    // Refresh rotates and the old token is single-use.
    let rotated = svc.refresh(&tokens.refresh_token).await.unwrap();
    assert_ne!(rotated.refresh_token, tokens.refresh_token);
    assert!(svc.refresh(&tokens.refresh_token).await.is_err());
}

#[tokio::test]
async fn totp_mfa_step_up() {
    let store = Arc::new(MemoryStore::new());
    let jwt = jwt();
    let secrets = Arc::new(InMemMfaStore::default());
    let totp = Arc::new(TotpService::new(secrets.clone(), "Arcly"));

    let svc = IdentityService::builder(
        store.clone(),
        store.clone(),
        Arc::new(Argon2idHasher::new()),
        jwt,
        Arc::new(MemoryRefreshStore::new()),
        Arc::new(MemoryChallengeStore::new()),
    )
    .mfa(totp.clone())
    .build();

    let user = svc
        .register(RegisterRequest {
            email: "bob@example.com".into(),
            password: Some("hunter2hunter2".into()),
            tenant: None,
            attributes: Default::default(),
        })
        .await
        .unwrap();

    // Enrol TOTP and mark the identity as MFA-enabled.
    let enrollment = totp
        .begin_enrollment(&user.id, "bob@example.com", 5)
        .await
        .unwrap();
    let code = current_totp(&enrollment.secret_base32);
    totp.confirm(&user.id, &code).await.unwrap();
    let mut u = store.find_by_id(&user.id).await.unwrap().unwrap();
    u.mfa.totp_enrolled = true;
    store.update(&u).await.unwrap();

    // Login now yields a challenge, not tokens.
    let outcome = svc
        .login(None, "bob@example.com", "hunter2hunter2")
        .await
        .unwrap();
    let challenge_id = match outcome {
        LoginOutcome::MfaChallenge {
            challenge_id,
            methods,
            ..
        } => {
            assert!(methods.contains(&"totp".to_string()));
            challenge_id
        }
        _ => panic!("expected MFA challenge"),
    };

    // Completing with a fresh TOTP code issues tokens.
    let code = current_totp(&enrollment.secret_base32);
    let tokens = svc.verify_mfa(&challenge_id, "totp", &code).await.unwrap();
    assert!(!tokens.access_token.is_empty());

    // A recovery code also satisfies the challenge (single use).
    let outcome = svc
        .login(None, "bob@example.com", "hunter2hunter2")
        .await
        .unwrap();
    let challenge_id = match outcome {
        LoginOutcome::MfaChallenge { challenge_id, .. } => challenge_id,
        _ => panic!("expected MFA challenge"),
    };
    let recovery = &enrollment.recovery_codes[0];
    assert!(svc
        .verify_mfa(&challenge_id, "recovery", recovery)
        .await
        .is_ok());
}

#[cfg(feature = "oidc")]
#[tokio::test]
async fn oidc_auth_code_pkce() {
    use arcly_http_identity::oidc::{
        AuthorizeRequest, ClientAuth, MemoryAuthCodeStore, OidcClient, OidcClientRegistry,
        OidcConfig, OidcProvider,
    };
    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
    use base64::Engine;
    use sha2::{Digest, Sha256};

    let store = Arc::new(MemoryStore::new());
    let jwt = jwt();
    let identity = build_service(store.clone(), jwt.clone());

    let user = identity
        .register(RegisterRequest {
            email: "carol@example.com".into(),
            password: Some("passworddddd".into()),
            tenant: None,
            attributes: Default::default(),
        })
        .await
        .unwrap();

    // Public client → PKCE required.
    let clients = Arc::new(OidcClientRegistry::new().register(OidcClient {
        client_id: "spa".into(),
        client_secret: None,
        redirect_uris: vec!["https://app.example.com/cb".into()],
        allowed_scopes: vec!["openid".into(), "email".into()],
    }));

    let provider = OidcProvider::new(
        OidcConfig {
            issuer: "https://idp.example.com".into(),
            jwks: serde_json::json!({ "keys": [] }),
            id_token_ttl_secs: 3600,
            code_ttl_secs: 60,
        },
        clients,
        Arc::new(MemoryAuthCodeStore::new()),
        jwt.clone(),
        store.clone(),
        identity,
    );

    // PKCE pair.
    let verifier = "a-high-entropy-code-verifier-string-1234567890";
    let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));

    let redirect = provider
        .authorize(
            &AuthorizeRequest {
                client_id: "spa".into(),
                redirect_uri: "https://app.example.com/cb".into(),
                response_type: "code".into(),
                scope: "openid email".into(),
                state: Some("xyz".into()),
                nonce: Some("n-0S6".into()),
                code_challenge: Some(challenge),
                code_challenge_method: Some("S256".into()),
            },
            &user.id,
        )
        .await
        .unwrap();

    // Pull the code out of the redirect URL.
    let code = redirect
        .split("code=")
        .nth(1)
        .unwrap()
        .split('&')
        .next()
        .unwrap()
        .to_string();

    // Wrong PKCE verifier is rejected.
    assert!(provider
        .token(
            &code,
            "https://app.example.com/cb",
            &ClientAuth {
                client_id: "spa".into(),
                client_secret: None
            },
            Some("wrong-verifier"),
        )
        .await
        .is_err());

    // The code was consumed by the failed attempt (single-use) — re-authorize.
    let redirect = provider
        .authorize(
            &AuthorizeRequest {
                client_id: "spa".into(),
                redirect_uri: "https://app.example.com/cb".into(),
                response_type: "code".into(),
                scope: "openid email".into(),
                state: None,
                nonce: Some("n-0S6".into()),
                code_challenge: Some(URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))),
                code_challenge_method: Some("S256".into()),
            },
            &user.id,
        )
        .await
        .unwrap();
    let code = redirect.split("code=").nth(1).unwrap().to_string();

    let tokens = provider
        .token(
            &code,
            "https://app.example.com/cb",
            &ClientAuth {
                client_id: "spa".into(),
                client_secret: None,
            },
            Some(verifier),
        )
        .await
        .unwrap();
    assert!(!tokens.id_token.is_empty());
    assert!(!tokens.access_token.is_empty());

    // UserInfo resolves from the freshly minted access token.
    let info = provider.userinfo(&tokens.access_token).await.unwrap();
    assert_eq!(info.get("email").unwrap(), "carol@example.com");
}

// ── test helpers ────────────────────────────────────────────────────────────

/// Compute the current TOTP code for a base32 secret (mirrors the service).
fn current_totp(secret_base32: &str) -> String {
    use hmac::{Hmac, Mac};
    use sha1::Sha1;
    let secret =
        base32::decode(base32::Alphabet::RFC4648 { padding: false }, secret_base32).unwrap();
    let counter = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs()
        / 30;
    let mut mac = Hmac::<Sha1>::new_from_slice(&secret).unwrap();
    mac.update(&counter.to_be_bytes());
    let d = mac.finalize().into_bytes();
    let off = (d[d.len() - 1] & 0x0f) as usize;
    let bin = ((u32::from(d[off]) & 0x7f) << 24)
        | (u32::from(d[off + 1]) << 16)
        | (u32::from(d[off + 2]) << 8)
        | u32::from(d[off + 3]);
    format!("{:06}", bin % 1_000_000)
}

#[derive(Default)]
struct InMemMfaStore {
    secrets: std::sync::RwLock<std::collections::HashMap<String, Vec<u8>>>,
    recovery: std::sync::RwLock<std::collections::HashMap<String, Vec<[u8; 32]>>>,
}

#[async_trait::async_trait]
impl MfaSecretStore for InMemMfaStore {
    async fn set_totp_secret(
        &self,
        user_id: &str,
        secret: &[u8],
    ) -> arcly_http_identity::error::Result<()> {
        self.secrets
            .write()
            .unwrap()
            .insert(user_id.into(), secret.to_vec());
        Ok(())
    }
    async fn get_totp_secret(
        &self,
        user_id: &str,
    ) -> arcly_http_identity::error::Result<Option<Vec<u8>>> {
        Ok(self.secrets.read().unwrap().get(user_id).cloned())
    }
    async fn clear_totp(&self, user_id: &str) -> arcly_http_identity::error::Result<()> {
        self.secrets.write().unwrap().remove(user_id);
        Ok(())
    }
    async fn set_recovery_hashes(
        &self,
        user_id: &str,
        hashes: Vec<[u8; 32]>,
    ) -> arcly_http_identity::error::Result<()> {
        self.recovery
            .write()
            .unwrap()
            .insert(user_id.into(), hashes);
        Ok(())
    }
    async fn take_recovery_hash(
        &self,
        user_id: &str,
        hash: &[u8; 32],
    ) -> arcly_http_identity::error::Result<bool> {
        let mut g = self.recovery.write().unwrap();
        let Some(v) = g.get_mut(user_id) else {
            return Ok(false);
        };
        if let Some(pos) = v.iter().position(|h| h == hash) {
            v.remove(pos);
            Ok(true)
        } else {
            Ok(false)
        }
    }
    async fn recovery_count(&self, user_id: &str) -> arcly_http_identity::error::Result<u32> {
        Ok(self
            .recovery
            .read()
            .unwrap()
            .get(user_id)
            .map(|v| v.len() as u32)
            .unwrap_or(0))
    }
}