Per-domain directories: the OIDC directory tests, rebuilt from the spec; SCIM's OIDC test runs (DIR-7, DIR-10, DIR-14, DIR-15, DIR-18, DIR-26, DIR-29, SCIM test 5)
directory_tests now runs a new oidc module in place of the removed one, with Keycloak as example.org's own directory: first sign-in creates the account with its name and group, an existing account is reused, a token named as another user is refused, password sign-in is refused, forged JWTs (HS256, unknown kid, another issuer, expired) are refused, an OIDC address is a recipient only once an administrator creates it, and sync can't pass a tenant's account limit. scim_oidc_tests, deferred until this feature, passes.
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod integration;
|
pub mod integration;
|
||||||
pub mod ldap;
|
pub mod ldap;
|
||||||
|
pub mod oidc; // inbuxa: rebuilt from the per-domain directories spec
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
pub mod per_domain; // inbuxa: per-domain directories
|
pub mod per_domain; // inbuxa: per-domain directories
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
@@ -17,7 +18,6 @@ pub mod unavailable;
|
|||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
pub async fn directory_tests() {
|
pub async fn directory_tests() {
|
||||||
ldap::test().await;
|
ldap::test().await;
|
||||||
#[cfg(feature = "pending-rebuild")] // inbuxa: pending-rebuild, see docs/spec/features/
|
|
||||||
oidc::test().await;
|
oidc::test().await;
|
||||||
unavailable::test().await;
|
unavailable::test().await;
|
||||||
discovery::test().await;
|
discovery::test().await;
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The OIDC directory as a domain's own directory, from
|
||||||
|
//! `docs/spec/features/per-domain-directories.md`, against the Keycloak
|
||||||
|
//! realm in `tests/docker/keycloak`. Replaces the removed `oidc.rs`, and
|
||||||
|
//! runs from `directory_tests`. Each check names its test number or
|
||||||
|
//! requirement.
|
||||||
|
|
||||||
|
use crate::utils::{server::TestServerBuilder, smtp::SmtpConnection};
|
||||||
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
|
use common::{BuildServer, auth::AuthRequest};
|
||||||
|
use directory::Credentials;
|
||||||
|
use registry::{
|
||||||
|
schema::{
|
||||||
|
enums::TenantStorageQuota,
|
||||||
|
prelude::{ObjectType, Property},
|
||||||
|
structs::{
|
||||||
|
self, CertificateManagement, DkimManagement, DnsManagement, Domain, Expression,
|
||||||
|
MtaStageRcpt, OidcDirectory, Tenant, UserAccount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
types::map::Map,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use trc::{Collector, MetricType};
|
||||||
|
use types::id::Id;
|
||||||
|
use utils::map::vec_map::VecMap;
|
||||||
|
|
||||||
|
const PASSWORD: &str = "this is an OIDC password";
|
||||||
|
const JOHN: &str = "[email protected]";
|
||||||
|
const JANE: &str = "[email protected]";
|
||||||
|
const BILL: &str = "[email protected]";
|
||||||
|
|
||||||
|
fn directory(tenant: Option<Id>) -> structs::Directory {
|
||||||
|
structs::Directory::Oidc(OidcDirectory {
|
||||||
|
description: "Keycloak".to_string(),
|
||||||
|
issuer_url: "http://localhost:9080/realms/stalwart".to_string(),
|
||||||
|
claim_username: "email".to_string(),
|
||||||
|
claim_name: Some("name".to_string()),
|
||||||
|
claim_groups: Some("groups".to_string()),
|
||||||
|
username_domain: None,
|
||||||
|
require_audience: Some("stalwart".to_string()),
|
||||||
|
require_scopes: Map::new(vec![
|
||||||
|
"email".to_string(),
|
||||||
|
"profile".to_string(),
|
||||||
|
"openid".to_string(),
|
||||||
|
]),
|
||||||
|
member_tenant_id: tenant,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn token(client: &str, secret: &str, username: &str, scope: &str) -> Option<String> {
|
||||||
|
let body = reqwest::Client::new()
|
||||||
|
.post("http://localhost:9080/realms/stalwart/protocol/openid-connect/token")
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "password"),
|
||||||
|
("client_id", client),
|
||||||
|
("client_secret", secret),
|
||||||
|
("username", username),
|
||||||
|
("password", PASSWORD),
|
||||||
|
("scope", scope),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
serde_json::from_str::<serde_json::Value>(&body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|json| json["access_token"].as_str().map(str::to_string))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn john_token(username: &str) -> String {
|
||||||
|
token(
|
||||||
|
"stalwart",
|
||||||
|
"stalwart-secret",
|
||||||
|
username,
|
||||||
|
"openid email profile",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("a Keycloak token")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server(test: &crate::utils::server::TestServer) -> common::Server {
|
||||||
|
test.server.inner.build_server()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bearer(
|
||||||
|
test: &crate::utils::server::TestServer,
|
||||||
|
username: Option<&str>,
|
||||||
|
token: &str,
|
||||||
|
) -> trc::Result<u32> {
|
||||||
|
server(test)
|
||||||
|
.authenticate(&AuthRequest::from_credentials(
|
||||||
|
Credentials::Bearer {
|
||||||
|
username: username.map(str::to_string),
|
||||||
|
token: token.to_string(),
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
IpAddr::from([127, 0, 0, 1]),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map(|token| token.account_id())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A JWT with a new header and payload but the original signature.
|
||||||
|
fn forged(
|
||||||
|
token: &str,
|
||||||
|
header: serde_json::Value,
|
||||||
|
edit: impl FnOnce(&mut serde_json::Value),
|
||||||
|
) -> String {
|
||||||
|
let mut parts = token.split('.');
|
||||||
|
let _ = parts.next();
|
||||||
|
let mut payload = serde_json::from_slice::<serde_json::Value>(
|
||||||
|
&URL_SAFE_NO_PAD.decode(parts.next().unwrap()).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
edit(&mut payload);
|
||||||
|
format!(
|
||||||
|
"{}.{}.{}",
|
||||||
|
URL_SAFE_NO_PAD.encode(header.to_string()),
|
||||||
|
URL_SAFE_NO_PAD.encode(payload.to_string()),
|
||||||
|
parts.next().unwrap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rcpt(address: &str) -> char {
|
||||||
|
let mut lmtp = SmtpConnection::connect().await;
|
||||||
|
lmtp.mail_from("[email protected]", 2).await;
|
||||||
|
lmtp.send(&format!("RCPT TO:<{address}>")).await;
|
||||||
|
let reply = lmtp.read(1, u8::MAX).await;
|
||||||
|
lmtp.quit().await;
|
||||||
|
reply
|
||||||
|
.last()
|
||||||
|
.and_then(|line| line.chars().next())
|
||||||
|
.unwrap_or('?')
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test() {
|
||||||
|
println!("Running OIDC directory tests...");
|
||||||
|
crate::utils::containers::ensure_keycloak().await;
|
||||||
|
let test = TestServerBuilder::new("oidc_directory_test")
|
||||||
|
.await
|
||||||
|
.with_default_listeners()
|
||||||
|
.await
|
||||||
|
.with_object(MtaStageRcpt {
|
||||||
|
wait_on_fail: Expression {
|
||||||
|
else_: "1ms".into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
let admin = test.account("admin");
|
||||||
|
admin.mta_no_auth().await;
|
||||||
|
admin.reload_settings().await;
|
||||||
|
|
||||||
|
// example.org signs in against Keycloak; there is no server default
|
||||||
|
let keycloak = admin.registry_create_object(directory(None)).await;
|
||||||
|
let domain = admin.find_or_create_domain("example.org").await;
|
||||||
|
admin
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Domain,
|
||||||
|
domain,
|
||||||
|
json!({ Property::DirectoryId: keycloak.to_string() }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Test 14: an account that already exists is the one sign-in finds
|
||||||
|
// (DIR-18)
|
||||||
|
let existing = admin
|
||||||
|
.registry_create_object(structs::Account::User(UserAccount {
|
||||||
|
name: "jane.smith".to_string(),
|
||||||
|
domain_id: domain,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Test 12: the first sign-in creates the account, with its name and
|
||||||
|
// groups (DIR-14)
|
||||||
|
let john = bearer(&test, None, &john_token(JOHN).await)
|
||||||
|
.await
|
||||||
|
.expect("test 12: first sign-in");
|
||||||
|
let account = admin
|
||||||
|
.registry_get::<structs::Account>(Id::from(john))
|
||||||
|
.await
|
||||||
|
.into_user()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(account.name, "john.doe", "test 12");
|
||||||
|
assert!(account.description.is_some(), "test 12: the name claim");
|
||||||
|
assert_eq!(
|
||||||
|
account.member_group_ids.len(),
|
||||||
|
1,
|
||||||
|
"test 12: the groups claim"
|
||||||
|
);
|
||||||
|
let jane = bearer(&test, None, &john_token(JANE).await)
|
||||||
|
.await
|
||||||
|
.expect("test 14");
|
||||||
|
assert_eq!(Id::from(jane), existing, "test 14: DIR-18");
|
||||||
|
|
||||||
|
// Test 5: a token for one user named as another is refused (DIR-7)
|
||||||
|
let john_token = john_token(JOHN).await;
|
||||||
|
assert!(
|
||||||
|
bearer(&test, Some(JANE), &john_token).await.is_err(),
|
||||||
|
"test 5"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bearer(&test, Some(JOHN), &john_token).await.unwrap(),
|
||||||
|
john,
|
||||||
|
"test 5: the named user"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 17: no password sign-in on an OIDC domain (DIR-29)
|
||||||
|
assert!(
|
||||||
|
server(&test)
|
||||||
|
.authenticate(&AuthRequest::from_plain(
|
||||||
|
JOHN,
|
||||||
|
PASSWORD,
|
||||||
|
0,
|
||||||
|
IpAddr::from([127, 0, 0, 1]),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"test 17: DIR-29"
|
||||||
|
);
|
||||||
|
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 mut header = json!({"alg": alg, "typ": "JWT"});
|
||||||
|
if let Some(kid) = kid {
|
||||||
|
header["kid"] = json!(kid);
|
||||||
|
}
|
||||||
|
header
|
||||||
|
};
|
||||||
|
for (what, forged) in [
|
||||||
|
("HS256", forged(&john_token, header("HS256", None), |_| {})),
|
||||||
|
(
|
||||||
|
"unknown kid",
|
||||||
|
forged(&john_token, header("RS256", Some("no-such-key")), |_| {}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"another issuer",
|
||||||
|
forged(&john_token, header("RS256", None), |p| {
|
||||||
|
p["iss"] = json!("http://localhost:9080/realms/other")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"expired",
|
||||||
|
forged(&john_token, header("RS256", None), |p| p["exp"] = json!(1)),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
bearer(&test, None, &forged).await.is_err(),
|
||||||
|
"test 16: {what}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Keycloak adds its default scopes to every token, so only a token that
|
||||||
|
// really lacks one of the required scopes is checked
|
||||||
|
if let Some(narrow) = token("stalwart", "stalwart-secret", JOHN, "openid").await {
|
||||||
|
let payload = serde_json::from_slice::<serde_json::Value>(
|
||||||
|
&URL_SAFE_NO_PAD
|
||||||
|
.decode(narrow.split('.').nth(1).unwrap())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let scopes = payload["scope"].as_str().unwrap_or_default().to_string();
|
||||||
|
let lacks = ["openid", "email", "profile"]
|
||||||
|
.iter()
|
||||||
|
.any(|s| !scopes.split(' ').any(|t| t == *s));
|
||||||
|
if lacks {
|
||||||
|
assert!(
|
||||||
|
bearer(&test, None, &narrow).await.is_err(),
|
||||||
|
"test 16: a required scope missing ({scopes})"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" Keycloak granted every required scope ({scopes}); scope check not exercised"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: an OIDC domain has no recipient lookup (DIR-10)
|
||||||
|
assert_eq!(rcpt(BILL).await, '5', "test 8: never signed in");
|
||||||
|
admin
|
||||||
|
.registry_create_object(structs::Account::User(UserAccount {
|
||||||
|
name: "bill.foobar".to_string(),
|
||||||
|
domain_id: domain,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(rcpt(BILL).await, '2', "test 8: created by an administrator");
|
||||||
|
|
||||||
|
// Test 13: sign-in can't pass a tenant's limit (DIR-15)
|
||||||
|
let tenant = admin
|
||||||
|
.registry_create_object(Tenant {
|
||||||
|
name: "oidc-tenant".into(),
|
||||||
|
quotas: VecMap::from_iter([(TenantStorageQuota::MaxAccounts, 0u64)]),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let tenant_directory = admin.registry_create_object(directory(Some(tenant))).await;
|
||||||
|
let tenant_domain = admin
|
||||||
|
.registry_create_object(Domain {
|
||||||
|
is_enabled: true,
|
||||||
|
name: "tenant.example.org".to_string(),
|
||||||
|
certificate_management: CertificateManagement::Manual,
|
||||||
|
dns_management: DnsManagement::Manual,
|
||||||
|
dkim_management: DkimManagement::Manual,
|
||||||
|
member_tenant_id: Some(tenant),
|
||||||
|
directory_id: Some(tenant_directory),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let events = Collector::read_metric(MetricType::LimitTenantQuota);
|
||||||
|
// Keycloak's users are on example.org, not on the tenant's domain, so
|
||||||
|
// DIR-6 refuses them first; the limit is checked through sync itself
|
||||||
|
let refused = server(&test)
|
||||||
|
.synchronize_account(directory::Account {
|
||||||
|
email: "[email protected]".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(refused.is_err(), "test 13");
|
||||||
|
assert!(
|
||||||
|
Collector::read_metric(MetricType::LimitTenantQuota) > events,
|
||||||
|
"test 13: limit.tenant-quota"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = tenant_domain;
|
||||||
|
test.temp_dir.delete();
|
||||||
|
}
|
||||||
@@ -437,8 +437,9 @@ pub async fn scim_tests() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acceptance test 5, deferred until per-domain directories (feature 9)
|
/// Acceptance test 5: SCIM's authority over sign-in sync, with Keycloak as
|
||||||
/// are built: it binds an OIDC directory to one domain (SCIM-61 decision).
|
/// one domain's own directory (per-domain directories, feature 9).
|
||||||
|
/// `cargo test -p tests scim_oidc_tests -- --ignored`.
|
||||||
#[ignore]
|
#[ignore]
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
pub async fn scim_oidc_tests() {
|
pub async fn scim_oidc_tests() {
|
||||||
|
|||||||
Reference in New Issue
Block a user