Skip to content

RUST-2236 Add e2e testing for GSSAPI auth on Linux and macOS #1431

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions .evergreen/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,20 @@ buildvariants:
# Limit the test to only schedule every 14 days to reduce external resource usage.
batchtime: 20160

- name: gssapi-auth
display_name: "GSSAPI Authentication"
- name: gssapi-auth-linux
display_name: "GSSAPI Authentication - Linux"
patchable: true
run_on:
- ubuntu2004-small
- ubuntu2204-small
tasks:
- test-gssapi-auth

- name: gssapi-auth-macos
display_name: "GSSAPI Authentication - macOS"
patchable: true
disable: true
run_on:
- macos-14
tasks:
- test-gssapi-auth

Expand Down Expand Up @@ -1389,6 +1398,9 @@ functions:
AWS_AUTH_TYPE: web-identity

"run gssapi auth test":
- command: ec2.assume_role
params:
role_arn: ${aws_test_secrets_role}
- command: subprocess.exec
type: test
params:
Expand All @@ -1397,6 +1409,10 @@ functions:
args:
- .evergreen/run-gssapi-tests.sh
include_expansions_in_env:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- AWS_SESSION_TOKEN
- DRIVERS_TOOLS
- PROJECT_DIRECTORY

"run x509 tests":
Expand Down
49 changes: 49 additions & 0 deletions .evergreen/run-gssapi-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,59 @@ cd ${PROJECT_DIRECTORY}
source .evergreen/env.sh
source .evergreen/cargo-test.sh

# Source the drivers/atlas_connect secrets, where GSSAPI test values are held
source "${DRIVERS_TOOLS}/.evergreen/secrets_handling/setup-secrets.sh" drivers/atlas_connect
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drivers/atlas_connect already had half of the secrets I needed, so I chose that vault and also added the remaining secrets I needed.


FEATURE_FLAGS+=("gssapi-auth")

set +o errexit

# Create a krb5 config file with relevant
touch krb5.conf
echo "[realms]
$SASL_REALM = {
kdc = $SASL_HOST
admin_server = $SASL_HOST
}

$SASL_REALM_CROSS = {
kdc = $SASL_HOST
admin_server = $SASL_HOST
}

[domain_realm]
.$SASL_DOMAIN = $SASL_REALM
$SASL_DOMAIN = $SASL_REALM
" > krb5.conf

export KRB5_CONFIG=krb5.conf
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The evergreen hosts come with an /etc/krb5.conf file that is partially sufficient, but it does not include the cross-realm mapping and I wanted to test that since that is apparently not too uncommon a feature to be used by customers.


# Authenticate the user principal in the KDC before running the e2e test
echo "Authenticating $PRINCIPAL"
echo "$SASL_PASS" | kinit -p $PRINCIPAL
klist

# Run end-to-end auth tests for "$PRINCIPAL" user
TEST_OPTIONS+=("--skip with_service_realm_and_host_options")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you can only kinit one principal at a time, I figured this was the most straightforward way to skip the other cross-realm user principal test. Then, later in the file, we only run this test. Open to other ideas if you have a different opinion on how to handle this.

cargo_test test::auth::gssapi_skip_local

# Unauthenticate
echo "Unauthenticating $PRINCIPAL"
kdestroy

# Authenticate the alternative user principal in the KDC and run other e2e test
echo "Authenticating $PRINCIPAL_CROSS"
echo "$SASL_PASS_CROSS" | kinit -p $PRINCIPAL_CROSS
klist

TEST_OPTIONS=()
cargo_test test::auth::gssapi_skip_local::with_service_realm_and_host_options

# Unauthenticate
echo "Unuthenticating $PRINCIPAL_CROSS"
kdestroy

# Run remaining tests
cargo_test spec::auth
cargo_test uri_options
cargo_test connection_string
Expand Down
41 changes: 25 additions & 16 deletions src/client/auth/gssapi.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use cross_krb5::{ClientCtx, InitiateFlags, K5Ctx, PendingClientCtx, Step};
use hickory_resolver::proto::rr::RData;

use crate::{
bson::Bson,
Expand Down Expand Up @@ -324,21 +323,24 @@ async fn canonicalize_hostname(
let resolver =
crate::runtime::AsyncResolver::new(resolver_config.map(|c| c.inner.clone())).await?;

match mode {
let hostname = match mode {
CanonicalizeHostName::Forward => {
let lookup_records = resolver.cname_lookup(hostname).await?;

if let Some(first_record) = lookup_records.records().first() {
if let Some(RData::CNAME(cname)) = first_record.data() {
Ok(cname.to_lowercase().to_string())
} else {
Ok(hostname.to_string())
}
if !lookup_records.records().is_empty() {
// As long as there is a record, we can return the original hostname.
// Although the spec says to return the canonical name, this is not
// done by any drivers in practice since the majority of them use
// libraries that do not follow CNAME chains. Also, we do not want to
// use the canonical name since it will likely differ from the input
// name, and the use of the input name is required for the service
// principal to be accepted by the GSSAPI auth flow.
hostname.to_lowercase().to_string()
Comment on lines +330 to +338
Copy link
Collaborator Author

@mattChiaravalloti mattChiaravalloti Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was certainly the most interesting find of this ticket. Initially, I did properly implement the spec algorithm for getting canonical names for hosts, however it turns out no drivers actually use that algorithm since GSSAPI often does not support the use of canonical names. I discussed this with mongoGPT and thought it may be hallucinating but I then checked the other drivers and it appears to be correct.

Using the python driver as a source of truth, for example, I saw that false, "forward", and "forwardAndReverse" lookup for ldaptest.10gen.cc all returned ldaptest.10gen.cc. My initial rust implementation returned the "correct" results according the the spec algorithm/DNS definitions: ldaptest.10gen.cc, ec2-54-225-237-121.compute-1.amazonaws.com., and ldaptest.10gen.cc., respectively. However, true FQDNs (with the trailing ".") are invalid in GSSAPI and most GSSAPI implementations do not support using canonical names that are different than the user-provided names (e.g. we can't replace ldaptest.10gen.cc with the ec2 name and expect auth to work).

Given all those findings, I updated this function to be in-line with the python implementation more directly. It now matches the results the python implementation produces.

} else {
Err(Error::authentication_error(
return Err(Error::authentication_error(
GSSAPI_STR,
&format!("No addresses found for hostname: {hostname}"),
))
));
}
}
CanonicalizeHostName::ForwardAndReverse => {
Expand All @@ -350,20 +352,27 @@ async fn canonicalize_hostname(
match resolver.reverse_lookup(first_address).await {
Ok(reverse_lookup) => {
if let Some(name) = reverse_lookup.iter().next() {
Ok(name.to_lowercase().to_string())
name.to_lowercase().to_string()
} else {
Ok(hostname.to_lowercase())
hostname.to_lowercase()
}
}
Err(_) => Ok(hostname.to_lowercase()),
Err(_) => hostname.to_lowercase(),
}
} else {
Err(Error::authentication_error(
return Err(Error::authentication_error(
GSSAPI_STR,
&format!("No addresses found for hostname: {hostname}"),
))
));
}
}
CanonicalizeHostName::None => unreachable!(),
}
};

// Sometimes reverse lookup results in a trailing "." since that is the correct
// way to present a FQDN. However, GSSAPI rejects the trailing "." so we remove
// it here manually.
let hostname = hostname.trim_end_matches(".");

Ok(hostname.to_string())
}
3 changes: 3 additions & 0 deletions src/test/auth.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#[cfg(feature = "aws-auth")]
mod aws;
#[cfg(feature = "gssapi-auth")]
#[path = "auth/gssapi.rs"]
mod gssapi_skip_local;

use serde::Deserialize;

Expand Down
107 changes: 107 additions & 0 deletions src/test/auth/gssapi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use crate::{
bson::{doc, Document},
Client,
};

/// Run a GSSAPI e2e test.
/// - user_principal_var is the name of the environment variable that stores the user principal
/// - gssapi_db_var is the name tof the environment variable that stores the db name to query
/// - auth_mechanism_properties is an optional set of authMechanismProperties to append to the uri
async fn run_gssapi_auth_test(
user_principal_var: &str,
gssapi_db_var: &str,
auth_mechanism_properties: Option<&str>,
) {
// Get env variables
let host = std::env::var("SASL_HOST").expect("SASL_HOST not set");
let user_principal = std::env::var(user_principal_var)
.unwrap_or_else(|_| panic!("{user_principal_var} not set"))
.replace("@", "%40");
let gssapi_db =
std::env::var(gssapi_db_var).unwrap_or_else(|_| panic!("{gssapi_db_var} not set"));

// Optionally create authMechanismProperties
let props = if let Some(auth_mech_props) = auth_mechanism_properties {
format!("&authMechanismProperties={auth_mech_props}")
} else {
String::new()
};

// Create client
let uri = format!(
"mongodb://{user_principal}@{host}/?authSource=%24external&authMechanism=GSSAPI{props}"
);
let client = Client::with_uri_str(uri)
.await
.expect("failed to create MongoDB Client");

// Check that auth worked by qurying the test collection
let coll = client.database(&gssapi_db).collection::<Document>("test");
let doc = coll.find_one(doc! {}).await;
match doc {
Ok(Some(doc)) => {
assert!(
doc.get_bool(&gssapi_db).unwrap(),
"expected '{gssapi_db}' field to exist and be 'true'"
);
assert_eq!(
doc.get_str("authenticated").unwrap(),
"yeah",
"unexpected 'authenticated' value"
);
}
Ok(None) => panic!("expected `find_one` to return a document, but it did not"),
Err(e) => panic!("expected `find_one` to return a document, but it failed: {e:?}"),
}
}

#[tokio::test]
async fn no_options() {
run_gssapi_auth_test("PRINCIPAL", "GSSAPI_DB", None).await
}

#[tokio::test]
async fn explicit_canonicalize_host_name_false() {
run_gssapi_auth_test(
"PRINCIPAL",
"GSSAPI_DB",
Some("CANONICALIZE_HOST_NAME:false"),
)
.await
}

#[tokio::test]
async fn canonicalize_host_name_forward() {
run_gssapi_auth_test(
"PRINCIPAL",
"GSSAPI_DB",
Some("CANONICALIZE_HOST_NAME:forward"),
)
.await
}

#[tokio::test]
async fn canonicalize_host_name_forward_and_reverse() {
run_gssapi_auth_test(
"PRINCIPAL",
"GSSAPI_DB",
Some("CANONICALIZE_HOST_NAME:forwardAndReverse"),
)
.await
}

#[tokio::test]
async fn with_service_realm_and_host_options() {
// This test uses a "cross-realm" user principal, however the service principal is not
// cross-realm. This is why we use SASL_REALM and SASL_HOST instead of SASL_REALM_CROSS
// and SASL_HOST_CROSS.
let service_realm = std::env::var("SASL_REALM").expect("SASL_REALM not set");
let service_host = std::env::var("SASL_HOST").expect("SASL_HOST not set");

run_gssapi_auth_test(
"PRINCIPAL_CROSS",
"GSSAPI_DB_CROSS",
Some(format!("SERVICE_REALM:{service_realm},SERVICE_HOST:{service_host}").as_str()),
)
.await
}