Skip to main content

krabka_security/
oauthbearer.rs

1//! Pure SASL/OAUTHBEARER logic (KIP-255 / RFC 7628).
2//!
3//! Two pieces, both I/O-free so the broker can unit-test them without a
4//! socket:
5//!
6//! 1. [`parse_client_initial_response`] decodes the RFC 7628 client initial
7//!    response (`n,,\x01auth=Bearer <token>\x01\x01`) into its bearer token
8//!    and optional authzid.
9//! 2. [`UnsecuredJwsValidator`] validates the bearer token as an *unsecured*
10//!    JWS (`alg: none`) and reads the connection principal from a claim.
11//!    This mirrors Kafka's `OAuthBearerUnsecuredValidatorCallbackHandler`,
12//!    the built-in development/testing validator. Signed-token (JWKS)
13//!    validation is separate.
14
15use std::{fmt, str::from_utf8, sync::Arc};
16
17use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64URL};
18use jsonpath_rust::{parser::model::JpQuery, query::js_path_process};
19use krabka_units::{Time, convert::TimeExt, fmt::Human as _, secs};
20use serde_json::Value;
21
22use crate::{AuthError, AuthMethod, Principal, jwks::JwksHandle};
23
24/// Outcome of an OAUTHBEARER validation: the authenticated principal plus the
25/// token's expiry.
26///
27/// The expiry populates `SaslAuthenticateResponse.session_lifetime_ms`. The
28/// dispatch loop also uses it to schedule per-connection re-auth deadlines
29/// (KIP-368).
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct AuthOutcome {
32    pub principal: Principal,
33    /// Token expiry as Unix epoch milliseconds. `None` means "no expiry / no
34    /// re-auth required" and is reserved for future non-OAuth paths. For
35    /// OAUTHBEARER this is always `Some`, because validators reject tokens
36    /// without `exp`.
37    pub expires_at_ms: Option<i64>,
38}
39
40/// Parsed RFC 7628 client initial response.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ClientInitialResponse {
43    /// The bearer token (the value after `auth=Bearer` and its spaces).
44    pub token: String,
45    /// The GS2 authorization id after `a=`, verbatim, if the client supplied
46    /// one.
47    pub authzid: Option<String>,
48}
49
50/// Parse the SASL/OAUTHBEARER client initial response (RFC 7628 §3.1).
51///
52/// The grammar is the one that Kafka 4.3.1's
53/// `org.apache.kafka.common.security.oauthbearer.internals.OAuthBearerClientInitialResponse`
54/// matches against the whole message (`^A` = `\x01`):
55///
56/// ```text
57/// message  = "n," ["a=" saslname] "," ^A *(key "=" value ^A) ^A
58/// saslname = 1*([\x01-\x7F] except "=" and "," / "=2C" / "=3D")
59/// key      = 1*[A-Za-z]
60/// value    = 1*[\x21-\x7E SP HTAB CR LF]
61/// auth     = scheme 1*SP token
62/// scheme   = 1*[A-Za-z0-9_]
63/// token    = 1*[-_~+/.A-Za-z0-9] *"="
64/// ```
65///
66/// The channel-binding flag must be `n`. The authzid is returned verbatim:
67/// Kafka does not decode `=2C` or `=3D`. An empty authzid is `None`. The
68/// `auth` kvpair is required, and its scheme must be `Bearer` in any letter
69/// case. If `auth` occurs more than once, the last one wins, as in Kafka's
70/// map. The other kvpairs (SASL extensions such as `host` and `port`) must
71/// match the grammar, but this function does not return them.
72///
73/// # Errors
74///
75/// [`AuthError::MalformedMessage`] when the message does not match the
76/// grammar, when the `auth` kvpair is missing, or when its scheme is not
77/// `Bearer`.
78pub fn parse_client_initial_response(bytes: &[u8]) -> Result<ClientInitialResponse, AuthError> {
79    let s = from_utf8(bytes).map_err(|_| AuthError::MalformedMessage)?;
80    let (authzid, rest) = parse_gs2_header(s)?;
81
82    // `rest` is `*(key "=" value ^A) ^A`, with nothing after the final `^A`.
83    let kvpairs = rest
84        .strip_suffix(KVSEP)
85        .ok_or(AuthError::MalformedMessage)?;
86    let mut auth = None;
87    if !kvpairs.is_empty() {
88        let kvpairs = kvpairs
89            .strip_suffix(KVSEP)
90            .ok_or(AuthError::MalformedMessage)?;
91        for pair in kvpairs.split(KVSEP) {
92            let (key, value) = pair.split_once('=').ok_or(AuthError::MalformedMessage)?;
93            if !is_kvpair_key(key) || !is_kvpair_value(value) {
94                return Err(AuthError::MalformedMessage);
95            }
96            if key == "auth" {
97                auth = Some(value);
98            }
99        }
100    }
101
102    let token = parse_bearer_auth(auth.ok_or(AuthError::MalformedMessage)?)?;
103    Ok(ClientInitialResponse {
104        token: token.to_owned(),
105        authzid,
106    })
107}
108
109/// The RFC 7628 kvpair separator.
110const KVSEP: char = '\u{1}';
111
112/// Parse the GS2 header `"n," ["a=" saslname] "," ^A` at the start of a
113/// client initial response.
114///
115/// This function returns the authzid verbatim (`None` when it is absent) and
116/// the input after the header's trailing `^A`. Kafka accepts only the `n`
117/// channel-binding flag: OAUTHBEARER never negotiates channel binding.
118fn parse_gs2_header(message: &str) -> Result<(Option<String>, &str), AuthError> {
119    let rest = message
120        .strip_prefix("n,")
121        .ok_or(AuthError::MalformedMessage)?;
122    let (authzid, rest) = if let Some(rest) = rest.strip_prefix(',') {
123        (None, rest)
124    } else {
125        // A saslname never contains ",", so the first "," ends it.
126        let (field, rest) = rest.split_once(',').ok_or(AuthError::MalformedMessage)?;
127        let name = field
128            .strip_prefix("a=")
129            .ok_or(AuthError::MalformedMessage)?;
130        if !is_saslname(name) {
131            return Err(AuthError::MalformedMessage);
132        }
133        (Some(name.to_owned()), rest)
134    };
135    let rest = rest
136        .strip_prefix(KVSEP)
137        .ok_or(AuthError::MalformedMessage)?;
138    Ok((authzid, rest))
139}
140
141/// Whether `name` matches Kafka's `SASLNAME`: one or more of an ASCII
142/// character from `\x01` to `\x7F` other than `=` and `,`, `=2C`, or `=3D`.
143fn is_saslname(name: &str) -> bool {
144    let bytes = name.as_bytes();
145    let mut i = 0;
146    while let Some(&byte) = bytes.get(i) {
147        match byte {
148            b'=' => {
149                let escape = bytes.get(i + 1..i + 3);
150                if escape != Some(b"2C".as_slice()) && escape != Some(b"3D".as_slice()) {
151                    return false;
152                }
153                i += 3;
154            }
155            b',' | 0x00 | 0x80.. => return false,
156            _ => i += 1,
157        }
158    }
159    !bytes.is_empty()
160}
161
162/// Whether `key` matches Kafka's kvpair `KEY`: one or more ASCII letters.
163fn is_kvpair_key(key: &str) -> bool {
164    !key.is_empty() && key.bytes().all(|b| b.is_ascii_alphabetic())
165}
166
167/// Whether `value` matches Kafka's kvpair `VALUE`: one or more of a visible
168/// ASCII character, space, tab, CR, or LF.
169fn is_kvpair_value(value: &str) -> bool {
170    !value.is_empty()
171        && value
172            .bytes()
173            .all(|b| matches!(b, 0x21..=0x7E | b' ' | b'\t' | b'\r' | b'\n'))
174}
175
176/// Return the token of an `auth` kvpair value, which must be
177/// `scheme 1*SP token` with the scheme `Bearer` in any letter case.
178///
179/// Kafka's `AUTH_PATTERN` is `(?<scheme>[\w]+)[ ]+(?<token>[-_~+/\.a-zA-Z0-9]+([=]*))`,
180/// where `\w` is the ASCII class `[A-Za-z0-9_]`.
181fn parse_bearer_auth(auth: &str) -> Result<&str, AuthError> {
182    let (scheme, rest) = auth.split_once(' ').ok_or(AuthError::MalformedMessage)?;
183    let token = rest.trim_start_matches(' ');
184    let is_scheme_byte = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
185    let is_token_byte = |b: u8| b.is_ascii_alphanumeric() || b"-_~+/.".contains(&b);
186    let unpadded = token.trim_end_matches('=');
187    let well_formed = !scheme.is_empty()
188        && scheme.bytes().all(is_scheme_byte)
189        && !unpadded.is_empty()
190        && unpadded.bytes().all(is_token_byte);
191    if well_formed && scheme.eq_ignore_ascii_case("bearer") {
192        Ok(token)
193    } else {
194        Err(AuthError::MalformedMessage)
195    }
196}
197
198/// Validates an *unsecured* JWS bearer token (`alg: none`) and derives the
199/// connection principal. Mirrors Kafka's
200/// `OAuthBearerUnsecuredValidatorCallbackHandler`.
201#[derive(Debug, Clone, PartialEq)]
202pub struct UnsecuredJwsValidator {
203    /// Claim whose string value becomes the principal name. Default `sub`.
204    pub principal_claim_name: String,
205    /// Tolerance applied to the `exp` / `iat` temporal checks to absorb clock
206    /// drift between the client and broker. Default 0, Kafka's
207    /// `unsecuredValidatorAllowableClockSkewMs`.
208    pub allowable_clock_skew: Time,
209    /// Precompiled `JsonPath` expression that the validator evaluates against
210    /// the token's claim set. The validator rejects the token when the
211    /// expression yields empty/null/false. Compile it once at validator
212    /// construction.
213    pub custom_claim_check: Option<JpQuery>,
214    /// When set, the JWT `typ` header field must equal this
215    /// string. The validator ignores this field when unset.
216    pub valid_token_type: Option<String>,
217    /// Alternate claim name to read the principal name from
218    /// when `principal_claim_name` is absent or empty. Strimzi calls this the
219    /// "service-account fallback": `sub` typically holds a UUID, and
220    /// `client_id` is the readable name.
221    pub fallback_user_name_claim: Option<String>,
222    /// Prepended to the resolved principal name ONLY when
223    /// the fallback claim fires. Strimzi convention: "service-account-".
224    pub fallback_user_name_prefix: Option<String>,
225    /// Precompiled `JsonPath` expression that extracts group
226    /// memberships from the token claims. Compile it once at startup.
227    pub groups_claim: Option<JpQuery>,
228    /// When `groups_claim` resolves to a string and not an
229    /// array, split on this delimiter. Common values: "," or " ".
230    pub groups_claim_delimiter: Option<String>,
231    /// Claim that holds the token's scope, Kafka's
232    /// `unsecuredValidatorScopeClaimName`. Default `scope`.
233    pub scope_claim_name: String,
234    /// Scope items the token must all carry, Kafka's
235    /// `unsecuredValidatorRequiredScope`. Empty, the default, requires none.
236    /// [`parse_required_scope`] reads the option's space-delimited value.
237    pub required_scope: Vec<String>,
238}
239
240impl Default for UnsecuredJwsValidator {
241    fn default() -> Self {
242        Self {
243            principal_claim_name: "sub".to_owned(),
244            allowable_clock_skew: <Time as TimeExt>::ZERO,
245            custom_claim_check: None,
246            valid_token_type: None,
247            fallback_user_name_claim: None,
248            fallback_user_name_prefix: None,
249            groups_claim: None,
250            groups_claim_delimiter: None,
251            scope_claim_name: "scope".to_owned(),
252            required_scope: Vec::new(),
253        }
254    }
255}
256
257impl UnsecuredJwsValidator {
258    /// Validate `token` against `now_ms` and return the authenticated
259    /// [`Principal`] on success.
260    ///
261    /// `now_ms` is Unix epoch milliseconds.
262    ///
263    /// # Errors
264    ///
265    /// [`AuthError::InvalidToken`] for any structural, signature, temporal,
266    /// scope-claim, or principal-claim failure, and
267    /// [`AuthError::InsufficientScope`] for an otherwise valid token that lacks
268    /// an item of [`required_scope`](Self::required_scope). Kafka's
269    /// `OAuthBearerUnsecuredValidatorCallbackHandler` makes the same split.
270    /// [`oauth_error_json`] renders either one as the RFC 7628 server error
271    /// challenge.
272    // skip_all keeps the bearer `token` out of span fields; only the
273    // non-sensitive mechanism is recorded. `err` surfaces the failure reason
274    // (Debug) without the token contents.
275    #[tracing::instrument(
276        level = "info",
277        skip_all,
278        fields(mechanism = "OAUTHBEARER", validator = "unsecured"),
279        err
280    )]
281    pub fn validate(&self, token: &str, now_ms: i64) -> Result<AuthOutcome, AuthError> {
282        // JWS compact serialization: header.payload.signature. For `alg:none`
283        // the signature segment is empty.
284        let mut segs = token.split('.');
285        let header_b64 = segs.next().ok_or(AuthError::InvalidToken)?;
286        let payload_b64 = segs.next().ok_or(AuthError::InvalidToken)?;
287        let sig = segs.next().ok_or(AuthError::InvalidToken)?;
288        if segs.next().is_some() {
289            return Err(AuthError::InvalidToken);
290        }
291        if !sig.is_empty() {
292            // Signed token — needs JWKS signature verification.
293            return Err(AuthError::InvalidToken);
294        }
295
296        let header: Value = decode_json_segment(header_b64)?;
297        if header.get("alg").and_then(Value::as_str) != Some("none") {
298            return Err(AuthError::InvalidToken);
299        }
300        // Optional JWT `typ` header check (JWT-mode validator only).
301        if let Some(expected_typ) = &self.valid_token_type
302            && header.get("typ").and_then(Value::as_str) != Some(expected_typ.as_str())
303        {
304            return Err(AuthError::InvalidToken);
305        }
306
307        let claims: Value = decode_json_segment(payload_b64)?;
308        // Kafka reads the scope claim while it parses the token, so a scope
309        // claim of the wrong type fails the token whatever the required scope.
310        let token_scope = scope_claim(&claims, &self.scope_claim_name)?;
311
312        let skew_ms = skew_millis(self.allowable_clock_skew);
313        // `exp` is required and must be in the future (within skew).
314        let exp_ms = numeric_date_ms(&claims, "exp").ok_or(AuthError::InvalidToken)?;
315        if exp_ms + skew_ms <= now_ms {
316            return Err(AuthError::InvalidToken);
317        }
318        // `iat` is optional. When present it must be a number, must not be in
319        // the future (within skew), and must be before `exp`. Kafka's
320        // `validateTimeConsistency` compares the two claims in seconds, without
321        // skew.
322        if let Some(iat) = claims.get("iat") {
323            let iat_ms = numeric_date_ms(&claims, "iat").ok_or(AuthError::InvalidToken)?;
324            if iat_ms - skew_ms > now_ms {
325                return Err(AuthError::InvalidToken);
326            }
327            let (Some(issued_at_secs), Some(expires_at_secs)) =
328                (iat.as_f64(), claims.get("exp").and_then(Value::as_f64))
329            else {
330                return Err(AuthError::InvalidToken);
331            };
332            if expires_at_secs <= issued_at_secs {
333                return Err(AuthError::InvalidToken);
334            }
335        }
336
337        // Optional JsonPath custom_claim_check.
338        if let Some(path) = &self.custom_claim_check
339            && !evaluate_custom_claim_check(path, &claims)
340        {
341            return Err(AuthError::InvalidToken);
342        }
343
344        // Primary → fallback → reject. Prefix applied only
345        // when fallback fires. Kafka rejects a blank principal with
346        // `Utils.isBlank`, so a blank claim counts as absent.
347        let (raw_name, used_fallback) = if let Some(n) = claims
348            .get(&self.principal_claim_name)
349            .and_then(Value::as_str)
350            .filter(|s| !is_java_blank(s))
351        {
352            (n.to_owned(), false)
353        } else {
354            let fallback_claim = self
355                .fallback_user_name_claim
356                .as_deref()
357                .ok_or(AuthError::InvalidToken)?;
358            let raw = claims
359                .get(fallback_claim)
360                .and_then(Value::as_str)
361                .filter(|s| !is_java_blank(s))
362                .ok_or(AuthError::InvalidToken)?;
363            (raw.to_owned(), true)
364        };
365        let name = if used_fallback {
366            match &self.fallback_user_name_prefix {
367                Some(prefix) => format!("{prefix}{raw_name}"),
368                None => raw_name,
369            }
370        } else {
371            raw_name
372        };
373
374        // Groups extraction.
375        let groups = self.groups_claim.as_ref().map_or_else(Vec::new, |path| {
376            extract_groups(path, &claims, self.groups_claim_delimiter.as_deref())
377        });
378
379        // Kafka's `validateScope` runs last, so only a token that passes every
380        // other check reports `insufficient_scope`.
381        if !self
382            .required_scope
383            .iter()
384            .all(|item| token_scope.contains(item))
385        {
386            return Err(AuthError::InsufficientScope {
387                required_scope: self.required_scope.clone(),
388            });
389        }
390
391        Ok(AuthOutcome {
392            principal: Principal {
393                name,
394                auth_method: AuthMethod::SaslOAuthBearer,
395                groups,
396            },
397            expires_at_ms: Some(exp_ms),
398        })
399    }
400}
401
402/// Extract group memberships from token claims with a precompiled `JsonPath`.
403///
404/// This function reads each result element by its JSON type:
405/// - `String`: if `delimiter` is set, split + trim + drop empty;
406///   otherwise the whole string becomes one group.
407/// - `Array`: each string element becomes a group.
408/// - `Number` / `Object` / `Null`: ignored, with no error.
409///
410/// This function returns `vec![]` for empty matches. Zero groups is not an
411/// error, because the token can legitimately have no groups.
412fn extract_groups(path: &JpQuery, claims: &Value, delimiter: Option<&str>) -> Vec<String> {
413    let Ok(refs) = js_path_process(path, claims) else {
414        return Vec::new();
415    };
416    let mut out = Vec::new();
417    for r in refs {
418        match r.val {
419            Value::String(s) => match delimiter {
420                Some(d) => out.extend(
421                    s.split(d)
422                        .map(str::trim)
423                        .filter(|s| !s.is_empty())
424                        .map(String::from),
425                ),
426                None => out.push(s.clone()),
427            },
428            Value::Array(items) => {
429                out.extend(items.iter().filter_map(Value::as_str).map(String::from));
430            }
431            _ => {} // ignore numbers, objects, nulls
432        }
433    }
434    out
435}
436
437/// Evaluate a precompiled `JsonPath` expression against the token claims.
438///
439/// This function returns true when the result is truthy, which means non-empty
440/// with no element that is null or false. It returns false in every other case.
441/// This matches Strimzi's "expression yields truthy" semantics. An error during
442/// evaluation counts as falsy and rejects the token.
443fn evaluate_custom_claim_check(path: &JpQuery, claims: &Value) -> bool {
444    let Ok(refs) = js_path_process(path, claims) else {
445        return false;
446    };
447    if refs.is_empty() {
448        return false;
449    }
450    for r in refs {
451        match r.val {
452            Value::Null | Value::Bool(false) => return false,
453            _ => {}
454        }
455    }
456    true
457}
458
459/// Whether the JWT `aud` claim contains `expected`. `aud` is a single string
460/// or an array of strings (RFC 7519 §4.1.3).
461fn audience_contains(claims: &Value, expected: &str) -> bool {
462    match claims.get("aud") {
463        Some(Value::String(s)) => s == expected,
464        Some(Value::Array(items)) => items
465            .iter()
466            .filter_map(Value::as_str)
467            .any(|a| a == expected),
468        _ => false,
469    }
470}
471
472/// base64url-decode a JWS segment and parse it as JSON.
473fn decode_json_segment(seg: &str) -> Result<Value, AuthError> {
474    let bytes = B64URL.decode(seg).map_err(|_| AuthError::InvalidToken)?;
475    serde_json::from_slice(&bytes).map_err(|_| AuthError::InvalidToken)
476}
477
478/// A clock-skew tolerance in whole milliseconds, the unit this module
479/// normalizes the JWT `NumericDate` claims to.
480///
481/// The temporal checks compare epoch-millisecond instants, which stay raw
482/// integers, so the tolerance must use that unit. Rounding to nearest matches
483/// every other timeout that crosses this seam. No wire field carries this
484/// value, so truncation is not necessary.
485fn skew_millis(skew: Time) -> i64 {
486    skew.millis_i64()
487}
488
489/// Read a JWT `NumericDate` claim and convert it to integer milliseconds.
490///
491/// A `NumericDate` is a count of seconds since the epoch and can be fractional.
492fn numeric_date_ms(claims: &Value, key: &str) -> Option<i64> {
493    let v = claims.get(key)?;
494    // The common case: an integer second count. Avoids any float rounding.
495    if let Some(secs) = v.as_i64() {
496        return secs.checked_mul(1000);
497    }
498    // Fractional NumericDate (rare): truncate to whole milliseconds.
499    let ms = v.as_f64()? * 1000.0;
500    if ms.is_finite() {
501        ms.trunc().to_string().parse().ok()
502    } else {
503        None
504    }
505}
506
507/// The RFC 7628 server error challenge for a failed OAUTHBEARER validation,
508/// byte for byte what Kafka 4.3.1's `OAuthBearerSaslServer.jsonErrorResponse`
509/// sends.
510///
511/// [`AuthError::InsufficientScope`] renders as
512/// `{"status":"insufficient_scope", "scope":"[a, b]"}`: the required scope in
513/// Java's `List.toString` form, after the `", "` separator Kafka writes before
514/// the `scope` field. Every other error renders as
515/// `{"status":"invalid_token"}`. No krabka validator produces an
516/// `openid-configuration` field, so the challenge never carries one.
517///
518/// The broker sends this as the server's first message after a failed
519/// validation. The JVM `OAuthBearerSaslClient` treats any non-empty first
520/// server message as an error and replies with a single `\x01` kvsep, and the
521/// broker then completes the failure handshake.
522#[must_use]
523pub fn oauth_error_json(err: &AuthError) -> String {
524    match err {
525        // A required scope item matches Kafka's scope-token grammar, which has
526        // no `"` or `\`, so the scope needs no JSON escaping. Kafka escapes
527        // nothing either.
528        AuthError::InsufficientScope { required_scope } => format!(
529            "{{\"status\":\"insufficient_scope\", \"scope\":\"[{}]\"}}",
530            required_scope.join(", ")
531        ),
532        _ => "{\"status\":\"invalid_token\"}".to_owned(),
533    }
534}
535
536/// A required-scope item outside Kafka's scope-token grammar
537/// (`%x21 / %x23-5B / %x5D-7E`, RFC 6749 §3.3), as
538/// [`parse_required_scope`] reports it.
539#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
540#[error("invalid scope value: {0}")]
541pub struct InvalidScopeItem(pub String);
542
543/// Parse Kafka's `unsecuredValidatorRequiredScope` option the way Kafka 4.3.1's
544/// `OAuthBearerUnsecuredValidatorCallbackHandler` does.
545///
546/// The option is trimmed, then split on single spaces, and empty items are
547/// skipped. A blank option requires no scope. The result is the value of
548/// [`UnsecuredJwsValidator::required_scope`].
549///
550/// # Errors
551///
552/// [`InvalidScopeItem`] for the first item that has a character outside the
553/// scope-token grammar, such as `"`, `\`, a tab, or a non-ASCII character.
554/// Kafka fails the validator's configuration on it.
555pub fn parse_required_scope(option: &str) -> Result<Vec<String>, InvalidScopeItem> {
556    java_trim(option)
557        .split(' ')
558        .filter(|item| !item.is_empty())
559        .map(|item| {
560            if item
561                .chars()
562                .all(|c| matches!(c, '\x21' | '\x23'..='\x5B' | '\x5D'..='\x7E'))
563            {
564                Ok(item.to_owned())
565            } else {
566                Err(InvalidScopeItem(item.to_owned()))
567            }
568        })
569        .collect()
570}
571
572/// Java's `String.trim`: strip every leading and trailing character at or
573/// below U+0020.
574fn java_trim(s: &str) -> &str {
575    s.trim_matches(|c: char| c <= ' ')
576}
577
578/// Kafka's `Utils.isBlank`: empty after [`java_trim`].
579fn is_java_blank(s: &str) -> bool {
580    java_trim(s).is_empty()
581}
582
583/// The text Jackson's `JsonNode.asText` gives a JSON value. Kafka's unsecured
584/// JWS reads every non-number claim, and every element of an array claim,
585/// through it. A container has no text.
586fn jackson_text(v: &Value) -> String {
587    match v {
588        Value::String(s) => s.clone(),
589        Value::Number(n) => n.to_string(),
590        Value::Bool(b) => b.to_string(),
591        Value::Null => "null".to_owned(),
592        Value::Array(_) | Value::Object(_) => String::new(),
593    }
594}
595
596/// The token's scope as Kafka's `OAuthBearerUnsecuredJws.calculateScope`
597/// reads it from the `claim_name` claim.
598///
599/// An absent claim is no scope. An array is one item per non-blank element,
600/// trimmed. A string is one item, trimmed and not split, or no scope when
601/// blank, and Kafka reads a boolean, `null`, or object claim as such a string.
602///
603/// # Errors
604///
605/// [`AuthError::InvalidToken`] for a numeric claim, which Kafka cannot cast to
606/// a string or a list.
607fn scope_claim(claims: &Value, claim_name: &str) -> Result<Vec<String>, AuthError> {
608    let items = match claims.get(claim_name) {
609        None => Vec::new(),
610        Some(Value::Number(_)) => return Err(AuthError::InvalidToken),
611        Some(Value::Array(elements)) => elements.iter().map(jackson_text).collect(),
612        Some(other) => vec![jackson_text(other)],
613    };
614    Ok(items
615        .iter()
616        .filter(|item| !is_java_blank(item))
617        .map(|item| java_trim(item).to_owned())
618        .collect())
619}
620
621/// Validates a *signed* JWS bearer token (`RS256` / `ES256`) against a JWKS
622/// key set from the identity provider.
623///
624/// The validator then checks the standard JWT claims and derives the connection
625/// principal.
626///
627/// The key set lives behind a [`JwksHandle`] so the broker's background
628/// refresher can rotate keys without a broker restart and without a lock. Each
629/// call to [`validate`](Self::validate) reads the current set.
630#[derive(Debug, Clone)]
631pub struct SignedJwsValidator {
632    /// Claim whose string value becomes the principal name. Default `sub`.
633    pub principal_claim_name: String,
634    /// Tolerance applied to `exp` / `iat` / `nbf`.
635    pub allowable_clock_skew: Time,
636    /// When set, the token `iss` claim must equal this exactly.
637    pub valid_issuer: Option<String>,
638    /// When set, the token `aud` claim must contain this value.
639    pub expected_audience: Option<String>,
640    /// Precompiled `JsonPath` `custom_claim_check`. See
641    /// [`UnsecuredJwsValidator`] for semantics.
642    pub custom_claim_check: Option<JpQuery>,
643    /// JWT `typ` header check. Ignored when unset.
644    pub valid_token_type: Option<String>,
645    /// Alternate principal claim. See [`UnsecuredJwsValidator`].
646    pub fallback_user_name_claim: Option<String>,
647    /// Prepended to the principal name only on fallback.
648    pub fallback_user_name_prefix: Option<String>,
649    /// Precompiled `JsonPath` that extracts group memberships.
650    pub groups_claim: Option<JpQuery>,
651    /// Delimiter when `groups_claim` resolves to a string.
652    pub groups_claim_delimiter: Option<String>,
653    /// Hard cache-expiry threshold. When set, the validator rejects tokens if
654    /// the paired refresher has not had a successful fetch within this window.
655    /// The check reads [`JwksHandle::last_successful_fetch_ms`]. `None` = no
656    /// expiry check. The validator fails closed on a long `IdP` outage, so a
657    /// rotated-out key can't keep signing valid tokens indefinitely.
658    pub cache_expiry: Option<Time>,
659    /// The live JWKS. The broker's refresher swaps it in.
660    keys: JwksHandle,
661}
662
663impl SignedJwsValidator {
664    /// A validator backed by `keys`, with the unsecured validator's claim
665    /// defaults, Kafka's 30 s `sasl.oauthbearer.clock.skew.seconds`, and no
666    /// issuer / audience constraint.
667    #[must_use]
668    pub fn new(keys: JwksHandle) -> Self {
669        Self {
670            principal_claim_name: "sub".to_owned(),
671            allowable_clock_skew: secs(30),
672            valid_issuer: None,
673            expected_audience: None,
674            custom_claim_check: None,
675            valid_token_type: None,
676            fallback_user_name_claim: None,
677            fallback_user_name_prefix: None,
678            groups_claim: None,
679            groups_claim_delimiter: None,
680            cache_expiry: None,
681            keys,
682        }
683    }
684
685    /// The shared key-set handle, so the broker can hand the same cell to its
686    /// JWKS refresher task.
687    #[must_use]
688    pub fn key_handle(&self) -> JwksHandle {
689        self.keys.clone()
690    }
691
692    /// Validate a signed bearer token against `now_ms`.
693    ///
694    /// `now_ms` is Unix epoch milliseconds.
695    ///
696    /// # Errors
697    ///
698    /// [`AuthError::InvalidToken`] for any structural, signature, temporal,
699    /// issuer, audience, scope, or principal-claim failure.
700    // skip_all keeps the bearer `token` (and its signature) out of span
701    // fields; only the non-sensitive mechanism is recorded. `err` surfaces the
702    // rejection reason (Debug) without leaking the token.
703    #[tracing::instrument(
704        level = "info",
705        skip_all,
706        fields(mechanism = "OAUTHBEARER", validator = "signed"),
707        err
708    )]
709    pub fn validate(&self, token: &str, now_ms: i64) -> Result<AuthOutcome, AuthError> {
710        // JWS compact serialization: header.payload.signature, all non-empty.
711        let mut segs = token.split('.');
712        let header_b64 = segs.next().ok_or(AuthError::InvalidToken)?;
713        let payload_b64 = segs.next().ok_or(AuthError::InvalidToken)?;
714        let sig_b64 = segs.next().ok_or(AuthError::InvalidToken)?;
715        if segs.next().is_some() || sig_b64.is_empty() {
716            return Err(AuthError::InvalidToken);
717        }
718
719        let header: Value = decode_json_segment(header_b64)?;
720        let alg = header
721            .get("alg")
722            .and_then(Value::as_str)
723            .ok_or(AuthError::InvalidToken)?;
724        if alg != "RS256" && alg != "ES256" {
725            return Err(AuthError::InvalidToken);
726        }
727        // Optional JWT `typ` check (JWT-mode validator only).
728        if let Some(expected_typ) = &self.valid_token_type
729            && header.get("typ").and_then(Value::as_str) != Some(expected_typ.as_str())
730        {
731            return Err(AuthError::InvalidToken);
732        }
733
734        // Hard cache-expiry. If the last successful refresh is older than
735        // `cache_expiry`, reject all tokens until the refresher succeeds
736        // again. The `last_fetch > 0` guard skips this on a never-fetched
737        // handle so the "broker is still starting up" path stays open (the
738        // verify-level check below will reject anyway because the key set is
739        // empty).
740        if let Some(cache_expiry) = self.cache_expiry {
741            let last_fetch = self.keys.last_successful_fetch_ms();
742            if last_fetch > 0 && now_ms.saturating_sub(last_fetch) > cache_expiry.millis_i64() {
743                tracing::debug!(
744                    last_fetch_ms = last_fetch,
745                    now_ms,
746                    cache_expiry = %cache_expiry.human(),
747                    "JWKS cache expired; rejecting token until next successful refresh",
748                );
749                return Err(AuthError::InvalidToken);
750            }
751        }
752
753        let kid = header.get("kid").and_then(Value::as_str);
754
755        let signing_input = format!("{header_b64}.{payload_b64}");
756        let sig = B64URL
757            .decode(sig_b64)
758            .map_err(|_| AuthError::InvalidToken)?;
759        // On any verify failure (unknown kid or bad signature)
760        // signal the refresher to attempt an on-demand JWKS fetch — the
761        // signing key may have rotated since the last periodic refresh.
762        // The current token still rejects; a subsequent reconnect will
763        // see the rotated keys. The refresher's `min_on_demand_pause`
764        // caps the signal-storm cost.
765        if let Err(e) = self
766            .keys
767            .load()
768            .verify(kid, alg, signing_input.as_bytes(), &sig)
769        {
770            self.keys.signal_refresh();
771            return Err(e);
772        }
773
774        let claims: Value = decode_json_segment(payload_b64)?;
775        self.check_claims(&claims, now_ms)
776    }
777
778    /// Apply the claim policy to already-signature-verified `claims`.
779    ///
780    /// The policy covers the temporal, issuer, audience, scope, and principal
781    /// checks. It is a separate function so tests can drive it without minting
782    /// signed tokens.
783    fn check_claims(&self, claims: &Value, now_ms: i64) -> Result<AuthOutcome, AuthError> {
784        let skew_ms = skew_millis(self.allowable_clock_skew);
785        // `exp` required and in the future (within skew).
786        let exp_ms = numeric_date_ms(claims, "exp").ok_or(AuthError::InvalidToken)?;
787        if exp_ms + skew_ms <= now_ms {
788            return Err(AuthError::InvalidToken);
789        }
790        // `iat` optional: must not be in the future (within skew).
791        if let Some(iat_ms) = numeric_date_ms(claims, "iat")
792            && iat_ms - skew_ms > now_ms
793        {
794            return Err(AuthError::InvalidToken);
795        }
796        // `nbf` optional: token not valid before it (within skew).
797        if let Some(nbf_ms) = numeric_date_ms(claims, "nbf")
798            && nbf_ms - skew_ms > now_ms
799        {
800            return Err(AuthError::InvalidToken);
801        }
802
803        if let Some(expected) = &self.valid_issuer
804            && claims.get("iss").and_then(Value::as_str) != Some(expected.as_str())
805        {
806            return Err(AuthError::InvalidToken);
807        }
808
809        if let Some(expected) = &self.expected_audience
810            && !audience_contains(claims, expected)
811        {
812            return Err(AuthError::InvalidToken);
813        }
814
815        // Optional JsonPath custom_claim_check.
816        if let Some(path) = &self.custom_claim_check
817            && !evaluate_custom_claim_check(path, claims)
818        {
819            return Err(AuthError::InvalidToken);
820        }
821
822        // Primary → fallback → reject. Prefix on fallback only.
823        let (raw_name, used_fallback) = if let Some(n) = claims
824            .get(&self.principal_claim_name)
825            .and_then(Value::as_str)
826            .filter(|s| !s.is_empty())
827        {
828            (n.to_owned(), false)
829        } else {
830            let fallback_claim = self
831                .fallback_user_name_claim
832                .as_deref()
833                .ok_or(AuthError::InvalidToken)?;
834            let raw = claims
835                .get(fallback_claim)
836                .and_then(Value::as_str)
837                .filter(|s| !s.is_empty())
838                .ok_or(AuthError::InvalidToken)?;
839            (raw.to_owned(), true)
840        };
841        let name = if used_fallback {
842            match &self.fallback_user_name_prefix {
843                Some(prefix) => format!("{prefix}{raw_name}"),
844                None => raw_name,
845            }
846        } else {
847            raw_name
848        };
849
850        let groups = self.groups_claim.as_ref().map_or_else(Vec::new, |path| {
851            extract_groups(path, claims, self.groups_claim_delimiter.as_deref())
852        });
853
854        Ok(AuthOutcome {
855            principal: Principal {
856                name,
857                auth_method: AuthMethod::SaslOAuthBearer,
858                groups,
859            },
860            expires_at_ms: Some(exp_ms),
861        })
862    }
863}
864
865/// The broker's configured OAUTHBEARER token validator.
866///
867/// The choices are the development-only unsecured-JWS path, production
868/// signed-JWT validation against a JWKS endpoint, or RFC 7662 opaque-token
869/// introspection. The default is unsecured.
870#[derive(Debug, Clone)]
871pub enum OAuthBearerValidator {
872    /// Unsecured JWS (`alg:none`). Development / testing only.
873    Unsecured(UnsecuredJwsValidator),
874    /// Signed JWS verified against a JWKS key set.
875    Signed(SignedJwsValidator),
876    /// RFC 7662 opaque-token introspection.
877    Introspection(IntrospectionValidator),
878}
879
880impl Default for OAuthBearerValidator {
881    fn default() -> Self {
882        Self::Unsecured(UnsecuredJwsValidator::default())
883    }
884}
885
886impl OAuthBearerValidator {
887    /// Validate `token` against `now_ms` and dispatch to the configured path.
888    ///
889    /// # Errors
890    ///
891    /// - [`AuthError::InvalidToken`] when the token fails validation.
892    /// - [`AuthError::IntrospectionTransport`] when the introspection variant's
893    ///   HTTP call fails at the transport layer.
894    // Dispatcher over the configured validator. skip_all keeps `token` out of
895    // span fields. The match arms each produce a `Result<AuthOutcome, _>`
896    // value (the introspection arm is `.await`ed to a value), NOT a bare
897    // future, so `async_yields_async` does not fire. `err` surfaces the
898    // rejection reason (Debug) without leaking the token.
899    #[tracing::instrument(level = "info", skip_all, fields(mechanism = "OAUTHBEARER"), err)]
900    pub async fn validate(&self, token: &str, now_ms: i64) -> Result<AuthOutcome, AuthError> {
901        match self {
902            Self::Unsecured(v) => v.validate(token, now_ms),
903            Self::Signed(v) => v.validate(token, now_ms),
904            Self::Introspection(v) => v.validate(token, now_ms).await,
905        }
906    }
907
908    /// The JWKS handle when this is a signed validator, so the broker can wire
909    /// a refresher to the same key cell. `None` for the unsecured + introspection paths.
910    #[must_use]
911    pub fn jwks_handle(&self) -> Option<JwksHandle> {
912        match self {
913            Self::Unsecured(_) | Self::Introspection(_) => None,
914            Self::Signed(v) => Some(v.key_handle()),
915        }
916    }
917}
918
919/// HTTP transport contract for RFC 7662 introspection + OIDC userinfo.
920///
921/// This trait lives in this crate to keep `crates/security` as the validator
922/// surface. The concrete reqwest-backed impl lives in `crates/broker`, in
923/// `oauth_introspection.rs`, so this crate stays I/O-free.
924#[async_trait::async_trait]
925pub trait IntrospectionClient: Send + Sync + fmt::Debug {
926    /// POST the `IdP`'s introspection endpoint with `token` in a
927    /// form-encoded body. The caller checks `active` + claims.
928    async fn introspect(&self, token: &str) -> Result<serde_json::Value, IntrospectionError>;
929
930    /// GET the `IdP`'s userinfo endpoint with `Authorization: Bearer
931    /// <token>`. `Ok(None)` when the validator is configured without
932    /// userinfo enrichment.
933    async fn userinfo(&self, token: &str) -> Result<Option<serde_json::Value>, IntrospectionError>;
934}
935
936/// Transport-layer failures that [`IntrospectionClient`] reports.
937///
938/// The validator maps these onto [`AuthError::IntrospectionTransport`] for the
939/// SASL handler.
940#[derive(Debug, thiserror::Error)]
941pub enum IntrospectionError {
942    #[error("transport: {0}")]
943    Transport(String),
944    #[error("non-2xx response: {0}")]
945    Status(u16),
946    #[error("invalid JSON body")]
947    Parse,
948}
949
950/// RFC 7662 opaque-token introspection validator.
951///
952/// The validator calls the introspection endpoint once per token and does no
953/// caching. RFC 7662 §4 discourages caching without explicit lifetime info, and
954/// SASL runs once per connection, so the cost is acceptable.
955///
956/// After a successful introspection the validator can also call OIDC userinfo
957/// and merge the profile claims over the introspection claims.
958#[derive(Debug, Clone)]
959pub struct IntrospectionValidator {
960    pub client: Arc<dyn IntrospectionClient>,
961    /// Claim whose string value becomes the principal name. Default `sub`
962    /// for generic OAuth flows; commonly `client_id` for Keycloak
963    /// client-credentials.
964    pub principal_claim_name: String,
965    /// Precompiled `JsonPath` `custom_claim_check`. See
966    /// [`UnsecuredJwsValidator`] for semantics. Introspection has no JWT
967    /// header, so there is no `valid_token_type` field here.
968    pub custom_claim_check: Option<JpQuery>,
969    /// `true` iff a `userinfo_endpoint_uri` is configured. The validator then
970    /// calls `client.userinfo(token)` after a successful introspection and
971    /// merges the response over the introspection claims.
972    pub call_userinfo: bool,
973    /// Clock-skew tolerance for `exp`/`iat`/`nbf` checks on
974    /// introspection-response timestamps, when present.
975    pub allowable_clock_skew: Time,
976    /// When set, the introspection-response `aud` claim (RFC 7662 §2.2) must
977    /// contain this value. The default is `None`, which applies no audience
978    /// restriction. This check stops a token minted for another resource server
979    /// of the same `IdP` from authenticating here, even though that token still
980    /// introspects as `active: true`.
981    pub expected_audience: Option<String>,
982    /// Alternate principal claim. See [`UnsecuredJwsValidator`].
983    pub fallback_user_name_claim: Option<String>,
984    /// Prepended to the principal name only on fallback.
985    pub fallback_user_name_prefix: Option<String>,
986    /// Precompiled `JsonPath` that extracts group memberships from the merged
987    /// claims, which are the introspection claims plus the optional userinfo
988    /// claims.
989    pub groups_claim: Option<JpQuery>,
990    /// Delimiter when `groups_claim` resolves to a string.
991    pub groups_claim_delimiter: Option<String>,
992}
993
994impl IntrospectionValidator {
995    /// Validate a bearer token with RFC 7662 introspection and optional
996    /// userinfo enrichment.
997    ///
998    /// # Errors
999    ///
1000    /// - [`AuthError::IntrospectionTransport`] on HTTP transport / parse failures.
1001    /// - [`AuthError::InvalidToken`] on `active != true`, missing `exp`,
1002    ///   missing principal claim, scope mismatch, or temporal-claim failure.
1003    ///   `exp` is required so the SASL handler can populate
1004    ///   `session_lifetime_ms` for KIP-368 re-authentication.
1005    // skip_all keeps the bearer `token` out of span fields; only the
1006    // non-sensitive mechanism is recorded. `err` surfaces the rejection /
1007    // transport error (Debug) without leaking the token. The async body ends
1008    // by returning an `AuthOutcome` value (not another future), so
1009    // `async_yields_async` does not apply.
1010    #[tracing::instrument(
1011        level = "info",
1012        skip_all,
1013        fields(mechanism = "OAUTHBEARER", validator = "introspection"),
1014        err
1015    )]
1016    pub async fn validate(&self, token: &str, now_ms: i64) -> Result<AuthOutcome, AuthError> {
1017        let mut claims = self
1018            .client
1019            .introspect(token)
1020            .await
1021            .map_err(|e| AuthError::IntrospectionTransport(e.to_string()))?;
1022        if claims.get("active").and_then(Value::as_bool) != Some(true) {
1023            return Err(AuthError::InvalidToken);
1024        }
1025        // Audience restriction. When configured, the introspection-response
1026        // `aud` claim (RFC 7662 §2.2) must contain the expected value, mirroring
1027        // the signed-JWS path. Guards against an `IdP` that serves multiple
1028        // resource servers handing out a token that introspects as active here
1029        // but was minted for a different audience.
1030        if let Some(expected) = &self.expected_audience
1031            && !audience_contains(&claims, expected)
1032        {
1033            return Err(AuthError::InvalidToken);
1034        }
1035        check_temporal_claims(&claims, now_ms, self.allowable_clock_skew)?;
1036        // Capture `exp_ms` from the introspection response BEFORE any userinfo
1037        // merge. Introspection's `exp` is the authoritative session expiry
1038        // (RFC 7662); userinfo typically doesn't carry `exp`, and the
1039        // `merge_userinfo_over_introspection` precedence already reserves
1040        // `exp` to introspection, but pulling it out here makes the
1041        // ordering explicit. Required for OAUTHBEARER (validators reject
1042        // tokens without `exp`).
1043        let exp_ms = numeric_date_ms(&claims, "exp").ok_or(AuthError::InvalidToken)?;
1044        if self.call_userinfo
1045            && let Some(ui) = self
1046                .client
1047                .userinfo(token)
1048                .await
1049                .map_err(|e| AuthError::IntrospectionTransport(e.to_string()))?
1050        {
1051            merge_userinfo_over_introspection(&mut claims, ui);
1052        }
1053        // Optional JsonPath custom_claim_check. Evaluated against the merged
1054        // claims (introspection plus optional userinfo).
1055        if let Some(path) = &self.custom_claim_check
1056            && !evaluate_custom_claim_check(path, &claims)
1057        {
1058            return Err(AuthError::InvalidToken);
1059        }
1060        // Primary → fallback → reject. Prefix on fallback only.
1061        let (raw_name, used_fallback) = if let Some(n) = claims
1062            .get(&self.principal_claim_name)
1063            .and_then(Value::as_str)
1064            .filter(|s| !s.is_empty())
1065        {
1066            (n.to_owned(), false)
1067        } else {
1068            let fallback_claim = self
1069                .fallback_user_name_claim
1070                .as_deref()
1071                .ok_or(AuthError::InvalidToken)?;
1072            let raw = claims
1073                .get(fallback_claim)
1074                .and_then(Value::as_str)
1075                .filter(|s| !s.is_empty())
1076                .ok_or(AuthError::InvalidToken)?;
1077            (raw.to_owned(), true)
1078        };
1079        let name = if used_fallback {
1080            match &self.fallback_user_name_prefix {
1081                Some(prefix) => format!("{prefix}{raw_name}"),
1082                None => raw_name,
1083            }
1084        } else {
1085            raw_name
1086        };
1087
1088        let groups = self.groups_claim.as_ref().map_or_else(Vec::new, |path| {
1089            extract_groups(path, &claims, self.groups_claim_delimiter.as_deref())
1090        });
1091
1092        Ok(AuthOutcome {
1093            principal: Principal {
1094                name,
1095                auth_method: AuthMethod::SaslOAuthBearer,
1096                groups,
1097            },
1098            expires_at_ms: Some(exp_ms),
1099        })
1100    }
1101}
1102
1103/// Skew-tolerant temporal-claims check for introspection responses.
1104///
1105/// RFC 7662 does not mandate exp/iat/nbf, but this check honors them when they
1106/// are present.
1107fn check_temporal_claims(claims: &Value, now_ms: i64, skew: Time) -> Result<(), AuthError> {
1108    let skew_ms = skew_millis(skew);
1109    if let Some(exp_s) = claims.get("exp").and_then(Value::as_i64) {
1110        let exp_ms = exp_s.saturating_mul(1000);
1111        if now_ms.saturating_sub(skew_ms) > exp_ms {
1112            return Err(AuthError::InvalidToken);
1113        }
1114    }
1115    if let Some(iat_s) = claims.get("iat").and_then(Value::as_i64) {
1116        let iat_ms = iat_s.saturating_mul(1000);
1117        if iat_ms.saturating_sub(skew_ms) > now_ms {
1118            return Err(AuthError::InvalidToken);
1119        }
1120    }
1121    if let Some(nbf_s) = claims.get("nbf").and_then(Value::as_i64) {
1122        let nbf_ms = nbf_s.saturating_mul(1000);
1123        if nbf_ms.saturating_sub(skew_ms) > now_ms {
1124            return Err(AuthError::InvalidToken);
1125        }
1126    }
1127    Ok(())
1128}
1129
1130/// Merge the userinfo response over the introspection claims.
1131///
1132/// Userinfo wins for profile-style claims such as `preferred_username`, email,
1133/// name, `given_name`, and `family_name`. Introspection wins for the small set
1134/// of authorization claims that `RESERVED` lists.
1135fn merge_userinfo_over_introspection(introspection: &mut Value, userinfo: Value) {
1136    const RESERVED: &[&str] = &["active", "exp", "iat", "nbf", "scope", "client_id", "sub"];
1137    let (Some(obj), Value::Object(ui_map)) = (introspection.as_object_mut(), userinfo) else {
1138        return;
1139    };
1140    for (k, v) in ui_map {
1141        if !RESERVED.contains(&k.as_str()) {
1142            obj.insert(k, v);
1143        }
1144    }
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149
1150    use jsonpath_rust::parser::parse_json_path;
1151    use krabka_units::{millis, minutes};
1152
1153    use super::*;
1154
1155    fn jws(header: &str, claims: &str) -> String {
1156        format!(
1157            "{}.{}.",
1158            B64URL.encode(header.as_bytes()),
1159            B64URL.encode(claims.as_bytes())
1160        )
1161    }
1162
1163    fn unsecured(sub: &str, iat_s: i64, exp_s: i64) -> String {
1164        jws(
1165            "{\"alg\":\"none\"}",
1166            &format!("{{\"sub\":\"{sub}\",\"iat\":{iat_s},\"exp\":{exp_s}}}"),
1167        )
1168    }
1169
1170    /// Build an unsecured-JWS from an explicit header + claim object, so
1171    /// callers can drive the `typ` header for `typ`-check tests.
1172    ///
1173    /// This helper leaves the signature segment empty, as `alg:none` requires.
1174    fn make_unsecured_jws_with_header(
1175        header: &serde_json::Value,
1176        claims: &serde_json::Value,
1177    ) -> String {
1178        format!(
1179            "{}.{}.",
1180            B64URL.encode(serde_json::to_vec(header).unwrap()),
1181            B64URL.encode(serde_json::to_vec(claims).unwrap()),
1182        )
1183    }
1184
1185    fn make_unsecured_jws(claims: &serde_json::Value) -> String {
1186        make_unsecured_jws_with_header(&serde_json::json!({"alg": "none"}), claims)
1187    }
1188
1189    fn parse_jp(expr: &str) -> JpQuery {
1190        parse_json_path(expr).expect("expression compiles")
1191    }
1192
1193    #[test]
1194    fn parse_accepts_client_initial_responses_that_kafka_accepts() {
1195        // Each case is accepted by Kafka 4.3.1's
1196        // `OAuthBearerClientInitialResponse` grammar.
1197        let cases: [(&str, &[u8], ClientInitialResponse); 14] = [
1198            (
1199                "empty authzid",
1200                b"n,,\x01auth=Bearer tok.en.\x01\x01",
1201                ClientInitialResponse {
1202                    token: "tok.en.".into(),
1203                    authzid: None,
1204                },
1205            ),
1206            (
1207                "authzid and extensions around auth",
1208                b"n,a=alice,\x01host=example.com\x01auth=Bearer abc\x01port=443\x01\x01",
1209                ClientInitialResponse {
1210                    token: "abc".into(),
1211                    authzid: Some("alice".into()),
1212                },
1213            ),
1214            (
1215                "lowercase scheme",
1216                b"n,,\x01auth=bearer T\x01\x01",
1217                ClientInitialResponse {
1218                    token: "T".into(),
1219                    authzid: None,
1220                },
1221            ),
1222            (
1223                "uppercase scheme",
1224                b"n,,\x01auth=BEARER T\x01\x01",
1225                ClientInitialResponse {
1226                    token: "T".into(),
1227                    authzid: None,
1228                },
1229            ),
1230            (
1231                "several spaces after the scheme",
1232                b"n,,\x01auth=Bearer   T\x01\x01",
1233                ClientInitialResponse {
1234                    token: "T".into(),
1235                    authzid: None,
1236                },
1237            ),
1238            (
1239                "token with padding",
1240                b"n,,\x01auth=Bearer abc==\x01\x01",
1241                ClientInitialResponse {
1242                    token: "abc==".into(),
1243                    authzid: None,
1244                },
1245            ),
1246            (
1247                "every token character class",
1248                b"n,,\x01auth=Bearer -_~+/.azAZ09=\x01\x01",
1249                ClientInitialResponse {
1250                    token: "-_~+/.azAZ09=".into(),
1251                    authzid: None,
1252                },
1253            ),
1254            (
1255                "authzid escapes stay verbatim",
1256                b"n,a=a=2Cb=3Dc,\x01auth=Bearer T\x01\x01",
1257                ClientInitialResponse {
1258                    token: "T".into(),
1259                    authzid: Some("a=2Cb=3Dc".into()),
1260                },
1261            ),
1262            (
1263                "authzid with a kvsep inside",
1264                b"n,a=x\x01y,\x01auth=Bearer T\x01\x01",
1265                ClientInitialResponse {
1266                    token: "T".into(),
1267                    authzid: Some("x\x01y".into()),
1268                },
1269            ),
1270            (
1271                "extension value with spaces and a tab",
1272                b"n,,\x01ext=val ue\tx\x01auth=Bearer T\x01\x01",
1273                ClientInitialResponse {
1274                    token: "T".into(),
1275                    authzid: None,
1276                },
1277            ),
1278            (
1279                "extension value with an equals sign",
1280                b"n,,\x01auth=Bearer T\x01ext=a=b\x01\x01",
1281                ClientInitialResponse {
1282                    token: "T".into(),
1283                    authzid: None,
1284                },
1285            ),
1286            (
1287                "later auth kvpair wins",
1288                b"n,,\x01auth=Bearer A\x01auth=Bearer B\x01\x01",
1289                ClientInitialResponse {
1290                    token: "B".into(),
1291                    authzid: None,
1292                },
1293            ),
1294            (
1295                "extension key that differs from auth only in case",
1296                b"n,,\x01auth=Bearer T\x01Auth=x\x01\x01",
1297                ClientInitialResponse {
1298                    token: "T".into(),
1299                    authzid: None,
1300                },
1301            ),
1302            (
1303                "authzid with DEL and punctuation",
1304                b"n,a=u\x7f!~,\x01auth=Bearer T\x01\x01",
1305                ClientInitialResponse {
1306                    token: "T".into(),
1307                    authzid: Some("u\x7f!~".into()),
1308                },
1309            ),
1310        ];
1311        for (name, bytes, expected) in cases {
1312            let parsed = parse_client_initial_response(bytes);
1313            assert2::assert!(parsed == Ok(expected), "case: {name}");
1314        }
1315    }
1316
1317    #[test]
1318    fn parse_rejects_client_initial_responses_that_kafka_rejects() {
1319        // Each case is rejected by Kafka 4.3.1's
1320        // `OAuthBearerClientInitialResponse` grammar.
1321        let cases: [(&str, &[u8]); 34] = [
1322            ("missing auth kvpair", b"n,,\x01host=example.com\x01\x01"),
1323            ("no kvpairs", b"n,,\x01\x01"),
1324            (
1325                "auth key is case sensitive",
1326                b"n,,\x01AUTH=Bearer T\x01\x01",
1327            ),
1328            ("basic scheme", b"n,,\x01auth=Basic abc\x01\x01"),
1329            (
1330                "scheme with a non-word character",
1331                b"n,,\x01auth=Bear-er T\x01\x01",
1332            ),
1333            ("no scheme", b"n,,\x01auth= T\x01\x01"),
1334            ("no space after the scheme", b"n,,\x01auth=BearerT\x01\x01"),
1335            ("tab after the scheme", b"n,,\x01auth=Bearer\tT\x01\x01"),
1336            ("no token", b"n,,\x01auth=Bearer \x01\x01"),
1337            (
1338                "trailing space after the token",
1339                b"n,,\x01auth=Bearer T \x01\x01",
1340            ),
1341            (
1342                "token outside the token class",
1343                b"n,,\x01auth=Bearer to*ken\x01\x01",
1344            ),
1345            (
1346                "equals sign inside the token",
1347                b"n,,\x01auth=Bearer a=b\x01\x01",
1348            ),
1349            ("padding only", b"n,,\x01auth=Bearer ==\x01\x01"),
1350            ("unknown cb flag", b"z,,\x01auth=Bearer abc\x01\x01"),
1351            ("y cb flag", b"y,,\x01auth=Bearer T\x01\x01"),
1352            ("p cb flag", b"p=tls-unique,,\x01auth=Bearer T\x01\x01"),
1353            ("authzid without a=", b"n,alice,\x01auth=Bearer T\x01\x01"),
1354            ("empty authzid after a=", b"n,a=,\x01auth=Bearer T\x01\x01"),
1355            (
1356                "authzid with a raw equals sign",
1357                b"n,a=a=b,\x01auth=Bearer T\x01\x01",
1358            ),
1359            (
1360                "authzid with a lowercase escape",
1361                b"n,a=a=2c,\x01auth=Bearer T\x01\x01",
1362            ),
1363            (
1364                "authzid with a non-ASCII character",
1365                "n,a=b\u{e9},\x01auth=Bearer T\x01\x01".as_bytes(),
1366            ),
1367            (
1368                "authzid with a NUL",
1369                b"n,a=a\x00b,\x01auth=Bearer T\x01\x01",
1370            ),
1371            ("no kvsep after the gs2 header", b"n,,auth=Bearer T\x01\x01"),
1372            ("missing final kvsep", b"n,,\x01auth=Bearer T\x01"),
1373            ("missing both final kvseps", b"n,,\x01auth=Bearer T"),
1374            ("trailing junk", b"n,,\x01auth=Bearer T\x01\x01x"),
1375            ("extra trailing kvsep", b"n,,\x01auth=Bearer T\x01\x01\x01"),
1376            (
1377                "extension key with a digit",
1378                b"n,,\x01k1=v\x01auth=Bearer T\x01\x01",
1379            ),
1380            ("empty extension key", b"n,,\x01=v\x01auth=Bearer T\x01\x01"),
1381            (
1382                "empty extension value",
1383                b"n,,\x01k=\x01auth=Bearer T\x01\x01",
1384            ),
1385            (
1386                "extension without an equals sign",
1387                b"n,,\x01k\x01auth=Bearer T\x01\x01",
1388            ),
1389            (
1390                "extension value with a non-ASCII character",
1391                "n,,\x01k=v\u{e9}\x01auth=Bearer T\x01\x01".as_bytes(),
1392            ),
1393            (
1394                "extension value with DEL",
1395                b"n,,\x01k=v\x7f\x01auth=Bearer T\x01\x01",
1396            ),
1397            ("invalid UTF-8", b"n,,\x01auth=Bearer T\x01k=\xff\x01\x01"),
1398        ];
1399        for (name, bytes) in cases {
1400            let parsed = parse_client_initial_response(bytes);
1401            assert2::assert!(parsed == Err(AuthError::MalformedMessage), "case: {name}");
1402        }
1403    }
1404
1405    #[test]
1406    fn validate_accepts_fresh_unsecured_token() {
1407        let v = UnsecuredJwsValidator::default();
1408        let now = 1_000_000_000_000;
1409        let token = unsecured("admin", 999_999_000, 1_000_000_900); // seconds
1410        let outcome = v.validate(&token, now).unwrap();
1411        assert2::assert!(
1412            (
1413                outcome.principal.name.as_str(),
1414                outcome.principal.auth_method
1415            ) == ("admin", AuthMethod::SaslOAuthBearer)
1416        );
1417    }
1418
1419    #[test]
1420    fn unsecured_validate_surfaces_exp_in_auth_outcome() {
1421        // exp = 2000 sec = 2_000_000 ms; now = 1_000_000 ms.
1422        let exp_secs: i64 = 2_000;
1423        let now_ms: i64 = 1_000_000;
1424        let token = unsecured("alice", 999, exp_secs);
1425        let v = UnsecuredJwsValidator::default();
1426        let outcome = v.validate(&token, now_ms).expect("token valid");
1427        assert2::assert!(
1428            (outcome.principal.name.as_str(), outcome.expires_at_ms)
1429                == ("alice", Some(exp_secs * 1000))
1430        );
1431    }
1432
1433    /// The temporal checks compare epoch-millisecond instants, so the
1434    /// configured skew must arrive there in whole milliseconds. This test pins
1435    /// the seam. It also pins that a sub-millisecond tolerance rounds and does
1436    /// not collapse to zero the way an `as i64` truncation would.
1437    #[test]
1438    fn clock_skew_crosses_into_the_temporal_checks_in_whole_milliseconds() {
1439        for (_name, skew, expected_ms) in [
1440            ("zero", <Time as TimeExt>::ZERO, 0),
1441            ("default", secs(30), 30_000),
1442            ("one millisecond", millis(1), 1),
1443            ("sub-millisecond rounds up", Time::from_secs_f64(0.0006), 1),
1444            (
1445                "sub-millisecond rounds down",
1446                Time::from_secs_f64(0.0004),
1447                0,
1448            ),
1449            ("five minutes", minutes(5), 300_000),
1450        ] {
1451            assert2::check!(skew_millis(skew) == expected_ms);
1452        }
1453    }
1454
1455    /// `exp` is compared as `exp_ms + skew <= now_ms`, so the accept/reject
1456    /// boundary must sit exactly at the configured tolerance.
1457    #[test]
1458    fn unsecured_exp_boundary_tracks_the_configured_skew_exactly() {
1459        // exp = 1000 s ⇒ exp_ms = 1_000_000. With 30 s of skew the token is
1460        // still good at now = 1_030_000 and dead one millisecond later.
1461        let token = unsecured("admin", 900, 1_000);
1462        let v = UnsecuredJwsValidator {
1463            allowable_clock_skew: secs(30),
1464            ..Default::default()
1465        };
1466        assert2::check!(v.validate(&token, 1_029_999).is_ok());
1467        assert2::check!(v.validate(&token, 1_030_000).is_err());
1468    }
1469
1470    /// Kafka's `unsecuredValidatorAllowableClockSkewMs` defaults to 0, so the
1471    /// default validator refuses a token from the millisecond its `exp` falls.
1472    #[test]
1473    fn unsecured_default_allows_no_clock_skew() {
1474        let token = unsecured("admin", 900, 1_000);
1475        let v = UnsecuredJwsValidator::default();
1476        assert2::check!(v.validate(&token, 999_999).is_ok());
1477        assert2::check!(v.validate(&token, 1_000_000).is_err());
1478    }
1479
1480    #[test]
1481    fn validate_rejects_expired_token() {
1482        let v = UnsecuredJwsValidator {
1483            allowable_clock_skew: <Time as TimeExt>::ZERO,
1484            ..Default::default()
1485        };
1486        let now = 2_000_000_000_000;
1487        let token = unsecured("admin", 1_000_000_000, 1_000_000_100);
1488        assert2::assert!(v.validate(&token, now) == Err(AuthError::InvalidToken));
1489    }
1490
1491    #[test]
1492    fn validate_rejects_future_iat() {
1493        let v = UnsecuredJwsValidator {
1494            allowable_clock_skew: <Time as TimeExt>::ZERO,
1495            ..Default::default()
1496        };
1497        let now = 1_000_000_000_000;
1498        // iat far in the future, exp even further.
1499        let token = unsecured("admin", 5_000_000_000, 5_000_000_100);
1500        assert2::assert!(v.validate(&token, now) == Err(AuthError::InvalidToken));
1501    }
1502
1503    #[test]
1504    fn validate_accepts_exp_and_iat_at_skew_boundaries() {
1505        let v = UnsecuredJwsValidator {
1506            allowable_clock_skew: secs(1),
1507            ..Default::default()
1508        };
1509
1510        let exp_boundary = make_unsecured_jws(&serde_json::json!({
1511            "sub": "admin",
1512            "exp": 1_999,
1513        }));
1514        assert2::assert!(v.validate(&exp_boundary, 2_000_000).is_err());
1515        let exp_inside = make_unsecured_jws(&serde_json::json!({
1516            "sub": "admin",
1517            "exp": 2_000,
1518        }));
1519        assert2::assert!(v.validate(&exp_inside, 2_000_000).is_ok());
1520
1521        let iat_boundary = make_unsecured_jws(&serde_json::json!({
1522            "sub": "admin",
1523            "iat": 2_001,
1524            "exp": 3_000,
1525        }));
1526        assert2::assert!(v.validate(&iat_boundary, 2_000_000).is_ok());
1527        let iat_outside = make_unsecured_jws(&serde_json::json!({
1528            "sub": "admin",
1529            "iat": 2_002,
1530            "exp": 3_000,
1531        }));
1532        assert2::assert!(v.validate(&iat_outside, 2_000_000).is_err());
1533    }
1534
1535    #[test]
1536    fn validate_rejects_signed_token() {
1537        let v = UnsecuredJwsValidator::default();
1538        let now = 1_000_000_000_000;
1539        // alg RS256 + a non-empty signature segment.
1540        let token = format!(
1541            "{}.{}.{}",
1542            B64URL.encode(b"{\"alg\":\"RS256\"}"),
1543            B64URL.encode(b"{\"sub\":\"admin\",\"exp\":1000000900}"),
1544            B64URL.encode(b"sig")
1545        );
1546        assert2::assert!(v.validate(&token, now) == Err(AuthError::InvalidToken));
1547    }
1548
1549    #[test]
1550    fn validate_rejects_missing_exp() {
1551        let v = UnsecuredJwsValidator::default();
1552        let token = jws("{\"alg\":\"none\"}", "{\"sub\":\"admin\"}");
1553        assert2::assert!(v.validate(&token, 1_000_000_000_000) == Err(AuthError::InvalidToken));
1554    }
1555
1556    #[test]
1557    fn validate_rejects_missing_principal_claim() {
1558        let v = UnsecuredJwsValidator::default();
1559        let token = jws("{\"alg\":\"none\"}", "{\"exp\":5000000000}");
1560        assert2::assert!(v.validate(&token, 1_000_000_000_000) == Err(AuthError::InvalidToken));
1561    }
1562
1563    // ---- custom_claim_check (JsonPath) + valid_token_type ---
1564
1565    #[test]
1566    fn unsecured_validate_rejects_when_custom_claim_check_fails() {
1567        let exp_secs: i64 = 2_000;
1568        let now_ms: i64 = 1_000_000;
1569        let token = make_unsecured_jws(&serde_json::json!({
1570            "sub": "alice",
1571            "exp": exp_secs,
1572            "scope": ["kafka.read"],
1573        }));
1574        let v = UnsecuredJwsValidator {
1575            custom_claim_check: Some(parse_jp("$.scope[?@ == 'kafka.admin']")),
1576            ..Default::default()
1577        };
1578        let result = v.validate(&token, now_ms);
1579        assert2::assert!(result.unwrap_err() == AuthError::InvalidToken);
1580    }
1581
1582    #[test]
1583    fn custom_claim_check_rejects_false_and_null_matches() {
1584        for (_case, claims, want) in [
1585            ("false claim", serde_json::json!({"enabled": false}), false),
1586            ("null claim", serde_json::json!({"enabled": null}), false),
1587            ("true claim", serde_json::json!({"enabled": true}), true),
1588        ] {
1589            assert2::assert!(evaluate_custom_claim_check(&parse_jp("$.enabled"), &claims) == want);
1590        }
1591    }
1592
1593    #[test]
1594    fn unsecured_validate_accepts_when_custom_claim_check_passes() {
1595        let exp_secs: i64 = 2_000;
1596        let now_ms: i64 = 1_000_000;
1597        let token = make_unsecured_jws(&serde_json::json!({
1598            "sub": "alice",
1599            "exp": exp_secs,
1600            "scope": ["kafka.admin", "kafka.read"],
1601        }));
1602        let v = UnsecuredJwsValidator {
1603            custom_claim_check: Some(parse_jp("$.scope[?@ == 'kafka.admin']")),
1604            ..Default::default()
1605        };
1606        let outcome = v.validate(&token, now_ms).expect("valid token");
1607        assert2::assert!(outcome.principal.name == "alice");
1608    }
1609
1610    #[test]
1611    fn unsecured_validate_rejects_when_valid_token_type_mismatch() {
1612        let exp_secs: i64 = 2_000;
1613        let now_ms: i64 = 1_000_000;
1614        // Token with typ=OPAQUE in the header.
1615        let token = make_unsecured_jws_with_header(
1616            &serde_json::json!({"alg": "none", "typ": "OPAQUE"}),
1617            &serde_json::json!({"sub": "alice", "exp": exp_secs}),
1618        );
1619        let v = UnsecuredJwsValidator {
1620            valid_token_type: Some("JWT".into()),
1621            ..Default::default()
1622        };
1623        let result = v.validate(&token, now_ms);
1624        assert2::assert!(result.unwrap_err() == AuthError::InvalidToken);
1625    }
1626
1627    #[test]
1628    fn unsecured_validate_accepts_when_valid_token_type_match() {
1629        let exp_secs: i64 = 2_000;
1630        let now_ms: i64 = 1_000_000;
1631        let token = make_unsecured_jws_with_header(
1632            &serde_json::json!({"alg": "none", "typ": "JWT"}),
1633            &serde_json::json!({"sub": "alice", "exp": exp_secs}),
1634        );
1635        let v = UnsecuredJwsValidator {
1636            valid_token_type: Some("JWT".into()),
1637            ..Default::default()
1638        };
1639        assert2::assert!(v.validate(&token, now_ms).is_ok());
1640    }
1641
1642    #[test]
1643    fn unsecured_validate_accepts_when_valid_token_type_unset_regardless_of_header() {
1644        let exp_secs: i64 = 2_000;
1645        let now_ms: i64 = 1_000_000;
1646        let token = make_unsecured_jws_with_header(
1647            &serde_json::json!({"alg": "none", "typ": "OPAQUE"}),
1648            &serde_json::json!({"sub": "alice", "exp": exp_secs}),
1649        );
1650        let v = UnsecuredJwsValidator::default();
1651        // No valid_token_type set → header `typ` ignored.
1652        assert2::assert!(v.validate(&token, now_ms).is_ok());
1653    }
1654
1655    // ---- name fallback chain + groups extraction --------------
1656
1657    #[test]
1658    fn unsecured_validate_uses_primary_principal_claim_when_present() {
1659        // Regression: primary claim present → use primary, no prefix.
1660        let exp_secs: i64 = 2_000;
1661        let now_ms: i64 = 1_000_000;
1662        let token = make_unsecured_jws_with_header(
1663            &serde_json::json!({"alg": "none", "typ": "JWT"}),
1664            &serde_json::json!({"sub": "alice", "exp": exp_secs}),
1665        );
1666        let v = UnsecuredJwsValidator {
1667            fallback_user_name_claim: Some("client_id".into()),
1668            fallback_user_name_prefix: Some("service-account-".into()),
1669            ..Default::default()
1670        };
1671        let outcome = v.validate(&token, now_ms).expect("valid");
1672        assert2::assert!(outcome.principal.name == "alice"); // primary, no prefix
1673    }
1674
1675    #[test]
1676    fn unsecured_validate_falls_back_to_alt_claim_when_primary_absent() {
1677        let exp_secs: i64 = 2_000;
1678        let now_ms: i64 = 1_000_000;
1679        let token = make_unsecured_jws_with_header(
1680            &serde_json::json!({"alg": "none", "typ": "JWT"}),
1681            // No `sub` — primary lookup fails.
1682            &serde_json::json!({"client_id": "svc1", "exp": exp_secs}),
1683        );
1684        let v = UnsecuredJwsValidator {
1685            fallback_user_name_claim: Some("client_id".into()),
1686            ..Default::default()
1687        };
1688        let outcome = v.validate(&token, now_ms).expect("valid");
1689        assert2::assert!(outcome.principal.name == "svc1"); // fallback, no prefix
1690    }
1691
1692    #[test]
1693    fn unsecured_validate_applies_fallback_prefix_only_on_fallback() {
1694        let exp_secs: i64 = 2_000;
1695        let now_ms: i64 = 1_000_000;
1696        let token = make_unsecured_jws_with_header(
1697            &serde_json::json!({"alg": "none", "typ": "JWT"}),
1698            &serde_json::json!({"client_id": "svc1", "exp": exp_secs}),
1699        );
1700        let v = UnsecuredJwsValidator {
1701            fallback_user_name_claim: Some("client_id".into()),
1702            fallback_user_name_prefix: Some("service-account-".into()),
1703            ..Default::default()
1704        };
1705        let outcome = v.validate(&token, now_ms).expect("valid");
1706        assert2::assert!(outcome.principal.name == "service-account-svc1");
1707    }
1708
1709    #[test]
1710    fn unsecured_validate_rejects_when_neither_primary_nor_fallback_present() {
1711        let exp_secs: i64 = 2_000;
1712        let now_ms: i64 = 1_000_000;
1713        let token = make_unsecured_jws_with_header(
1714            &serde_json::json!({"alg": "none", "typ": "JWT"}),
1715            // Neither sub nor client_id.
1716            &serde_json::json!({"exp": exp_secs}),
1717        );
1718        let v = UnsecuredJwsValidator {
1719            fallback_user_name_claim: Some("client_id".into()),
1720            ..Default::default()
1721        };
1722        assert2::assert!(v.validate(&token, now_ms) == Err(AuthError::InvalidToken));
1723    }
1724
1725    #[test]
1726    fn unsecured_validate_extracts_groups_from_array_claim() {
1727        let exp_secs: i64 = 2_000;
1728        let now_ms: i64 = 1_000_000;
1729        let token = make_unsecured_jws_with_header(
1730            &serde_json::json!({"alg": "none", "typ": "JWT"}),
1731            &serde_json::json!({
1732                "sub": "alice",
1733                "exp": exp_secs,
1734                "groups": ["admin", "ops"],
1735            }),
1736        );
1737        let v = UnsecuredJwsValidator {
1738            groups_claim: Some(parse_jp("$.groups")),
1739            ..Default::default()
1740        };
1741        let outcome = v.validate(&token, now_ms).expect("valid");
1742        assert2::assert!(outcome.principal.groups == vec!["admin".to_owned(), "ops".to_owned()]);
1743    }
1744
1745    #[test]
1746    fn unsecured_validate_extracts_groups_from_delimited_string() {
1747        let exp_secs: i64 = 2_000;
1748        let now_ms: i64 = 1_000_000;
1749        let token = make_unsecured_jws_with_header(
1750            &serde_json::json!({"alg": "none", "typ": "JWT"}),
1751            &serde_json::json!({
1752                "sub": "alice",
1753                "exp": exp_secs,
1754                "groups": "admin,ops, kafka",
1755            }),
1756        );
1757        let v = UnsecuredJwsValidator {
1758            groups_claim: Some(parse_jp("$.groups")),
1759            groups_claim_delimiter: Some(",".into()),
1760            ..Default::default()
1761        };
1762        let outcome = v.validate(&token, now_ms).expect("valid");
1763        assert2::assert!(
1764            outcome.principal.groups
1765                == vec!["admin".to_owned(), "ops".to_owned(), "kafka".to_owned()]
1766        );
1767    }
1768
1769    #[test]
1770    fn unsecured_validate_extracts_groups_from_nested_claim_via_jsonpath() {
1771        let exp_secs: i64 = 2_000;
1772        let now_ms: i64 = 1_000_000;
1773        let token = make_unsecured_jws_with_header(
1774            &serde_json::json!({"alg": "none", "typ": "JWT"}),
1775            &serde_json::json!({
1776                "sub": "alice",
1777                "exp": exp_secs,
1778                "realm_access": { "roles": ["admin", "ops"] },
1779            }),
1780        );
1781        let v = UnsecuredJwsValidator {
1782            groups_claim: Some(parse_jp("$.realm_access.roles[*]")),
1783            ..Default::default()
1784        };
1785        let outcome = v.validate(&token, now_ms).expect("valid");
1786        assert2::assert!(outcome.principal.groups == vec!["admin".to_owned(), "ops".to_owned()]);
1787    }
1788
1789    #[test]
1790    fn unsecured_validate_returns_empty_groups_when_claim_unset() {
1791        let exp_secs: i64 = 2_000;
1792        let now_ms: i64 = 1_000_000;
1793        let token = make_unsecured_jws_with_header(
1794            &serde_json::json!({"alg": "none", "typ": "JWT"}),
1795            &serde_json::json!({
1796                "sub": "alice",
1797                "exp": exp_secs,
1798                "groups": ["admin"],
1799            }),
1800        );
1801        let v = UnsecuredJwsValidator::default(); // no groups_claim
1802        let outcome = v.validate(&token, now_ms).expect("valid");
1803        assert2::assert!(outcome.principal.groups == Vec::<String>::new());
1804    }
1805
1806    #[test]
1807    fn unsecured_validate_returns_empty_groups_when_claim_resolves_to_empty() {
1808        let exp_secs: i64 = 2_000;
1809        let now_ms: i64 = 1_000_000;
1810        let token = make_unsecured_jws_with_header(
1811            &serde_json::json!({"alg": "none", "typ": "JWT"}),
1812            &serde_json::json!({
1813                "sub": "alice",
1814                "exp": exp_secs,
1815            }),
1816        );
1817        let v = UnsecuredJwsValidator {
1818            groups_claim: Some(parse_jp("$.nonexistent")),
1819            ..Default::default()
1820        };
1821        let outcome = v.validate(&token, now_ms).expect("valid");
1822        assert2::assert!(outcome.principal.groups == Vec::<String>::new());
1823    }
1824
1825    #[test]
1826    fn validate_custom_principal_claim() {
1827        let v = UnsecuredJwsValidator {
1828            principal_claim_name: "client_id".to_owned(),
1829            ..Default::default()
1830        };
1831        let token = jws(
1832            "{\"alg\":\"none\"}",
1833            "{\"client_id\":\"svc-1\",\"exp\":5000000000}",
1834        );
1835        let outcome = v.validate(&token, 1_000_000_000_000).unwrap();
1836        assert2::assert!(outcome.principal.name == "svc-1");
1837    }
1838
1839    /// The [`AuthOutcome`] the unsecured validator returns for `name` and an
1840    /// `exp` of `exp_ms`, with no groups.
1841    fn outcome(name: &str, exp_ms: i64) -> AuthOutcome {
1842        AuthOutcome {
1843            principal: Principal {
1844                name: name.to_owned(),
1845                auth_method: AuthMethod::SaslOAuthBearer,
1846                groups: Vec::new(),
1847            },
1848            expires_at_ms: Some(exp_ms),
1849        }
1850    }
1851
1852    /// Each case is a claim set Kafka 4.3.1's
1853    /// `OAuthBearerUnsecuredValidatorCallbackHandler` answers the same way:
1854    /// the validator runs with a 30 s skew at `now` = 1000 s.
1855    #[test]
1856    fn unsecured_validate_matches_kafka_claim_checks() {
1857        let now_ms = 1_000_000;
1858        let cases: Vec<(&str, serde_json::Value, Result<AuthOutcome, AuthError>)> = vec![
1859            (
1860                "exp before iat, both inside the skew",
1861                serde_json::json!({"sub": "admin", "iat": 1_010, "exp": 1_005}),
1862                Err(AuthError::InvalidToken),
1863            ),
1864            (
1865                "exp equal to iat",
1866                serde_json::json!({"sub": "admin", "iat": 1_005, "exp": 1_005}),
1867                Err(AuthError::InvalidToken),
1868            ),
1869            (
1870                "exp a fraction of a millisecond after iat",
1871                serde_json::json!({"sub": "admin", "iat": 1_005.000_1, "exp": 1_005.000_2}),
1872                Ok(outcome("admin", 1_005_000)),
1873            ),
1874            (
1875                "exp after iat",
1876                serde_json::json!({"sub": "admin", "iat": 1_005, "exp": 1_006}),
1877                Ok(outcome("admin", 1_006_000)),
1878            ),
1879            (
1880                "no iat skips the consistency check",
1881                serde_json::json!({"sub": "admin", "exp": 1_006}),
1882                Ok(outcome("admin", 1_006_000)),
1883            ),
1884            (
1885                "string iat",
1886                serde_json::json!({"sub": "admin", "iat": "999", "exp": 1_006}),
1887                Err(AuthError::InvalidToken),
1888            ),
1889            (
1890                "null iat",
1891                serde_json::json!({"sub": "admin", "iat": null, "exp": 1_006}),
1892                Err(AuthError::InvalidToken),
1893            ),
1894            (
1895                "space-only principal",
1896                serde_json::json!({"sub": " ", "exp": 1_006}),
1897                Err(AuthError::InvalidToken),
1898            ),
1899            (
1900                "control-character-only principal",
1901                serde_json::json!({"sub": "\t\n\u{1}", "exp": 1_006}),
1902                Err(AuthError::InvalidToken),
1903            ),
1904            (
1905                "principal with surrounding spaces stays verbatim",
1906                serde_json::json!({"sub": " alice ", "exp": 1_006}),
1907                Ok(outcome(" alice ", 1_006_000)),
1908            ),
1909            (
1910                "numeric scope",
1911                serde_json::json!({"sub": "admin", "exp": 1_006, "scope": 5}),
1912                Err(AuthError::InvalidToken),
1913            ),
1914            (
1915                "string scope",
1916                serde_json::json!({"sub": "admin", "exp": 1_006, "scope": "a b"}),
1917                Ok(outcome("admin", 1_006_000)),
1918            ),
1919            (
1920                "list scope with non-string elements, which Kafka reads as text",
1921                serde_json::json!({"sub": "admin", "exp": 1_006, "scope": ["a", 5, true, null, {}]}),
1922                Ok(outcome("admin", 1_006_000)),
1923            ),
1924            (
1925                "boolean scope, which Kafka reads as text",
1926                serde_json::json!({"sub": "admin", "exp": 1_006, "scope": true}),
1927                Ok(outcome("admin", 1_006_000)),
1928            ),
1929            (
1930                "object scope, which Kafka reads as blank",
1931                serde_json::json!({"sub": "admin", "exp": 1_006, "scope": {"a": 1}}),
1932                Ok(outcome("admin", 1_006_000)),
1933            ),
1934        ];
1935        // The cases put `iat` up to 10 s ahead of `now`, inside this skew.
1936        let v = UnsecuredJwsValidator {
1937            allowable_clock_skew: secs(30),
1938            ..Default::default()
1939        };
1940        for (name, claims, want) in cases {
1941            let token = make_unsecured_jws(&claims);
1942            assert2::check!(v.validate(&token, now_ms) == want, "case: {name}");
1943        }
1944    }
1945
1946    /// A blank principal claim falls through to the fallback claim, and a
1947    /// blank fallback claim is rejected the way an absent one is.
1948    #[test]
1949    fn unsecured_validate_treats_blank_principal_claims_as_absent() {
1950        let v = UnsecuredJwsValidator {
1951            fallback_user_name_claim: Some("client_id".to_owned()),
1952            fallback_user_name_prefix: Some("service-account-".to_owned()),
1953            ..Default::default()
1954        };
1955        for (name, claims, want) in [
1956            (
1957                "blank primary, present fallback",
1958                serde_json::json!({"sub": "  ", "client_id": "svc", "exp": 1_006}),
1959                Ok(outcome("service-account-svc", 1_006_000)),
1960            ),
1961            (
1962                "blank primary, blank fallback",
1963                serde_json::json!({"sub": "  ", "client_id": "\t", "exp": 1_006}),
1964                Err(AuthError::InvalidToken),
1965            ),
1966        ] {
1967            let token = make_unsecured_jws(&claims);
1968            assert2::check!(v.validate(&token, 1_000_000) == want, "case: {name}");
1969        }
1970    }
1971
1972    /// Kafka's `unsecuredValidatorRequiredScope`: every required scope item
1973    /// must be in the token's scope claim, or the validation fails with
1974    /// `insufficient_scope` and the whole required list.
1975    #[test]
1976    fn unsecured_validate_enforces_required_scope() {
1977        let required = vec!["a".to_owned(), "b".to_owned()];
1978        let insufficient = Err(AuthError::InsufficientScope {
1979            required_scope: required.clone(),
1980        });
1981        let scope_claim = UnsecuredJwsValidator {
1982            required_scope: required.clone(),
1983            ..Default::default()
1984        };
1985        let scp_claim = UnsecuredJwsValidator {
1986            required_scope: required,
1987            scope_claim_name: "scp".to_owned(),
1988            ..Default::default()
1989        };
1990        let cases = [
1991            (
1992                "list holding every required item",
1993                &scope_claim,
1994                serde_json::json!({"sub": "admin", "exp": 1_006, "scope": ["b", "a"]}),
1995                Ok(outcome("admin", 1_006_000)),
1996            ),
1997            (
1998                "list items are trimmed, extra items are fine",
1999                &scope_claim,
2000                serde_json::json!({"sub": "admin", "exp": 1_006, "scope": [" a ", "b", "c"]}),
2001                Ok(outcome("admin", 1_006_000)),
2002            ),
2003            (
2004                "list missing one item",
2005                &scope_claim,
2006                serde_json::json!({"sub": "admin", "exp": 1_006, "scope": ["a"]}),
2007                insufficient.clone(),
2008            ),
2009            (
2010                "a string scope is one item, not split on spaces",
2011                &scope_claim,
2012                serde_json::json!({"sub": "admin", "exp": 1_006, "scope": "a b"}),
2013                insufficient.clone(),
2014            ),
2015            (
2016                "no scope claim",
2017                &scope_claim,
2018                serde_json::json!({"sub": "admin", "exp": 1_006}),
2019                insufficient.clone(),
2020            ),
2021            (
2022                "configured scope claim name",
2023                &scp_claim,
2024                serde_json::json!({"sub": "admin", "exp": 1_006, "scp": ["a", "b"]}),
2025                Ok(outcome("admin", 1_006_000)),
2026            ),
2027            (
2028                "default claim name ignored once another is configured",
2029                &scp_claim,
2030                serde_json::json!({"sub": "admin", "exp": 1_006, "scope": ["a", "b"]}),
2031                insufficient,
2032            ),
2033            (
2034                "an expired token is invalid_token, not insufficient_scope",
2035                &scope_claim,
2036                serde_json::json!({"sub": "admin", "exp": 900}),
2037                Err(AuthError::InvalidToken),
2038            ),
2039            (
2040                "a blank principal is invalid_token, not insufficient_scope",
2041                &scope_claim,
2042                serde_json::json!({"sub": " ", "exp": 1_006}),
2043                Err(AuthError::InvalidToken),
2044            ),
2045        ];
2046        for (name, v, claims, want) in cases {
2047            let token = make_unsecured_jws(&claims);
2048            assert2::check!(v.validate(&token, 1_000_000) == want, "case: {name}");
2049        }
2050    }
2051
2052    #[test]
2053    fn unsecured_validate_without_required_scope_accepts_a_token_without_scope() {
2054        let token = make_unsecured_jws(&serde_json::json!({"sub": "admin", "exp": 1_006}));
2055        assert2::assert!(
2056            UnsecuredJwsValidator::default().validate(&token, 1_000_000)
2057                == Ok(outcome("admin", 1_006_000))
2058        );
2059    }
2060
2061    /// Kafka's `OAuthBearerScopeUtils.parseScope` after the option's trim.
2062    #[test]
2063    fn parse_required_scope_matches_kafka() {
2064        let items = |v: &[&str]| v.iter().map(ToString::to_string).collect::<Vec<_>>();
2065        for (input, want) in [
2066            ("", Ok(Vec::new())),
2067            (" \t ", Ok(Vec::new())),
2068            ("a", Ok(items(&["a"]))),
2069            ("a b", Ok(items(&["a", "b"]))),
2070            ("\t a   b \n", Ok(items(&["a", "b"]))),
2071            ("a,b", Ok(items(&["a,b"]))),
2072            ("!#[]~", Ok(items(&["!#[]~"]))),
2073            ("a\"b", Err(InvalidScopeItem("a\"b".to_owned()))),
2074            ("a\\b", Err(InvalidScopeItem("a\\b".to_owned()))),
2075            ("a\tb", Err(InvalidScopeItem("a\tb".to_owned()))),
2076            ("a é", Err(InvalidScopeItem("é".to_owned()))),
2077        ] {
2078            assert2::check!(parse_required_scope(input) == want, "input: {input:?}");
2079        }
2080    }
2081
2082    /// Golden server challenges: the bytes of Kafka 4.3.1's
2083    /// `OAuthBearerSaslServer.jsonErrorResponse`.
2084    #[test]
2085    fn oauth_error_json_matches_kafka_json_error_response() {
2086        for (err, want) in [
2087            (AuthError::InvalidToken, r#"{"status":"invalid_token"}"#),
2088            (
2089                AuthError::IntrospectionTransport("connection refused".to_owned()),
2090                r#"{"status":"invalid_token"}"#,
2091            ),
2092            (
2093                AuthError::InsufficientScope {
2094                    required_scope: vec!["a".to_owned()],
2095                },
2096                r#"{"status":"insufficient_scope", "scope":"[a]"}"#,
2097            ),
2098            (
2099                AuthError::InsufficientScope {
2100                    required_scope: vec!["a".to_owned(), "b".to_owned()],
2101                },
2102                r#"{"status":"insufficient_scope", "scope":"[a, b]"}"#,
2103            ),
2104        ] {
2105            assert2::check!(oauth_error_json(&err) == want, "error: {err:?}");
2106        }
2107    }
2108
2109    // ---- SignedJwsValidator -------------------------------------
2110
2111    use tokio::sync::mpsc;
2112
2113    use crate::jwks::{Jwks, JwksHandle, mint_es256, mint_rs256, mint_rs256_with_header};
2114
2115    /// Build a `SignedJwsValidator` whose key set comes from `jwks_json`.
2116    fn signed(jwks_json: &str) -> (SignedJwsValidator, JwksHandle) {
2117        let handle = JwksHandle::new(Jwks::from_json(jwks_json, false).unwrap());
2118        (SignedJwsValidator::new(handle.clone()), handle)
2119    }
2120
2121    #[test]
2122    fn signed_accepts_fresh_rs256_token() {
2123        let (token, jwks) = mint_rs256("k1", "{\"sub\":\"admin\",\"exp\":9999999999}");
2124        let (v, _h) = signed(&jwks);
2125        let outcome = v.validate(&token, 1_000_000_000_000).unwrap();
2126        assert2::assert!(
2127            (
2128                outcome.principal.name.as_str(),
2129                outcome.principal.auth_method
2130            ) == ("admin", AuthMethod::SaslOAuthBearer)
2131        );
2132    }
2133
2134    #[test]
2135    fn signed_validate_surfaces_exp_in_auth_outcome() {
2136        let exp_secs: i64 = 2_000;
2137        let now_ms: i64 = 1_000_000;
2138        let (token, jwks) = mint_rs256("k1", &format!("{{\"sub\":\"alice\",\"exp\":{exp_secs}}}"));
2139        let (v, _h) = signed(&jwks);
2140        let outcome = v.validate(&token, now_ms).expect("token valid");
2141        assert2::assert!(
2142            (outcome.principal.name.as_str(), outcome.expires_at_ms)
2143                == ("alice", Some(exp_secs * 1000))
2144        );
2145    }
2146
2147    #[test]
2148    fn signed_rejects_unsecured_alg_none() {
2149        // An `alg:none` token must never pass the signed validator even if a
2150        // key happens to be present.
2151        let (_token, jwks) = mint_rs256("k1", "{\"sub\":\"a\",\"exp\":9999999999}");
2152        let (v, _h) = signed(&jwks);
2153        let unsecured = jws("{\"alg\":\"none\"}", "{\"sub\":\"a\",\"exp\":9999999999}");
2154        assert2::assert!(v.validate(&unsecured, 1_000_000_000_000) == Err(AuthError::InvalidToken));
2155    }
2156
2157    #[test]
2158    fn signed_rejects_malformed_compact_jws_segments() {
2159        let (token, jwks) = mint_rs256("k1", "{\"sub\":\"a\",\"exp\":9999999999}");
2160        let (v, _h) = signed(&jwks);
2161        let header = B64URL.encode(b"{\"alg\":\"RS256\",\"kid\":\"k1\"}");
2162        let payload = B64URL.encode(b"{\"sub\":\"a\",\"exp\":9999999999}");
2163
2164        let empty_signature = format!("{header}.{payload}.");
2165        let extra_segment = format!("{token}.extra");
2166        for (_name, malformed) in [
2167            ("empty signature", empty_signature.as_str()),
2168            ("extra segment", extra_segment.as_str()),
2169        ] {
2170            assert2::assert!(v.validate(malformed, 1_000_000_000_000).is_err());
2171        }
2172    }
2173
2174    #[test]
2175    fn signed_rejects_expired() {
2176        let (token, jwks) = mint_rs256("k1", "{\"sub\":\"a\",\"exp\":1000}");
2177        let (mut v, _h) = signed(&jwks);
2178        v.allowable_clock_skew = <Time as TimeExt>::ZERO;
2179        // now (ms) far past exp (1000 s).
2180        assert2::assert!(v.validate(&token, 5_000_000_000_000) == Err(AuthError::InvalidToken));
2181    }
2182
2183    #[test]
2184    fn signed_rejects_future_nbf() {
2185        let (token, jwks) = mint_rs256(
2186            "k1",
2187            "{\"sub\":\"a\",\"exp\":9999999999,\"nbf\":5000000000}",
2188        );
2189        let (mut v, _h) = signed(&jwks);
2190        v.allowable_clock_skew = <Time as TimeExt>::ZERO;
2191        // now = 1e12 ms = 1e9 s, which is before nbf (5e9 s).
2192        assert2::assert!(v.validate(&token, 1_000_000_000_000) == Err(AuthError::InvalidToken));
2193    }
2194
2195    #[test]
2196    fn signed_honors_issuer() {
2197        let (token, jwks) = mint_rs256(
2198            "k1",
2199            "{\"sub\":\"a\",\"exp\":9999999999,\"iss\":\"https://idp\"}",
2200        );
2201        let (mut v, _h) = signed(&jwks);
2202        v.valid_issuer = Some("https://idp".to_owned());
2203        assert2::assert!(v.validate(&token, 1_000_000_000_000).is_ok());
2204        v.valid_issuer = Some("https://other".to_owned());
2205        assert2::assert!(v.validate(&token, 1_000_000_000_000) == Err(AuthError::InvalidToken));
2206    }
2207
2208    #[test]
2209    fn signed_rejects_missing_issuer_when_required() {
2210        let (token, jwks) = mint_rs256("k1", "{\"sub\":\"a\",\"exp\":9999999999}");
2211        let (mut v, _h) = signed(&jwks);
2212        v.valid_issuer = Some("https://idp".to_owned());
2213        assert2::assert!(v.validate(&token, 1_000_000_000_000) == Err(AuthError::InvalidToken));
2214    }
2215
2216    #[test]
2217    fn signed_honors_audience_string_and_array() {
2218        let (tok_str, jwks) =
2219            mint_rs256("k1", "{\"sub\":\"a\",\"exp\":9999999999,\"aud\":\"kafka\"}");
2220        let (mut v, _h) = signed(&jwks);
2221        v.expected_audience = Some("kafka".to_owned());
2222        assert2::assert!(v.validate(&tok_str, 1_000_000_000_000).is_ok());
2223
2224        let (tok_arr, jwks2) = mint_rs256(
2225            "k1",
2226            "{\"sub\":\"a\",\"exp\":9999999999,\"aud\":[\"other\",\"kafka\"]}",
2227        );
2228        let (mut v2, _h2) = signed(&jwks2);
2229        v2.expected_audience = Some("kafka".to_owned());
2230        assert2::assert!(v2.validate(&tok_arr, 1_000_000_000_000).is_ok());
2231
2232        let (tok_bad, jwks3) =
2233            mint_rs256("k1", "{\"sub\":\"a\",\"exp\":9999999999,\"aud\":\"web\"}");
2234        let (mut v3, _h3) = signed(&jwks3);
2235        v3.expected_audience = Some("kafka".to_owned());
2236        assert2::assert!(v3.validate(&tok_bad, 1_000_000_000_000) == Err(AuthError::InvalidToken));
2237    }
2238
2239    // ---- SignedJwsValidator custom_claim_check + valid_token_type
2240
2241    #[test]
2242    fn signed_validate_rejects_when_custom_claim_check_fails() {
2243        let (token, jwks) = mint_rs256(
2244            "k1",
2245            "{\"sub\":\"alice\",\"exp\":9999999999,\"scope\":[\"kafka.read\"]}",
2246        );
2247        let (mut v, _h) = signed(&jwks);
2248        v.custom_claim_check = Some(parse_jp("$.scope[?@ == 'kafka.admin']"));
2249        let result = v.validate(&token, 1_000_000_000_000);
2250        assert2::assert!(result.unwrap_err() == AuthError::InvalidToken);
2251    }
2252
2253    #[test]
2254    fn signed_validate_accepts_when_custom_claim_check_passes() {
2255        let (token, jwks) = mint_rs256(
2256            "k1",
2257            "{\"sub\":\"alice\",\"exp\":9999999999,\"scope\":[\"kafka.admin\",\"kafka.read\"]}",
2258        );
2259        let (mut v, _h) = signed(&jwks);
2260        v.custom_claim_check = Some(parse_jp("$.scope[?@ == 'kafka.admin']"));
2261        let outcome = v.validate(&token, 1_000_000_000_000).expect("valid token");
2262        assert2::assert!(outcome.principal.name == "alice");
2263    }
2264
2265    #[test]
2266    fn signed_validate_rejects_when_valid_token_type_mismatch() {
2267        let (token, jwks) = mint_rs256_with_header(
2268            "{\"alg\":\"RS256\",\"kid\":\"k1\",\"typ\":\"OPAQUE\"}",
2269            "{\"sub\":\"alice\",\"exp\":9999999999}",
2270        );
2271        let (mut v, _h) = signed(&jwks);
2272        v.valid_token_type = Some("JWT".into());
2273        let result = v.validate(&token, 1_000_000_000_000);
2274        assert2::assert!(result.unwrap_err() == AuthError::InvalidToken);
2275    }
2276
2277    #[test]
2278    fn signed_validate_accepts_when_valid_token_type_match() {
2279        let (token, jwks) = mint_rs256_with_header(
2280            "{\"alg\":\"RS256\",\"kid\":\"k1\",\"typ\":\"JWT\"}",
2281            "{\"sub\":\"alice\",\"exp\":9999999999}",
2282        );
2283        let (mut v, _h) = signed(&jwks);
2284        v.valid_token_type = Some("JWT".into());
2285        assert2::assert!(v.validate(&token, 1_000_000_000_000).is_ok());
2286    }
2287
2288    #[test]
2289    fn signed_validate_accepts_when_valid_token_type_unset_regardless_of_header() {
2290        let (token, jwks) = mint_rs256_with_header(
2291            "{\"alg\":\"RS256\",\"kid\":\"k1\",\"typ\":\"OPAQUE\"}",
2292            "{\"sub\":\"alice\",\"exp\":9999999999}",
2293        );
2294        let (v, _h) = signed(&jwks);
2295        // No valid_token_type set → header `typ` ignored.
2296        assert2::assert!(v.validate(&token, 1_000_000_000_000).is_ok());
2297    }
2298
2299    #[test]
2300    fn signed_rejects_missing_principal() {
2301        let (token, jwks) = mint_rs256("k1", "{\"exp\":9999999999}");
2302        let (v, _h) = signed(&jwks);
2303        assert2::assert!(v.validate(&token, 1_000_000_000_000) == Err(AuthError::InvalidToken));
2304    }
2305
2306    #[test]
2307    fn signed_custom_principal_claim() {
2308        let (token, jwks) = mint_rs256("k1", "{\"client_id\":\"svc-1\",\"exp\":9999999999}");
2309        let (mut v, _h) = signed(&jwks);
2310        v.principal_claim_name = "client_id".to_owned();
2311        assert2::assert!(
2312            v.validate(&token, 1_000_000_000_000)
2313                .unwrap()
2314                .principal
2315                .name
2316                == "svc-1"
2317        );
2318    }
2319
2320    #[test]
2321    fn signed_key_rotation_via_handle() {
2322        // Token A verifies under key set A. ES256 with a fresh key per mint, so
2323        // set B is a genuinely different key (RS256's fixed test key can't be).
2324        let (token_a, jwks_a) = mint_es256("k1", "{\"sub\":\"a\",\"exp\":9999999999}");
2325        let (v, handle) = signed(&jwks_a);
2326        assert2::assert!(v.validate(&token_a, 1_000_000_000_000).is_ok());
2327
2328        // Rotate to a fresh key set (same kid, new key). Token A no longer
2329        // verifies; a token under the new key does. Same validator instance.
2330        let (token_b, jwks_b) = mint_es256("k1", "{\"sub\":\"b\",\"exp\":9999999999}");
2331        handle.store(Jwks::from_json(&jwks_b, false).unwrap());
2332        assert2::assert!(
2333            (
2334                v.validate(&token_a, 1_000_000_000_000),
2335                v.validate(&token_b, 1_000_000_000_000)
2336                    .map(|outcome| outcome.principal.name),
2337            ) == (Err(AuthError::InvalidToken), Ok("b".to_owned()))
2338        );
2339    }
2340
2341    #[test]
2342    fn signed_key_handle_shares_validator_key_cell() {
2343        let (token_a, jwks_a) = mint_es256("k1", "{\"sub\":\"a\",\"exp\":9999999999}");
2344        let (v, _handle) = signed(&jwks_a);
2345        assert2::assert!(v.validate(&token_a, 1_000_000_000_000).is_ok());
2346
2347        let handle = v.key_handle();
2348        let (token_b, jwks_b) = mint_es256("k1", "{\"sub\":\"b\",\"exp\":9999999999}");
2349        handle.store(Jwks::from_json(&jwks_b, false).expect("parse rotated jwks"));
2350
2351        assert2::assert!(
2352            (
2353                v.validate(&token_a, 1_000_000_000_000),
2354                v.validate(&token_b, 1_000_000_000_000)
2355                    .map(|outcome| outcome.principal.name),
2356            ) == (Err(AuthError::InvalidToken), Ok("b".to_owned()))
2357        );
2358    }
2359
2360    #[test]
2361    fn signed_rejects_tokens_with_expected_issuer_or_audience_missing() {
2362        let (token, jwks) = mint_rs256("k1", "{\"sub\":\"a\",\"exp\":9999999999}");
2363        let (mut issuer_validator, _h) = signed(&jwks);
2364        issuer_validator.valid_issuer = Some("https://idp".to_owned());
2365        assert2::assert!(
2366            issuer_validator.validate(&token, 1_000_000_000_000) == Err(AuthError::InvalidToken)
2367        );
2368
2369        let (mut audience_validator, _h) = signed(&jwks);
2370        audience_validator.expected_audience = Some("kafka".to_owned());
2371        assert2::assert!(
2372            audience_validator.validate(&token, 1_000_000_000_000) == Err(AuthError::InvalidToken)
2373        );
2374    }
2375
2376    #[test]
2377    fn signed_accepts_temporal_claims_at_skew_boundaries() {
2378        let (token, jwks) = mint_rs256(
2379            "k1",
2380            "{\"sub\":\"a\",\"exp\":2000,\"iat\":2001,\"nbf\":2001}",
2381        );
2382        let (mut v, _h) = signed(&jwks);
2383        v.allowable_clock_skew = secs(1);
2384        assert2::assert!(v.validate(&token, 2_000_000).is_ok());
2385    }
2386
2387    #[test]
2388    fn signed_rejects_temporal_claims_outside_skew_boundaries() {
2389        let (expired, expired_jwks) = mint_rs256("k1", "{\"sub\":\"a\",\"exp\":1999}");
2390        let (mut expired_v, _h) = signed(&expired_jwks);
2391        expired_v.allowable_clock_skew = secs(1);
2392        assert2::assert!(expired_v.validate(&expired, 2_000_000).is_err());
2393
2394        let (future_iat, iat_jwks) = mint_rs256("k1", "{\"sub\":\"a\",\"exp\":3000,\"iat\":2002}");
2395        let (mut iat_v, _h) = signed(&iat_jwks);
2396        iat_v.allowable_clock_skew = secs(1);
2397        assert2::assert!(iat_v.validate(&future_iat, 2_000_000).is_err());
2398
2399        let (future_nbf, nbf_jwks) = mint_rs256("k1", "{\"sub\":\"a\",\"exp\":3000,\"nbf\":2002}");
2400        let (mut nbf_v, _h) = signed(&nbf_jwks);
2401        nbf_v.allowable_clock_skew = secs(1);
2402        assert2::assert!(nbf_v.validate(&future_nbf, 2_000_000).is_err());
2403    }
2404
2405    #[test]
2406    fn signed_rejects_when_keyset_empty() {
2407        let (token, _jwks) = mint_rs256("k1", "{\"sub\":\"a\",\"exp\":9999999999}");
2408        let v = SignedJwsValidator::new(JwksHandle::default());
2409        assert2::assert!(v.validate(&token, 1_000_000_000_000) == Err(AuthError::InvalidToken));
2410    }
2411
2412    // ---- signed-validator parity --------------------------------
2413
2414    #[test]
2415    fn signed_validate_falls_back_to_alt_claim_when_primary_absent() {
2416        // Mirror the unsecured fallback test using the signed validator.
2417        // No `sub` claim → fallback to `client_id`, then apply prefix.
2418        let (token, jwks) = mint_rs256_with_header(
2419            "{\"alg\":\"RS256\",\"kid\":\"k1\",\"typ\":\"JWT\"}",
2420            "{\"client_id\":\"svc1\",\"exp\":9999999999,\"iss\":\"https://test.example\"}",
2421        );
2422        let (mut v, _h) = signed(&jwks);
2423        v.fallback_user_name_claim = Some("client_id".into());
2424        v.fallback_user_name_prefix = Some("service-account-".into());
2425        let outcome = v.validate(&token, 1_000_000_000_000).expect("valid");
2426        assert2::assert!(outcome.principal.name == "service-account-svc1");
2427    }
2428
2429    #[test]
2430    fn signed_validate_extracts_groups_from_array_claim() {
2431        let (token, jwks) = mint_rs256(
2432            "k1",
2433            "{\"sub\":\"alice\",\"exp\":9999999999,\"groups\":[\"admin\",\"ops\"]}",
2434        );
2435        let (mut v, _h) = signed(&jwks);
2436        v.groups_claim = Some(parse_jp("$.groups"));
2437        let outcome = v.validate(&token, 1_000_000_000_000).expect("valid");
2438        assert2::assert!(outcome.principal.groups == vec!["admin".to_owned(), "ops".to_owned()]);
2439    }
2440
2441    // ---- cache expiry + signal-on-verify-failure ----------------
2442
2443    /// Build a signed validator whose paired `JwksHandle` carries an explicit
2444    /// `last_successful_fetch_ms` and a fresh signal channel.
2445    ///
2446    /// This helper returns the validator and the receiver, so tests can assert
2447    /// on the emitted signals.
2448    fn signed_with_handles(
2449        jwks_json: &str,
2450        last_successful_fetch_ms: i64,
2451    ) -> (SignedJwsValidator, mpsc::Receiver<()>) {
2452        use std::sync::{Arc, atomic::AtomicI64};
2453        let (tx, rx) = mpsc::channel::<()>(1);
2454        let ts = Arc::new(AtomicI64::new(last_successful_fetch_ms));
2455        let handle = JwksHandle::new_with_refresher_handles(
2456            Jwks::from_json(jwks_json, false).unwrap(),
2457            ts,
2458            tx,
2459        );
2460        (SignedJwsValidator::new(handle), rx)
2461    }
2462
2463    #[test]
2464    fn signed_validate_rejects_when_jwks_cache_expired() {
2465        let now_ms: i64 = 10_000_000;
2466        let exp_secs: i64 = (now_ms / 1000) + 60;
2467        let (token, jwks) = mint_rs256("k1", &format!("{{\"sub\":\"alice\",\"exp\":{exp_secs}}}"));
2468        // Last fetch 2s ago; expiry threshold 1s ⇒ expired.
2469        let (mut v, _rx) = signed_with_handles(&jwks, now_ms - 2_000);
2470        v.cache_expiry = Some(secs(1));
2471        assert2::assert!(v.validate(&token, now_ms) == Err(AuthError::InvalidToken));
2472    }
2473
2474    #[test]
2475    fn signed_validate_accepts_when_jwks_cache_within_expiry() {
2476        let now_ms: i64 = 10_000_000;
2477        let exp_secs: i64 = (now_ms / 1000) + 60;
2478        let (token, jwks) = mint_rs256("k1", &format!("{{\"sub\":\"alice\",\"exp\":{exp_secs}}}"));
2479        // Last fetch 500ms ago; expiry threshold 1s ⇒ still fresh.
2480        let (mut v, _rx) = signed_with_handles(&jwks, now_ms - 500);
2481        v.cache_expiry = Some(secs(1));
2482        let outcome = v.validate(&token, now_ms).expect("valid");
2483        assert2::assert!(outcome.principal.name == "alice");
2484    }
2485
2486    #[test]
2487    fn signed_validate_accepts_when_jwks_cache_age_equals_expiry() {
2488        let now_ms: i64 = 10_000_000;
2489        let exp_secs: i64 = (now_ms / 1000) + 60;
2490        let (token, jwks) = mint_rs256("k1", &format!("{{\"sub\":\"alice\",\"exp\":{exp_secs}}}"));
2491        let (mut v, _rx) = signed_with_handles(&jwks, now_ms - 1_000);
2492        v.cache_expiry = Some(secs(1));
2493        let outcome = v.validate(&token, now_ms).expect("valid");
2494        assert2::assert!(outcome.principal.name == "alice");
2495    }
2496
2497    #[test]
2498    fn signed_validate_accepts_when_expiry_unset_regardless_of_cache_age() {
2499        let now_ms: i64 = 10_000_000;
2500        let exp_secs: i64 = (now_ms / 1000) + 60;
2501        let (token, jwks) = mint_rs256("k1", &format!("{{\"sub\":\"alice\",\"exp\":{exp_secs}}}"));
2502        // Cache "is" very stale, but cache_expiry = None ⇒ no check fires.
2503        let (v, _rx) = signed_with_handles(&jwks, now_ms - 999_999_999);
2504        // cache_expiry left at default None.
2505        assert2::assert!(v.validate(&token, now_ms).is_ok());
2506    }
2507
2508    #[test]
2509    fn signed_validate_skips_expiry_check_when_never_fetched() {
2510        // last_successful_fetch_ms still at 0 ⇒ no expiry math runs (the
2511        // never-fetched broker-startup window stays open; verify will fail
2512        // anyway because the served key set is intact, but the expiry path
2513        // must not preempt with a spurious rejection).
2514        let now_ms: i64 = 10_000_000;
2515        let exp_secs: i64 = (now_ms / 1000) + 60;
2516        let (token, jwks) = mint_rs256("k1", &format!("{{\"sub\":\"alice\",\"exp\":{exp_secs}}}"));
2517        let (mut v, _rx) = signed_with_handles(&jwks, 0);
2518        v.cache_expiry = Some(millis(1));
2519        // Cache "age" math would say "very expired" if we ran it, but the
2520        // sentinel-zero guard skips. Verification succeeds.
2521        assert2::assert!(v.validate(&token, now_ms).is_ok());
2522    }
2523
2524    #[test]
2525    fn signed_validate_signals_refresh_on_unknown_kid() {
2526        let now_ms: i64 = 10_000_000;
2527        let exp_secs: i64 = (now_ms / 1000) + 60;
2528        // Token's header advertises kid="k1" (the default rs256 mint), but the
2529        // served JWKS has a different kid ⇒ verify() returns InvalidToken AND
2530        // signal_refresh() fires.
2531        let (token, _jwks_with_k1) =
2532            mint_rs256("k1", &format!("{{\"sub\":\"alice\",\"exp\":{exp_secs}}}"));
2533        // Hand-craft a JWKS with a different kid so verify can't find k1.
2534        let mismatched_jwks =
2535            r#"{"keys":[{"kty":"RSA","kid":"other","n":"AQAB","e":"AQAB"}]}"#.to_owned();
2536        let (v, mut rx) = signed_with_handles(&mismatched_jwks, now_ms);
2537        assert2::assert!(
2538            (v.validate(&token, now_ms), rx.try_recv().is_ok())
2539                == (Err(AuthError::InvalidToken), true)
2540        );
2541    }
2542
2543    #[test]
2544    fn signed_validate_does_not_signal_when_verification_succeeds() {
2545        let now_ms: i64 = 10_000_000;
2546        let exp_secs: i64 = (now_ms / 1000) + 60;
2547        let (token, jwks) = mint_rs256("k1", &format!("{{\"sub\":\"alice\",\"exp\":{exp_secs}}}"));
2548        let (v, mut rx) = signed_with_handles(&jwks, now_ms);
2549        assert2::assert!(
2550            (v.validate(&token, now_ms).is_ok(), rx.try_recv().is_err()) == (true, true)
2551        );
2552    }
2553
2554    #[tokio::test]
2555    async fn enum_dispatches_unsecured_and_signed() {
2556        // Unsecured default.
2557        let unsecured = OAuthBearerValidator::default();
2558        let tok = unsecured_token("admin", 999_999_000, 9_999_999_999);
2559        assert2::assert!(
2560            (
2561                unsecured.jwks_handle().is_none(),
2562                unsecured.validate(&tok, 1_000_000_000_000).await.is_ok(),
2563            ) == (true, true)
2564        );
2565
2566        // Signed.
2567        let (token, jwks) = mint_rs256("k1", "{\"sub\":\"x\",\"exp\":9999999999}");
2568        let (sv, _h) = signed(&jwks);
2569        let signed_enum = OAuthBearerValidator::Signed(sv);
2570        assert2::assert!(
2571            (
2572                signed_enum.jwks_handle().is_some(),
2573                signed_enum
2574                    .validate(&token, 1_000_000_000_000)
2575                    .await
2576                    .map(|outcome| outcome.principal.name),
2577            ) == (true, Ok("x".to_owned()))
2578        );
2579    }
2580
2581    fn unsecured_token(sub: &str, iat_s: i64, exp_s: i64) -> String {
2582        jws(
2583            "{\"alg\":\"none\"}",
2584            &format!("{{\"sub\":\"{sub}\",\"iat\":{iat_s},\"exp\":{exp_s}}}"),
2585        )
2586    }
2587
2588    #[test]
2589    fn custom_claim_check_compile_error_at_validator_construction() {
2590        // Operators paste a malformed expression. We catch it at parse
2591        // time (validator construction), not per-token validation.
2592        let result = parse_json_path("@.unterminated");
2593        assert2::assert!(result.is_err());
2594    }
2595}
2596
2597#[cfg(test)]
2598mod introspection_tests {
2599    use std::{collections::HashMap, sync::Mutex};
2600
2601    use jsonpath_rust::parser::parse_json_path;
2602    use serde_json::{Value, json};
2603
2604    use super::*;
2605    use crate::{AuthError, AuthMethod};
2606
2607    // --- pure temporal/claim helpers (no IdP/JWKS fixtures needed) ----------
2608
2609    #[test]
2610    fn temporal_claims_exp_boundary_is_inclusive() {
2611        // now - skew == exp_ms is NOT expired (the guard is `>`, not `>=`).
2612        let claims = json!({ "exp": 10 }); // exp_ms = 10_000
2613        for (_name, now_ms, expected) in [
2614            ("at boundary", 10_000, true),
2615            ("past expiry", 10_001, false),
2616        ] {
2617            assert2::assert!(
2618                check_temporal_claims(&claims, now_ms, <Time as TimeExt>::ZERO).is_ok() == expected
2619            );
2620        }
2621    }
2622
2623    #[test]
2624    fn temporal_claims_iat_and_nbf_future_rejected() {
2625        // iat: at the boundary it's valid; issued in the future is rejected.
2626        let iat = json!({ "iat": 10 }); // iat_ms = 10_000
2627        assert2::assert!(check_temporal_claims(&iat, 10_000, <Time as TimeExt>::ZERO).is_ok());
2628        assert2::assert!(check_temporal_claims(&iat, 9_999, <Time as TimeExt>::ZERO).is_err());
2629        // nbf (not-before): same shape.
2630        let nbf = json!({ "nbf": 10 });
2631        assert2::assert!(check_temporal_claims(&nbf, 10_000, <Time as TimeExt>::ZERO).is_ok());
2632        assert2::assert!(check_temporal_claims(&nbf, 9_999, <Time as TimeExt>::ZERO).is_err());
2633    }
2634
2635    #[test]
2636    fn numeric_date_fractional_seconds_to_ms() {
2637        // Fractional NumericDate path: 10.5 s -> 10_500 ms (pins the `* 1000`).
2638        assert2::assert!(numeric_date_ms(&json!({ "k": 10.5 }), "k") == Some(10_500));
2639    }
2640
2641    #[test]
2642    fn audience_contains_array_membership() {
2643        // `aud` array membership: `any(a == expected)` (kills `==` -> `!=`).
2644        for (_name, audience, expected) in [
2645            ("present audience", "svc-a", true),
2646            ("absent audience", "svc-b", false),
2647        ] {
2648            assert2::assert!(audience_contains(&json!({ "aud": ["svc-a"] }), audience) == expected);
2649        }
2650    }
2651
2652    /// Per-token canned responses.
2653    ///
2654    /// `introspect` returns the entry for the matching token. When there is no
2655    /// entry it returns a Transport error, so a test can drive the
2656    /// transport-error path.
2657    #[derive(Debug, Default)]
2658    struct MockIntrospectionClient {
2659        introspect_responses: Mutex<HashMap<String, Result<Value, IntrospectionError>>>,
2660        userinfo_responses: Mutex<HashMap<String, Result<Option<Value>, IntrospectionError>>>,
2661    }
2662
2663    impl MockIntrospectionClient {
2664        fn arc() -> Arc<Self> {
2665            Arc::new(Self::default())
2666        }
2667        fn set_introspect(&self, token: &str, resp: Result<Value, IntrospectionError>) {
2668            self.introspect_responses
2669                .lock()
2670                .unwrap()
2671                .insert(token.into(), resp);
2672        }
2673        fn set_userinfo(&self, token: &str, resp: Result<Option<Value>, IntrospectionError>) {
2674            self.userinfo_responses
2675                .lock()
2676                .unwrap()
2677                .insert(token.into(), resp);
2678        }
2679    }
2680
2681    #[async_trait::async_trait]
2682    impl IntrospectionClient for MockIntrospectionClient {
2683        async fn introspect(&self, token: &str) -> Result<Value, IntrospectionError> {
2684            self.introspect_responses
2685                .lock()
2686                .unwrap()
2687                .remove(token)
2688                .unwrap_or_else(|| Err(IntrospectionError::Transport("no canned response".into())))
2689        }
2690        async fn userinfo(&self, token: &str) -> Result<Option<Value>, IntrospectionError> {
2691            self.userinfo_responses
2692                .lock()
2693                .unwrap()
2694                .remove(token)
2695                .unwrap_or(Ok(None))
2696        }
2697    }
2698
2699    fn validator(client: Arc<MockIntrospectionClient>) -> IntrospectionValidator {
2700        IntrospectionValidator {
2701            client,
2702            principal_claim_name: "sub".into(),
2703            custom_claim_check: None,
2704            call_userinfo: false,
2705            allowable_clock_skew: secs(30),
2706            expected_audience: None,
2707            fallback_user_name_claim: None,
2708            fallback_user_name_prefix: None,
2709            groups_claim: None,
2710            groups_claim_delimiter: None,
2711        }
2712    }
2713
2714    fn parse_jp(expr: &str) -> JpQuery {
2715        parse_json_path(expr).expect("expression compiles")
2716    }
2717
2718    const NOW_MS: i64 = 1_700_000_000_000;
2719
2720    #[tokio::test]
2721    async fn introspection_active_true_with_principal_returns_ok() {
2722        let mock = MockIntrospectionClient::arc();
2723        mock.set_introspect(
2724            "tok",
2725            Ok(json!({"active": true, "sub": "alice", "exp": NOW_MS/1000 + 60})),
2726        );
2727        let v = validator(Arc::clone(&mock));
2728        let outcome = v.validate("tok", NOW_MS).await.unwrap();
2729        assert2::assert!(outcome.principal.name.as_str() == "alice");
2730        assert2::assert!(outcome.principal.auth_method == AuthMethod::SaslOAuthBearer);
2731    }
2732
2733    #[tokio::test]
2734    async fn introspection_active_false_rejected() {
2735        let mock = MockIntrospectionClient::arc();
2736        mock.set_introspect("tok", Ok(json!({"active": false})));
2737        let v = validator(Arc::clone(&mock));
2738        assert2::assert!(matches!(
2739            v.validate("tok", NOW_MS).await,
2740            Err(AuthError::InvalidToken)
2741        ));
2742    }
2743
2744    #[tokio::test]
2745    async fn introspection_missing_active_field_rejected() {
2746        let mock = MockIntrospectionClient::arc();
2747        mock.set_introspect("tok", Ok(json!({"sub": "alice"})));
2748        let v = validator(Arc::clone(&mock));
2749        assert2::assert!(matches!(
2750            v.validate("tok", NOW_MS).await,
2751            Err(AuthError::InvalidToken)
2752        ));
2753    }
2754
2755    #[tokio::test]
2756    async fn introspection_expired_exp_rejected() {
2757        let mock = MockIntrospectionClient::arc();
2758        mock.set_introspect(
2759            "tok",
2760            Ok(json!({"active": true, "sub": "alice", "exp": NOW_MS/1000 - 3600})),
2761        );
2762        let v = validator(Arc::clone(&mock));
2763        assert2::assert!(matches!(
2764            v.validate("tok", NOW_MS).await,
2765            Err(AuthError::InvalidToken)
2766        ));
2767    }
2768
2769    // ---- IntrospectionValidator custom_claim_check -------------
2770
2771    #[tokio::test]
2772    async fn introspection_validate_rejects_when_custom_claim_check_fails() {
2773        let mock = MockIntrospectionClient::arc();
2774        mock.set_introspect(
2775            "tok",
2776            Ok(json!({
2777                "active": true,
2778                "sub": "alice",
2779                "exp": NOW_MS/1000 + 60,
2780                "scope": ["kafka.read"],
2781            })),
2782        );
2783        let mut v = validator(Arc::clone(&mock));
2784        v.custom_claim_check = Some(parse_jp("$.scope[?@ == 'kafka.admin']"));
2785        let result = v.validate("tok", NOW_MS).await;
2786        assert2::assert!(result.unwrap_err() == AuthError::InvalidToken);
2787    }
2788
2789    #[tokio::test]
2790    async fn introspection_validate_accepts_when_custom_claim_check_passes() {
2791        let mock = MockIntrospectionClient::arc();
2792        mock.set_introspect(
2793            "tok",
2794            Ok(json!({
2795                "active": true,
2796                "sub": "alice",
2797                "exp": NOW_MS/1000 + 60,
2798                "scope": ["kafka.admin", "kafka.read"],
2799            })),
2800        );
2801        let mut v = validator(Arc::clone(&mock));
2802        v.custom_claim_check = Some(parse_jp("$.scope[?@ == 'kafka.admin']"));
2803        let outcome = v.validate("tok", NOW_MS).await.expect("valid");
2804        assert2::assert!(outcome.principal.name == "alice");
2805    }
2806
2807    // ---- IntrospectionValidator expected_audience --------------
2808
2809    #[tokio::test]
2810    async fn introspection_honors_audience_string_and_array() {
2811        // Matching string `aud`.
2812        let mock = MockIntrospectionClient::arc();
2813        mock.set_introspect(
2814            "tok",
2815            Ok(json!({"active": true, "sub": "a", "exp": NOW_MS/1000 + 60, "aud": "kafka"})),
2816        );
2817        let mut v = validator(Arc::clone(&mock));
2818        v.expected_audience = Some("kafka".to_owned());
2819        assert2::assert!(v.validate("tok", NOW_MS).await.is_ok());
2820
2821        // Matching value inside an `aud` array.
2822        let mock2 = MockIntrospectionClient::arc();
2823        mock2.set_introspect(
2824            "tok",
2825            Ok(json!({
2826                "active": true, "sub": "a", "exp": NOW_MS/1000 + 60,
2827                "aud": ["other", "kafka"],
2828            })),
2829        );
2830        let mut v2 = validator(Arc::clone(&mock2));
2831        v2.expected_audience = Some("kafka".to_owned());
2832        assert2::assert!(v2.validate("tok", NOW_MS).await.is_ok());
2833    }
2834
2835    #[tokio::test]
2836    async fn introspection_rejects_non_matching_audience() {
2837        let mock = MockIntrospectionClient::arc();
2838        mock.set_introspect(
2839            "tok",
2840            Ok(json!({"active": true, "sub": "a", "exp": NOW_MS/1000 + 60, "aud": "web"})),
2841        );
2842        let mut v = validator(Arc::clone(&mock));
2843        v.expected_audience = Some("kafka".to_owned());
2844        assert2::assert!(v.validate("tok", NOW_MS).await == Err(AuthError::InvalidToken));
2845    }
2846
2847    #[tokio::test]
2848    async fn introspection_rejects_missing_audience_when_expected() {
2849        let mock = MockIntrospectionClient::arc();
2850        mock.set_introspect(
2851            "tok",
2852            Ok(json!({"active": true, "sub": "a", "exp": NOW_MS/1000 + 60})),
2853        );
2854        let mut v = validator(Arc::clone(&mock));
2855        v.expected_audience = Some("kafka".to_owned());
2856        assert2::assert!(v.validate("tok", NOW_MS).await == Err(AuthError::InvalidToken));
2857    }
2858
2859    #[tokio::test]
2860    async fn introspection_ignores_audience_when_unset() {
2861        // Default (expected_audience == None): any `aud` is accepted.
2862        let mock = MockIntrospectionClient::arc();
2863        mock.set_introspect(
2864            "tok",
2865            Ok(json!({"active": true, "sub": "a", "exp": NOW_MS/1000 + 60, "aud": "web"})),
2866        );
2867        let v = validator(Arc::clone(&mock));
2868        assert2::assert!(v.validate("tok", NOW_MS).await.is_ok());
2869    }
2870
2871    #[tokio::test]
2872    async fn introspection_validate_does_not_check_valid_token_type() {
2873        // Introspection responses have no JWT header → typ check is N/A.
2874        // The struct doesn't even expose a valid_token_type field; this
2875        // is a regression test that validation passes regardless of any
2876        // hypothetical typ in the response (introspection responses
2877        // don't carry `typ`).
2878        let mock = MockIntrospectionClient::arc();
2879        mock.set_introspect(
2880            "tok",
2881            Ok(json!({
2882                "active": true,
2883                "sub": "alice",
2884                "exp": NOW_MS/1000 + 60,
2885            })),
2886        );
2887        let v = validator(Arc::clone(&mock));
2888        let outcome = v.validate("tok", NOW_MS).await.expect("valid");
2889        assert2::assert!(outcome.principal.name == "alice");
2890    }
2891
2892    #[tokio::test]
2893    async fn introspection_userinfo_claims_override_introspection_for_profile_keys() {
2894        let mock = MockIntrospectionClient::arc();
2895        mock.set_introspect(
2896            "tok",
2897            Ok(json!({
2898                "active": true,
2899                "sub": "alice",
2900                "exp": NOW_MS/1000 + 60,
2901                "preferred_username": "intros-name",
2902            })),
2903        );
2904        mock.set_userinfo(
2905            "tok",
2906            Ok(Some(
2907                json!({"preferred_username": "userinfo-name", "email": "[email protected]"}),
2908            )),
2909        );
2910        let mut v = validator(Arc::clone(&mock));
2911        v.call_userinfo = true;
2912        v.principal_claim_name = "preferred_username".into();
2913        let outcome = v.validate("tok", NOW_MS).await.unwrap();
2914        assert2::assert!(outcome.principal.name == "userinfo-name");
2915    }
2916
2917    #[tokio::test]
2918    async fn introspection_userinfo_does_not_override_authorization_keys() {
2919        let mock = MockIntrospectionClient::arc();
2920        mock.set_introspect(
2921            "tok",
2922            Ok(json!({"active": true, "sub": "alice", "exp": NOW_MS/1000 + 60})),
2923        );
2924        mock.set_userinfo("tok", Ok(Some(json!({"active": false, "sub": "mallory"}))));
2925        let mut v = validator(Arc::clone(&mock));
2926        v.call_userinfo = true;
2927        let outcome = v.validate("tok", NOW_MS).await.unwrap();
2928        assert2::assert!(outcome.principal.name == "alice");
2929    }
2930
2931    #[tokio::test]
2932    async fn introspection_userinfo_disabled_when_call_userinfo_false() {
2933        let mock = MockIntrospectionClient::arc();
2934        mock.set_introspect(
2935            "tok",
2936            Ok(json!({"active": true, "sub": "alice", "exp": NOW_MS/1000 + 60})),
2937        );
2938        // Deliberately set a userinfo response — should be ignored.
2939        mock.set_userinfo("tok", Ok(Some(json!({"preferred_username": "ignored"}))));
2940        let v = validator(Arc::clone(&mock)); // call_userinfo: false (default)
2941        let outcome = v.validate("tok", NOW_MS).await.unwrap();
2942        assert2::assert!(outcome.principal.name == "alice");
2943    }
2944
2945    #[tokio::test]
2946    async fn introspection_transport_error_becomes_introspection_transport() {
2947        let mock = MockIntrospectionClient::arc();
2948        mock.set_introspect(
2949            "tok",
2950            Err(IntrospectionError::Transport("connection refused".into())),
2951        );
2952        let v = validator(Arc::clone(&mock));
2953        let err = v.validate("tok", NOW_MS).await.unwrap_err();
2954        assert2::assert!(
2955            matches!(err, AuthError::IntrospectionTransport(ref msg) if msg.contains("connection refused"))
2956        );
2957    }
2958
2959    #[tokio::test]
2960    async fn introspection_default_principal_claim_sub() {
2961        let mock = MockIntrospectionClient::arc();
2962        mock.set_introspect(
2963            "tok",
2964            Ok(json!({"active": true, "sub": "sub-name", "exp": NOW_MS/1000 + 60})),
2965        );
2966        let v = validator(Arc::clone(&mock));
2967        assert2::assert!(v.validate("tok", NOW_MS).await.unwrap().principal.name == "sub-name");
2968    }
2969
2970    #[tokio::test]
2971    async fn introspection_custom_principal_claim_client_id() {
2972        let mock = MockIntrospectionClient::arc();
2973        mock.set_introspect(
2974            "tok",
2975            Ok(json!({
2976                "active": true,
2977                "sub": "sub-name",
2978                "exp": NOW_MS/1000 + 60,
2979                "client_id": "my-client",
2980            })),
2981        );
2982        let mut v = validator(Arc::clone(&mock));
2983        v.principal_claim_name = "client_id".into();
2984        assert2::assert!(v.validate("tok", NOW_MS).await.unwrap().principal.name == "my-client");
2985    }
2986
2987    #[tokio::test]
2988    async fn introspection_rejects_empty_fallback_principal_claim() {
2989        let mock = MockIntrospectionClient::arc();
2990        mock.set_introspect(
2991            "tok",
2992            Ok(json!({
2993                "active": true,
2994                "exp": NOW_MS/1000 + 60,
2995                "client_id": "",
2996            })),
2997        );
2998        let mut v = validator(Arc::clone(&mock));
2999        v.fallback_user_name_claim = Some("client_id".into());
3000        assert2::assert!(v.validate("tok", NOW_MS).await == Err(AuthError::InvalidToken));
3001    }
3002
3003    #[tokio::test]
3004    async fn enum_dispatch_introspection_async() {
3005        let mock = MockIntrospectionClient::arc();
3006        mock.set_introspect(
3007            "tok",
3008            Ok(json!({"active": true, "sub": "alice", "exp": NOW_MS/1000 + 60})),
3009        );
3010        let v = validator(Arc::clone(&mock));
3011        let enum_v = OAuthBearerValidator::Introspection(v);
3012        let outcome = enum_v.validate("tok", NOW_MS).await.unwrap();
3013        assert2::assert!(outcome.principal.name == "alice");
3014    }
3015
3016    #[tokio::test]
3017    async fn introspection_validate_surfaces_exp_from_introspection_response() {
3018        let exp_secs: i64 = 2_000;
3019        let now_ms: i64 = 1_000_000;
3020        let mock = MockIntrospectionClient::arc();
3021        mock.set_introspect(
3022            "opaque-token",
3023            Ok(json!({
3024                "active": true,
3025                "sub": "alice",
3026                "exp": exp_secs,
3027                "scope": "kafka.write",
3028            })),
3029        );
3030        let v = validator(Arc::clone(&mock));
3031        let outcome = v
3032            .validate("opaque-token", now_ms)
3033            .await
3034            .expect("token valid");
3035        assert2::assert!(outcome.principal.name.as_str() == "alice");
3036        assert2::assert!(outcome.expires_at_ms == Some(exp_secs * 1000));
3037    }
3038
3039    // ---- introspection parity -----------------------------------
3040
3041    #[tokio::test]
3042    async fn introspection_validate_extracts_groups_from_introspection_response() {
3043        let exp_secs: i64 = 2_000;
3044        let now_ms: i64 = 1_000_000;
3045        let mock = MockIntrospectionClient::arc();
3046        mock.set_introspect(
3047            "opaque-token",
3048            Ok(json!({
3049                "active": true,
3050                "sub": "alice",
3051                "exp": exp_secs,
3052                "groups": ["admin", "ops"],
3053            })),
3054        );
3055        let mut v = validator(Arc::clone(&mock));
3056        v.groups_claim = Some(parse_jp("$.groups"));
3057        let outcome = v.validate("opaque-token", now_ms).await.expect("valid");
3058        assert2::assert!(outcome.principal.groups == vec!["admin".to_owned(), "ops".to_owned()]);
3059    }
3060}