mssql-auth 0.19.2

Authentication strategies for SQL Server connections
Documentation
//! Integrated authentication (Kerberos/SPNEGO) provider.
//!
//! This module provides Kerberos authentication using GSSAPI (Generic Security
//! Services Application Program Interface) for SQL Server connections on Linux
//! and macOS. Windows uses SSPI natively, but GSSAPI is compatible on the wire.
//!
//! ## Prerequisites
//!
//! - **Kerberos libraries**: libkrb5-dev (Debian/Ubuntu) or krb5-devel (RHEL/Fedora)
//! - **Valid Kerberos ticket**: Run `kinit user@REALM` before connecting
//! - **DNS/SPN configuration**: SQL Server SPN must be registered in AD
//!
//! ## Example
//!
//! ```rust,ignore
//! use mssql_auth::IntegratedAuth;
//!
//! // Create authenticator for SQL Server
//! let auth = IntegratedAuth::new("sqlserver.example.com", 1433)?;
//!
//! // Start authentication (returns initial SPNEGO token)
//! let initial_token = auth.initialize()?;
//!
//! // Process server's response and get next token (if needed)
//! let response_token = auth.step(&server_token)?;
//! ```
//!
//! ## How It Works
//!
//! SQL Server integrated authentication uses SPNEGO (Simple and Protected
//! GSS-API Negotiation) as specified in [RFC 4178](https://tools.ietf.org/html/rfc4178).
//! The TDS protocol carries SSPI tokens in packet type 0x11.
//!
//! 1. Client sends Login7 with integrated auth flag
//! 2. Server responds with SSPI challenge
//! 3. Client processes challenge via GSSAPI, sends response
//! 4. Server validates and completes authentication

use std::sync::Mutex;

use libgssapi::{
    context::{ClientCtx, CtxFlags},
    credential::{Cred, CredUsage},
    name::Name,
    oid::{GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL, OidSet},
};

use crate::error::AuthError;
use crate::provider::{AuthData, AuthMethod, AuthProvider};

/// SPNEGO mechanism OID for negotiating authentication.
///
/// SPNEGO allows the client and server to negotiate which underlying
/// mechanism to use (typically Kerberos 5).
const GSS_MECH_SPNEGO: libgssapi::oid::Oid = libgssapi::oid::Oid::from_slice(&[
    0x2b, 0x06, 0x01, 0x05, 0x05, 0x02, // 1.3.6.1.5.5.2
]);

/// Integrated authentication provider using Kerberos/SPNEGO.
///
/// This provider implements GSSAPI-based authentication for SQL Server,
/// compatible with Windows integrated authentication (SSPI).
///
/// # Thread Safety
///
/// The GSSAPI context is wrapped in a Mutex for thread safety, though
/// authentication is typically single-threaded per connection.
pub struct IntegratedAuth {
    /// The target service principal name string (e.g., "MSSQLSvc/host:port").
    spn: String,
    /// GSSAPI client context, wrapped for interior mutability.
    context: Mutex<Option<ClientCtx>>,
    /// Whether authentication has completed.
    complete: Mutex<bool>,
}

impl IntegratedAuth {
    /// Create a new integrated authentication provider.
    ///
    /// # Arguments
    ///
    /// * `hostname` - The SQL Server hostname (must match SPN in Active Directory)
    /// * `port` - The SQL Server port (typically 1433)
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// let auth = IntegratedAuth::new("sqlserver.contoso.com", 1433);
    /// ```
    #[must_use]
    pub fn new(hostname: &str, port: u16) -> Self {
        // SQL Server service principal format: MSSQLSvc/hostname:port
        let spn = format!("MSSQLSvc/{hostname}:{port}");

        Self {
            spn,
            context: Mutex::new(None),
            complete: Mutex::new(false),
        }
    }

    /// Create with a custom service principal name.
    ///
    /// Use this when the SPN doesn't follow the standard format,
    /// such as when using a SQL Server alias or cluster name.
    ///
    /// # Arguments
    ///
    /// * `spn` - The full service principal name
    #[must_use]
    pub fn with_spn(spn: impl Into<String>) -> Self {
        Self {
            spn: spn.into(),
            context: Mutex::new(None),
            complete: Mutex::new(false),
        }
    }

    /// Create a GSSAPI Name from the stored SPN.
    ///
    /// The SPN is a Kerberos principal in `MSSQLSvc/host:port` form, so it
    /// must be imported with the krb5 principal name-type. Importing it as
    /// `GSS_NT_HOSTBASED_SERVICE` (the `service@host` form) makes GSSAPI treat
    /// the whole `MSSQLSvc/host:port` as the service and append the local
    /// hostname, yielding a principal the KDC cannot resolve.
    fn create_service_name(&self) -> Result<Name, AuthError> {
        Name::new(self.spn.as_bytes(), Some(GSS_NT_KRB5_PRINCIPAL))
            .map_err(|e| AuthError::Sspi(format!("Failed to create service name: {e}")))
    }

    /// Initialize the GSSAPI context and get the initial token.
    ///
    /// This must be called first to start the authentication handshake.
    /// The returned token should be sent to the server.
    ///
    /// # Errors
    ///
    /// Returns an error if credential acquisition or context initialization fails.
    pub fn initialize(&self) -> Result<Vec<u8>, AuthError> {
        // Create service name from stored SPN
        let service_name = self.create_service_name()?;

        // Acquire default credentials from the Kerberos ticket cache
        let mut mechs = OidSet::new();

        // Add SPNEGO mechanism for negotiation
        mechs
            .add(GSS_MECH_SPNEGO)
            .map_err(|e| AuthError::Sspi(format!("Failed to add SPNEGO mechanism: {e}")))?;

        // Also add Kerberos as fallback
        mechs
            .add(GSS_MECH_KRB5)
            .map_err(|e| AuthError::Sspi(format!("Failed to add Kerberos mechanism: {e}")))?;

        let cred = Cred::acquire(None, None, CredUsage::Initiate, Some(&mechs))
            .map_err(|e| AuthError::Sspi(format!("Failed to acquire credentials: {e}")))?;

        // Create client context with mutual authentication flag
        let mut ctx = ClientCtx::new(
            Some(cred),
            service_name,
            CtxFlags::GSS_C_MUTUAL_FLAG | CtxFlags::GSS_C_REPLAY_FLAG,
            Some(GSS_MECH_SPNEGO),
        );

        // Get initial token
        let token = ctx
            .step(None, None)
            .map_err(|e| AuthError::Sspi(format!("Failed to initialize context: {e}")))?
            .ok_or_else(|| {
                AuthError::Sspi("No initial token generated (context already complete?)".into())
            })?;

        // Store the context for subsequent steps
        let mut context_guard = self
            .context
            .lock()
            .map_err(|_| AuthError::Sspi("Failed to acquire context lock".into()))?;
        *context_guard = Some(ctx);

        Ok(token.to_vec())
    }

    /// Process a server token and generate a response.
    ///
    /// Call this method each time the server sends an SSPI token.
    /// If the return value is `None`, authentication is complete.
    ///
    /// # Arguments
    ///
    /// * `server_token` - The SSPI token received from the server
    ///
    /// # Errors
    ///
    /// Returns an error if the context step fails or the context
    /// hasn't been initialized.
    pub fn step(&self, server_token: &[u8]) -> Result<Option<Vec<u8>>, AuthError> {
        let mut context_guard = self
            .context
            .lock()
            .map_err(|_| AuthError::Sspi("Failed to acquire context lock".into()))?;

        let ctx = context_guard.as_mut().ok_or_else(|| {
            AuthError::Sspi("Context not initialized - call initialize() first".into())
        })?;

        match ctx.step(Some(server_token), None) {
            Ok(Some(token)) => Ok(Some(token.to_vec())),
            Ok(None) => {
                // Authentication complete
                let mut complete_guard = self
                    .complete
                    .lock()
                    .map_err(|_| AuthError::Sspi("Failed to acquire complete lock".into()))?;
                *complete_guard = true;
                Ok(None)
            }
            Err(e) => Err(AuthError::Sspi(format!("GSSAPI step failed: {e}"))),
        }
    }

    /// Check if authentication has completed successfully.
    pub fn is_complete(&self) -> bool {
        self.complete.lock().map(|guard| *guard).unwrap_or(false)
    }

    /// Get the negotiated mechanism OID (after authentication completes).
    ///
    /// This indicates which mechanism (Kerberos, NTLM, etc.) was actually used.
    pub fn negotiated_mechanism(&self) -> Option<String> {
        self.context.lock().ok().and_then(|guard| {
            guard.as_ref().map(|_ctx| {
                // Note: libgssapi doesn't expose mech_type directly
                // after negotiation in a convenient way
                "SPNEGO/Kerberos".to_string()
            })
        })
    }
}

impl std::fmt::Debug for IntegratedAuth {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("IntegratedAuth")
            .field("complete", &self.is_complete())
            .finish_non_exhaustive()
    }
}

impl crate::negotiator::SspiNegotiator for IntegratedAuth {
    fn initialize(&self) -> Result<Vec<u8>, AuthError> {
        IntegratedAuth::initialize(self)
    }

    fn step(&self, server_token: &[u8]) -> Result<Option<Vec<u8>>, AuthError> {
        IntegratedAuth::step(self, server_token)
    }

    fn is_complete(&self) -> bool {
        IntegratedAuth::is_complete(self)
    }
}

impl AuthProvider for IntegratedAuth {
    fn method(&self) -> AuthMethod {
        AuthMethod::Integrated
    }

    fn authenticate(&self) -> Result<AuthData, AuthError> {
        // Generate initial SSPI blob
        let blob = self.initialize()?;
        Ok(AuthData::Sspi { blob })
    }
}

// Note: IntegratedAuth is not Clone because GSSAPI contexts are stateful
// and cannot be cloned. Each connection needs its own authenticator.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_service_name_format() {
        // This test verifies the SPN format
        let auth = IntegratedAuth::new("sqlserver.example.com", 1433);
        assert_eq!(auth.spn, "MSSQLSvc/sqlserver.example.com:1433");
    }

    #[test]
    fn test_custom_spn() {
        let auth = IntegratedAuth::with_spn("MSSQLSvc/cluster.example.com:1433");
        assert_eq!(auth.spn, "MSSQLSvc/cluster.example.com:1433");
    }

    #[test]
    fn test_debug_output() {
        let auth = IntegratedAuth::new("test.example.com", 1433);
        let debug = format!("{auth:?}");
        assert!(debug.contains("IntegratedAuth"));
    }

    #[test]
    fn test_is_complete_initially_false() {
        let auth = IntegratedAuth::new("test.example.com", 1433);
        assert!(!auth.is_complete());
    }

    /// `create_service_name()` must build a GSSAPI `Name` for the SPN the
    /// default constructor produces. This exercises the name-construction path
    /// in CI without a KDC.
    ///
    /// Note: the GSSAPI *name-type* (krb5 principal vs host-based service)
    /// cannot be distinguished here — `gss_import_name` accepts the bytes
    /// under either OID and only the KDC rejects a wrongly-typed principal
    /// when a ticket is requested. The name-type is therefore proven by the
    /// `kerberos_live` end-to-end test; this guard catches a regression that
    /// breaks name construction outright.
    #[test]
    fn test_create_service_name_succeeds() {
        let auth = IntegratedAuth::new("localhost", 1433);
        let name = auth.create_service_name();
        assert!(
            name.is_ok(),
            "create_service_name() must build a GSSAPI Name for the default SPN: {:?}",
            name.err()
        );
    }
}