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:
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user