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"));
assert!(svc.login(None, "alice@example.com", "nope").await.is_err());
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());
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();
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();
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"),
};
let code = current_totp(&enrollment.secret_base32);
let tokens = svc.verify_mfa(&challenge_id, "totp", &code).await.unwrap();
assert!(!tokens.access_token.is_empty());
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();
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,
);
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();
let code = redirect
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap()
.to_string();
assert!(provider
.token(
&code,
"https://app.example.com/cb",
&ClientAuth {
client_id: "spa".into(),
client_secret: None
},
Some("wrong-verifier"),
)
.await
.is_err());
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());
let info = provider.userinfo(&tokens.access_token).await.unwrap();
assert_eq!(info.get("email").unwrap(), "carol@example.com");
}
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))
}
}