assay_core/mcp/
g3_auth_context.rs1use super::policy::PolicyMatchMetadata;
6
7pub const AUTH_SCHEME_OAUTH2: &str = "oauth2";
9pub const AUTH_SCHEME_JWT_BEARER: &str = "jwt_bearer";
10
11const MAX_AUTH_ISSUER_BYTES: usize = 2048;
13
14fn looks_like_jws_compact(s: &str) -> bool {
16 let parts: Vec<&str> = s.split('.').collect();
17 if parts.len() != 3 {
18 return false;
19 }
20 let (h, p, sig) = (parts[0], parts[1], parts[2]);
21 if h.len() < 4 || p.len() < 4 || sig.len() < 4 {
22 return false;
23 }
24 if !h.starts_with("eyJ") {
26 return false;
27 }
28 let is_b64url = |part: &str| {
29 part.chars()
30 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
31 };
32 is_b64url(h) && is_b64url(p) && is_b64url(sig)
33}
34
35fn has_bearer_credential_prefix(s: &str) -> bool {
36 let b = s.trim().as_bytes();
37 b.len() >= 7 && b[..7].eq_ignore_ascii_case(b"bearer ")
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct AuthContextProjection {
44 pub auth_scheme: Option<String>,
45 pub auth_issuer: Option<String>,
46 pub principal: Option<String>,
47}
48
49impl AuthContextProjection {
50 pub fn merge_into_metadata(&self, metadata: &mut PolicyMatchMetadata) {
52 if let Some(s) = normalize_auth_scheme(self.auth_scheme.as_deref()) {
53 metadata.auth_scheme = Some(s);
54 }
55 if let Some(i) = normalize_auth_issuer(self.auth_issuer.as_deref()) {
56 metadata.auth_issuer = Some(i);
57 }
58 if let Some(p) = normalize_principal(self.principal.as_deref()) {
59 metadata.principal = Some(p);
60 }
61 }
62}
63
64pub fn normalize_auth_scheme(input: Option<&str>) -> Option<String> {
66 let s = input?.trim();
67 if s.is_empty() {
68 return None;
69 }
70 let lower = s.to_ascii_lowercase();
71 match lower.as_str() {
72 AUTH_SCHEME_OAUTH2 | AUTH_SCHEME_JWT_BEARER => Some(lower),
73 _ => None,
74 }
75}
76
77pub fn normalize_auth_issuer(input: Option<&str>) -> Option<String> {
79 let s = input?.trim();
80 if s.is_empty() {
81 return None;
82 }
83 if has_bearer_credential_prefix(s) || looks_like_jws_compact(s) {
84 return None;
85 }
86 if s.len() > MAX_AUTH_ISSUER_BYTES {
87 return None;
88 }
89 Some(s.to_string())
90}
91
92pub fn normalize_principal(input: Option<&str>) -> Option<String> {
94 let s = input.map(str::trim)?;
95 if s.is_empty() {
96 return None;
97 }
98 if has_bearer_credential_prefix(s) || looks_like_jws_compact(s) {
99 return None;
100 }
101 Some(s.to_string())
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn scheme_allowlist() {
110 assert_eq!(
111 normalize_auth_scheme(Some("JWT_BEARER")).as_deref(),
112 Some(AUTH_SCHEME_JWT_BEARER)
113 );
114 assert_eq!(
115 normalize_auth_scheme(Some(" oauth2 ")).as_deref(),
116 Some("oauth2")
117 );
118 assert_eq!(normalize_auth_scheme(Some("openid")), None);
119 }
120
121 #[test]
122 fn issuer_trim_and_cap() {
123 assert_eq!(
124 normalize_auth_issuer(Some(" https://issuer.example ")).as_deref(),
125 Some("https://issuer.example")
126 );
127 let huge = "x".repeat(MAX_AUTH_ISSUER_BYTES + 1);
128 assert_eq!(normalize_auth_issuer(Some(&huge)), None);
129 }
130
131 #[test]
132 fn principal_whitespace_absent() {
133 assert_eq!(normalize_principal(Some(" \n\t ")), None);
134 assert_eq!(normalize_principal(Some("alice")).as_deref(), Some("alice"));
135 }
136
137 const SYNTHETIC_JWS_COMPACT: &str =
139 "eyJxxxxxxxxxxxxxxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyy.zzzzzzzzzzzzzzzzzzzzzzzz";
140
141 #[test]
142 fn issuer_and_principal_reject_jws_compact_and_bearer_material() {
143 assert_eq!(normalize_auth_issuer(Some(SYNTHETIC_JWS_COMPACT)), None);
144 assert_eq!(normalize_auth_issuer(Some("Bearer secret-token")), None);
145 assert_eq!(normalize_principal(Some(SYNTHETIC_JWS_COMPACT)), None);
146 assert_eq!(normalize_principal(Some("Bearer opaque-credential")), None);
147 }
148
149 #[test]
150 fn bearer_prefix_check_does_not_panic_on_non_ascii_leading_chars() {
151 let s = "\u{00e9}Bearer token";
153 assert!(!has_bearer_credential_prefix(s));
154 assert!(has_bearer_credential_prefix("Bearer ok"));
155 }
156}