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
|
||||
}
|
||||
.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)
|
||||
}
|
||||
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]";
|
||||
|
||||
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 {
|
||||
description: "Keycloak".to_string(),
|
||||
issuer_url: "http://localhost:9080/realms/stalwart".to_string(),
|
||||
description: format!("Keycloak {realm}"),
|
||||
issuer_url: format!("http://localhost:9080/realms/{realm}"),
|
||||
claim_username: "email".to_string(),
|
||||
claim_name: Some("name".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");
|
||||
|
||||
// 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"});
|
||||
if let Some(kid) = kid {
|
||||
header["kid"] = json!(kid);
|
||||
@@ -299,6 +303,48 @@ pub async fn test() {
|
||||
.await;
|
||||
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)
|
||||
let tenant = admin
|
||||
.registry_create_object(Tenant {
|
||||
@@ -335,6 +381,50 @@ pub async fn test() {
|
||||
"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;
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
|
||||
@@ -307,6 +307,11 @@ pub async fn ensure_keycloak() {
|
||||
"/opt/keycloak/data/import/stalwart-realm.json",
|
||||
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_startup_timeout(READY_TIMEOUT)
|
||||
.with_container_name("stalwart-test-keycloak")
|
||||
@@ -317,6 +322,7 @@ pub async fn ensure_keycloak() {
|
||||
})
|
||||
.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> {
|
||||
|
||||
Reference in New Issue
Block a user