diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index 91ff6cb..7d09abf 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -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), diff --git a/tests/docker/keycloak/inbuxa-realm.json b/tests/docker/keycloak/inbuxa-realm.json new file mode 100644 index 0000000..86a2722 --- /dev/null +++ b/tests/docker/keycloak/inbuxa-realm.json @@ -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": "john.doe@example.org", + "enabled": true, + "email": "john.doe@example.org", + "emailVerified": true, + "firstName": "John", + "lastName": "Doe", + "credentials": [ + { + "type": "password", + "value": "this is an OIDC password", + "temporary": false + } + ], + "groups": [ + "/sales@example.org" + ] + }, + { + "username": "jane.smith@example.org", + "enabled": true, + "email": "jane.smith@example.org", + "emailVerified": true, + "firstName": "Jane", + "lastName": "Smith", + "credentials": [ + { + "type": "password", + "value": "this is an OIDC password", + "temporary": false + } + ], + "groups": [ + "/sales@example.org", + "/corporate@example.org" + ] + }, + { + "username": "bill.foobar@example.org", + "enabled": true, + "email": "bill.foobar@example.org", + "emailVerified": true, + "firstName": "Bill", + "lastName": "Foobar", + "credentials": [ + { + "type": "password", + "value": "this is an OIDC password", + "temporary": false + } + ], + "groups": [ + "/corporate@example.org" + ] + } + ], + "groups": [ + { + "name": "sales@example.org", + "path": "/sales@example.org" + }, + { + "name": "corporate@example.org", + "path": "/corporate@example.org" + } + ], + "id": "inbuxa", + "displayName": "inbuxa test realm" +} \ No newline at end of file diff --git a/tests/src/directory/oidc.rs b/tests/src/directory/oidc.rs index be7e158..6284097 100644 --- a/tests/src/directory/oidc.rs +++ b/tests/src/directory/oidc.rs @@ -37,9 +37,13 @@ const JANE: &str = "jane.smith@example.org"; const BILL: &str = "bill.foobar@example.org"; fn directory(tenant: Option) -> structs::Directory { + realm_directory("stalwart", tenant) +} + +fn realm_directory(realm: &str, tenant: Option) -> 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"), + ("someone@second.example.org", "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(); } diff --git a/tests/src/utils/containers.rs b/tests/src/utils/containers.rs index f77031f..519c91d 100644 --- a/tests/src/utils/containers.rs +++ b/tests/src/utils/containers.rs @@ -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 {