diff --git a/.evergreen/config.yml b/.evergreen/config.yml index e40e29c64..c7a03538c 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -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 @@ -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: @@ -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": diff --git a/.evergreen/run-gssapi-tests.sh b/.evergreen/run-gssapi-tests.sh index 63478b563..6957a705d 100644 --- a/.evergreen/run-gssapi-tests.sh +++ b/.evergreen/run-gssapi-tests.sh @@ -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 + 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 + +# 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") +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 diff --git a/src/client/auth/gssapi.rs b/src/client/auth/gssapi.rs index b554dd353..835ceaec3 100644 --- a/src/client/auth/gssapi.rs +++ b/src/client/auth/gssapi.rs @@ -1,5 +1,4 @@ use cross_krb5::{ClientCtx, InitiateFlags, K5Ctx, PendingClientCtx, Step}; -use hickory_resolver::proto::rr::RData; use crate::{ bson::Bson, @@ -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() } else { - Err(Error::authentication_error( + return Err(Error::authentication_error( GSSAPI_STR, &format!("No addresses found for hostname: {hostname}"), - )) + )); } } CanonicalizeHostName::ForwardAndReverse => { @@ -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()) } diff --git a/src/test/auth.rs b/src/test/auth.rs index c6f4ca430..3790e07b4 100644 --- a/src/test/auth.rs +++ b/src/test/auth.rs @@ -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; diff --git a/src/test/auth/gssapi.rs b/src/test/auth/gssapi.rs new file mode 100644 index 000000000..45154d8b2 --- /dev/null +++ b/src/test/auth/gssapi.rs @@ -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::("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 +}