use std::sync::Arc;
use axum::http::StatusCode;
use issuerd_server::{config::ServerConfig, state::ServerState};
use crate::harness::TestHarness;
#[derive(Default)]
struct RecordingSender {
sent: std::sync::Mutex<Vec<(String, String, String, Option<String>)>>,
}
#[async_trait::async_trait]
impl issuerd_core::EmailSender for RecordingSender {
async fn send(
&self,
_realm: &issuerd_core::Realm,
to: &str,
subject: &str,
text_body: &str,
html_body: Option<String>,
) -> Result<(), issuerd_core::IssuerdError> {
self.sent.lock().unwrap().push((
to.to_string(),
subject.to_string(),
text_body.to_string(),
html_body,
));
Ok(())
}
}
impl RecordingSender {
fn mails(&self) -> Vec<(String, String, String, Option<String>)> {
self.sent.lock().unwrap().clone()
}
}
async fn email_code_harness() -> (TestHarness, Arc<RecordingSender>) {
let config = ServerConfig::default();
let recorder = Arc::new(RecordingSender::default());
let state = ServerState::from_components_with_email_sender(
&config,
Arc::new(issuerd_storage::InMemoryStorage::new()),
Arc::new(issuerd_cluster::InMemoryCache::new()),
recorder.clone(),
)
.await
.unwrap();
(TestHarness::with_state(Arc::new(state)), recorder)
}
async fn create_email_code_realm(harness: &TestHarness, realm: &str) {
let mut r = harness.create_realm(realm).await;
r.attributes.insert("email_code_login".to_string(), "true".to_string());
r.browser_flow = Some("email-code-browser".to_string());
harness.storage.update_realm(&r).await.unwrap();
let realm_id = issuerd_core::RealmId::new(realm).unwrap();
let flow = issuerd_core::FlowConfig {
alias: issuerd_core::Alias::new("email-code-browser").unwrap(),
realm_id: realm_id.clone(),
provider_id: "basic-flow".to_string(),
top_level: true,
built_in: false,
stages: vec![
issuerd_core::FlowStage {
id: issuerd_core::FlowStageId::new("cookie-auth").unwrap(),
requirement: issuerd_core::Requirement::Alternative,
authenticator: issuerd_core::Alias::new("auth-cookie").unwrap(),
priority: 1,
sub_flow_alias: None,
authenticator_config: None,
},
issuerd_core::FlowStage {
id: issuerd_core::FlowStageId::new("email-code").unwrap(),
requirement: issuerd_core::Requirement::Alternative,
authenticator: issuerd_core::Alias::new("auth-email-code").unwrap(),
priority: 2,
sub_flow_alias: None,
authenticator_config: None,
},
],
};
harness.storage.create_flow_config(&realm_id, &flow).await.unwrap();
}
async fn start_auth_flow(harness: &TestHarness, realm: &str, client_id: &str) -> String {
let auth_path = format!(
"/realms/{realm}/protocol/openid-connect/auth?response_type=code&client_id={client_id}&redirect_uri=http://localhost:8080/cb&scope=openid&state=xyz"
);
let resp = harness.get(&auth_path).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers().get("location").unwrap().to_str().unwrap();
TestHarness::extract_query_param(location, "execution_id").expect("missing execution_id")
}
async fn submit_login_form(
harness: &TestHarness,
realm: &str,
execution_id: &str,
params: &[(&str, &str)],
) -> axum::response::Response {
let mut all = vec![("execution_id", execution_id)];
all.extend_from_slice(params);
harness
.post_form_with_cookie(
&format!("/api/v1/auth/login?realm={realm}"),
&all,
&TestHarness::flow_cookie(execution_id),
)
.await
}
async fn body_string(resp: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
fn code_from_mail(text: &str) -> String {
text.lines()
.map(str::trim)
.find(|l| l.len() == 6 && l.chars().all(|c| c.is_ascii_digit()))
.expect("6-digit code in the mail text body")
.to_string()
}
async fn exchange_code(
harness: &TestHarness,
realm: &str,
client: &issuerd_core::Client,
code: &str,
) -> serde_json::Value {
let client_id = client.client_id.to_string();
let resp = harness
.post_form(
&format!("/realms/{realm}/protocol/openid-connect/token"),
&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", "http://localhost:8080/cb"),
("client_id", &client_id),
("client_secret", client.secret.as_deref().unwrap_or("")),
],
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&body).unwrap()
}
#[tokio::test]
async fn email_code_login_end_to_end() {
let (harness, recorder) = email_code_harness().await;
create_email_code_realm(&harness, "ec-e2e").await;
let client = harness.create_client("ec-e2e", false).await;
harness.create_user("ec-e2e", "alice", "Unused123!").await;
let resp = harness.get("/realms/ec-e2e/login/context").await;
assert_eq!(resp.status(), StatusCode::OK);
let ctx: serde_json::Value =
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(ctx["email_code_login"], true);
let resp = harness.get("/realms/master/login/context").await;
let ctx: serde_json::Value =
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(ctx["email_code_login"], false);
let exec = start_auth_flow(&harness, "ec-e2e", client.client_id.as_ref()).await;
let resp =
submit_login_form(&harness, "ec-e2e", &exec, &[("username", "alice@example.com")]).await;
assert_eq!(resp.status(), StatusCode::OK);
let page = body_string(resp).await;
assert!(page.contains("Check your email"), "expected code page: {page}");
assert!(page.contains("a***@e***.com"), "expected masked email: {page}");
assert!(page.contains("name=\"otp\""), "expected otp input: {page}");
assert!(page.contains("name=\"resend\""), "expected resend form: {page}");
assert!(
page.contains(&format!("value=\"{exec}\"")),
"expected hidden execution id in page: {page}"
);
let mails = recorder.mails();
assert_eq!(mails.len(), 1, "exactly one mail: {mails:?}");
assert_eq!(mails[0].0, "alice@example.com");
assert_eq!(mails[0].1, "Your sign-in code");
let code = code_from_mail(&mails[0].2);
let html = mails[0].3.as_deref().expect("multipart mail with HTML part");
assert!(html.contains(&code), "code missing from HTML body");
let resp = submit_login_form(&harness, "ec-e2e", &exec, &[("otp", &code)]).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert!(location.starts_with("http://localhost:8080/cb?"), "unexpected: {location}");
let auth_code = TestHarness::extract_query_param(location, "code").expect("code in redirect");
let tokens = exchange_code(&harness, "ec-e2e", &client, &auth_code).await;
assert!(tokens["access_token"].as_str().unwrap().len() > 10);
}
#[tokio::test]
async fn email_code_wrong_code_renders_error_and_resend_mails_fresh_code() {
let (harness, recorder) = email_code_harness().await;
create_email_code_realm(&harness, "ec-resend").await;
let client = harness.create_client("ec-resend", false).await;
harness.create_user("ec-resend", "bob", "Unused123!").await;
let exec = start_auth_flow(&harness, "ec-resend", client.client_id.as_ref()).await;
let resp =
submit_login_form(&harness, "ec-resend", &exec, &[("username", "bob@example.com")]).await;
assert_eq!(resp.status(), StatusCode::OK);
let first_code = code_from_mail(&recorder.mails()[0].2);
let wrong = if first_code == "000000" {
"000001"
} else {
"000000"
};
let resp = submit_login_form(&harness, "ec-resend", &exec, &[("otp", wrong)]).await;
assert_eq!(resp.status(), StatusCode::OK);
let page = body_string(resp).await;
assert!(page.contains("Invalid or expired code."), "expected error banner: {page}");
let resp = submit_login_form(&harness, "ec-resend", &exec, &[("resend", "1")]).await;
assert_eq!(resp.status(), StatusCode::OK);
let page = body_string(resp).await;
assert!(page.contains("Check your email"), "expected code page: {page}");
assert!(!page.contains("Invalid or expired code."), "resend must not error: {page}");
let mails = recorder.mails();
assert_eq!(mails.len(), 2, "resend must mail a fresh code");
let second_code = code_from_mail(&mails[1].2);
let resp = submit_login_form(&harness, "ec-resend", &exec, &[("otp", &second_code)]).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers().get("location").unwrap().to_str().unwrap();
let auth_code = TestHarness::extract_query_param(location, "code").expect("code in redirect");
let tokens = exchange_code(&harness, "ec-resend", &client, &auth_code).await;
assert!(tokens["access_token"].as_str().unwrap().len() > 10);
}
#[tokio::test]
async fn email_code_unknown_address_never_leaks_account_existence() {
let (harness, recorder) = email_code_harness().await;
create_email_code_realm(&harness, "ec-unknown").await;
let client = harness.create_client("ec-unknown", false).await;
let exec = start_auth_flow(&harness, "ec-unknown", client.client_id.as_ref()).await;
let resp =
submit_login_form(&harness, "ec-unknown", &exec, &[("username", "ghost@example.com")])
.await;
assert_eq!(resp.status(), StatusCode::OK);
let page = body_string(resp).await;
assert!(page.contains("Check your email"), "same page as for real users: {page}");
assert!(
page.contains("Enter the 6-digit code we sent to your email address."),
"generic copy without an address: {page}"
);
assert!(!page.contains("ghost"), "address must not be reflected: {page}");
assert!(recorder.mails().is_empty(), "no mail for unknown addresses");
let resp = submit_login_form(&harness, "ec-unknown", &exec, &[("otp", "123456")]).await;
assert_eq!(resp.status(), StatusCode::OK);
let page = body_string(resp).await;
assert!(page.contains("Check your email"), "still the code page: {page}");
let resp = submit_login_form(&harness, "ec-unknown", &exec, &[("resend", "1")]).await;
assert_eq!(resp.status(), StatusCode::OK);
assert!(recorder.mails().is_empty(), "no mail without a bound user");
}
#[tokio::test]
async fn email_code_json_caller_gets_challenge_contract() {
let (harness, recorder) = email_code_harness().await;
create_email_code_realm(&harness, "ec-json").await;
let client = harness.create_client("ec-json", false).await;
harness.create_user("ec-json", "carol", "Unused123!").await;
let exec = start_auth_flow(&harness, "ec-json", client.client_id.as_ref()).await;
let resp = harness
.post_json_with_cookie(
"/api/v1/auth/login?realm=ec-json",
serde_json::json!({
"execution_id": exec,
"username": "carol@example.com",
}),
&TestHarness::flow_cookie(&exec),
)
.await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body: serde_json::Value =
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(body["error"], "authentication_challenge");
assert_eq!(body["challenge"], "email_code");
let code = code_from_mail(&recorder.mails()[0].2);
let resp = harness
.post_json_with_cookie(
"/api/v1/auth/login?realm=ec-json",
serde_json::json!({
"execution_id": exec,
"otp": code,
}),
&TestHarness::flow_cookie(&exec),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body: serde_json::Value =
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert!(body["code"].as_str().unwrap().len() > 10);
}