Skip to main content

krabka_client_core/
sasl.rs

1//! Transport-agnostic outbound SASL handshake for the client.
2//!
3//! [`outbound_sasl`] drives the client side of Kafka's
4//! `SaslHandshake` + `SaslAuthenticate` exchange over any
5//! `AsyncRead + AsyncWrite` stream. That stream can be a plaintext
6//! `TcpStream`, a `tokio_rustls` TLS stream, or an in-process duplex for
7//! tests. It supports PLAIN with one round-trip, SCRAM-SHA-256/512 with two
8//! round-trips and server-final verification, OAUTHBEARER with the RFC 7628
9//! success/failure exchange, and GSSAPI with multi-round AP-REQ / AP-REP plus
10//! RFC 4752 security-layer negotiation. The GSSAPI client needs the `gssapi`
11//! Cargo feature and an operating system: a build without the feature, or for
12//! WebAssembly, fails GSSAPI with
13//! [`SaslAuthenticationError::UnsupportedMechanism`].
14//!
15//! This is the shared implementation the broker's inter-broker dialer
16//! and the public clients both call. The only difference is the
17//! credentials value and the reporter `client_id`.
18
19mod scram;
20
21use std::{
22    collections::BTreeMap,
23    fmt,
24    future::Future,
25    io,
26    path::{Path, PathBuf},
27    sync::{
28        Arc,
29        atomic::{AtomicI64, Ordering},
30    },
31};
32#[cfg(target_os = "wasi")]
33use std::{
34    fs,
35    future::{Ready, ready},
36};
37
38use bytes::{Buf, BufMut, BytesMut};
39use krabka_ids::{ApiKey, ApiVersion};
40#[cfg(test)]
41use krabka_protocol::owned::api_versions_response;
42use krabka_protocol::{
43    Decode, Encode,
44    owned::{
45        api_versions_request::ApiVersionsRequest, api_versions_response::ApiVersionsResponse,
46        sasl_authenticate_request, sasl_authenticate_request::SaslAuthenticateRequest,
47        sasl_authenticate_response::SaslAuthenticateResponse, sasl_handshake_request,
48        sasl_handshake_request::SaslHandshakeRequest,
49        sasl_handshake_response::SaslHandshakeResponse,
50    },
51};
52use krabka_security::SaslMechanism;
53use thiserror::Error;
54#[cfg(not(target_os = "wasi"))]
55use tokio::fs;
56use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
57#[cfg(all(feature = "gssapi", not(target_family = "wasm")))]
58use tokio::task::spawn_blocking;
59
60use crate::{ClientFrameMax, connection::skip_tagged_fields, oauth};
61
62const API_KEY_SASL_HANDSHAKE: i16 = 17;
63const API_KEY_SASL_AUTHENTICATE: i16 = 36;
64const API_KEY_API_VERSIONS: i16 = 18;
65
66/// Kafka's `SaslClientAuthenticator.MAX_RESERVED_CORRELATION_ID`.
67const MAX_RESERVED_CORRELATION_ID: i32 = i32::MAX;
68/// Kafka's `SaslClientAuthenticator.MIN_RESERVED_CORRELATION_ID`. The SASL
69/// requests of a connection use the reserved ids, so they never share an id
70/// with a normal request.
71pub(crate) const MIN_RESERVED_CORRELATION_ID: i32 = MAX_RESERVED_CORRELATION_ID - 7;
72
73/// The next reserved SASL correlation id, as Kafka's
74/// `SaslClientAuthenticator.nextCorrelationId` gives it.
75fn next_correlation_id(corr_id: &mut i32) -> i32 {
76    if *corr_id < MIN_RESERVED_CORRELATION_ID {
77        *corr_id = MIN_RESERVED_CORRELATION_ID;
78    }
79    let current = *corr_id;
80    *corr_id = corr_id.wrapping_add(1);
81    current
82}
83
84#[derive(Clone, Copy)]
85pub(crate) struct SaslPolicy<'a> {
86    client_id: &'a str,
87    frame_max: ClientFrameMax,
88    /// The `SaslAuthenticate` version, or `None` when the broker does not list
89    /// `SaslAuthenticate` and the tokens go without a Kafka header
90    /// (`DISABLE_KAFKA_SASL_AUTHENTICATE_HEADER`).
91    authenticate_version: Option<i16>,
92    /// The last positive `session_lifetime_ms` of a `SaslAuthenticate`
93    /// response, as Kafka's `ReauthInfo.positiveSessionLifetimeMs` holds it.
94    session_lifetime_ms: &'a AtomicI64,
95}
96
97/// The request path of one SASL exchange: a stream before the connection
98/// carries other requests, or a live connection for re-authentication
99/// (KIP-368).
100pub(crate) trait SaslChannel: Send {
101    /// Send one framed Kafka request and return the response body after the
102    /// response header.
103    fn request(
104        &mut self,
105        header: (ApiKey, ApiVersion, i32),
106        flexible: bool,
107        body: &[u8],
108        policy: SaslPolicy<'_>,
109    ) -> impl Future<Output = Result<Vec<u8>, OutboundSaslError>> + Send;
110
111    /// Send a size-prefixed token with no Kafka header and return the answer.
112    fn token(
113        &mut self,
114        token: &[u8],
115        frame_max: ClientFrameMax,
116    ) -> impl Future<Output = Result<SaslAuthenticateResponse, OutboundSaslError>> + Send;
117}
118
119impl<S> SaslChannel for S
120where
121    S: AsyncRead + AsyncWrite + Unpin + Send + ?Sized,
122{
123    async fn request(
124        &mut self,
125        (api_key, api_version, corr_id): (ApiKey, ApiVersion, i32),
126        flexible: bool,
127        body: &[u8],
128        policy: SaslPolicy<'_>,
129    ) -> Result<Vec<u8>, OutboundSaslError> {
130        round_trip(self, api_key, api_version, corr_id, flexible, body, policy).await
131    }
132
133    async fn token(
134        &mut self,
135        token: &[u8],
136        frame_max: ClientFrameMax,
137    ) -> Result<SaslAuthenticateResponse, OutboundSaslError> {
138        send_raw_token(self, token, frame_max).await
139    }
140}
141
142/// What a completed SASL exchange leaves for re-authentication.
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub struct SaslSession {
145    /// The `SaslHandshake` version of the exchange.
146    pub handshake_version: i16,
147    /// The `SaslAuthenticate` version, or `None` for tokens with no Kafka
148    /// header.
149    pub authenticate_version: Option<i16>,
150    /// The positive session lifetime that the broker sent, or `None` when the
151    /// broker does not require re-authentication.
152    pub session_lifetime_ms: Option<i64>,
153}
154
155impl SaslSession {
156    /// Whether the connection must re-authenticate before the session ends.
157    /// Kafka's `SaslClientAuthenticator` re-authenticates only with a positive
158    /// session lifetime, which `SaslAuthenticate` v1 and later carry.
159    #[must_use]
160    pub fn needs_reauthentication(&self) -> bool {
161        self.session_lifetime_ms.is_some() && self.authenticate_version.is_some_and(|v| v >= 1)
162    }
163}
164
165/// The `SaslHandshake` and `SaslAuthenticate` versions for a broker's
166/// `ApiVersions` answer, as Kafka's
167/// `SaslClientAuthenticator.setSaslAuthenticateAndHandshakeVersions` picks
168/// them: the highest version of each that both sides support. With no
169/// `SaslHandshake` entry the handshake uses version 0, and with no
170/// `SaslAuthenticate` entry the tokens go without a Kafka header.
171fn sasl_versions(response: &ApiVersionsResponse) -> (i16, Option<i16>) {
172    let max = |api_key: i16| {
173        response
174            .api_keys
175            .iter()
176            .find(|key| key.api_key == api_key)
177            .map(|key| key.max_version)
178    };
179    let handshake = max(API_KEY_SASL_HANDSHAKE).map_or(0, |version| {
180        version.min(sasl_handshake_request::MAX_VERSION)
181    });
182    let authenticate = max(API_KEY_SASL_AUTHENTICATE)
183        .map(|version| version.min(sasl_authenticate_request::MAX_VERSION));
184    (handshake, authenticate)
185}
186
187/// Maximum receive size advertised in the client's RFC 4752 security-layer
188/// choice. Auth-only QOP wraps no data after the handshake, so the value only
189/// needs to be a reasonable non-zero buffer. It mirrors the server's offer
190/// size.
191#[cfg(all(feature = "gssapi", not(target_family = "wasm")))]
192const GSSAPI_MAX_RECV: krabka_units::ByteSize = krabka_units::kibibytes(64);
193
194/// Outbound SASL credentials.
195///
196/// This enum mirrors the broker's `InterBrokerCredentials`. It has one
197/// variant per supported mechanism.
198///
199/// `Debug` prints each password as `[hidden]`, as Kafka's
200/// `org.apache.kafka.common.config.types.Password` does, so logging a
201/// credential set or an error that holds one never shows the secret.
202#[derive(Clone)]
203pub enum SaslCredentials {
204    /// SASL/PLAIN: `\0username\0password`.
205    Plain { username: String, password: String },
206    /// SASL/SCRAM (SHA-256 or SHA-512).
207    Scram {
208        mechanism: SaslMechanism,
209        username: String,
210        password: String,
211        /// Log in with a delegation token (KIP-48): `username` is the token
212        /// id and `password` the token HMAC. The client-first message then
213        /// carries the `tokenauth=true` extension, as Kafka's
214        /// `ScramLoginModule` with `tokenauth=true` sends it.
215        delegation_token: bool,
216    },
217    /// SASL/GSSAPI: authenticate as `client_principal` with the long-term
218    /// key in `keytab_path`. This mechanism needs no password. A build without
219    /// the `gssapi` feature, or for WebAssembly, rejects it in
220    /// [`ConnectionOptions::validate`](crate::ConnectionOptions::validate) and
221    /// fails the handshake with [`SaslAuthenticationError::UnsupportedMechanism`].
222    Gssapi {
223        keytab_path: PathBuf,
224        client_principal: String,
225        service_name: String,
226        kdc_url: String,
227    },
228    /// SASL/OAUTHBEARER (RFC 7628).
229    OAuthBearer {
230        /// Where each exchange gets its token.
231        token: OAuthBearerTokenSource,
232        /// SASL extensions (KIP-342) that the initial client response carries,
233        /// such as `logicalCluster`. A name is letters only and must not be
234        /// `auth`, and a value is printable ASCII, space, tab, CR or LF, as
235        /// Kafka's `OAuthBearerClientInitialResponse.validateExtensions`
236        /// requires.
237        extensions: BTreeMap<String, String>,
238    },
239}
240
241/// Where an OAUTHBEARER exchange gets its token.
242#[derive(Debug, Clone)]
243pub enum OAuthBearerTokenSource {
244    /// A file with an RFC 6750 bearer token. Each exchange reads the file
245    /// again, so token rotation needs no restart.
246    File(PathBuf),
247    /// A token provider, such as
248    /// [`ClientCredentialsTokenProvider`](oauth::ClientCredentialsTokenProvider)
249    /// for an OIDC token endpoint (KIP-768).
250    Provider(Arc<dyn oauth::OAuthBearerTokenProvider>),
251}
252
253impl fmt::Debug for SaslCredentials {
254    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
255        const HIDDEN: &str = "[hidden]";
256        match self {
257            Self::Plain { username, .. } => formatter
258                .debug_struct("Plain")
259                .field("username", username)
260                .field("password", &format_args!("{HIDDEN}"))
261                .finish(),
262            Self::Scram {
263                mechanism,
264                username,
265                delegation_token,
266                ..
267            } => formatter
268                .debug_struct("Scram")
269                .field("mechanism", mechanism)
270                .field("username", username)
271                .field("password", &format_args!("{HIDDEN}"))
272                .field("delegation_token", delegation_token)
273                .finish(),
274            Self::Gssapi {
275                keytab_path,
276                client_principal,
277                service_name,
278                kdc_url,
279            } => formatter
280                .debug_struct("Gssapi")
281                .field("keytab_path", keytab_path)
282                .field("client_principal", client_principal)
283                .field("service_name", service_name)
284                .field("kdc_url", kdc_url)
285                .finish(),
286            Self::OAuthBearer { token, extensions } => formatter
287                .debug_struct("OAuthBearer")
288                .field("token", token)
289                .field("extensions", extensions)
290                .finish(),
291        }
292    }
293}
294
295impl SaslCredentials {
296    /// The SASL mechanism this credential set authenticates with.
297    #[must_use]
298    pub fn mechanism(&self) -> SaslMechanism {
299        match self {
300            Self::Plain { .. } => SaslMechanism::Plain,
301            Self::Scram { mechanism, .. } => *mechanism,
302            Self::Gssapi { .. } => SaslMechanism::Gssapi,
303            Self::OAuthBearer { .. } => SaslMechanism::OAuthBearer,
304        }
305    }
306}
307
308/// Kafka `Errors.UNSUPPORTED_SASL_MECHANISM`.
309const UNSUPPORTED_SASL_MECHANISM: i16 = 33;
310/// Kafka `Errors.ILLEGAL_SASL_STATE`.
311const ILLEGAL_SASL_STATE: i16 = 34;
312/// Kafka `Errors.SASL_AUTHENTICATION_FAILED`.
313const SASL_AUTHENTICATION_FAILED: i16 = 58;
314
315/// Errors raised during the outbound SASL handshake.
316///
317/// Only [`OutboundSaslError::Authentication`] is a rejection. The other
318/// variants give no verdict from the broker, and the caller can connect again.
319#[derive(Debug, Error)]
320pub enum OutboundSaslError {
321    /// The stream failed before the broker answered, for example on EOF or a
322    /// reset. Kafka's `NetworkClient` keeps a disconnect in the `AUTHENTICATE`
323    /// state retriable.
324    #[error("io: {0}")]
325    Io(#[from] io::Error),
326    /// The client could not encode a request, or a response frame was larger
327    /// than the frame limit.
328    #[error("codec: {0}")]
329    Codec(String),
330    /// The broker answered `SaslAuthenticate` with an error code that is not
331    /// an `AuthenticationException` in Kafka. Kafka's `Selector` closes the
332    /// connection in the `AUTHENTICATE` state, and the client retries.
333    #[error("SaslAuthenticate error_code={error_code} error_message={error_message:?}")]
334    Server {
335        error_code: i16,
336        error_message: Option<String>,
337    },
338    /// The broker or the SASL mechanism rejected authentication.
339    #[error(transparent)]
340    Authentication(#[from] SaslAuthenticationError),
341}
342
343/// A SASL authentication rejection. Each variant is a subclass of Kafka's
344/// `AuthenticationException`, which the client raises to the application
345/// without a retry.
346#[derive(Clone, Debug, Error, PartialEq, Eq)]
347pub enum SaslAuthenticationError {
348    /// Kafka `UnsupportedSaslMechanismException`: the broker does not enable
349    /// the client mechanism (`UNSUPPORTED_SASL_MECHANISM`, 33).
350    #[error("unsupported SASL mechanism: {0}")]
351    UnsupportedMechanism(String),
352    /// Kafka `IllegalSaslStateException`: `ILLEGAL_SASL_STATE` (34), an
353    /// unknown `SaslHandshake` error code, or a response that the client
354    /// cannot parse.
355    #[error("illegal SASL state: {0}")]
356    IllegalState(String),
357    /// Kafka `SaslAuthenticationException`: `SASL_AUTHENTICATION_FAILED` (58),
358    /// or a failure of the mechanism on the client.
359    #[error("SASL authentication failed: {0}")]
360    Failed(String),
361}
362
363/// Map a `SaslAuthenticate` response with a non-zero error code to the error
364/// that Kafka's `SaslClientAuthenticator.receiveToken` raises
365/// (`Errors.exception`).
366fn authenticate_error(mechanism: &str, response: &SaslAuthenticateResponse) -> OutboundSaslError {
367    let message = format!(
368        "SaslAuthenticate({mechanism}) error_code={} error_message={:?}",
369        response.error_code, response.error_message
370    );
371    match response.error_code {
372        UNSUPPORTED_SASL_MECHANISM => SaslAuthenticationError::UnsupportedMechanism(message).into(),
373        ILLEGAL_SASL_STATE => SaslAuthenticationError::IllegalState(message).into(),
374        SASL_AUTHENTICATION_FAILED => SaslAuthenticationError::Failed(message).into(),
375        error_code => OutboundSaslError::Server {
376            error_code,
377            error_message: response.error_message.clone(),
378        },
379    }
380}
381
382/// A failure of the mechanism on the client. Kafka's
383/// `SaslClientAuthenticator.createSaslToken` raises it as a
384/// `SaslAuthenticationException`.
385fn mechanism_failure(message: String) -> OutboundSaslError {
386    SaslAuthenticationError::Failed(message).into()
387}
388
389/// A response that the client cannot parse. Kafka's
390/// `SaslClientAuthenticator.receiveKafkaResponse` raises it as an
391/// `IllegalSaslStateException`.
392fn unparsable_response(message: String) -> OutboundSaslError {
393    SaslAuthenticationError::IllegalState(message).into()
394}
395
396/// Run the outbound SASL handshake to completion over `stream`.
397///
398/// This function sends `ApiVersions` v0, then `SaslHandshake` with the
399/// mechanism in `creds` at the version that the broker lists, then drives the
400/// mechanism-specific `SaslAuthenticate` round-trips, as Kafka's
401/// `SaslClientAuthenticator` does. The requests use Kafka's reserved SASL
402/// correlation ids. `server_name`
403/// is the broker's canonical hostname. Only the GSSAPI path uses it, to build
404/// the target SPN (`service_name/server_name`). The function returns once the
405/// broker has accepted the credentials. The same `stream` is then usable for
406/// normal Kafka RPCs.
407///
408/// # Errors
409///
410/// Returns [`OutboundSaslError`] on I/O failure, codec failure, or a
411/// non-zero broker SASL error code.
412pub async fn outbound_sasl<S>(
413    stream: &mut S,
414    creds: &SaslCredentials,
415    server_name: &str,
416    client_id: &str,
417    frame_max: ClientFrameMax,
418) -> Result<SaslSession, OutboundSaslError>
419where
420    S: AsyncRead + AsyncWrite + Unpin + Send + ?Sized,
421{
422    let mut corr_id = MIN_RESERVED_CORRELATION_ID;
423    // Step 1: ApiVersions v0, as Kafka's `SaslClientAuthenticator` sends it
424    //         first. A broker reads a request that it cannot parse as a
425    //         GSSAPI token, so the client uses version 0.
426    let api_versions = send_api_versions(stream, &mut corr_id, client_id, frame_max).await?;
427    let (handshake_version, authenticate_version) = sasl_versions(&api_versions);
428    // Steps 2 and 3: SaslHandshake and SaslAuthenticate.
429    authenticate(
430        stream,
431        creds,
432        server_name,
433        SaslSession {
434            handshake_version,
435            authenticate_version,
436            session_lifetime_ms: None,
437        },
438        (&mut corr_id, client_id, frame_max),
439    )
440    .await
441}
442
443/// Run `SaslHandshake` and the `SaslAuthenticate` rounds of `creds` over
444/// `channel` at the versions of `versions`, and return the new session.
445///
446/// A re-authentication (KIP-368) calls this with the versions of the first
447/// exchange, as Kafka's `SaslClientAuthenticator.reauthenticate` uses the
448/// original `ApiVersions` response.
449pub(crate) async fn authenticate<C>(
450    channel: &mut C,
451    creds: &SaslCredentials,
452    server_name: &str,
453    versions: SaslSession,
454    (corr_id, client_id, frame_max): (&mut i32, &str, ClientFrameMax),
455) -> Result<SaslSession, OutboundSaslError>
456where
457    C: SaslChannel + ?Sized,
458{
459    let session_lifetime_ms = AtomicI64::new(0);
460    let policy = SaslPolicy {
461        client_id,
462        frame_max,
463        authenticate_version: versions.authenticate_version,
464        session_lifetime_ms: &session_lifetime_ms,
465    };
466    // SaslHandshake with the chosen mechanism, at the version that
467    // ApiVersions allows.
468    send_sasl_handshake(
469        channel,
470        creds.mechanism(),
471        versions.handshake_version,
472        corr_id,
473        policy,
474    )
475    .await?;
476    // SaslAuthenticate (one round for PLAIN, two for SCRAM, three for GSSAPI).
477    match creds {
478        SaslCredentials::Plain { username, password } => {
479            send_plain_authenticate(channel, username, password, corr_id, policy).await
480        }
481        SaslCredentials::Scram {
482            mechanism,
483            username,
484            password,
485            delegation_token,
486        } => {
487            run_scram_client(
488                channel,
489                (username, password, *delegation_token),
490                *mechanism,
491                corr_id,
492                policy,
493            )
494            .await
495        }
496        #[cfg(all(feature = "gssapi", not(target_family = "wasm")))]
497        SaslCredentials::Gssapi {
498            keytab_path,
499            client_principal,
500            service_name,
501            kdc_url,
502        } => {
503            run_gssapi_client(
504                channel,
505                keytab_path,
506                client_principal,
507                (service_name, server_name),
508                kdc_url,
509                corr_id,
510                policy,
511            )
512            .await
513        }
514        // The Kerberos client of `krabka-security` needs the `gssapi` feature
515        // and an operating system.
516        #[cfg(not(all(feature = "gssapi", not(target_family = "wasm"))))]
517        SaslCredentials::Gssapi { .. } => Err(SaslAuthenticationError::UnsupportedMechanism(
518            format!("GSSAPI to {server_name}: {GSSAPI_UNAVAILABLE}"),
519        )
520        .into()),
521        SaslCredentials::OAuthBearer { token, extensions } => {
522            // Each exchange gets the token again, so a re-authentication sends
523            // a refreshed token.
524            let token = match token {
525                OAuthBearerTokenSource::File(token_path) => {
526                    read_token_file(token_path).await.map_err(|error| {
527                        mechanism_failure(format!(
528                            "cannot read OAUTHBEARER token {}: {error}",
529                            token_path.display()
530                        ))
531                    })?
532                }
533                OAuthBearerTokenSource::Provider(provider) => provider
534                    .token()
535                    .await
536                    .map_err(|error| {
537                        mechanism_failure(format!("cannot get an OAUTHBEARER token: {error}"))
538                    })?
539                    .into_bytes(),
540            };
541            run_oauthbearer_client(channel, token.trim_ascii(), extensions, corr_id, policy).await
542        }
543    }?;
544    let lifetime = session_lifetime_ms.load(Ordering::Relaxed);
545    Ok(SaslSession {
546        session_lifetime_ms: (lifetime > 0).then_some(lifetime),
547        ..versions
548    })
549}
550
551/// Read the OAUTHBEARER token file at `path` on the blocking pool.
552#[cfg(not(target_os = "wasi"))]
553async fn read_token_file(path: &Path) -> io::Result<Vec<u8>> {
554    fs::read(path).await
555}
556
557/// Read the OAUTHBEARER token file at `path` on the calling thread. WASI has
558/// no threads for a blocking pool, and a token file is small.
559#[cfg(target_os = "wasi")]
560fn read_token_file(path: &Path) -> Ready<io::Result<Vec<u8>>> {
561    ready(fs::read(path))
562}
563
564/// Run the RFC 7628 OAUTHBEARER client exchange.
565///
566/// Success is one `SaslAuthenticate` round with empty server `auth_bytes`.
567/// On rejection the server returns RFC 7628 error JSON with `error_code = 0`;
568/// the client must send a single `\x01` final message before surfacing the
569/// authentication failure.
570async fn run_oauthbearer_client<S>(
571    stream: &mut S,
572    token: &[u8],
573    extensions: &BTreeMap<String, String>,
574    corr_id: &mut i32,
575    policy: SaslPolicy<'_>,
576) -> Result<(), OutboundSaslError>
577where
578    S: SaslChannel + ?Sized,
579{
580    if token.is_empty() || token.contains(&b'\x01') {
581        return Err(mechanism_failure(
582            "OAUTHBEARER token must be non-empty and contain no RFC 7628 separator".into(),
583        ));
584    }
585
586    let initial = oauthbearer_initial_response(token, extensions)?;
587
588    let response = send_sasl_authenticate(stream, initial, corr_id, policy).await?;
589    if response.error_code != 0 {
590        return Err(authenticate_error("OAUTHBEARER", &response));
591    }
592    if response.auth_bytes.is_empty() {
593        return Ok(());
594    }
595
596    let final_response = send_sasl_authenticate(stream, vec![b'\x01'], corr_id, policy).await?;
597    if final_response.error_code != 0 {
598        return Err(authenticate_error("OAUTHBEARER", &final_response));
599    }
600    Err(mechanism_failure(format!(
601        "SaslAuthenticate(OAUTHBEARER) rejected bearer token: {}",
602        String::from_utf8_lossy(&response.auth_bytes)
603    )))
604}
605
606/// The initial client response of Kafka's
607/// `OAuthBearerClientInitialResponse.toBytes`:
608/// `n,,\x01auth=Bearer <token>[\x01key=value]...\x01\x01`.
609fn oauthbearer_initial_response(
610    token: &[u8],
611    extensions: &BTreeMap<String, String>,
612) -> Result<Vec<u8>, OutboundSaslError> {
613    for (name, value) in extensions {
614        if name == "auth" {
615            return Err(mechanism_failure("Extension name auth is invalid".into()));
616        }
617        if name.is_empty() || !name.bytes().all(|byte| byte.is_ascii_alphabetic()) {
618            return Err(mechanism_failure(format!(
619                "Extension name {name} is invalid"
620            )));
621        }
622        if value.is_empty()
623            || !value
624                .bytes()
625                .all(|byte| matches!(byte, 0x21..=0x7E | b' ' | b'\t' | b'\r' | b'\n'))
626        {
627            return Err(mechanism_failure(format!(
628                "Extension value ({value}) for extension {name} is invalid"
629            )));
630        }
631    }
632    let mut initial = Vec::with_capacity(token.len() + 20);
633    initial.extend_from_slice(b"n,,\x01auth=Bearer ");
634    initial.extend_from_slice(token);
635    for (name, value) in extensions {
636        initial.push(b'\x01');
637        initial.extend_from_slice(name.as_bytes());
638        initial.push(b'=');
639        initial.extend_from_slice(value.as_bytes());
640    }
641    initial.extend_from_slice(b"\x01\x01");
642    Ok(initial)
643}
644
645/// Send `ApiVersions` v0 and read the response.
646async fn send_api_versions<S>(
647    stream: &mut S,
648    corr_id: &mut i32,
649    client_id: &str,
650    frame_max: ClientFrameMax,
651) -> Result<ApiVersionsResponse, OutboundSaslError>
652where
653    S: AsyncRead + AsyncWrite + Unpin + Send + ?Sized,
654{
655    let mut body = BytesMut::new();
656    ApiVersionsRequest::default()
657        .encode(&mut body, 0)
658        .map_err(|e| OutboundSaslError::Codec(format!("ApiVersions encode: {e}")))?;
659    let resp_bytes = round_trip(
660        stream,
661        ApiKey(API_KEY_API_VERSIONS),
662        ApiVersion(0),
663        next_correlation_id(corr_id),
664        false,
665        &body,
666        SaslPolicy {
667            client_id,
668            frame_max,
669            authenticate_version: None,
670            session_lifetime_ms: &AtomicI64::new(0),
671        },
672    )
673    .await?;
674    let mut cur: &[u8] = &resp_bytes;
675    ApiVersionsResponse::decode(&mut cur, 0)
676        .map_err(|e| unparsable_response(format!("ApiVersions decode: {e}")))
677}
678
679/// Send `SaslHandshake` at `version` with the wire name for `mechanism`, read
680/// the response, and fail if `error_code != 0`.
681///
682/// Wire framing: `SaslHandshake` v0 and v1 use the non-flexible request
683/// header (v1, no trailing tagged-fields byte) and a non-flexible response
684/// header (v0, bare `correlation_id`).
685async fn send_sasl_handshake<S>(
686    stream: &mut S,
687    mechanism: SaslMechanism,
688    version: i16,
689    corr_id: &mut i32,
690    policy: SaslPolicy<'_>,
691) -> Result<(), OutboundSaslError>
692where
693    S: SaslChannel + ?Sized,
694{
695    let req = SaslHandshakeRequest {
696        mechanism: mechanism.wire_name().to_owned(),
697        ..Default::default()
698    };
699    let mut body = BytesMut::new();
700    req.encode(&mut body, version)
701        .map_err(|e| OutboundSaslError::Codec(format!("SaslHandshake encode: {e}")))?;
702    let resp_bytes = stream
703        .request(
704            (
705                ApiKey(API_KEY_SASL_HANDSHAKE),
706                ApiVersion(version),
707                next_correlation_id(corr_id),
708            ),
709            false,
710            &body,
711            policy,
712        )
713        .await?;
714    let mut cur: &[u8] = &resp_bytes;
715    let resp = SaslHandshakeResponse::decode(&mut cur, version)
716        .map_err(|e| unparsable_response(format!("SaslHandshake decode: {e}")))?;
717    handshake_result(mechanism, &resp)
718}
719
720/// Map a `SaslHandshake` response to the result that Kafka's
721/// `SaslClientAuthenticator.handleSaslHandshakeResponse` gives.
722fn handshake_result(
723    mechanism: SaslMechanism,
724    response: &SaslHandshakeResponse,
725) -> Result<(), OutboundSaslError> {
726    let mechanism = mechanism.wire_name();
727    let enabled = &response.mechanisms;
728    let error = match response.error_code {
729        0 => return Ok(()),
730        UNSUPPORTED_SASL_MECHANISM => SaslAuthenticationError::UnsupportedMechanism(format!(
731            "client SASL mechanism '{mechanism}' not enabled in the server, enabled mechanisms \
732             are {enabled:?}"
733        )),
734        ILLEGAL_SASL_STATE => SaslAuthenticationError::IllegalState(format!(
735            "unexpected handshake request with client mechanism {mechanism}, enabled mechanisms \
736             are {enabled:?}"
737        )),
738        code => SaslAuthenticationError::IllegalState(format!(
739            "unknown error code {code}, client mechanism is {mechanism}, enabled mechanisms are \
740             {enabled:?}"
741        )),
742    };
743    Err(error.into())
744}
745
746/// Send `SaslAuthenticate v2` with PLAIN payload `\0user\0password`, read
747/// the response, fail if `error_code != 0`. PLAIN is one round-trip.
748async fn send_plain_authenticate<S>(
749    stream: &mut S,
750    user: &str,
751    pass: &str,
752    corr_id: &mut i32,
753    policy: SaslPolicy<'_>,
754) -> Result<(), OutboundSaslError>
755where
756    S: SaslChannel + ?Sized,
757{
758    let mut payload = Vec::with_capacity(2 + user.len() + pass.len());
759    payload.push(0); // authzid (empty)
760    payload.extend_from_slice(user.as_bytes());
761    payload.push(0);
762    payload.extend_from_slice(pass.as_bytes());
763
764    let resp = send_sasl_authenticate(stream, payload, corr_id, policy).await?;
765    if resp.error_code != 0 {
766        return Err(authenticate_error("PLAIN", &resp));
767    }
768    Ok(())
769}
770
771/// Run the RFC 5802 SCRAM (SHA-256 or SHA-512) client state machine
772/// over two `SaslAuthenticate v2` round-trips.
773///
774/// This function verifies the server-final signature before it declares the
775/// connection authenticated.
776async fn run_scram_client<S>(
777    stream: &mut S,
778    (user, pass, delegation_token): (&str, &str, bool),
779    mechanism: SaslMechanism,
780    corr_id: &mut i32,
781    policy: SaslPolicy<'_>,
782) -> Result<(), OutboundSaslError>
783where
784    S: SaslChannel + ?Sized,
785{
786    // Round 1: client-first → server-first.
787    let (client_first, exchange) = scram::client_first(mechanism, user, pass, delegation_token)
788        .map_err(|e| mechanism_failure(format!("scram client_first: {e}")))?;
789    let resp1 = send_sasl_authenticate(stream, client_first, corr_id, policy).await?;
790    if resp1.error_code != 0 {
791        return Err(authenticate_error("SCRAM round 1", &resp1));
792    }
793
794    // Round 2: client-final → server-final.
795    let (client_final, exchange) = exchange
796        .step(&resp1.auth_bytes)
797        .map_err(|e| mechanism_failure(format!("scram client step: {e}")))?;
798    let resp2 = send_sasl_authenticate(stream, client_final, corr_id, policy).await?;
799    if resp2.error_code != 0 {
800        return Err(authenticate_error("SCRAM round 2", &resp2));
801    }
802    // Server-final verification proves the broker holds the matching
803    // `server_key`, not just any compatible `stored_key`.
804    exchange
805        .verify(&resp2.auth_bytes)
806        .map_err(|e| mechanism_failure(format!("server-final verify: {e}")))?;
807    Ok(())
808}
809
810/// Whether this build of krabka-client-core has the Kerberos client.
811///
812/// This is the crate's own `gssapi` feature, not `krabka-security/gssapi`:
813/// Cargo can turn the second on for another dependency while this crate's
814/// handshake still has no GSSAPI path.
815pub(crate) const GSSAPI_CLIENT: bool = cfg!(all(feature = "gssapi", not(target_family = "wasm")));
816
817/// Whether this build can authenticate with `mechanism`.
818pub(crate) fn mechanism_supported(mechanism: SaslMechanism) -> bool {
819    mechanism != SaslMechanism::Gssapi || GSSAPI_CLIENT
820}
821
822/// Why a GSSAPI credential fails in a build without the Kerberos client.
823pub(crate) const GSSAPI_UNAVAILABLE: &str = "this build has no Kerberos client; turn on the \
824     `gssapi` feature of krabka-client-core (or of the admin, consumer or producer crate) on a \
825     native target";
826
827/// Run the SASL/GSSAPI (Kerberos) client state machine over
828/// `SaslAuthenticate v2` round-trips.
829///
830/// This function builds an `sspi`-backed initiator that authenticates as
831/// `client_principal` with the long-term key in `keytab_path` and no password.
832/// The initiator targets the SPN `service_name/server_name` of the broker it
833/// dials. `server_name` is the broker's canonical hostname, the same value
834/// used for TLS SNI, and not the dialed IP. The SPN must match the service key
835/// in the broker's keytab.
836///
837/// The function then drives [`GssapiClientExchange`]: the GSS context
838/// establishment (AP-REQ → AP-REP) and then the RFC 4752 auth-only
839/// security-layer negotiation. It sends each non-terminal client token as
840/// request `auth_bytes`. The server's reply token feeds the next step until
841/// the exchange reports `Done`.
842///
843/// The first initiator step does the synchronous AS/TGS exchange with the
844/// KDC. Later steps only process tokens locally.
845#[cfg(all(feature = "gssapi", not(target_family = "wasm")))]
846async fn run_gssapi_client<S>(
847    stream: &mut S,
848    keytab_path: &Path,
849    client_principal: &str,
850    service_and_server_name: (&str, &str),
851    kdc_url: &str,
852    corr_id: &mut i32,
853    policy: SaslPolicy<'_>,
854) -> Result<(), OutboundSaslError>
855where
856    S: SaslChannel + ?Sized,
857{
858    use krabka_security::gssapi::{
859        client::{ClientStep, GssapiClientExchange},
860        provider::SspiInitiator,
861    };
862
863    let (service_name, server_name) = service_and_server_name;
864    let target_spn = format!("{service_name}/{server_name}");
865    let keytab = keytab_path.to_string_lossy().into_owned();
866    let (client_principal, kdc_url) = (client_principal.to_owned(), kdc_url.to_owned());
867    // The first step runs the synchronous AS and TGS exchanges with the KDC.
868    // A blocking worker runs it, so a slow KDC does not hold the runtime and
869    // the connection setup timeout still applies.
870    let mut step = spawn_blocking(move || {
871        let initiator = SspiInitiator::new(&keytab, &client_principal, &target_spn, &kdc_url)
872            .map_err(|e| mechanism_failure(format!("GSSAPI initiator init failed: {e}")))?;
873        let exchange = GssapiClientExchange::new(Box::new(initiator), GSSAPI_MAX_RECV, None);
874        // Seed the exchange with no server token; this produces the AP-REQ.
875        exchange
876            .step(None)
877            .map_err(|e| mechanism_failure(format!("GSSAPI initiate failed: {e}")))
878    })
879    .await
880    .map_err(|e| mechanism_failure(format!("GSSAPI initiate task failed: {e}")))??;
881    loop {
882        match step {
883            ClientStep::Token(token, next) => {
884                let resp = send_sasl_authenticate(stream, token, corr_id, policy).await?;
885                if resp.error_code != 0 {
886                    return Err(authenticate_error("GSSAPI", &resp));
887                }
888                step = next
889                    .step(Some(&resp.auth_bytes))
890                    .map_err(|e| mechanism_failure(format!("GSSAPI step failed: {e}")))?;
891            }
892            ClientStep::Final(token) => {
893                let resp = send_sasl_authenticate(stream, token, corr_id, policy).await?;
894                if resp.error_code != 0 {
895                    return Err(authenticate_error("GSSAPI", &resp));
896                }
897                return Ok(());
898            }
899        }
900    }
901}
902
903/// Send one SASL token and read the answer of the broker.
904///
905/// With a `SaslAuthenticate` version, the token goes in a `SaslAuthenticate`
906/// request at that version. Without one, the token goes as a size-prefixed
907/// frame with no Kafka header, and the answer is a size-prefixed token, as
908/// Kafka's `SaslClientAuthenticator.sendSaslClientToken` and `receiveToken`
909/// do. Such an answer counts as a response with no error.
910async fn send_sasl_authenticate<S>(
911    stream: &mut S,
912    auth_bytes: Vec<u8>,
913    corr_id: &mut i32,
914    policy: SaslPolicy<'_>,
915) -> Result<SaslAuthenticateResponse, OutboundSaslError>
916where
917    S: SaslChannel + ?Sized,
918{
919    let Some(version) = policy.authenticate_version else {
920        return stream.token(&auth_bytes, policy.frame_max).await;
921    };
922    let req = SaslAuthenticateRequest {
923        auth_bytes: bytes::Bytes::from(auth_bytes),
924        ..Default::default()
925    };
926    let mut body = BytesMut::new();
927    req.encode(&mut body, version)
928        .map_err(|e| OutboundSaslError::Codec(format!("SaslAuthenticate encode: {e}")))?;
929    let resp_bytes = stream
930        .request(
931            (
932                ApiKey(API_KEY_SASL_AUTHENTICATE),
933                ApiVersion(version),
934                next_correlation_id(corr_id),
935            ),
936            version >= sasl_authenticate_request::FLEXIBLE_MIN,
937            &body,
938            policy,
939        )
940        .await?;
941    let mut cur: &[u8] = &resp_bytes;
942    let resp = SaslAuthenticateResponse::decode(&mut cur, version)
943        .map_err(|e| unparsable_response(format!("SaslAuthenticate decode: {e}")))?;
944    // Kafka's `SaslClientAuthenticator.receiveToken` keeps a positive
945    // lifetime of a response without an error.
946    if resp.error_code == 0 && resp.session_lifetime_ms > 0 {
947        policy
948            .session_lifetime_ms
949            .store(resp.session_lifetime_ms, Ordering::Relaxed);
950    }
951    Ok(resp)
952}
953
954/// Send `token` as a size-prefixed frame and read the size-prefixed answer.
955async fn send_raw_token<S>(
956    stream: &mut S,
957    token: &[u8],
958    frame_max: ClientFrameMax,
959) -> Result<SaslAuthenticateResponse, OutboundSaslError>
960where
961    S: AsyncRead + AsyncWrite + Unpin + Send + ?Sized,
962{
963    if token.len() > frame_max.bytes() {
964        return Err(OutboundSaslError::Codec(format!(
965            "SASL token encoded {} bytes, maximum {}",
966            token.len(),
967            frame_max.bytes()
968        )));
969    }
970    stream
971        .write_u32(
972            u32::try_from(token.len())
973                .map_err(|_| OutboundSaslError::Codec("token size exceeds u32".into()))?,
974        )
975        .await?;
976    stream.write_all(token).await?;
977    stream.flush().await?;
978    let len = usize::try_from(stream.read_u32().await?)
979        .map_err(|_| OutboundSaslError::Codec("SASL token length does not fit usize".into()))?;
980    if len > frame_max.bytes() {
981        return Err(OutboundSaslError::Codec(format!(
982            "SASL token announced {len} bytes, maximum {}",
983            frame_max.bytes()
984        )));
985    }
986    let mut answer = vec![0_u8; len];
987    stream.read_exact(&mut answer).await?;
988    Ok(SaslAuthenticateResponse {
989        auth_bytes: bytes::Bytes::from(answer),
990        ..Default::default()
991    })
992}
993
994/// Send one framed request and return the response body bytes.
995///
996/// This function builds a `RequestHeader v1` (or v2 when `flexible`), appends
997/// `body`, writes the length-prefixed frame, reads one response frame, and
998/// strips the `ResponseHeader`.
999///
1000/// Header rules that match Kafka:
1001/// - Request header: v1 for non-flexible, v2 for flexible (trailing 0x00
1002///   tagged-fields byte).
1003/// - Response header: v0 for non-flexible *and* for `ApiVersions(18)`
1004///   regardless of body flexibility; v1 (`corr_id` + 0x00 tagged byte)
1005///   for every other flexible response.
1006async fn round_trip<S>(
1007    stream: &mut S,
1008    api_key: ApiKey,
1009    api_version: ApiVersion,
1010    corr_id: i32,
1011    flexible: bool,
1012    body: &[u8],
1013    policy: SaslPolicy<'_>,
1014) -> Result<Vec<u8>, OutboundSaslError>
1015where
1016    S: AsyncRead + AsyncWrite + Unpin + Send + ?Sized,
1017{
1018    let SaslPolicy {
1019        client_id,
1020        frame_max,
1021        ..
1022    } = policy;
1023    let mut frame = BytesMut::with_capacity(16 + body.len());
1024    // RequestHeader: api_key + version + corr_id + client_id (i16 NULLABLE_STRING).
1025    frame.put_i16(api_key.0);
1026    frame.put_i16(api_version.0);
1027    frame.put_i32(corr_id);
1028    frame.put_i16(
1029        i16::try_from(client_id.len())
1030            .map_err(|_| OutboundSaslError::Codec("client_id too long".into()))?,
1031    );
1032    frame.put_slice(client_id.as_bytes());
1033    if flexible {
1034        frame.put_u8(0); // empty header tagged-fields
1035    }
1036    frame.put_slice(body);
1037
1038    if frame.len() > frame_max.bytes() {
1039        return Err(OutboundSaslError::Codec(format!(
1040            "SASL request encoded {} bytes, maximum {}",
1041            frame.len(),
1042            frame_max.bytes()
1043        )));
1044    }
1045
1046    stream
1047        .write_u32(
1048            u32::try_from(frame.len())
1049                .map_err(|_| OutboundSaslError::Codec("frame size exceeds u32".into()))?,
1050        )
1051        .await?;
1052    stream.write_all(&frame).await?;
1053    stream.flush().await?;
1054
1055    // Read length prefix then exactly that many bytes.
1056    let resp_len = usize::try_from(stream.read_u32().await?)
1057        .map_err(|_| OutboundSaslError::Codec("SASL response length does not fit usize".into()))?;
1058    if resp_len > frame_max.bytes() {
1059        return Err(OutboundSaslError::Codec(format!(
1060            "SASL response announced {resp_len} bytes, maximum {}",
1061            frame_max.bytes()
1062        )));
1063    }
1064    let mut resp = vec![0u8; resp_len];
1065    stream.read_exact(&mut resp).await?;
1066
1067    // Strip ResponseHeader: 4-byte corr_id, plus 1-byte tagged-fields for
1068    // v1 (flexible body AND api_key != 18). ApiVersions is special-cased
1069    // by the Kafka spec — its response header is always v0.
1070    let mut cur = &resp[..];
1071    if cur.len() < 4 {
1072        return Err(unparsable_response("response missing corr_id".into()));
1073    }
1074    let _resp_corr_id = cur.get_i32();
1075    let uses_v1_header = flexible && api_key != 18;
1076    if uses_v1_header {
1077        if cur.is_empty() {
1078            return Err(unparsable_response(
1079                "flexible response missing tagged-fields byte".into(),
1080            ));
1081        }
1082        cur = skip_tagged_fields(cur).map_err(|error| unparsable_response(error.to_string()))?;
1083    }
1084    Ok(cur.to_vec())
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089    use std::{fs, future::ready, iter::repeat_with};
1090
1091    use assert2::check;
1092    use krabka_protocol::owned::{
1093        sasl_authenticate_response::SaslAuthenticateResponse,
1094        sasl_handshake_response::SaslHandshakeResponse,
1095    };
1096    use krabka_security::{ScramServerExchange, StepResult, hash_scram_password};
1097    use tokio::{
1098        io::{AsyncReadExt, AsyncWriteExt, duplex},
1099        time::{Duration, timeout},
1100    };
1101
1102    use super::*;
1103
1104    #[test]
1105    fn debug_hides_every_password() {
1106        let cases = [
1107            (
1108                SaslCredentials::Plain {
1109                    username: "alice".into(),
1110                    password: "plain-secret".into(),
1111                },
1112                r#"Plain { username: "alice", password: [hidden] }"#,
1113            ),
1114            (
1115                SaslCredentials::Scram {
1116                    mechanism: SaslMechanism::ScramSha512,
1117                    username: "token-id".into(),
1118                    password: "token-hmac".into(),
1119                    delegation_token: true,
1120                },
1121                r#"Scram { mechanism: ScramSha512, username: "token-id", password: [hidden], delegation_token: true }"#,
1122            ),
1123            (
1124                SaslCredentials::Gssapi {
1125                    keytab_path: PathBuf::from("/etc/krb5.keytab"),
1126                    client_principal: "kafka/[email protected]".into(),
1127                    service_name: "kafka".into(),
1128                    kdc_url: "kdc.example.com:88".into(),
1129                },
1130                r#"Gssapi { keytab_path: "/etc/krb5.keytab", client_principal: "kafka/[email protected]", service_name: "kafka", kdc_url: "kdc.example.com:88" }"#,
1131            ),
1132            (
1133                SaslCredentials::OAuthBearer {
1134                    token: OAuthBearerTokenSource::File(PathBuf::from("/run/token")),
1135                    extensions: maplit::btreemap! {"logicalCluster".into() => "lkc-1".into()},
1136                },
1137                r#"OAuthBearer { token: File("/run/token"), extensions: {"logicalCluster": "lkc-1"} }"#,
1138            ),
1139        ];
1140        for (credentials, expected) in cases {
1141            check!(format!("{credentials:?}") == expected);
1142        }
1143    }
1144
1145    const TEST_CLIENT_ID: &str = "configured-sasl-client";
1146
1147    // Minimal server: read one request frame, reply with a response
1148    // header (corr_id, plus a 0x00 tagged-fields byte when `flex_header`)
1149    // carrying `body`. SaslHandshake uses a v0 response header; the
1150    // flexible SaslAuthenticate v2 uses a v1 response header.
1151    async fn reply_frame<S>(stream: &mut S, body: &[u8], flex_header: bool) -> Vec<u8>
1152    where
1153        S: AsyncRead + AsyncWrite + Unpin,
1154    {
1155        let req_len = stream.read_u32().await.unwrap();
1156        let mut req = vec![0u8; req_len as usize];
1157        stream.read_exact(&mut req).await.unwrap();
1158        // corr_id is at request header bytes [4..8] (api_key,version,corr_id).
1159        let corr_id = i32::from_be_bytes([req[4], req[5], req[6], req[7]]);
1160        let mut frame = BytesMut::new();
1161        frame.put_i32(corr_id);
1162        if flex_header {
1163            frame.put_u8(0); // empty response-header tagged-fields
1164        }
1165        frame.put_slice(body);
1166        stream
1167            .write_u32(u32::try_from(frame.len()).unwrap())
1168            .await
1169            .unwrap();
1170        stream.write_all(&frame).await.unwrap();
1171        stream.flush().await.unwrap();
1172        req
1173    }
1174
1175    /// Answer the `ApiVersions` v0 request that starts the SASL exchange, and
1176    /// list `SaslHandshake` v0-v1 and `SaslAuthenticate` v0-v2.
1177    async fn answer_api_versions<S>(stream: &mut S)
1178    where
1179        S: AsyncRead + AsyncWrite + Unpin,
1180    {
1181        use api_versions_response::ApiVersion as Advertised;
1182
1183        let mut body = BytesMut::new();
1184        ApiVersionsResponse {
1185            api_keys: vec![
1186                Advertised {
1187                    api_key: API_KEY_SASL_HANDSHAKE,
1188                    min_version: 0,
1189                    max_version: 1,
1190                    ..Default::default()
1191                },
1192                Advertised {
1193                    api_key: API_KEY_SASL_AUTHENTICATE,
1194                    min_version: 0,
1195                    max_version: 2,
1196                    ..Default::default()
1197                },
1198            ],
1199            ..Default::default()
1200        }
1201        .encode(&mut body, 0)
1202        .unwrap();
1203        let request = reply_frame(stream, &body, false).await;
1204        assert_request_header(
1205            &request,
1206            ApiKey(API_KEY_API_VERSIONS),
1207            ApiVersion(0),
1208            MIN_RESERVED_CORRELATION_ID,
1209            false,
1210        );
1211    }
1212
1213    macro_rules! decode_sasl_authenticate_frame {
1214        ($req:expr, $corr_id:expr) => {{
1215            assert_request_header(
1216                &$req,
1217                ApiKey(API_KEY_SASL_AUTHENTICATE),
1218                ApiVersion(2),
1219                $corr_id,
1220                true,
1221            );
1222            let mut body = request_body(&$req, true);
1223            let decoded = SaslAuthenticateRequest::decode(&mut body, 2).unwrap();
1224            assert!(body.is_empty());
1225            decoded
1226        }};
1227    }
1228
1229    /// One frame that the client sent in a SASL exchange.
1230    #[derive(Debug, PartialEq, Eq)]
1231    enum Frame {
1232        /// A Kafka request: API key, version and correlation id.
1233        Request(i16, i16, i32),
1234        /// A size-prefixed token with no Kafka header.
1235        Token(Vec<u8>),
1236    }
1237
1238    /// Kafka's `SaslClientAuthenticator` sends `ApiVersions` v0 first, then
1239    /// `SaslHandshake` and `SaslAuthenticate` at the highest versions that the
1240    /// broker lists. With no `SaslAuthenticate` entry the token goes with no
1241    /// Kafka header. Each row gives the `(SaslHandshake, SaslAuthenticate)`
1242    /// maxima that the broker lists.
1243    #[tokio::test]
1244    async fn plain_exchange_uses_the_sasl_versions_that_api_versions_lists() {
1245        use api_versions_response::ApiVersion as Advertised;
1246
1247        const C: i32 = MIN_RESERVED_CORRELATION_ID;
1248        for (name, listed, expected) in [
1249            (
1250                "current broker",
1251                (Some(1), Some(2)),
1252                vec![
1253                    Frame::Request(API_KEY_API_VERSIONS, 0, C),
1254                    Frame::Request(API_KEY_SASL_HANDSHAKE, 1, C + 1),
1255                    Frame::Request(API_KEY_SASL_AUTHENTICATE, 2, C + 2),
1256                ],
1257            ),
1258            (
1259                "SaslAuthenticate v1",
1260                (Some(1), Some(1)),
1261                vec![
1262                    Frame::Request(API_KEY_API_VERSIONS, 0, C),
1263                    Frame::Request(API_KEY_SASL_HANDSHAKE, 1, C + 1),
1264                    Frame::Request(API_KEY_SASL_AUTHENTICATE, 1, C + 2),
1265                ],
1266            ),
1267            (
1268                "no SaslAuthenticate",
1269                (Some(0), None),
1270                vec![
1271                    Frame::Request(API_KEY_API_VERSIONS, 0, C),
1272                    Frame::Request(API_KEY_SASL_HANDSHAKE, 0, C + 1),
1273                    Frame::Token(b"\0u\0p".to_vec()),
1274                ],
1275            ),
1276        ] {
1277            let (mut client, mut server) = duplex(8192);
1278            let server_task = tokio::spawn(async move {
1279                let mut frames = Vec::new();
1280                for _ in 0..3 {
1281                    let len = server.read_u32().await.unwrap();
1282                    let mut request = vec![0_u8; len as usize];
1283                    server.read_exact(&mut request).await.unwrap();
1284                    if frames.len() == 2 && listed.1.is_none() {
1285                        frames.push(Frame::Token(request));
1286                        server.write_u32(0).await.unwrap();
1287                        continue;
1288                    }
1289                    let api_key = i16::from_be_bytes([request[0], request[1]]);
1290                    let version = i16::from_be_bytes([request[2], request[3]]);
1291                    let corr_id =
1292                        i32::from_be_bytes([request[4], request[5], request[6], request[7]]);
1293                    frames.push(Frame::Request(api_key, version, corr_id));
1294                    let mut body = BytesMut::new();
1295                    body.put_i32(corr_id);
1296                    match api_key {
1297                        API_KEY_API_VERSIONS => ApiVersionsResponse {
1298                            api_keys: [
1299                                (API_KEY_SASL_HANDSHAKE, listed.0),
1300                                (API_KEY_SASL_AUTHENTICATE, listed.1),
1301                            ]
1302                            .into_iter()
1303                            .filter_map(|(api_key, max)| {
1304                                max.map(|max_version| Advertised {
1305                                    api_key,
1306                                    min_version: 0,
1307                                    max_version,
1308                                    ..Default::default()
1309                                })
1310                            })
1311                            .collect(),
1312                            ..Default::default()
1313                        }
1314                        .encode(&mut body, 0)
1315                        .unwrap(),
1316                        API_KEY_SASL_HANDSHAKE => SaslHandshakeResponse::default()
1317                            .encode(&mut body, version)
1318                            .unwrap(),
1319                        _ => {
1320                            if version >= 2 {
1321                                body.put_u8(0);
1322                            }
1323                            SaslAuthenticateResponse::default()
1324                                .encode(&mut body, version)
1325                                .unwrap();
1326                        }
1327                    }
1328                    server
1329                        .write_u32(u32::try_from(body.len()).unwrap())
1330                        .await
1331                        .unwrap();
1332                    server.write_all(&body).await.unwrap();
1333                }
1334                frames
1335            });
1336            let result = outbound_sasl(
1337                &mut client,
1338                &SaslCredentials::Plain {
1339                    username: "u".into(),
1340                    password: "p".into(),
1341                },
1342                "localhost",
1343                TEST_CLIENT_ID,
1344                ClientFrameMax::default(),
1345            )
1346            .await;
1347            let frames = timeout(Duration::from_secs(1), server_task)
1348                .await
1349                .expect("server saw three frames")
1350                .unwrap();
1351            check!(result.is_ok(), "{name}: {result:?}");
1352            check!(frames == expected, "{name}");
1353        }
1354    }
1355
1356    /// Kafka's `OAuthBearerClientInitialResponse.toBytes` and
1357    /// `validateExtensions`.
1358    #[test]
1359    fn oauthbearer_initial_responses_carry_valid_extensions() {
1360        let extensions = |pairs: &[(&str, &str)]| {
1361            pairs
1362                .iter()
1363                .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
1364                .collect::<BTreeMap<_, _>>()
1365        };
1366        for (name, pairs, expected) in [
1367            ("no extensions", vec![], Ok(b"n,,\x01auth=Bearer t\x01\x01".to_vec())),
1368            (
1369                "two extensions",
1370                vec![("logicalCluster", "lkc-1"), ("identityPoolId", "pool-2")],
1371                Ok(b"n,,\x01auth=Bearer t\x01identityPoolId=pool-2\x01logicalCluster=lkc-1\x01\x01"
1372                    .to_vec()),
1373            ),
1374            (
1375                "reserved name",
1376                vec![("auth", "x")],
1377                Err("Extension name auth is invalid".to_owned()),
1378            ),
1379            (
1380                "name with a digit",
1381                vec![("cluster1", "x")],
1382                Err("Extension name cluster1 is invalid".to_owned()),
1383            ),
1384            (
1385                "value with a control character",
1386                vec![("cluster", "a\x01b")],
1387                Err("Extension value (a\x01b) for extension cluster is invalid".to_owned()),
1388            ),
1389        ] {
1390            let result = oauthbearer_initial_response(b"t", &extensions(&pairs)).map_err(|error| {
1391                match error {
1392                    OutboundSaslError::Authentication(SaslAuthenticationError::Failed(message)) => {
1393                        message
1394                    }
1395                    other => other.to_string(),
1396                }
1397            });
1398            check!(result == expected, "{name}");
1399        }
1400    }
1401
1402    /// A SASL channel that answers every request with success and records the
1403    /// `auth_bytes` of each `SaslAuthenticate` or headerless token.
1404    struct RecordingChannel {
1405        tokens: Vec<Vec<u8>>,
1406        server: Option<krabka_security::ScramServerExchange>,
1407    }
1408
1409    impl RecordingChannel {
1410        /// Record `token` and return the SCRAM server's answer, or nothing
1411        /// when the exchange has no server.
1412        fn answer(&mut self, token: &[u8]) -> Vec<u8> {
1413            self.tokens.push(token.to_vec());
1414            match self.server.take() {
1415                Some(server) => match server.step(token) {
1416                    StepResult::Continue(bytes, next) => {
1417                        self.server = Some(next);
1418                        bytes
1419                    }
1420                    StepResult::Done(_, bytes) => bytes,
1421                    StepResult::Failed(error) => panic!("SCRAM server failed: {error:?}"),
1422                    StepResult::Disconnect => panic!("SCRAM server disconnected"),
1423                },
1424                None => Vec::new(),
1425            }
1426        }
1427    }
1428
1429    impl SaslChannel for RecordingChannel {
1430        fn request(
1431            &mut self,
1432            (api_key, version, _corr_id): (ApiKey, ApiVersion, i32),
1433            _flexible: bool,
1434            body: &[u8],
1435            _policy: SaslPolicy<'_>,
1436        ) -> impl Future<Output = Result<Vec<u8>, OutboundSaslError>> + Send {
1437            let mut out = BytesMut::new();
1438            if api_key == ApiKey(API_KEY_SASL_HANDSHAKE) {
1439                SaslHandshakeResponse::default()
1440                    .encode(&mut out, version.0)
1441                    .unwrap();
1442                return ready(Ok(out.to_vec()));
1443            }
1444            let mut cursor = body;
1445            let request = SaslAuthenticateRequest::decode(&mut cursor, version.0).unwrap();
1446            SaslAuthenticateResponse {
1447                auth_bytes: self.answer(&request.auth_bytes).into(),
1448                ..Default::default()
1449            }
1450            .encode(&mut out, version.0)
1451            .unwrap();
1452            ready(Ok(out.to_vec()))
1453        }
1454
1455        fn token(
1456            &mut self,
1457            token: &[u8],
1458            _frame_max: ClientFrameMax,
1459        ) -> impl Future<Output = Result<SaslAuthenticateResponse, OutboundSaslError>> + Send
1460        {
1461            ready(Ok(SaslAuthenticateResponse {
1462                auth_bytes: self.answer(token).into(),
1463                ..Default::default()
1464            }))
1465        }
1466    }
1467
1468    #[derive(Debug)]
1469    struct FixedToken;
1470
1471    impl oauth::OAuthBearerTokenProvider for FixedToken {
1472        fn token(&self) -> oauth::TokenFuture<'_> {
1473            Box::pin(async { Ok("provided.token".to_owned()) })
1474        }
1475    }
1476
1477    /// The first token of each credential kind, as the broker receives it,
1478    /// both in a `SaslAuthenticate` and as a headerless token.
1479    #[tokio::test]
1480    async fn credentials_send_provider_tokens_extensions_and_delegation_token_login() {
1481        let sessions = [
1482            (
1483                "SaslAuthenticate v1",
1484                SaslSession {
1485                    handshake_version: 1,
1486                    authenticate_version: Some(1),
1487                    session_lifetime_ms: None,
1488                },
1489            ),
1490            (
1491                "headerless",
1492                SaslSession {
1493                    handshake_version: 0,
1494                    authenticate_version: None,
1495                    session_lifetime_ms: None,
1496                },
1497            ),
1498        ];
1499        let scram_server = || {
1500            Some(ScramServerExchange::new(
1501                "token-id".into(),
1502                hash_scram_password(b"hmac", SaslMechanism::ScramSha256, 4096),
1503            ))
1504        };
1505        for (session_name, session) in sessions {
1506            for (name, credentials, server, check_first) in [
1507                (
1508                    "OAUTHBEARER provider with an extension",
1509                    SaslCredentials::OAuthBearer {
1510                        token: OAuthBearerTokenSource::Provider(Arc::new(FixedToken)),
1511                        extensions: maplit::btreemap! {"logicalCluster".into() => "lkc-1".into()},
1512                    },
1513                    None,
1514                    Box::new(|first: &[u8]| {
1515                        first
1516                            == b"n,,\x01auth=Bearer provided.token\x01logicalCluster=lkc-1\x01\x01"
1517                    }) as Box<dyn Fn(&[u8]) -> bool>,
1518                ),
1519                (
1520                    "SCRAM delegation token",
1521                    SaslCredentials::Scram {
1522                        mechanism: SaslMechanism::ScramSha256,
1523                        username: "token-id".into(),
1524                        password: "hmac".into(),
1525                        delegation_token: true,
1526                    },
1527                    scram_server(),
1528                    Box::new(|first: &[u8]| {
1529                        let first = String::from_utf8_lossy(first);
1530                        first.starts_with("n,,n=token-id,r=") && first.ends_with(",tokenauth=true")
1531                    }),
1532                ),
1533            ] {
1534                let mut channel = RecordingChannel {
1535                    tokens: Vec::new(),
1536                    server,
1537                };
1538                let mut corr_id = 0;
1539                let result = authenticate(
1540                    &mut channel,
1541                    &credentials,
1542                    "localhost",
1543                    session,
1544                    (&mut corr_id, TEST_CLIENT_ID, ClientFrameMax::default()),
1545                )
1546                .await;
1547                check!(result.is_ok(), "{session_name}, {name}: {result:?}");
1548                check!(
1549                    check_first(&channel.tokens[0]),
1550                    "{session_name}, {name}: {:?}",
1551                    String::from_utf8_lossy(&channel.tokens[0])
1552                );
1553            }
1554        }
1555    }
1556
1557    #[test]
1558    fn sasl_correlation_ids_stay_in_the_reserved_range() {
1559        let mut corr_id = i32::MAX - 1;
1560        let ids = repeat_with(|| next_correlation_id(&mut corr_id))
1561            .take(4)
1562            .collect::<Vec<_>>();
1563        check!(
1564            ids == vec![
1565                i32::MAX - 1,
1566                i32::MAX,
1567                MIN_RESERVED_CORRELATION_ID,
1568                MIN_RESERVED_CORRELATION_ID + 1
1569            ]
1570        );
1571    }
1572
1573    /// Without the Kerberos client, a GSSAPI exchange that the broker accepts
1574    /// fails on the client with `UnsupportedMechanism` and sends no token.
1575    #[cfg(not(all(feature = "gssapi", not(target_family = "wasm"))))]
1576    #[tokio::test]
1577    async fn gssapi_without_the_kerberos_client_is_an_unsupported_mechanism() {
1578        let (mut client, mut server) = duplex(8192);
1579        let server_task = tokio::spawn(async move {
1580            answer_api_versions(&mut server).await;
1581            let mut hs = BytesMut::new();
1582            SaslHandshakeResponse {
1583                error_code: 0,
1584                mechanisms: vec!["GSSAPI".into()],
1585                ..Default::default()
1586            }
1587            .encode(&mut hs, 1)
1588            .unwrap();
1589            let hs_req = reply_frame(&mut server, &hs, false).await;
1590            let mut hs_body = request_body(&hs_req, false);
1591            SaslHandshakeRequest::decode(&mut hs_body, 1)
1592                .unwrap()
1593                .mechanism
1594        });
1595        let creds = SaslCredentials::Gssapi {
1596            keytab_path: PathBuf::from("/etc/krb5.keytab"),
1597            client_principal: "[email protected]".into(),
1598            service_name: "kafka".into(),
1599            kdc_url: "kdc.example.com:88".into(),
1600        };
1601        let result = outbound_sasl(
1602            &mut client,
1603            &creds,
1604            "broker-1.example.com",
1605            TEST_CLIENT_ID,
1606            ClientFrameMax::default(),
1607        )
1608        .await;
1609        drop(client);
1610        let mechanism = timeout(Duration::from_secs(1), server_task)
1611            .await
1612            .expect("server observed the handshake")
1613            .unwrap();
1614        check!(
1615            (verdict(result.map(|_| ())), mechanism)
1616                == (
1617                    Verdict::Rejected(SaslAuthenticationError::UnsupportedMechanism(format!(
1618                        "GSSAPI to broker-1.example.com: {GSSAPI_UNAVAILABLE}"
1619                    ))),
1620                    "GSSAPI".to_owned(),
1621                )
1622        );
1623    }
1624
1625    #[tokio::test]
1626    async fn outbound_plain_completes() {
1627        let (mut client, mut server) = duplex(8192);
1628        let server_task = tokio::spawn(async move {
1629            answer_api_versions(&mut server).await;
1630            // 1. SaslHandshake v1 → error_code 0 + empty mechanisms.
1631            let mut hs = BytesMut::new();
1632            SaslHandshakeResponse {
1633                error_code: 0,
1634                ..Default::default()
1635            }
1636            .encode(&mut hs, 1)
1637            .unwrap();
1638            let hs_req = reply_frame(&mut server, &hs, false).await;
1639            assert_request_header(
1640                &hs_req,
1641                ApiKey(API_KEY_SASL_HANDSHAKE),
1642                ApiVersion(1),
1643                MIN_RESERVED_CORRELATION_ID + 1,
1644                false,
1645            );
1646            let mut hs_body = request_body(&hs_req, false);
1647            let hs_decoded = SaslHandshakeRequest::decode(&mut hs_body, 1).unwrap();
1648            assert_eq!(hs_decoded.mechanism, "PLAIN");
1649            assert!(hs_body.is_empty());
1650
1651            // 2. SaslAuthenticate v2 → error_code 0 (flexible response header).
1652            let mut au = BytesMut::new();
1653            SaslAuthenticateResponse {
1654                error_code: 0,
1655                ..Default::default()
1656            }
1657            .encode(&mut au, 2)
1658            .unwrap();
1659            let au_req = reply_frame(&mut server, &au, true).await;
1660            let au_decoded =
1661                decode_sasl_authenticate_frame!(au_req, MIN_RESERVED_CORRELATION_ID + 2);
1662            assert_eq!(au_decoded.auth_bytes.as_ref(), b"\0u\0p");
1663        });
1664        let creds = SaslCredentials::Plain {
1665            username: "u".into(),
1666            password: "p".into(),
1667        };
1668        outbound_sasl(
1669            &mut client,
1670            &creds,
1671            "localhost",
1672            TEST_CLIENT_ID,
1673            ClientFrameMax::default(),
1674        )
1675        .await
1676        .expect("PLAIN outbound handshake completes");
1677        timeout(Duration::from_secs(1), server_task)
1678            .await
1679            .expect("server observed both SASL client frames")
1680            .unwrap();
1681    }
1682
1683    #[tokio::test]
1684    async fn outbound_oauthbearer_sends_rfc7628_initial_response_and_rereads_token_file() {
1685        let dir = tempfile::tempdir().unwrap();
1686        let token_path = dir.path().join("token");
1687        for token in ["header.payload.", "rotated.token."] {
1688            fs::write(&token_path, format!("{token}\n")).unwrap();
1689            let expected = format!("n,,\x01auth=Bearer {token}\x01\x01").into_bytes();
1690            let (mut client, mut server) = duplex(8192);
1691            let server_task = tokio::spawn(async move {
1692                answer_api_versions(&mut server).await;
1693                let mut handshake = BytesMut::new();
1694                SaslHandshakeResponse {
1695                    error_code: 0,
1696                    ..Default::default()
1697                }
1698                .encode(&mut handshake, 1)
1699                .unwrap();
1700                let request = reply_frame(&mut server, &handshake, false).await;
1701                let mut body = request_body(&request, false);
1702                let decoded = SaslHandshakeRequest::decode(&mut body, 1).unwrap();
1703                check!(decoded.mechanism == "OAUTHBEARER");
1704
1705                let mut authenticate = BytesMut::new();
1706                SaslAuthenticateResponse {
1707                    error_code: 0,
1708                    auth_bytes: bytes::Bytes::new(),
1709                    session_lifetime_ms: 60_000,
1710                    ..Default::default()
1711                }
1712                .encode(&mut authenticate, 2)
1713                .unwrap();
1714                let request = reply_frame(&mut server, &authenticate, true).await;
1715                let decoded =
1716                    decode_sasl_authenticate_frame!(request, MIN_RESERVED_CORRELATION_ID + 2);
1717                check!(decoded.auth_bytes.as_ref() == expected);
1718            });
1719
1720            let credentials = SaslCredentials::OAuthBearer {
1721                token: OAuthBearerTokenSource::File(token_path.clone()),
1722                extensions: BTreeMap::new(),
1723            };
1724            outbound_sasl(
1725                &mut client,
1726                &credentials,
1727                "localhost",
1728                TEST_CLIENT_ID,
1729                ClientFrameMax::default(),
1730            )
1731            .await
1732            .expect("OAUTHBEARER outbound handshake completes");
1733            timeout(Duration::from_secs(1), server_task)
1734                .await
1735                .expect("server observed OAUTHBEARER frames")
1736                .unwrap();
1737        }
1738    }
1739
1740    #[tokio::test]
1741    async fn outbound_oauthbearer_completes_rfc7628_rejection_exchange() {
1742        let dir = tempfile::tempdir().unwrap();
1743        let token_path = dir.path().join("token");
1744        fs::write(&token_path, "invalid").unwrap();
1745        let (mut client, mut server) = duplex(8192);
1746        let server_task = tokio::spawn(async move {
1747            answer_api_versions(&mut server).await;
1748            let mut handshake = BytesMut::new();
1749            SaslHandshakeResponse {
1750                error_code: 0,
1751                ..Default::default()
1752            }
1753            .encode(&mut handshake, 1)
1754            .unwrap();
1755            let _ = reply_frame(&mut server, &handshake, false).await;
1756
1757            let mut challenge = BytesMut::new();
1758            SaslAuthenticateResponse {
1759                error_code: 0,
1760                auth_bytes: bytes::Bytes::from_static(br#"{"status":"invalid_token"}"#),
1761                ..Default::default()
1762            }
1763            .encode(&mut challenge, 2)
1764            .unwrap();
1765            let first = reply_frame(&mut server, &challenge, true).await;
1766            let _ = decode_sasl_authenticate_frame!(first, MIN_RESERVED_CORRELATION_ID + 2);
1767
1768            let mut rejected = BytesMut::new();
1769            SaslAuthenticateResponse {
1770                error_code: 58,
1771                error_message: Some("oauthbearer token rejected".into()),
1772                ..Default::default()
1773            }
1774            .encode(&mut rejected, 2)
1775            .unwrap();
1776            let final_request = reply_frame(&mut server, &rejected, true).await;
1777            let decoded =
1778                decode_sasl_authenticate_frame!(final_request, MIN_RESERVED_CORRELATION_ID + 3);
1779            check!(decoded.auth_bytes.as_ref() == b"\x01");
1780        });
1781
1782        let credentials = SaslCredentials::OAuthBearer {
1783            token: OAuthBearerTokenSource::File(token_path),
1784            extensions: BTreeMap::new(),
1785        };
1786        let error = outbound_sasl(
1787            &mut client,
1788            &credentials,
1789            "localhost",
1790            TEST_CLIENT_ID,
1791            ClientFrameMax::default(),
1792        )
1793        .await
1794        .expect_err("invalid bearer token is rejected");
1795        check!(matches!(
1796            error,
1797            OutboundSaslError::Authentication(SaslAuthenticationError::Failed(message))
1798                if message
1799                    == "SaslAuthenticate(OAUTHBEARER) error_code=58 \
1800                        error_message=Some(\"oauthbearer token rejected\")"
1801        ));
1802        timeout(Duration::from_secs(1), server_task)
1803            .await
1804            .expect("server observed RFC 7628 final message")
1805            .unwrap();
1806    }
1807
1808    #[tokio::test]
1809    async fn send_sasl_authenticate_increments_correlation_id_and_sends_auth_bytes() {
1810        let (mut client, mut server) = duplex(8192);
1811        let server_task = tokio::spawn(async move {
1812            for (expected_corr, expected_payload) in [
1813                (MIN_RESERVED_CORRELATION_ID, b"first".as_ref()),
1814                (MIN_RESERVED_CORRELATION_ID + 1, b"second".as_ref()),
1815            ] {
1816                let mut au = BytesMut::new();
1817                SaslAuthenticateResponse {
1818                    error_code: 0,
1819                    ..Default::default()
1820                }
1821                .encode(&mut au, 2)
1822                .unwrap();
1823                let req = reply_frame(&mut server, &au, true).await;
1824                let decoded = decode_sasl_authenticate_frame!(req, expected_corr);
1825                assert_eq!(decoded.auth_bytes.as_ref(), expected_payload);
1826            }
1827        });
1828
1829        // An id below the reserved range starts the range, as Kafka's
1830        // `nextCorrelationId` does.
1831        let mut corr_id = 7;
1832        send_sasl_authenticate(
1833            &mut client,
1834            b"first".to_vec(),
1835            &mut corr_id,
1836            SaslPolicy {
1837                client_id: TEST_CLIENT_ID,
1838                frame_max: ClientFrameMax::default(),
1839                authenticate_version: Some(2),
1840                session_lifetime_ms: &AtomicI64::new(0),
1841            },
1842        )
1843        .await
1844        .unwrap();
1845        assert_eq!(corr_id, MIN_RESERVED_CORRELATION_ID + 1);
1846        send_sasl_authenticate(
1847            &mut client,
1848            b"second".to_vec(),
1849            &mut corr_id,
1850            SaslPolicy {
1851                client_id: TEST_CLIENT_ID,
1852                frame_max: ClientFrameMax::default(),
1853                authenticate_version: Some(2),
1854                session_lifetime_ms: &AtomicI64::new(0),
1855            },
1856        )
1857        .await
1858        .unwrap();
1859        assert_eq!(corr_id, MIN_RESERVED_CORRELATION_ID + 2);
1860        timeout(Duration::from_secs(1), server_task)
1861            .await
1862            .expect("server observed both authenticate frames")
1863            .unwrap();
1864    }
1865
1866    #[tokio::test]
1867    async fn outbound_scram_rejects_broker_error_on_first_round() {
1868        let (mut client, mut server) = duplex(8192);
1869        let server_task = tokio::spawn(async move {
1870            answer_api_versions(&mut server).await;
1871            let mut hs = BytesMut::new();
1872            SaslHandshakeResponse {
1873                error_code: 0,
1874                ..Default::default()
1875            }
1876            .encode(&mut hs, 1)
1877            .unwrap();
1878            let hs_req = reply_frame(&mut server, &hs, false).await;
1879            assert_request_header(
1880                &hs_req,
1881                ApiKey(API_KEY_SASL_HANDSHAKE),
1882                ApiVersion(1),
1883                MIN_RESERVED_CORRELATION_ID + 1,
1884                false,
1885            );
1886            let mut hs_body = request_body(&hs_req, false);
1887            let hs_decoded = SaslHandshakeRequest::decode(&mut hs_body, 1).unwrap();
1888            assert_eq!(hs_decoded.mechanism, "SCRAM-SHA-256");
1889
1890            let mut au = BytesMut::new();
1891            SaslAuthenticateResponse {
1892                error_code: SASL_AUTHENTICATION_FAILED,
1893                error_message: Some("nope".into()),
1894                ..Default::default()
1895            }
1896            .encode(&mut au, 2)
1897            .unwrap();
1898            let au_req = reply_frame(&mut server, &au, true).await;
1899            let _ = decode_sasl_authenticate_frame!(au_req, MIN_RESERVED_CORRELATION_ID + 2);
1900        });
1901
1902        let creds = SaslCredentials::Scram {
1903            mechanism: SaslMechanism::ScramSha256,
1904            username: "u".into(),
1905            password: "p".into(),
1906            delegation_token: false,
1907        };
1908        let err = outbound_sasl(
1909            &mut client,
1910            &creds,
1911            "localhost",
1912            TEST_CLIENT_ID,
1913            ClientFrameMax::default(),
1914        )
1915        .await
1916        .unwrap_err();
1917        assert2::assert!(
1918            let OutboundSaslError::Authentication(SaslAuthenticationError::Failed(msg)) = err
1919        );
1920        check!(msg.contains("round 1"));
1921        timeout(Duration::from_secs(1), server_task)
1922            .await
1923            .expect("server observed SCRAM first round")
1924            .unwrap();
1925    }
1926
1927    #[tokio::test]
1928    async fn outbound_scram_rejects_broker_error_on_second_round() {
1929        let (mut client, mut server) = duplex(8192);
1930        let server_task = tokio::spawn(async move {
1931            answer_api_versions(&mut server).await;
1932            let mut hs = BytesMut::new();
1933            SaslHandshakeResponse {
1934                error_code: 0,
1935                ..Default::default()
1936            }
1937            .encode(&mut hs, 1)
1938            .unwrap();
1939            let hs_req = reply_frame(&mut server, &hs, false).await;
1940            assert_request_header(
1941                &hs_req,
1942                ApiKey(API_KEY_SASL_HANDSHAKE),
1943                ApiVersion(1),
1944                MIN_RESERVED_CORRELATION_ID + 1,
1945                false,
1946            );
1947
1948            let cred = hash_scram_password(b"p", SaslMechanism::ScramSha256, 4096);
1949            let scram_server = ScramServerExchange::new("u".to_owned(), cred);
1950
1951            let first_req_len = server.read_u32().await.unwrap();
1952            let mut first_req = vec![0u8; first_req_len as usize];
1953            server.read_exact(&mut first_req).await.unwrap();
1954            let first_auth =
1955                decode_sasl_authenticate_frame!(first_req, MIN_RESERVED_CORRELATION_ID + 2);
1956            let server_first = match scram_server.step(&first_auth.auth_bytes) {
1957                StepResult::Continue(bytes, _next) => bytes,
1958                other => panic!("server first SCRAM step must continue, got {other:?}"),
1959            };
1960
1961            let mut first_resp = BytesMut::new();
1962            SaslAuthenticateResponse {
1963                error_code: 0,
1964                auth_bytes: bytes::Bytes::from(server_first),
1965                ..Default::default()
1966            }
1967            .encode(&mut first_resp, 2)
1968            .unwrap();
1969            write_response_frame(
1970                &mut server,
1971                MIN_RESERVED_CORRELATION_ID + 2,
1972                &first_resp,
1973                true,
1974            )
1975            .await;
1976
1977            let mut second_resp = BytesMut::new();
1978            SaslAuthenticateResponse {
1979                error_code: SASL_AUTHENTICATION_FAILED,
1980                error_message: Some("second nope".into()),
1981                ..Default::default()
1982            }
1983            .encode(&mut second_resp, 2)
1984            .unwrap();
1985            let second_req = reply_frame(&mut server, &second_resp, true).await;
1986            let _ = decode_sasl_authenticate_frame!(second_req, MIN_RESERVED_CORRELATION_ID + 3);
1987        });
1988
1989        let creds = SaslCredentials::Scram {
1990            mechanism: SaslMechanism::ScramSha256,
1991            username: "u".into(),
1992            password: "p".into(),
1993            delegation_token: false,
1994        };
1995        let err = outbound_sasl(
1996            &mut client,
1997            &creds,
1998            "localhost",
1999            TEST_CLIENT_ID,
2000            ClientFrameMax::default(),
2001        )
2002        .await
2003        .unwrap_err();
2004        assert2::assert!(
2005            let OutboundSaslError::Authentication(SaslAuthenticationError::Failed(msg)) = err
2006        );
2007        check!(msg.contains("round 2"));
2008        timeout(Duration::from_secs(1), server_task)
2009            .await
2010            .expect("server observed SCRAM second round")
2011            .unwrap();
2012    }
2013
2014    #[tokio::test]
2015    async fn round_trip_rejects_response_without_correlation_id() {
2016        let (mut client, mut server) = duplex(8192);
2017        let server_task = tokio::spawn(async move {
2018            let req_len = server.read_u32().await.unwrap();
2019            let mut req = vec![0u8; req_len as usize];
2020            server.read_exact(&mut req).await.unwrap();
2021            server.write_u32(3).await.unwrap();
2022            server.write_all(&[1, 2, 3]).await.unwrap();
2023            server.flush().await.unwrap();
2024        });
2025
2026        let err = round_trip(
2027            &mut client,
2028            ApiKey(API_KEY_SASL_HANDSHAKE),
2029            ApiVersion(1),
2030            99,
2031            false,
2032            &[],
2033            SaslPolicy {
2034                client_id: TEST_CLIENT_ID,
2035                frame_max: ClientFrameMax::default(),
2036                authenticate_version: Some(2),
2037                session_lifetime_ms: &AtomicI64::new(0),
2038            },
2039        )
2040        .await
2041        .unwrap_err();
2042        check!(matches!(
2043            err,
2044            OutboundSaslError::Authentication(SaslAuthenticationError::IllegalState(msg))
2045                if msg == "response missing corr_id"
2046        ));
2047        server_task.await.unwrap();
2048    }
2049
2050    #[tokio::test]
2051    async fn round_trip_rejects_flexible_response_without_tagged_fields_byte() {
2052        let (mut client, mut server) = duplex(8192);
2053        let server_task = tokio::spawn(async move {
2054            let req_len = server.read_u32().await.unwrap();
2055            let mut req = vec![0u8; req_len as usize];
2056            server.read_exact(&mut req).await.unwrap();
2057            server.write_u32(4).await.unwrap();
2058            server.write_i32(99).await.unwrap();
2059            server.flush().await.unwrap();
2060        });
2061
2062        let err = round_trip(
2063            &mut client,
2064            ApiKey(API_KEY_SASL_AUTHENTICATE),
2065            ApiVersion(2),
2066            99,
2067            true,
2068            &[],
2069            SaslPolicy {
2070                client_id: TEST_CLIENT_ID,
2071                frame_max: ClientFrameMax::default(),
2072                authenticate_version: Some(2),
2073                session_lifetime_ms: &AtomicI64::new(0),
2074            },
2075        )
2076        .await
2077        .unwrap_err();
2078        check!(matches!(
2079            err,
2080            OutboundSaslError::Authentication(SaslAuthenticationError::IllegalState(msg))
2081                if msg == "flexible response missing tagged-fields byte"
2082        ));
2083        server_task.await.unwrap();
2084    }
2085
2086    #[tokio::test]
2087    async fn round_trip_uses_the_configured_client_id() {
2088        let (mut client, mut server) = duplex(8192);
2089        let server_task = tokio::spawn(async move {
2090            let req = reply_frame(&mut server, &[], false).await;
2091            let client_len = usize::try_from(i16::from_be_bytes([req[8], req[9]])).unwrap();
2092            assert_eq!(&req[10..10 + client_len], b"configured-sasl-client");
2093        });
2094
2095        round_trip(
2096            &mut client,
2097            ApiKey(API_KEY_SASL_HANDSHAKE),
2098            ApiVersion(1),
2099            99,
2100            false,
2101            &[],
2102            SaslPolicy {
2103                client_id: TEST_CLIENT_ID,
2104                frame_max: ClientFrameMax::default(),
2105                authenticate_version: Some(2),
2106                session_lifetime_ms: &AtomicI64::new(0),
2107            },
2108        )
2109        .await
2110        .unwrap();
2111        server_task.await.unwrap();
2112    }
2113
2114    #[tokio::test]
2115    async fn round_trip_rejects_an_oversized_request_before_writing() {
2116        let (mut client, _server) = duplex(8192);
2117        let error = round_trip(
2118            &mut client,
2119            ApiKey(API_KEY_SASL_HANDSHAKE),
2120            ApiVersion(1),
2121            99,
2122            false,
2123            &[0],
2124            SaslPolicy {
2125                client_id: "",
2126                frame_max: ClientFrameMax::try_from(krabka_units::bytes(10)).unwrap(),
2127                authenticate_version: Some(2),
2128                session_lifetime_ms: &AtomicI64::new(0),
2129            },
2130        )
2131        .await
2132        .unwrap_err();
2133
2134        assert!(error.to_string().contains("encoded 11"));
2135        assert!(error.to_string().contains("maximum 10"));
2136    }
2137
2138    #[tokio::test]
2139    async fn round_trip_rejects_an_oversized_response_before_allocating() {
2140        let (mut client, mut server) = duplex(8192);
2141        let server_task = tokio::spawn(async move {
2142            let request_len = server.read_u32().await.unwrap();
2143            let mut request = vec![0; request_len as usize];
2144            server.read_exact(&mut request).await.unwrap();
2145            server.write_u32(17).await.unwrap();
2146            server.flush().await.unwrap();
2147        });
2148
2149        let error = round_trip(
2150            &mut client,
2151            ApiKey(API_KEY_SASL_HANDSHAKE),
2152            ApiVersion(1),
2153            99,
2154            false,
2155            &[],
2156            SaslPolicy {
2157                client_id: "",
2158                frame_max: ClientFrameMax::try_from(krabka_units::bytes(16)).unwrap(),
2159                authenticate_version: Some(2),
2160                session_lifetime_ms: &AtomicI64::new(0),
2161            },
2162        )
2163        .await
2164        .unwrap_err();
2165
2166        assert!(error.to_string().contains("announced 17"));
2167        assert!(error.to_string().contains("maximum 16"));
2168        server_task.await.unwrap();
2169    }
2170
2171    /// How the client classifies one SASL response.
2172    #[derive(Debug, PartialEq, Eq)]
2173    enum Verdict {
2174        Ok,
2175        Rejected(SaslAuthenticationError),
2176        Server(i16),
2177        Other(String),
2178    }
2179
2180    fn verdict(result: Result<(), OutboundSaslError>) -> Verdict {
2181        match result {
2182            Ok(()) => Verdict::Ok,
2183            Err(OutboundSaslError::Authentication(error)) => Verdict::Rejected(error),
2184            Err(OutboundSaslError::Server { error_code, .. }) => Verdict::Server(error_code),
2185            Err(error) => Verdict::Other(error.to_string()),
2186        }
2187    }
2188
2189    /// `SaslClientAuthenticator.handleSaslHandshakeResponse` raises
2190    /// `UnsupportedSaslMechanismException` for 33 and
2191    /// `IllegalSaslStateException` for 34 and any other code. Both extend
2192    /// `AuthenticationException`.
2193    #[test]
2194    fn handshake_error_codes_map_to_kafka_authentication_exceptions() {
2195        let enabled = "enabled mechanisms are [\"GSSAPI\"]";
2196        for (error_code, expected) in [
2197            (0, Verdict::Ok),
2198            (
2199                UNSUPPORTED_SASL_MECHANISM,
2200                Verdict::Rejected(SaslAuthenticationError::UnsupportedMechanism(format!(
2201                    "client SASL mechanism 'PLAIN' not enabled in the server, {enabled}"
2202                ))),
2203            ),
2204            (
2205                ILLEGAL_SASL_STATE,
2206                Verdict::Rejected(SaslAuthenticationError::IllegalState(format!(
2207                    "unexpected handshake request with client mechanism PLAIN, {enabled}"
2208                ))),
2209            ),
2210            (
2211                87,
2212                Verdict::Rejected(SaslAuthenticationError::IllegalState(format!(
2213                    "unknown error code 87, client mechanism is PLAIN, {enabled}"
2214                ))),
2215            ),
2216        ] {
2217            let response = SaslHandshakeResponse {
2218                error_code,
2219                mechanisms: vec!["GSSAPI".into()],
2220                ..Default::default()
2221            };
2222            check!(
2223                verdict(handshake_result(SaslMechanism::Plain, &response)) == expected,
2224                "error_code {error_code}"
2225            );
2226        }
2227    }
2228
2229    /// `SaslClientAuthenticator.receiveToken` raises `Errors.exception()`.
2230    /// Only 33, 34 and 58 give an `AuthenticationException`. Another code
2231    /// closes the connection in the `AUTHENTICATE` state, which Kafka retries.
2232    #[test]
2233    fn authenticate_error_codes_map_to_kafka_exceptions() {
2234        let message = |code: i16| {
2235            format!("SaslAuthenticate(PLAIN) error_code={code} error_message=Some(\"no\")")
2236        };
2237        for (error_code, expected) in [
2238            (
2239                UNSUPPORTED_SASL_MECHANISM,
2240                Verdict::Rejected(SaslAuthenticationError::UnsupportedMechanism(message(33))),
2241            ),
2242            (
2243                ILLEGAL_SASL_STATE,
2244                Verdict::Rejected(SaslAuthenticationError::IllegalState(message(34))),
2245            ),
2246            (
2247                SASL_AUTHENTICATION_FAILED,
2248                Verdict::Rejected(SaslAuthenticationError::Failed(message(58))),
2249            ),
2250            (35, Verdict::Server(35)),
2251            (-1, Verdict::Server(-1)),
2252        ] {
2253            let response = SaslAuthenticateResponse {
2254                error_code,
2255                error_message: Some("no".into()),
2256                ..Default::default()
2257            };
2258            check!(
2259                verdict(Err(authenticate_error("PLAIN", &response))) == expected,
2260                "error_code {error_code}"
2261            );
2262        }
2263    }
2264
2265    fn assert_request_header(
2266        req: &[u8],
2267        api_key: ApiKey,
2268        api_version: ApiVersion,
2269        corr_id: i32,
2270        flexible: bool,
2271    ) {
2272        check!(ApiKey(i16::from_be_bytes([req[0], req[1]])) == api_key);
2273        check!(ApiVersion(i16::from_be_bytes([req[2], req[3]])) == api_version);
2274        check!(i32::from_be_bytes([req[4], req[5], req[6], req[7]]) == corr_id);
2275        let client_len = i16::from_be_bytes([req[8], req[9]]);
2276        assert_eq!(client_len, i16::try_from(TEST_CLIENT_ID.len()).unwrap());
2277        assert_eq!(
2278            &req[10..10 + TEST_CLIENT_ID.len()],
2279            TEST_CLIENT_ID.as_bytes()
2280        );
2281        if flexible {
2282            assert_eq!(req[10 + TEST_CLIENT_ID.len()], 0);
2283        }
2284    }
2285
2286    fn request_body(req: &[u8], flexible: bool) -> &[u8] {
2287        let header_len = 10 + TEST_CLIENT_ID.len() + usize::from(flexible);
2288        &req[header_len..]
2289    }
2290
2291    async fn write_response_frame<S>(stream: &mut S, corr_id: i32, body: &[u8], flex_header: bool)
2292    where
2293        S: AsyncWrite + Unpin,
2294    {
2295        let mut frame = BytesMut::new();
2296        frame.put_i32(corr_id);
2297        if flex_header {
2298            frame.put_u8(0);
2299        }
2300        frame.put_slice(body);
2301        stream
2302            .write_u32(u32::try_from(frame.len()).unwrap())
2303            .await
2304            .unwrap();
2305        stream.write_all(&frame).await.unwrap();
2306        stream.flush().await.unwrap();
2307    }
2308}