Files
Stalwart/tests/src/directory/oidc.rs
2026-06-11 18:08:13 +02:00

214 lines
6.4 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: LicenseRef-SEL
*
* This file is subject to the Stalwart Enterprise License Agreement (SEL) and
* is NOT open source software.
*
*/
use directory::{Account, Credentials, Directory, backend::oidc::OpenIdDirectory};
use registry::{schema::structs, types::map::Map};
pub async fn test() {
println!("Running OIDC directory tests...");
crate::utils::containers::ensure_keycloak().await;
let config = structs::OidcDirectory {
description: "Test OIDC directory".to_string(),
issuer_url: "http://localhost:9080/realms/stalwart".to_string(),
claim_username: "preferred_username".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: None,
};
let mut oidc = OpenIdDirectory::open(config.clone()).await.unwrap();
let token = get_token("john.doe@example.org", "this is an OIDC password").await;
// Test the userinfo endpoint
assert_eq!(
oidc.authenticate(&Credentials::Bearer {
username: None,
token: format!(".{token}"), // Prefix with '.' to force userinfo in test mode
})
.await
.unwrap(),
Account {
email: "john.doe@example.org".to_string(),
email_aliases: vec![],
secret: None,
groups: vec!["sales@example.org".to_string()],
description: Some("John Doe".to_string())
}
);
// Make sure the userinfo endpoint is not being used
if let Directory::OpenId(directory) = &mut oidc {
directory.discovery.document.userinfo_endpoint = "http://invalid".to_string();
}
// JWT authentication should still work without the userinfo endpoint
assert_eq!(
oidc.authenticate(&Credentials::Bearer {
username: None,
token: token.clone(),
})
.await
.unwrap(),
Account {
email: "john.doe@example.org".to_string(),
email_aliases: vec![],
secret: None,
groups: vec!["sales@example.org".to_string()],
description: Some("John Doe".to_string())
}
);
// Test ODIC userinfo fallback
let mut config_userinfo_fallback = config.clone();
config_userinfo_fallback.claim_username = "email".to_string();
config_userinfo_fallback.require_scopes = Map::new(vec!["openid".to_string()]);
let token_openid_only = get_token_for_client(
"stalwart-fallback",
"stalwart-fallback-secret",
"john.doe@example.org",
"this is an OIDC password",
"openid",
)
.await;
let mut oidc_broken_userinfo = OpenIdDirectory::open(config_userinfo_fallback.clone())
.await
.unwrap();
if let Directory::OpenId(directory) = &mut oidc_broken_userinfo {
directory.discovery.document.userinfo_endpoint = "http://invalid".to_string();
}
assert!(
oidc_broken_userinfo
.authenticate(&Credentials::Bearer {
username: None,
token: token_openid_only.clone(),
})
.await
.is_err()
);
let oidc_userinfo_fallback = OpenIdDirectory::open(config_userinfo_fallback)
.await
.unwrap();
assert_eq!(
oidc_userinfo_fallback
.authenticate(&Credentials::Bearer {
username: None,
token: token_openid_only,
})
.await
.unwrap(),
Account {
email: "john.doe@example.org".to_string(),
email_aliases: vec![],
secret: None,
groups: vec!["sales@example.org".to_string()],
description: None,
}
);
// Not matching the required audience should fail
let mut config_wrong_audience = config.clone();
config_wrong_audience.require_audience = Some("wrong_audience".to_string());
assert!(
OpenIdDirectory::open(config_wrong_audience)
.await
.unwrap()
.authenticate(&Credentials::Bearer {
username: None,
token: token.clone(),
})
.await
.is_err()
);
// Not having the required scopes should fail
let mut config_wrong_scopes = config.clone();
config_wrong_scopes.require_scopes = Map::new(vec![
"email".to_string(),
"profile".to_string(),
"openid".to_string(),
"missing_scope".to_string(),
]);
assert!(
OpenIdDirectory::open(config_wrong_scopes)
.await
.unwrap()
.authenticate(&Credentials::Bearer {
username: None,
token,
})
.await
.is_err()
);
// Test authorization endpoint retrieval
assert_eq!(
oidc.oidc_discovery_document()
.as_ref()
.map(|oidc| oidc.document.authorization_endpoint.as_str()),
Some("http://localhost:9080/realms/stalwart/protocol/openid-connect/auth")
);
}
async fn get_token(username: &str, password: &str) -> String {
get_token_for_client(
"stalwart",
"stalwart-secret",
username,
password,
"openid email profile",
)
.await
}
async fn get_token_for_client(
client_id: &str,
client_secret: &str,
username: &str,
password: &str,
scope: &str,
) -> String {
let client = reqwest::Client::new();
let response = client
.post("http://localhost:9080/realms/stalwart/protocol/openid-connect/token")
.form(&[
("grant_type", "password"),
("client_id", client_id),
("client_secret", client_secret),
("username", username),
("password", password),
("scope", scope),
])
.send()
.await
.expect("Failed to send token request");
let body = response
.text()
.await
.expect("Failed to read token response");
let json: serde_json::Value =
serde_json::from_str(&body).expect("Failed to parse token response");
json["access_token"]
.as_str()
.expect("No access_token in response")
.to_string()
}