Per-domain directories: a refused token counts toward the sign-in ban, and tests 10 and 18 (DIR-12, DIR-30)

A token the OIDC directory rejects is an authentication failure, so it
counts toward the ban; a network, provider or configuration fault stays
an error and doesn't. Before, a rejected token was an error too, so bad
tokens never led to a ban.

The Keycloak container now imports a second realm, so test 10 checks
/api/discover and the PACC record answer with each domain's own provider.
Test 18 checks that eight sign-ins during an outage don't ban the client,
while bad tokens do.
This commit is contained in:
2026-09-19 15:09:52 -07:00
parent 0755fad51e
commit c0377df942
4 changed files with 310 additions and 4 deletions
+4 -1
View File
@@ -33,7 +33,10 @@ impl OpenIdDirectory {
self.authenticate_opaque(token).await self.authenticate_opaque(token).await
} }
.map_err(|err| match err { .map_err(|err| match err {
OidcError::AuthorizationFailed(reason) => { // inbuxa: DIR-30: a refused token is an authentication
// failure and counts toward the sign-in ban; a network,
// provider or configuration fault is an error and doesn't
OidcError::AuthorizationFailed(reason) | OidcError::TokenValidation(reason) => {
AuthEvent::Failed.into_err().reason(reason) AuthEvent::Failed.into_err().reason(reason)
} }
err => AuthEvent::Error.into_err().reason(err), err => AuthEvent::Error.into_err().reason(err),
+207
View File
@@ -0,0 +1,207 @@
{
"realm": "inbuxa",
"enabled": true,
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"sslRequired": "none",
"clients": [
{
"clientId": "stalwart",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "stalwart-secret",
"redirectUris": [
"*"
],
"webOrigins": [
"*"
],
"publicClient": false,
"protocol": "openid-connect",
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"serviceAccountsEnabled": true,
"defaultClientScopes": [
"openid",
"email",
"profile",
"roles"
],
"protocolMappers": [
{
"name": "groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"consentRequired": false,
"config": {
"full.path": "false",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "groups",
"userinfo.token.claim": "true"
}
},
{
"name": "email-claim",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"userinfo.token.claim": "true",
"jsonType.label": "String"
}
},
{
"name": "audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "stalwart",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
},
{
"clientId": "stalwart-fallback",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "stalwart-fallback-secret",
"redirectUris": [
"*"
],
"webOrigins": [
"*"
],
"publicClient": false,
"protocol": "openid-connect",
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"serviceAccountsEnabled": true,
"defaultClientScopes": [
"openid"
],
"optionalClientScopes": [
"email",
"profile",
"roles"
],
"protocolMappers": [
{
"name": "email-claim-userinfo-only",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "email",
"id.token.claim": "false",
"access.token.claim": "false",
"claim.name": "email",
"userinfo.token.claim": "true",
"jsonType.label": "String"
}
},
{
"name": "groups-userinfo-only",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"consentRequired": false,
"config": {
"full.path": "false",
"id.token.claim": "false",
"access.token.claim": "false",
"claim.name": "groups",
"userinfo.token.claim": "true"
}
},
{
"name": "audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "stalwart",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "[email protected]",
"enabled": true,
"email": "[email protected]",
"emailVerified": true,
"firstName": "John",
"lastName": "Doe",
"credentials": [
{
"type": "password",
"value": "this is an OIDC password",
"temporary": false
}
],
"groups": [
"/[email protected]"
]
},
{
"username": "[email protected]",
"enabled": true,
"email": "[email protected]",
"emailVerified": true,
"firstName": "Jane",
"lastName": "Smith",
"credentials": [
{
"type": "password",
"value": "this is an OIDC password",
"temporary": false
}
],
"groups": [
"/[email protected]",
"/[email protected]"
]
},
{
"username": "[email protected]",
"enabled": true,
"email": "[email protected]",
"emailVerified": true,
"firstName": "Bill",
"lastName": "Foobar",
"credentials": [
{
"type": "password",
"value": "this is an OIDC password",
"temporary": false
}
],
"groups": [
"/[email protected]"
]
}
],
"groups": [
{
"name": "[email protected]",
"path": "/[email protected]"
},
{
"name": "[email protected]",
"path": "/[email protected]"
}
],
"id": "inbuxa",
"displayName": "inbuxa test realm"
}
+93 -3
View File
@@ -37,9 +37,13 @@ const JANE: &str = "[email protected]";
const BILL: &str = "[email protected]"; const BILL: &str = "[email protected]";
fn directory(tenant: Option<Id>) -> structs::Directory { fn directory(tenant: Option<Id>) -> structs::Directory {
realm_directory("stalwart", tenant)
}
fn realm_directory(realm: &str, tenant: Option<Id>) -> structs::Directory {
structs::Directory::Oidc(OidcDirectory { structs::Directory::Oidc(OidcDirectory {
description: "Keycloak".to_string(), description: format!("Keycloak {realm}"),
issuer_url: "http://localhost:9080/realms/stalwart".to_string(), issuer_url: format!("http://localhost:9080/realms/{realm}"),
claim_username: "email".to_string(), claim_username: "email".to_string(),
claim_name: Some("name".to_string()), claim_name: Some("name".to_string()),
claim_groups: Some("groups".to_string()), claim_groups: Some("groups".to_string()),
@@ -234,7 +238,7 @@ pub async fn test() {
assert!(bearer(&test, None, "not a token").await.is_err(), "test 17"); assert!(bearer(&test, None, "not a token").await.is_err(), "test 17");
// Test 16: JWTs the directory must refuse (DIR-26) // Test 16: JWTs the directory must refuse (DIR-26)
let header = |alg: &str, kid: Option<&str>| { let header = |alg: &str, kid: Option<&str>| -> serde_json::Value {
let mut header = json!({"alg": alg, "typ": "JWT"}); let mut header = json!({"alg": alg, "typ": "JWT"});
if let Some(kid) = kid { if let Some(kid) = kid {
header["kid"] = json!(kid); header["kid"] = json!(kid);
@@ -299,6 +303,48 @@ pub async fn test() {
.await; .await;
assert_eq!(rcpt(BILL).await, '2', "test 8: created by an administrator"); assert_eq!(rcpt(BILL).await, '2', "test 8: created by an administrator");
// Test 10: each domain's own provider answers discovery (DIR-12)
let second = admin
.registry_create_object(realm_directory("inbuxa", None))
.await;
let other_domain = admin
.registry_create_object(Domain {
is_enabled: true,
name: "second.example.org".to_string(),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
directory_id: Some(second),
..Default::default()
})
.await;
let http = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap();
for (address, realm) in [
(JOHN, "realms/stalwart"),
("[email protected]", "realms/inbuxa"),
] {
let document = http
.get(format!("https://127.0.0.1:8899/api/discover/{address}"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(document.contains(realm), "test 10: {address}: {document}");
}
for (domain, realm) in [
("example.org", "realms/stalwart"),
("second.example.org", "realms/inbuxa"),
] {
let pacc = server(&test).get_pacc_for_domain(domain).await.unwrap();
assert!(pacc.contains(realm), "test 10: PACC for {domain}: {pacc}");
}
let _ = other_domain;
// Test 13: sign-in can't pass a tenant's limit (DIR-15) // Test 13: sign-in can't pass a tenant's limit (DIR-15)
let tenant = admin let tenant = admin
.registry_create_object(Tenant { .registry_create_object(Tenant {
@@ -335,6 +381,50 @@ pub async fn test() {
"test 13: limit.tenant-quota" "test 13: limit.tenant-quota"
); );
// Test 18: an outage isn't a wrong password (DIR-30)
admin
.registry_update_setting(
structs::Security {
auth_ban_rate: Some(structs::Rate {
count: 3,
period: registry::types::duration::Duration::from_millis(60_000),
}),
..Default::default()
},
&[Property::AuthBanRate],
)
.await;
admin.reload_settings().await;
// An opaque token has to reach the provider's userinfo endpoint, so with
// the provider stopped this is an outage, not a bad token
crate::utils::containers::docker("stop", "stalwart-test-keycloak");
for attempt in 0..8 {
let err = bearer(&test, Some(JOHN), "an-opaque-token")
.await
.unwrap_err();
assert!(
!err.matches(trc::EventType::Security(
trc::SecurityEvent::AuthenticationBan
)),
"test 18: an outage counted toward the ban (attempt {attempt})"
);
}
crate::utils::containers::docker("start", "stalwart-test-keycloak");
crate::utils::containers::ensure_keycloak().await;
// ... but bad tokens are, and they end in a ban
let mut banned = false;
for _ in 0..8 {
let err = bearer(&test, Some(JOHN), "not.a.token").await.unwrap_err();
if err.matches(trc::EventType::Security(
trc::SecurityEvent::AuthenticationBan,
)) {
banned = true;
break;
}
}
assert!(banned, "test 18: bad tokens must end in a ban");
let _ = tenant_domain; let _ = tenant_domain;
test.temp_dir.delete(); test.temp_dir.delete();
} }
+6
View File
@@ -307,6 +307,11 @@ pub async fn ensure_keycloak() {
"/opt/keycloak/data/import/stalwart-realm.json", "/opt/keycloak/data/import/stalwart-realm.json",
include_bytes!("../../docker/keycloak/stalwart-realm.json").to_vec(), include_bytes!("../../docker/keycloak/stalwart-realm.json").to_vec(),
) )
// inbuxa: a second provider, for per-domain directories (test 10)
.with_copy_to(
"/opt/keycloak/data/import/inbuxa-realm.json",
include_bytes!("../../docker/keycloak/inbuxa-realm.json").to_vec(),
)
.with_mapped_port(9080, 9080.tcp()) .with_mapped_port(9080, 9080.tcp())
.with_startup_timeout(READY_TIMEOUT) .with_startup_timeout(READY_TIMEOUT)
.with_container_name("stalwart-test-keycloak") .with_container_name("stalwart-test-keycloak")
@@ -317,6 +322,7 @@ pub async fn ensure_keycloak() {
}) })
.await; .await;
wait_for_http("http://localhost:9080/realms/stalwart/.well-known/openid-configuration").await; wait_for_http("http://localhost:9080/realms/stalwart/.well-known/openid-configuration").await;
wait_for_http("http://localhost:9080/realms/inbuxa/.well-known/openid-configuration").await;
} }
pub async fn ensure_scim_tester() -> &'static ContainerAsync<GenericImage> { pub async fn ensure_scim_tester() -> &'static ContainerAsync<GenericImage> {