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
//! L3 enterprise B2B tests: SCIM provisioning/deprovisioning and OIDC
//! back-channel logout (SLO). Home-realm discovery is unit-tested in-module.

#![cfg(feature = "scim")]

use std::sync::Arc;

use arcly_http_identity::federation::scim::{ScimProvisioner, ScimUser};
use arcly_http_identity::model::AccountStatus;
use arcly_http_identity::store::{MemoryStore, UserStore};

fn scim_user(email: &str, active: bool) -> ScimUser {
    ScimUser {
        schemas: vec!["urn:ietf:params:scim:schemas:core:2.0:User".into()],
        id: None,
        user_name: email.into(),
        name: None,
        emails: vec![arcly_http_identity::federation::scim::ScimEmail {
            value: email.into(),
            primary: true,
        }],
        active,
        external_id: Some("okta-123".into()),
    }
}

#[tokio::test]
async fn scim_provision_then_deprovision() {
    let store = Arc::new(MemoryStore::new());
    let scim = ScimProvisioner::new(store.clone(), Some("acme".into()));

    // Provision (org grants access).
    let created = scim.create(&scim_user("sso@acme.com", true)).await.unwrap();
    let id = created.id.clone().unwrap();
    let user = store.find_by_id(&id).await.unwrap().unwrap();
    assert_eq!(user.status, AccountStatus::Active);
    assert!(user.email_verified); // SCIM users come from a trusted IdP
    assert_eq!(user.tenant.as_deref(), Some("acme"));

    // Duplicate provisioning is rejected.
    assert!(scim.create(&scim_user("sso@acme.com", true)).await.is_err());

    // Deprovision (user leaves the org → active=false → suspended).
    let patched = scim.set_active(&id, false).await.unwrap();
    assert!(!patched.active);
    assert_eq!(
        store.find_by_id(&id).await.unwrap().unwrap().status,
        AccountStatus::Suspended
    );

    // Hard delete marks Deleted.
    scim.delete(&id).await.unwrap();
    assert_eq!(
        store.find_by_id(&id).await.unwrap().unwrap().status,
        AccountStatus::Deleted
    );
}

#[tokio::test]
async fn back_channel_logout_revokes_refresh_tokens() {
    use arcly_http_identity::error::Result as IdResult;
    use arcly_http_identity::federation::{BackChannelLogout, IdTokenVerifier, VerifiedIdToken};
    use arcly_http_identity::session::{MemoryRefreshStore, RefreshStore};

    // A stub verifier that returns a fixed logout token's claims (a real one
    // verifies the IdP signature — covered by the id_token test).
    struct StubVerifier;
    #[async_trait::async_trait]
    impl IdTokenVerifier for StubVerifier {
        async fn verify(&self, token: &str, _nonce: Option<&str>) -> IdResult<VerifiedIdToken> {
            // token encodes the sub for the test; assert it's the logout event.
            let mut extra = serde_json::Map::new();
            extra.insert(
                "events".into(),
                serde_json::json!({
                    "http://schemas.openid.net/event/backchannel-logout": {}
                }),
            );
            Ok(VerifiedIdToken {
                iss: "idp".into(),
                sub: token.to_owned(),
                aud: "app".into(),
                email: None,
                email_verified: false,
                name: None,
                nonce: None,
                extra,
            })
        }
    }

    let refresh = Arc::new(MemoryRefreshStore::new());
    refresh.save("jti-1", "user-42", 3600).await.unwrap();
    refresh.save("jti-2", "user-42", 3600).await.unwrap();

    let slo = BackChannelLogout::new(Arc::new(StubVerifier), refresh.clone());
    let sub = slo.handle("user-42").await.unwrap();
    assert_eq!(sub, "user-42");

    // Both refresh tokens for the subject are now gone.
    assert!(refresh.take("jti-1").await.unwrap().is_none());
    assert!(refresh.take("jti-2").await.unwrap().is_none());
}