diff --git a/.evergreen/aws-lambda-test/README.md b/.evergreen/aws-lambda-test/README.md index 1affe1c5e..46b2eaa99 100644 --- a/.evergreen/aws-lambda-test/README.md +++ b/.evergreen/aws-lambda-test/README.md @@ -33,7 +33,7 @@ To deploy the application, you need the folllowing tools: * SAM CLI - [Install the SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) * Docker - [Install Docker community edition](https://hub.docker.com/search/?type=edition&offering=community) -* [Rust](https://www.rust-lang.org/) version 1.81.0 or newer +* [Rust](https://www.rust-lang.org/) version 1.82.0 or newer * [cargo-lambda](https://github.com/cargo-lambda/cargo-lambda) for cross-compilation To build and deploy your application for the first time, run the following in your shell: diff --git a/.evergreen/compile-only.sh b/.evergreen/compile-only.sh index b88796837..3742566da 100755 --- a/.evergreen/compile-only.sh +++ b/.evergreen/compile-only.sh @@ -17,7 +17,7 @@ cargo $TOOLCHAIN build # Test with all features. if [ "$RUST_VERSION" != "" ]; then - cargo $TOOLCHAIN build --features openssl-tls,sync,aws-auth,zlib-compression,zstd-compression,snappy-compression,in-use-encryption,tracing-unstable + cargo $TOOLCHAIN build --features openssl-tls,sync,aws-auth,gssapi-auth,zlib-compression,zstd-compression,snappy-compression,in-use-encryption,tracing-unstable else cargo $TOOLCHAIN build --all-features fi diff --git a/.evergreen/config.yml b/.evergreen/config.yml index fcfd33c93..e40e29c64 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -257,6 +257,14 @@ buildvariants: # Limit the test to only schedule every 14 days to reduce external resource usage. batchtime: 20160 + - name: gssapi-auth + display_name: "GSSAPI Authentication" + patchable: true + run_on: + - ubuntu2004-small + tasks: + - test-gssapi-auth + - name: x509-auth display_name: "x509 Authentication" patchable: false @@ -772,7 +780,7 @@ tasks: - func: "compile only" vars: # Our minimum supported Rust version. This should be updated whenever the MSRV is bumped. - RUST_VERSION: 1.81.0 + RUST_VERSION: 1.82.0 - name: check-cargo-deny commands: @@ -924,6 +932,10 @@ tasks: vars: AWS_ROLE_SESSION_NAME: test + - name: test-gssapi-auth + commands: + - func: "run gssapi auth test" + - name: test-atlas-connectivity commands: - func: "run atlas tests" @@ -1376,6 +1388,17 @@ functions: env: AWS_AUTH_TYPE: web-identity + "run gssapi auth test": + - command: subprocess.exec + type: test + params: + binary: bash + working_dir: ${PROJECT_DIRECTORY} + args: + - .evergreen/run-gssapi-tests.sh + include_expansions_in_env: + - PROJECT_DIRECTORY + "run x509 tests": - command: shell.exec type: test diff --git a/.evergreen/run-gssapi-tests.sh b/.evergreen/run-gssapi-tests.sh new file mode 100644 index 000000000..63478b563 --- /dev/null +++ b/.evergreen/run-gssapi-tests.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -o xtrace +set -o errexit # Exit the script with error if any of the commands fail + +echo "Running MONGODB-GSSAPI authentication tests" + +cd ${PROJECT_DIRECTORY} +source .evergreen/env.sh +source .evergreen/cargo-test.sh + +FEATURE_FLAGS+=("gssapi-auth") + +set +o errexit + +cargo_test spec::auth +cargo_test uri_options +cargo_test connection_string + +exit $CARGO_RESULT diff --git a/Cargo.lock b/Cargo.lock index 9631b6d95..4bf3cdd0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,26 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.9.0", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.101", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -290,6 +310,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.1" @@ -325,6 +354,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -391,6 +431,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cross-krb5" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d4ddf7139e64dc916b11d434421031bcc5ba02e521a49a011652a0f68775188" +dependencies = [ + "anyhow", + "bitflags 2.9.0", + "bytes", + "libgssapi", + "windows", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -828,6 +881,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "h2" version = "0.4.11" @@ -1318,6 +1377,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1388,6 +1456,38 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libgssapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "834339e86b2561169d45d3b01741967fee3e5716c7d0b6e33cd4e3b34c9558cd" +dependencies = [ + "bitflags 2.9.0", + "bytes", + "lazy_static", + "libgssapi-sys", +] + +[[package]] +name = "libgssapi-sys" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7518e6902e94f92e7c7271232684b60988b4bd813529b4ef9d97aead96956ae8" +dependencies = [ + "bindgen", + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.52.6", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1513,6 +1613,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1563,6 +1669,7 @@ dependencies = [ "bson 2.15.0", "bson 3.0.0", "chrono", + "cross-krb5", "ctrlc", "derive-where", "derive_more", @@ -1673,6 +1780,16 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1932,6 +2049,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +dependencies = [ + "proc-macro2", + "syn 2.0.101", +] + [[package]] name = "proc-macro-crate" version = "0.1.5" @@ -3331,6 +3458,28 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -3344,6 +3493,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.0" @@ -3372,6 +3532,16 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -3459,6 +3629,15 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index f828fb4a9..263c7fc5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ license = "Apache-2.0" readme = "README.md" name = "mongodb" version = "3.2.3" -rust-version = "1.81" +rust-version = "1.82" exclude = [ "etc/**", @@ -41,11 +41,9 @@ dns-resolver = ["dep:hickory-resolver", "dep:hickory-proto"] cert-key-password = ["dep:pem", "dep:pkcs8"] # Enable support for MONGODB-AWS authentication. -# This can only be used with the tokio-runtime feature flag. aws-auth = ["dep:reqwest"] # Enable support for on-demand Azure KMS credentials. -# This can only be used with the tokio-runtime feature flag. azure-kms = ["dep:reqwest"] # Enable support for azure OIDC authentication. @@ -55,9 +53,11 @@ azure-oidc = ["dep:reqwest"] gcp-oidc = ["dep:reqwest"] # Enable support for on-demand GCP KMS credentials. -# This can only be used with the tokio-runtime feature flag. gcp-kms = ["dep:reqwest"] +# Enable support for GSSAPI (Kerberos) authentication. +gssapi-auth = ["dep:cross-krb5", "dns-resolver"] + zstd-compression = ["dep:zstd"] zlib-compression = ["dep:flate2"] snappy-compression = ["dep:snap"] @@ -80,6 +80,7 @@ chrono = { version = "0.4.7", default-features = false, features = [ "clock", "std", ] } +cross-krb5 = { version = "0.4.2", optional = true, default-features = false } derive_more = "0.99.17" derive-where = "1.2.7" flate2 = { version = "1.0", optional = true } diff --git a/README.md b/README.md index 4ef2138cd..cc314ba61 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ For more details, including features, runnable examples, troubleshooting resourc ## Installation ### Requirements -- Rust 1.81.0+ (See the [MSRV policy](#minimum-supported-rust-version-msrv-policy) for more information) +- Rust 1.82.0+ (See the [MSRV policy](#minimum-supported-rust-version-msrv-policy) for more information) - MongoDB 4.0+ #### Supported Platforms @@ -149,7 +149,7 @@ Commits to main are run automatically on [evergreen](https://evergreen.mongodb.c ## Minimum supported Rust version (MSRV) policy -The MSRV for this crate is currently 1.81.0. Increases to the MSRV will only happen in a minor or major version release, and will be to a Rust version at least six months old. +The MSRV for this crate is currently 1.82.0. Increases to the MSRV will only happen in a minor or major version release, and will be to a Rust version at least six months old. ## License diff --git a/clippy.toml b/clippy.toml index 5e90250c4..c3aa6421b 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1 @@ -msrv = "1.81.0" +msrv = "1.82.0" diff --git a/src/action/client_options.rs b/src/action/client_options.rs index 1de6f4946..a3234edc6 100644 --- a/src/action/client_options.rs +++ b/src/action/client_options.rs @@ -100,7 +100,8 @@ pub struct ParseConnectionString { #[export_tokens(parse_conn_str_setters)] impl ParseConnectionString { /// In the case that "mongodb+srv" is used, SRV and TXT record lookups will be done using the - /// provided `ResolverConfig` as part of this method. + /// provided `ResolverConfig` as part of this method. In the case that "GSSAPI" auth is used, + /// hostname canonicalization will be done using the provided `ResolverConfig`. #[cfg(feature = "dns-resolver")] pub fn resolver_config(mut self, value: ResolverConfig) -> Self { self.resolver_config = Some(value); diff --git a/src/client/action/perf.rs b/src/client/action/perf.rs index 1d2ab780c..eda5de0a0 100644 --- a/src/client/action/perf.rs +++ b/src/client/action/perf.rs @@ -5,12 +5,12 @@ impl<'a> Action for crate::action::WarmConnectionPool<'a> { type Future = WarmConnectionPoolFuture; async fn execute(self) -> () { - if !self + if self .client .inner .options .min_pool_size - .is_some_and(|size| size > 0) + .is_some_and(|size| size == 0) { // No-op when min_pool_size is zero. return; diff --git a/src/client/auth.rs b/src/client/auth.rs index eef3f2b81..9624f9c56 100644 --- a/src/client/auth.rs +++ b/src/client/auth.rs @@ -3,6 +3,8 @@ #[cfg(feature = "aws-auth")] pub(crate) mod aws; +#[cfg(feature = "gssapi-auth")] +mod gssapi; /// Contains the functionality for [`OIDC`](https://openid.net/developers/how-connect-works/) authorization and authentication. pub mod oidc; mod plain; @@ -22,6 +24,8 @@ use serde::Deserialize; use typed_builder::TypedBuilder; use self::scram::ScramVersion; +#[cfg(feature = "gssapi-auth")] +use crate::options::ResolverConfig; use crate::{ bson::Document, client::options::ServerApi, @@ -67,8 +71,7 @@ pub enum AuthMechanism { /// Kerberos authentication mechanism as defined in [RFC 4752](http://tools.ietf.org/html/rfc4752). /// /// See the [MongoDB documentation](https://www.mongodb.com/docs/manual/core/kerberos/) for more information. - /// - /// Note: This mechanism is not currently supported by this driver but will be in the future. + #[cfg(feature = "gssapi-auth")] Gssapi, /// The SASL PLAIN mechanism, as defined in [RFC 4616](), is used in MongoDB to perform LDAP @@ -148,6 +151,25 @@ impl AuthMechanism { Ok(()) } + #[cfg(feature = "gssapi-auth")] + AuthMechanism::Gssapi => { + if credential.username.is_none() { + return Err(ErrorKind::InvalidArgument { + message: "No username provided for GSSAPI authentication".to_string(), + } + .into()); + } + + if credential.source.as_deref().unwrap_or("$external") != "$external" { + return Err(ErrorKind::InvalidArgument { + message: "only $external may be specified as an auth source for GSSAPI" + .to_string(), + } + .into()); + } + + Ok(()) + } AuthMechanism::Plain => { if credential.username.is_none() { return Err(ErrorKind::InvalidArgument { @@ -197,6 +219,7 @@ impl AuthMechanism { AuthMechanism::ScramSha256 => SCRAM_SHA_256_STR, AuthMechanism::MongoDbCr => MONGODB_CR_STR, AuthMechanism::MongoDbX509 => MONGODB_X509_STR, + #[cfg(feature = "gssapi-auth")] AuthMechanism::Gssapi => GSSAPI_STR, AuthMechanism::Plain => PLAIN_STR, #[cfg(feature = "aws-auth")] @@ -217,7 +240,8 @@ impl AuthMechanism { AuthMechanism::MongoDbOidc => "$external", #[cfg(feature = "aws-auth")] AuthMechanism::MongoDbAws => "$external", - AuthMechanism::Gssapi => "", + #[cfg(feature = "gssapi-auth")] + AuthMechanism::Gssapi => "$external", } } @@ -242,6 +266,8 @@ impl AuthMechanism { Self::MongoDbX509 => Ok(Some(ClientFirst::X509(Box::new( x509::build_speculative_client_first(credential)?, )))), + #[cfg(feature = "gssapi-auth")] + AuthMechanism::Gssapi => Ok(None), Self::Plain => Ok(None), Self::MongoDbOidc => Ok(oidc::build_speculative_client_first(credential) .await @@ -254,10 +280,6 @@ impl AuthMechanism { .into(), } .into()), - _ => Err(ErrorKind::Authentication { - message: format!("Authentication mechanism {:?} not yet implemented.", self), - } - .into()), } } @@ -267,6 +289,7 @@ impl AuthMechanism { credential: &Credential, server_api: Option<&ServerApi>, #[cfg(feature = "aws-auth")] http_client: &crate::runtime::HttpClient, + #[cfg(feature = "gssapi-auth")] resolver_config: Option<&ResolverConfig>, ) -> Result<()> { self.validate_credential(credential)?; @@ -284,6 +307,10 @@ impl AuthMechanism { AuthMechanism::MongoDbX509 => { x509::authenticate_stream(stream, credential, server_api, None).await } + #[cfg(feature = "gssapi-auth")] + AuthMechanism::Gssapi => { + gssapi::authenticate_stream(stream, credential, server_api, resolver_config).await + } AuthMechanism::Plain => { plain::authenticate_stream(stream, credential, server_api).await } @@ -300,10 +327,6 @@ impl AuthMechanism { AuthMechanism::MongoDbOidc => { oidc::authenticate_stream(stream, credential, server_api, None).await } - _ => Err(ErrorKind::Authentication { - message: format!("Authentication mechanism {:?} not yet implemented.", self), - } - .into()), } } @@ -327,6 +350,14 @@ impl AuthMechanism { ), } .into()), + #[cfg(feature = "gssapi-auth")] + AuthMechanism::Gssapi => Err(ErrorKind::Authentication { + message: format!( + "Reauthentication for authentication mechanism {:?} is not supported.", + self + ), + } + .into()), #[cfg(feature = "aws-auth")] AuthMechanism::MongoDbAws => Err(ErrorKind::Authentication { message: format!( @@ -338,10 +369,6 @@ impl AuthMechanism { AuthMechanism::MongoDbOidc => { oidc::reauthenticate_stream(stream, credential, server_api).await } - _ => Err(ErrorKind::Authentication { - message: format!("Authentication mechanism {:?} not yet implemented.", self), - } - .into()), } } } @@ -355,7 +382,13 @@ impl FromStr for AuthMechanism { SCRAM_SHA_256_STR => Ok(AuthMechanism::ScramSha256), MONGODB_CR_STR => Ok(AuthMechanism::MongoDbCr), MONGODB_X509_STR => Ok(AuthMechanism::MongoDbX509), + #[cfg(feature = "gssapi-auth")] GSSAPI_STR => Ok(AuthMechanism::Gssapi), + #[cfg(not(feature = "gssapi-auth"))] + GSSAPI_STR => Err(ErrorKind::InvalidArgument { + message: "GSSAPI auth is only supported with the gssapi-auth feature flag".into(), + } + .into()), PLAIN_STR => Ok(AuthMechanism::Plain), MONGODB_OIDC_STR => Ok(AuthMechanism::MongoDbOidc), #[cfg(feature = "aws-auth")] @@ -465,6 +498,7 @@ impl Credential { server_api: Option<&ServerApi>, first_round: Option, #[cfg(feature = "aws-auth")] http_client: &crate::runtime::HttpClient, + #[cfg(feature = "gssapi-auth")] resolver_config: Option<&ResolverConfig>, ) -> Result<()> { let stream_description = conn.stream_description()?; @@ -495,7 +529,6 @@ impl Credential { None => Cow::Owned(AuthMechanism::from_stream_description(stream_description)), Some(ref m) => Cow::Borrowed(m), }; - // Authenticate according to the chosen mechanism. mechanism .authenticate_stream( @@ -504,6 +537,8 @@ impl Credential { server_api, #[cfg(feature = "aws-auth")] http_client, + #[cfg(feature = "gssapi-auth")] + resolver_config, ) .await } diff --git a/src/client/auth/gssapi.rs b/src/client/auth/gssapi.rs new file mode 100644 index 000000000..b554dd353 --- /dev/null +++ b/src/client/auth/gssapi.rs @@ -0,0 +1,369 @@ +use cross_krb5::{ClientCtx, InitiateFlags, K5Ctx, PendingClientCtx, Step}; +use hickory_resolver::proto::rr::RData; + +use crate::{ + bson::Bson, + client::{ + auth::{ + sasl::{SaslContinue, SaslResponse, SaslStart}, + Credential, + GSSAPI_STR, + }, + options::ServerApi, + }, + cmap::Connection, + error::{Error, Result}, + options::ResolverConfig, +}; + +const SERVICE_NAME: &str = "SERVICE_NAME"; +const CANONICALIZE_HOST_NAME: &str = "CANONICALIZE_HOST_NAME"; +const SERVICE_REALM: &str = "SERVICE_REALM"; +const SERVICE_HOST: &str = "SERVICE_HOST"; + +#[derive(Debug, Clone)] +pub(crate) struct GssapiProperties { + pub service_name: String, + pub canonicalize_host_name: CanonicalizeHostName, + pub service_realm: Option, + pub service_host: Option, +} + +#[derive(Debug, Default, Clone, PartialEq)] +pub(crate) enum CanonicalizeHostName { + #[default] + None, + Forward, + ForwardAndReverse, +} + +pub(crate) async fn authenticate_stream( + conn: &mut Connection, + credential: &Credential, + server_api: Option<&ServerApi>, + resolver_config: Option<&ResolverConfig>, +) -> Result<()> { + let properties = GssapiProperties::from_credential(credential)?; + + let conn_host = conn.address.host().to_string(); + let hostname = properties.service_host.as_ref().unwrap_or(&conn_host); + let hostname = canonicalize_hostname( + hostname, + &properties.canonicalize_host_name, + resolver_config, + ) + .await?; + + let user_principal = credential.username.clone(); + let (mut authenticator, initial_token) = + GssapiAuthenticator::init(user_principal, properties.clone(), &hostname).await?; + + let source = credential.source.as_deref().unwrap_or("$external"); + + let command = SaslStart::new( + source.to_string(), + crate::client::auth::AuthMechanism::Gssapi, + initial_token, + server_api.cloned(), + ) + .into_command()?; + + let response_doc = conn.send_message(command).await?; + let sasl_response = + SaslResponse::parse(GSSAPI_STR, response_doc.auth_response_body(GSSAPI_STR)?)?; + + let mut conversation_id = Some(sasl_response.conversation_id); + let mut payload = sasl_response.payload; + + // Limit number of auth challenge steps (typically, only one step is needed, however + // different configurations may require more). + for _ in 0..10 { + let challenge = payload.as_slice(); + let output_token = authenticator.step(challenge).await?; + + // The step may return None, which is a valid final step. We still need to + // send a saslContinue command, so we send an empty payload if there is no + // token. + let token = output_token.unwrap_or(vec![]); + let command = SaslContinue::new( + source.to_string(), + conversation_id.clone().unwrap(), + token, + server_api.cloned(), + ) + .into_command(); + + let response_doc = conn.send_message(command).await?; + let sasl_response = + SaslResponse::parse(GSSAPI_STR, response_doc.auth_response_body(GSSAPI_STR)?)?; + + conversation_id = Some(sasl_response.conversation_id); + payload = sasl_response.payload; + + // Although unlikely, there are cases where authentication can be done + // at this point. + if sasl_response.done { + return Ok(()); + } + + // The authenticator is considered "complete" when the Kerberos auth + // process is done. However, this is not the end of the full auth flow. + // We no longer need to issue challenges to the authenticator, so we + // break the loop and continue with the rest of the flow. + if authenticator.is_complete() { + break; + } + } + + let output_token = authenticator.do_unwrap_wrap(payload.as_slice())?; + let command = SaslContinue::new( + source.to_string(), + conversation_id.unwrap(), + output_token, + server_api.cloned(), + ) + .into_command(); + + let response_doc = conn.send_message(command).await?; + let sasl_response = + SaslResponse::parse(GSSAPI_STR, response_doc.auth_response_body(GSSAPI_STR)?)?; + + if sasl_response.done { + Ok(()) + } else { + Err(Error::authentication_error( + GSSAPI_STR, + "GSSAPI authentication failed after 10 attempts", + )) + } +} + +impl GssapiProperties { + pub fn from_credential(credential: &Credential) -> Result { + let mut properties = GssapiProperties { + service_name: "mongodb".to_string(), + canonicalize_host_name: CanonicalizeHostName::None, + service_realm: None, + service_host: None, + }; + + if let Some(mechanism_properties) = &credential.mechanism_properties { + if let Some(Bson::String(name)) = mechanism_properties.get(SERVICE_NAME) { + properties.service_name = name.clone(); + } + + if let Some(canonicalize) = mechanism_properties.get(CANONICALIZE_HOST_NAME) { + properties.canonicalize_host_name = match canonicalize { + Bson::String(s) => match s.as_str() { + "none" => CanonicalizeHostName::None, + "forward" => CanonicalizeHostName::Forward, + "forwardAndReverse" => CanonicalizeHostName::ForwardAndReverse, + _ => { + return Err(Error::authentication_error( + GSSAPI_STR, + format!( + "Invalid CANONICALIZE_HOST_NAME value: {s}. Valid values are \ + 'none', 'forward', 'forwardAndReverse'", + ) + .as_str(), + )) + } + }, + Bson::Boolean(true) => CanonicalizeHostName::ForwardAndReverse, + Bson::Boolean(false) => CanonicalizeHostName::None, + _ => { + return Err(Error::authentication_error( + GSSAPI_STR, + "CANONICALIZE_HOST_NAME must be a string or boolean", + )) + } + }; + } + + if let Some(Bson::String(realm)) = mechanism_properties.get(SERVICE_REALM) { + properties.service_realm = Some(realm.clone()); + } + + if let Some(Bson::String(host)) = mechanism_properties.get(SERVICE_HOST) { + properties.service_host = Some(host.clone()); + } + } + + Ok(properties) + } +} + +struct GssapiAuthenticator { + pending_ctx: Option, + established_ctx: Option, + user_principal: Option, + is_complete: bool, +} + +impl GssapiAuthenticator { + // Initialize the GssapiAuthenticator by creating a PendingClientCtx and + // getting an initial token to send to the server. + async fn init( + user_principal: Option, + properties: GssapiProperties, + hostname: &str, + ) -> Result<(Self, Vec)> { + let service_name: &str = properties.service_name.as_ref(); + let mut service_principal = format!("{service_name}/{hostname}"); + if let Some(service_realm) = properties.service_realm.as_ref() { + service_principal = format!("{service_principal}@{service_realm}"); + } else if let Some(user_principal) = user_principal.as_ref() { + if let Some(idx) = user_principal.find('@') { + // If no SERVICE_REALM was specified, use realm specified in the + // username. Note that `realm` starts with '@'. + let (_, realm) = user_principal.split_at(idx); + service_principal = format!("{service_principal}{realm}"); + } + } + + let (pending_ctx, initial_token) = ClientCtx::new( + InitiateFlags::empty(), + user_principal.as_deref(), + &service_principal, + None, // No channel bindings + ) + .map_err(|e| { + Error::authentication_error( + GSSAPI_STR, + &format!("Failed to initialize GSSAPI context: {e}"), + ) + })?; + + Ok(( + Self { + pending_ctx: Some(pending_ctx), + established_ctx: None, + user_principal, + is_complete: false, + }, + initial_token.to_vec(), + )) + } + + // Issue the server provided token to the client context. If the ClientCtx + // is established, an optional final token that must be sent to the server + // may be returned; otherwise another token to pass to the server is + // returned and the client context remains in the pending state. + async fn step(&mut self, challenge: &[u8]) -> Result>> { + if challenge.is_empty() { + Err(Error::authentication_error( + GSSAPI_STR, + "Expected challenge data for GSSAPI continuation", + )) + } else if let Some(pending_ctx) = self.pending_ctx.take() { + match pending_ctx.step(challenge).map_err(|e| { + Error::authentication_error(GSSAPI_STR, &format!("GSSAPI step failed: {e}")) + })? { + Step::Finished((ctx, token)) => { + self.is_complete = true; + self.established_ctx = Some(ctx); + Ok(token.map(|t| t.to_vec())) + } + Step::Continue((ctx, token)) => { + self.pending_ctx = Some(ctx); + Ok(Some(token.to_vec())) + } + } + } else { + Err(Error::authentication_error( + GSSAPI_STR, + "Authentication context not initialized", + )) + } + } + + // Perform the final step of Kerberos authentication by gss_unwrap-ing the + // final server challenge, then wrapping the protocol bytes + user principal. + // The resulting token must be sent to the server. + fn do_unwrap_wrap(&mut self, payload: &[u8]) -> Result> { + if let Some(mut established_ctx) = self.established_ctx.take() { + let _ = established_ctx.unwrap(payload).map_err(|e| { + Error::authentication_error(GSSAPI_STR, &format!("GSSAPI unwrap failed: {e}")) + })?; + + if let Some(user_principal) = self.user_principal.take() { + let bytes: &[u8] = &[0x1, 0x0, 0x0, 0x0]; + let bytes = [bytes, user_principal.as_bytes()].concat(); + let output_token = established_ctx.wrap(false, bytes.as_slice()).map_err(|e| { + Error::authentication_error(GSSAPI_STR, &format!("GSSAPI wrap failed: {e}")) + })?; + Ok(output_token.to_vec()) + } else { + Err(Error::authentication_error( + GSSAPI_STR, + "User principal not specified", + )) + } + } else { + Err(Error::authentication_error( + GSSAPI_STR, + "Authentication context not established", + )) + } + } + + fn is_complete(&self) -> bool { + self.is_complete + } +} + +async fn canonicalize_hostname( + hostname: &str, + mode: &CanonicalizeHostName, + resolver_config: Option<&ResolverConfig>, +) -> Result { + if mode == &CanonicalizeHostName::None { + return Ok(hostname.to_string()); + } + + let resolver = + crate::runtime::AsyncResolver::new(resolver_config.map(|c| c.inner.clone())).await?; + + 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()) + } + } else { + Err(Error::authentication_error( + GSSAPI_STR, + &format!("No addresses found for hostname: {hostname}"), + )) + } + } + CanonicalizeHostName::ForwardAndReverse => { + // forward lookup + let ips = resolver.ip_lookup(hostname).await?; + + if let Some(first_address) = ips.iter().next() { + // reverse lookup + match resolver.reverse_lookup(first_address).await { + Ok(reverse_lookup) => { + if let Some(name) = reverse_lookup.iter().next() { + Ok(name.to_lowercase().to_string()) + } else { + Ok(hostname.to_lowercase()) + } + } + Err(_) => Ok(hostname.to_lowercase()), + } + } else { + Err(Error::authentication_error( + GSSAPI_STR, + &format!("No addresses found for hostname: {hostname}"), + )) + } + } + CanonicalizeHostName::None => unreachable!(), + } +} diff --git a/src/client/auth/oidc.rs b/src/client/auth/oidc.rs index ceb36bc2c..17d069ad1 100644 --- a/src/client/auth/oidc.rs +++ b/src/client/auth/oidc.rs @@ -310,6 +310,7 @@ enum CallbackKind { } use std::fmt::Debug; + impl std::fmt::Debug for Function { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct(format!("Callback: {:?}", self.kind).as_str()) @@ -972,8 +973,7 @@ pub(super) fn validate_credential(credential: &Credential) -> Result<()> { .is_some_and(|source| source != "$external") { return Err(Error::invalid_argument(format!( - "source must be $external for {} authentication, found: {:?}", - MONGODB_OIDC_STR, credential.source + "only $external may be specified as an auth source for {MONGODB_OIDC_STR}", ))); } #[cfg(test)] diff --git a/src/client/options.rs b/src/client/options.rs index ea6a7e18c..aea136115 100644 --- a/src/client/options.rs +++ b/src/client/options.rs @@ -1583,7 +1583,18 @@ impl ConnectionString { let val = match &s.to_lowercase()[..] { "true" => Bson::Boolean(true), "false" => Bson::Boolean(false), - _ => Bson::String(s), + "none" | "forward" | "forwardandreverse" => Bson::String(s), + _ => { + return Err(ErrorKind::InvalidArgument { + message: format!( + "Invalid CANONICALIZE_HOST_NAME value: {}. Valid \ + values are 'none', 'forward', 'forwardAndReverse', \ + 'true', 'false'", + s + ), + } + .into()); + } }; doc.insert("CANONICALIZE_HOST_NAME", val); } @@ -1596,6 +1607,22 @@ impl ConnectionString { credential.mechanism_properties = Some(doc); } + #[cfg(feature = "gssapi-auth")] + if mechanism == &AuthMechanism::Gssapi { + // Set mongodb as the default SERVICE_NAME if none is provided + let mut doc = if let Some(doc) = credential.mechanism_properties.take() { + doc + } else { + Document::new() + }; + + if !doc.contains_key("SERVICE_NAME") { + doc.insert("SERVICE_NAME", "mongodb"); + } + + credential.mechanism_properties = Some(doc); + } + credential.mechanism = Some(mechanism.clone()); mechanism.validate_credential(credential)?; } diff --git a/src/client/options/test.rs b/src/client/options/test.rs index 928d6cf2e..86159e3cf 100644 --- a/src/client/options/test.rs +++ b/src/client/options/test.rs @@ -217,17 +217,23 @@ async fn run_tests(path: &[&str], skipped_files: &[&str]) { #[tokio::test] async fn run_uri_options_spec_tests() { - let skipped_files = vec![ + let mut skipped_files = vec![ "single-threaded-options.json", // TODO RUST-1054 unskip this file "proxy-options.json", ]; + if cfg!(not(feature = "gssapi-auth")) { + skipped_files.push("auth-options.json"); + } run_tests(&["uri-options"], &skipped_files).await; } #[tokio::test] async fn run_connection_string_spec_tests() { let mut skipped_files = Vec::new(); + if cfg!(not(feature = "gssapi-auth")) { + skipped_files.push("valid-auth.json"); + } if cfg!(not(unix)) { skipped_files.push("valid-unix_socket-absolute.json"); skipped_files.push("valid-unix_socket-relative.json"); diff --git a/src/cmap/establish.rs b/src/cmap/establish.rs index 58873a052..9520ff13c 100644 --- a/src/cmap/establish.rs +++ b/src/cmap/establish.rs @@ -62,6 +62,8 @@ impl EstablisherOptions { driver_info: opts.driver_info.clone(), server_api: opts.server_api.clone(), load_balanced: opts.load_balanced.unwrap_or(false), + #[cfg(feature = "gssapi-auth")] + resolver_config: opts.resolver_config.clone(), }, tls_options: opts.tls_options(), connect_timeout: opts.connect_timeout, diff --git a/src/cmap/establish/handshake.rs b/src/cmap/establish/handshake.rs index 605f2f6b2..53f118710 100644 --- a/src/cmap/establish/handshake.rs +++ b/src/cmap/establish/handshake.rs @@ -16,6 +16,8 @@ use tokio::sync::broadcast; feature = "snappy-compression" ))] use crate::options::Compressor; +#[cfg(feature = "gssapi-auth")] +use crate::options::ResolverConfig; use crate::{ client::auth::ClientFirst, cmap::{Command, Connection, StreamDescription}, @@ -342,6 +344,9 @@ pub(crate) struct Handshaker { #[cfg(feature = "aws-auth")] http_client: crate::runtime::HttpClient, + + #[cfg(feature = "gssapi-auth")] + resolver_config: Option, } #[cfg(test)] @@ -411,6 +416,8 @@ impl Handshaker { metadata, #[cfg(feature = "aws-auth")] http_client: crate::runtime::HttpClient::default(), + #[cfg(feature = "gssapi-auth")] + resolver_config: options.resolver_config, }) } @@ -498,6 +505,8 @@ impl Handshaker { first_round, #[cfg(feature = "aws-auth")] &self.http_client, + #[cfg(feature = "gssapi-auth")] + self.resolver_config.as_ref(), ) .await? } @@ -532,9 +541,13 @@ pub(crate) struct HandshakerOptions { /// Whether or not the client is connecting to a MongoDB cluster through a load balancer. pub(crate) load_balanced: bool, + + /// Configuration of the DNS resolver used for hostname canonicalization for GSSAPI. + #[cfg(feature = "gssapi-auth")] + pub(crate) resolver_config: Option, } -/// Updates the handshake command document with the speculative authenitication info. +/// Updates the handshake command document with the speculative authentication info. async fn set_speculative_auth_info( command: &mut RawDocumentBuf, credential: Option<&Credential>, diff --git a/src/cmap/establish/handshake/test.rs b/src/cmap/establish/handshake/test.rs index ff861c2eb..846f38590 100644 --- a/src/cmap/establish/handshake/test.rs +++ b/src/cmap/establish/handshake/test.rs @@ -18,6 +18,8 @@ async fn metadata_no_options() { driver_info: None, server_api: None, load_balanced: false, + #[cfg(feature = "gssapi-auth")] + resolver_config: None, }) .unwrap(); @@ -66,6 +68,8 @@ async fn metadata_with_options() { compressors: None, server_api: None, load_balanced: false, + #[cfg(feature = "gssapi-auth")] + resolver_config: None, }; let handshaker = Handshaker::new(options).unwrap(); diff --git a/src/cursor/session.rs b/src/cursor/session.rs index 33f17ca76..a2a0b3dc6 100644 --- a/src/cursor/session.rs +++ b/src/cursor/session.rs @@ -349,7 +349,7 @@ impl SessionCursor { impl SessionCursor { pub(crate) fn is_exhausted(&self) -> bool { - self.state.as_ref().map_or(true, |state| state.exhausted) + self.state.as_ref().is_none_or(|state| state.exhausted) } #[cfg(test)] diff --git a/src/operation/aggregate/change_stream.rs b/src/operation/aggregate/change_stream.rs index d405ec5ae..8b8bab53c 100644 --- a/src/operation/aggregate/change_stream.rs +++ b/src/operation/aggregate/change_stream.rs @@ -109,7 +109,7 @@ impl OperationWithDefaults for ChangeStreamAggregate { }; let description = context.connection.stream_description()?; - if self.args.options.as_ref().map_or(true, has_no_time) + if self.args.options.as_ref().is_none_or(has_no_time) && description.max_wire_version.is_some_and(|v| v >= 7) && spec.initial_buffer.is_empty() && spec.post_batch_resume_token.is_none() diff --git a/src/runtime/resolver.rs b/src/runtime/resolver.rs index bd75a9d09..94ba80123 100644 --- a/src/runtime/resolver.rs +++ b/src/runtime/resolver.rs @@ -1,3 +1,4 @@ +use crate::error::{Error, Result}; use hickory_resolver::{ config::ResolverConfig, error::ResolveErrorKind, @@ -5,7 +6,14 @@ use hickory_resolver::{ Name, }; -use crate::error::{Error, Result}; +#[cfg(feature = "gssapi-auth")] +use hickory_resolver::{ + lookup::{Lookup, ReverseLookup}, + lookup_ip::LookupIp, + proto::rr::RecordType, +}; +#[cfg(feature = "gssapi-auth")] +use std::net::IpAddr; /// An async runtime agnostic DNS resolver. pub(crate) struct AsyncResolver { @@ -25,6 +33,38 @@ impl AsyncResolver { } impl AsyncResolver { + #[cfg(feature = "gssapi-auth")] + pub async fn cname_lookup(&self, query: &str) -> Result { + let name = Name::from_str_relaxed(query).map_err(Error::from_resolve_proto_error)?; + let lookup = self + .resolver + .lookup(name, RecordType::CNAME) + .await + .map_err(Error::from_resolve_error)?; + Ok(lookup) + } + + #[cfg(feature = "gssapi-auth")] + pub async fn ip_lookup(&self, query: &str) -> Result { + let name = Name::from_str_relaxed(query).map_err(Error::from_resolve_proto_error)?; + let lookup = self + .resolver + .lookup_ip(name) + .await + .map_err(Error::from_resolve_error)?; + Ok(lookup) + } + + #[cfg(feature = "gssapi-auth")] + pub async fn reverse_lookup(&self, ip_addr: IpAddr) -> Result { + let lookup = self + .resolver + .reverse_lookup(ip_addr) + .await + .map_err(Error::from_resolve_error)?; + Ok(lookup) + } + pub async fn srv_lookup(&self, query: &str) -> Result { let name = Name::from_str_relaxed(query).map_err(Error::from_resolve_proto_error)?; let lookup = self diff --git a/src/serde_util.rs b/src/serde_util.rs index 35d8896c6..3a6f62e52 100644 --- a/src/serde_util.rs +++ b/src/serde_util.rs @@ -164,7 +164,7 @@ where pub(crate) fn write_concern_is_empty(write_concern: &Option) -> bool { write_concern .as_ref() - .map_or(true, |write_concern| write_concern.is_empty()) + .is_none_or(|write_concern| write_concern.is_empty()) } #[cfg(test)] diff --git a/src/test/spec/auth.rs b/src/test/spec/auth.rs index e19b72b64..db1962726 100644 --- a/src/test/spec/auth.rs +++ b/src/test/spec/auth.rs @@ -52,19 +52,20 @@ async fn run_auth_test(test_file: TestFile) { test_case.description = test_case.description.replace('$', "%"); let skipped_mechanisms = [ + #[cfg(not(feature = "gssapi-auth"))] "GSSAPI", "MONGODB-CR", #[cfg(not(feature = "aws-auth"))] "MONGODB-AWS", ]; - // TODO: GSSAPI (RUST-196) if skipped_mechanisms .iter() .any(|mech| test_case.description.contains(mech)) { continue; } + #[cfg(not(feature = "gssapi-auth"))] // This one's GSSAPI but doesn't include it in the description if test_case .description