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
//! L2 secure-federation tests: the account-linking decision matrix (the part
//! enterprises get wrong) and inbound id_token verification.

use std::sync::Arc;

use arcly_http_core::auth::{JwtConfig, JwtService};
use arcly_http_identity::federation::{
    FederatedOutcome, FederatedProfile, LinkingConfig, MemoryFederatedStore,
};
use arcly_http_identity::identity::{IdentityConfig, IdentityService};
use arcly_http_identity::model::{AccountStatus, Identity, 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: "fed-test-secret".into(),
        ..Default::default()
    }))
}

fn service(
    store: Arc<MemoryStore>,
    fed: Arc<MemoryFederatedStore>,
    linking: LinkingConfig,
) -> IdentityService {
    IdentityService::builder(
        store.clone(),
        store.clone(),
        Arc::new(Argon2idHasher::new()),
        jwt(),
        Arc::new(MemoryRefreshStore::new()),
        Arc::new(MemoryChallengeStore::new()),
    )
    .federated(fed)
    .linking(linking)
    .config(IdentityConfig {
        bind_tenant: false,
        ..Default::default()
    })
    .build()
}

fn google(email: &str, verified: bool) -> FederatedProfile {
    FederatedProfile::from_oauth("google", format!("g-{email}"), Some(email.into()), verified)
}

#[tokio::test]
async fn jit_provisions_new_user_and_returning_login_reuses_it() {
    let store = Arc::new(MemoryStore::new());
    let fed = Arc::new(MemoryFederatedStore::new());
    let svc = service(store.clone(), fed, LinkingConfig::default());

    let out = svc
        .login_federated(None, &google("new@corp.com", true), None)
        .await
        .unwrap();
    assert!(matches!(out, FederatedOutcome::Authenticated(_)));

    // A second login with the same provider identity must NOT create a 2nd user.
    let out = svc
        .login_federated(None, &google("new@corp.com", true), None)
        .await
        .unwrap();
    assert!(matches!(out, FederatedOutcome::Authenticated(_)));
    assert!(store
        .find_by_email(None, "new@corp.com")
        .await
        .unwrap()
        .is_some());
}

#[tokio::test]
async fn unverified_provider_email_never_adopts_existing_account() {
    // Pre-existing local (password) account, email verified.
    let store = Arc::new(MemoryStore::new());
    let fed = Arc::new(MemoryFederatedStore::new());
    let svc = service(store.clone(), fed, LinkingConfig::default());
    seed_local(&store, "victim@corp.com", true).await;

    // Google returns the same email but UNVERIFIED → the engine must refuse to
    // adopt the victim's account and must not mint tokens: it holds for an
    // explicit, authenticated linking step instead.
    let out = svc
        .login_federated(None, &google("victim@corp.com", false), None)
        .await
        .unwrap();
    match out {
        FederatedOutcome::LinkRequired { email } => assert_eq!(email, "victim@corp.com"),
        other => panic!("unverified provider email must not authenticate: {other:?}"),
    }
}

#[tokio::test]
async fn verified_collision_requires_explicit_link_by_default() {
    let store = Arc::new(MemoryStore::new());
    let fed = Arc::new(MemoryFederatedStore::new());
    let svc = service(store.clone(), fed.clone(), LinkingConfig::default());
    seed_local(&store, "user@corp.com", true).await;

    // Both verified, default policy → hold for an explicit linking step.
    let out = svc
        .login_federated(None, &google("user@corp.com", true), None)
        .await
        .unwrap();
    match out {
        FederatedOutcome::LinkRequired { email } => assert_eq!(email, "user@corp.com"),
        other => panic!("expected LinkRequired, got {other:?}"),
    }

    // After the app runs an authenticated link, the same login authenticates.
    let user = store
        .find_by_email(None, "user@corp.com")
        .await
        .unwrap()
        .unwrap();
    svc.link_federated(&user.id, &google("user@corp.com", true))
        .await
        .unwrap();
    let out = svc
        .login_federated(None, &google("user@corp.com", true), None)
        .await
        .unwrap();
    assert!(matches!(out, FederatedOutcome::Authenticated(_)));
}

#[tokio::test]
async fn opt_in_auto_link_merges_when_both_verified() {
    let store = Arc::new(MemoryStore::new());
    let fed = Arc::new(MemoryFederatedStore::new());
    let svc = service(
        store.clone(),
        fed,
        LinkingConfig {
            jit_provisioning: true,
            auto_link_verified_email: true,
        },
    );
    seed_local(&store, "auto@corp.com", true).await;

    let out = svc
        .login_federated(None, &google("auto@corp.com", true), None)
        .await
        .unwrap();
    assert!(matches!(out, FederatedOutcome::Authenticated(_)));
}

// ── helpers ───────────────────────────────────────────────────────────────

async fn seed_local(store: &Arc<MemoryStore>, email: &str, verified: bool) {
    store
        .insert(&Identity {
            id: format!("local-{email}"),
            tenant: None,
            email: Some(email.into()),
            email_verified: verified,
            phone: None,
            phone_verified: false,
            status: AccountStatus::Active,
            roles: vec!["user".into()],
            perms: vec![],
            mfa: Default::default(),
            attributes: Default::default(),
        })
        .await
        .unwrap();
}

// keep RegisterRequest import used (documents the password path exists alongside)
#[allow(dead_code)]
fn _unused(_r: RegisterRequest) {}

// ── inbound id_token verification (item 3 of L2) ────────────────────────────

const EC_PRIV_PK8: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgxPlAvt7wyrE/yb5k
1eph3a0ZYY3yD/Pv8AcgiRfyCM2hRANCAATCfueeGQ/EfPaEJPiWtYeEGVJmpTq2
rp+ce+FZVRh9hE8UINlG0eW9RgeQ1vjbS6GuAngjOyVLMR0MRIzGmwnY
-----END PRIVATE KEY-----";

const EC_PUB: &str = "-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwn7nnhkPxHz2hCT4lrWHhBlSZqU6
tq6fnHvhWVUYfYRPFCDZRtHlvUYHkNb420uhrgJ4IzslSzEdDESMxpsJ2A==
-----END PUBLIC KEY-----";

#[tokio::test]
async fn id_token_verification_enforces_sig_aud_iss_nonce() {
    use arcly_http_identity::federation::{IdTokenVerifier, JwtIdTokenVerifier};
    use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};

    // A provider (issuer=idp, audience=our-client) signs an id_token (ES256).
    let signing = EncodingKey::from_ec_pem(EC_PRIV_PK8.as_bytes()).unwrap();
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs();
    let claims = serde_json::json!({
        "iss": "https://idp.example.com",
        "sub": "provider-user-123",
        "aud": "our-client-id",
        "exp": now + 300,
        "iat": now,
        "email": "sso@corp.com",
        "email_verified": true,
        "nonce": "n-abc",
    });
    let mut header = Header::new(Algorithm::ES256);
    header.kid = Some("k1".into());
    let token = encode(&header, &claims, &signing).unwrap();

    let verifier = JwtIdTokenVerifier::new("https://idp.example.com", "our-client-id")
        .add_ec_pem(Some("k1".into()), Algorithm::ES256, EC_PUB)
        .unwrap();

    // Correct nonce → verified claims come back.
    let v = verifier.verify(&token, Some("n-abc")).await.unwrap();
    assert_eq!(v.sub, "provider-user-123");
    assert!(v.email_verified);
    assert_eq!(v.email.as_deref(), Some("sso@corp.com"));

    // Wrong nonce (replay) → rejected.
    assert!(verifier.verify(&token, Some("different")).await.is_err());

    // Wrong audience → rejected.
    let wrong_aud = JwtIdTokenVerifier::new("https://idp.example.com", "someone-else")
        .add_ec_pem(Some("k1".into()), Algorithm::ES256, EC_PUB)
        .unwrap();
    assert!(wrong_aud.verify(&token, Some("n-abc")).await.is_err());

    // A FederatedProfile derived from the verified token carries the trust.
    let profile = FederatedProfile::from_id_token("oidc", &v);
    assert_eq!(profile.provider_id, "provider-user-123");
    assert!(profile.email_verified);
}

#[tokio::test]
async fn verified_id_token_drives_login_end_to_end() {
    // Full L2 OIDC path: verify a signed id_token → build profile → federated
    // login mints tokens through the shared path. This is the difference from
    // trusting a userinfo endpoint.
    use arcly_http_identity::federation::{IdTokenVerifier, JwtIdTokenVerifier};
    use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};

    let store = Arc::new(MemoryStore::new());
    let fed = Arc::new(MemoryFederatedStore::new());
    let svc = service(store.clone(), fed, LinkingConfig::default());

    let signing = EncodingKey::from_ec_pem(EC_PRIV_PK8.as_bytes()).unwrap();
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs();
    let token = encode(
        &{
            let mut h = Header::new(Algorithm::ES256);
            h.kid = Some("k1".into());
            h
        },
        &serde_json::json!({
            "iss": "https://idp.example.com", "sub": "u-oidc-1", "aud": "our-client-id",
            "exp": now + 300, "iat": now, "email": "oidc@corp.com",
            "email_verified": true, "nonce": "n-1",
        }),
        &signing,
    )
    .unwrap();

    let verifier = JwtIdTokenVerifier::new("https://idp.example.com", "our-client-id")
        .add_ec_pem(Some("k1".into()), Algorithm::ES256, EC_PUB)
        .unwrap();

    // Verify the assertion, then drive the federated login from it.
    let verified = verifier.verify(&token, Some("n-1")).await.unwrap();
    let profile = FederatedProfile::from_id_token("corp-oidc", &verified);
    let out = svc.login_federated(None, &profile, None).await.unwrap();
    assert!(matches!(out, FederatedOutcome::Authenticated(_)));
}