1use 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#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct AuthOutcome {
32 pub principal: Principal,
33 pub expires_at_ms: Option<i64>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ClientInitialResponse {
43 pub token: String,
45 pub authzid: Option<String>,
48}
49
50pub 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 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
109const KVSEP: char = '\u{1}';
111
112fn 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 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
141fn 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
162fn is_kvpair_key(key: &str) -> bool {
164 !key.is_empty() && key.bytes().all(|b| b.is_ascii_alphabetic())
165}
166
167fn 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
176fn 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#[derive(Debug, Clone, PartialEq)]
202pub struct UnsecuredJwsValidator {
203 pub principal_claim_name: String,
205 pub allowable_clock_skew: Time,
209 pub custom_claim_check: Option<JpQuery>,
214 pub valid_token_type: Option<String>,
217 pub fallback_user_name_claim: Option<String>,
222 pub fallback_user_name_prefix: Option<String>,
225 pub groups_claim: Option<JpQuery>,
228 pub groups_claim_delimiter: Option<String>,
231 pub scope_claim_name: String,
234 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 #[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 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 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 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 let token_scope = scope_claim(&claims, &self.scope_claim_name)?;
311
312 let skew_ms = skew_millis(self.allowable_clock_skew);
313 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 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 if let Some(path) = &self.custom_claim_check
339 && !evaluate_custom_claim_check(path, &claims)
340 {
341 return Err(AuthError::InvalidToken);
342 }
343
344 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 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 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
402fn 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 _ => {} }
433 }
434 out
435}
436
437fn 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
459fn 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
472fn 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
478fn skew_millis(skew: Time) -> i64 {
486 skew.millis_i64()
487}
488
489fn numeric_date_ms(claims: &Value, key: &str) -> Option<i64> {
493 let v = claims.get(key)?;
494 if let Some(secs) = v.as_i64() {
496 return secs.checked_mul(1000);
497 }
498 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#[must_use]
523pub fn oauth_error_json(err: &AuthError) -> String {
524 match err {
525 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#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
540#[error("invalid scope value: {0}")]
541pub struct InvalidScopeItem(pub String);
542
543pub 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
572fn java_trim(s: &str) -> &str {
575 s.trim_matches(|c: char| c <= ' ')
576}
577
578fn is_java_blank(s: &str) -> bool {
580 java_trim(s).is_empty()
581}
582
583fn 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
596fn 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#[derive(Debug, Clone)]
631pub struct SignedJwsValidator {
632 pub principal_claim_name: String,
634 pub allowable_clock_skew: Time,
636 pub valid_issuer: Option<String>,
638 pub expected_audience: Option<String>,
640 pub custom_claim_check: Option<JpQuery>,
643 pub valid_token_type: Option<String>,
645 pub fallback_user_name_claim: Option<String>,
647 pub fallback_user_name_prefix: Option<String>,
649 pub groups_claim: Option<JpQuery>,
651 pub groups_claim_delimiter: Option<String>,
653 pub cache_expiry: Option<Time>,
659 keys: JwksHandle,
661}
662
663impl SignedJwsValidator {
664 #[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 #[must_use]
688 pub fn key_handle(&self) -> JwksHandle {
689 self.keys.clone()
690 }
691
692 #[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 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 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 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 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 fn check_claims(&self, claims: &Value, now_ms: i64) -> Result<AuthOutcome, AuthError> {
784 let skew_ms = skew_millis(self.allowable_clock_skew);
785 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 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 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 if let Some(path) = &self.custom_claim_check
817 && !evaluate_custom_claim_check(path, claims)
818 {
819 return Err(AuthError::InvalidToken);
820 }
821
822 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#[derive(Debug, Clone)]
871pub enum OAuthBearerValidator {
872 Unsecured(UnsecuredJwsValidator),
874 Signed(SignedJwsValidator),
876 Introspection(IntrospectionValidator),
878}
879
880impl Default for OAuthBearerValidator {
881 fn default() -> Self {
882 Self::Unsecured(UnsecuredJwsValidator::default())
883 }
884}
885
886impl OAuthBearerValidator {
887 #[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 #[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#[async_trait::async_trait]
925pub trait IntrospectionClient: Send + Sync + fmt::Debug {
926 async fn introspect(&self, token: &str) -> Result<serde_json::Value, IntrospectionError>;
929
930 async fn userinfo(&self, token: &str) -> Result<Option<serde_json::Value>, IntrospectionError>;
934}
935
936#[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#[derive(Debug, Clone)]
959pub struct IntrospectionValidator {
960 pub client: Arc<dyn IntrospectionClient>,
961 pub principal_claim_name: String,
965 pub custom_claim_check: Option<JpQuery>,
969 pub call_userinfo: bool,
973 pub allowable_clock_skew: Time,
976 pub expected_audience: Option<String>,
982 pub fallback_user_name_claim: Option<String>,
984 pub fallback_user_name_prefix: Option<String>,
986 pub groups_claim: Option<JpQuery>,
990 pub groups_claim_delimiter: Option<String>,
992}
993
994impl IntrospectionValidator {
995 #[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 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 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 if let Some(path) = &self.custom_claim_check
1056 && !evaluate_custom_claim_check(path, &claims)
1057 {
1058 return Err(AuthError::InvalidToken);
1059 }
1060 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
1103fn 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
1130fn 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 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 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 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); 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 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 #[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 #[test]
1458 fn unsecured_exp_boundary_tracks_the_configured_skew_exactly() {
1459 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 #[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 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 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 #[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 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 assert2::assert!(v.validate(&token, now_ms).is_ok());
1653 }
1654
1655 #[test]
1658 fn unsecured_validate_uses_primary_principal_claim_when_present() {
1659 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"); }
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 &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"); }
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 &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(); 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 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 #[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 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 #[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 #[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 #[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 #[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 use tokio::sync::mpsc;
2112
2113 use crate::jwks::{Jwks, JwksHandle, mint_es256, mint_rs256, mint_rs256_with_header};
2114
2115 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 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 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 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 #[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 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 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 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 #[test]
2415 fn signed_validate_falls_back_to_alt_claim_when_primary_absent() {
2416 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 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 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 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 let (v, _rx) = signed_with_handles(&jwks, now_ms - 999_999_999);
2504 assert2::assert!(v.validate(&token, now_ms).is_ok());
2506 }
2507
2508 #[test]
2509 fn signed_validate_skips_expiry_check_when_never_fetched() {
2510 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 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 let (token, _jwks_with_k1) =
2532 mint_rs256("k1", &format!("{{\"sub\":\"alice\",\"exp\":{exp_secs}}}"));
2533 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 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 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 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 #[test]
2610 fn temporal_claims_exp_boundary_is_inclusive() {
2611 let claims = json!({ "exp": 10 }); 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 let iat = json!({ "iat": 10 }); 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 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 assert2::assert!(numeric_date_ms(&json!({ "k": 10.5 }), "k") == Some(10_500));
2639 }
2640
2641 #[test]
2642 fn audience_contains_array_membership() {
2643 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 #[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 #[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 #[tokio::test]
2810 async fn introspection_honors_audience_string_and_array() {
2811 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 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 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 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 mock.set_userinfo("tok", Ok(Some(json!({"preferred_username": "ignored"}))));
2940 let v = validator(Arc::clone(&mock)); 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 #[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}