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(_)));
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() {
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;
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;
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:?}"),
}
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(_)));
}
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();
}
#[allow(dead_code)]
fn _unused(_r: RegisterRequest) {}
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};
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();
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"));
assert!(verifier.verify(&token, Some("different")).await.is_err());
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());
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() {
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();
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(_)));
}