Skip to main content

assay_core/mcp/
g3_auth_context.rs

1//! G3 v1 — Authorization Context Evidence (policy-projected fields only).
2//!
3//! See `docs/architecture/PLAN-G3-AUTHORIZATION-CONTEXT-EVIDENCE-2026q2.md`.
4
5use super::policy::PolicyMatchMetadata;
6
7/// Allowlisted `auth_scheme` values (lowercase JSON).
8pub const AUTH_SCHEME_OAUTH2: &str = "oauth2";
9pub const AUTH_SCHEME_JWT_BEARER: &str = "jwt_bearer";
10
11/// Maximum stored length for `auth_issuer` after trim (drop if exceeded).
12const MAX_AUTH_ISSUER_BYTES: usize = 2048;
13
14/// Rejects JWS compact strings (`header.payload.signature`) — not valid `iss` / principal (v1: no JWT dumps).
15fn 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    // Typical JWT header base64url begins with `{"` → `eyJ`
25    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/// Optional projection merged into [`PolicyMatchMetadata`] after policy evaluation
41/// on the supported MCP tool-call / decision path (tests and configured handlers).
42#[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    /// Normalize and assign G3 fields on metadata. Unknown `auth_scheme` values are dropped.
51    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
64/// Returns allowlisted scheme string or `None` if unknown / empty.
65pub 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
77/// v1 norm: trimmed JWT `iss`-style string (no raw JWT dump). Oversized input dropped.
78pub 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
92/// Principal for G3: Unicode-trimmed; whitespace-only ⇒ absent.
93pub 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    /// Synthetic JWS-shaped string (not a real credential; avoids well-known JWT literals in scanners).
138    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        // Must not slice `str` at byte 7 — use byte prefix compare only.
152        let s = "\u{00e9}Bearer token";
153        assert!(!has_bearer_credential_prefix(s));
154        assert!(has_bearer_credential_prefix("Bearer ok"));
155    }
156}