Skip to main content

chio_http_core/
identity.rs

1//! Caller identity and authentication method types.
2
3use serde::{Deserialize, Serialize};
4
5/// How the caller authenticated to the upstream API.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(tag = "method", rename_all = "snake_case")]
8pub enum AuthMethod {
9    /// Bearer token (JWT or opaque).
10    Bearer {
11        /// SHA-256 hash of the token value (never store raw tokens).
12        token_hash: String,
13    },
14    /// API key in a header or query parameter.
15    ApiKey {
16        /// Name of the header or query parameter carrying the key.
17        key_name: String,
18        /// SHA-256 hash of the key value.
19        key_hash: String,
20    },
21    /// Session cookie.
22    Cookie {
23        /// Cookie name.
24        cookie_name: String,
25        /// SHA-256 hash of the cookie value.
26        cookie_hash: String,
27    },
28    /// mTLS client certificate.
29    MtlsCertificate {
30        /// Subject DN from the client certificate.
31        subject_dn: String,
32        /// SHA-256 fingerprint of the certificate.
33        fingerprint: String,
34    },
35    /// No authentication was presented.
36    Anonymous,
37}
38
39/// The identity of the caller as extracted from the HTTP request.
40/// This is protocol-agnostic -- the same type is used regardless of
41/// whether the request came through a reverse proxy, framework middleware,
42/// or sidecar.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct CallerIdentity {
45    /// Stable identifier for the caller (e.g., user ID, service account, agent ID).
46    /// Extracted from the auth credential.
47    pub subject: String,
48
49    /// How the caller authenticated.
50    pub auth_method: AuthMethod,
51
52    /// Whether this identity has been verified (e.g., JWT signature checked,
53    /// API key looked up). False means the identity was extracted but not
54    /// cryptographically validated.
55    #[serde(default)]
56    pub verified: bool,
57
58    /// Optional tenant or organization the caller belongs to.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub tenant: Option<String>,
61
62    /// Optional agent identifier when the caller is an AI agent.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub agent_id: Option<String>,
65}
66
67impl CallerIdentity {
68    /// Create an anonymous caller identity.
69    #[must_use]
70    pub fn anonymous() -> Self {
71        Self {
72            subject: "anonymous".to_string(),
73            auth_method: AuthMethod::Anonymous,
74            verified: false,
75            tenant: None,
76            agent_id: None,
77        }
78    }
79
80    /// Compute a stable hash of this identity for inclusion in receipts.
81    /// Uses SHA-256 over the canonical JSON representation.
82    pub fn identity_hash(&self) -> chio_core_types::Result<String> {
83        let bytes = chio_core_types::canonical_json_bytes(self)?;
84        Ok(chio_core_types::sha256_hex(&bytes))
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn anonymous_identity() {
94        let id = CallerIdentity::anonymous();
95        assert_eq!(id.subject, "anonymous");
96        assert!(!id.verified);
97        assert!(matches!(id.auth_method, AuthMethod::Anonymous));
98    }
99
100    #[test]
101    fn identity_hash_deterministic() {
102        let id = CallerIdentity {
103            subject: "user-123".to_string(),
104            auth_method: AuthMethod::Bearer {
105                token_hash: "abc123".to_string(),
106            },
107            verified: true,
108            tenant: Some("acme".to_string()),
109            agent_id: None,
110        };
111        let h1 = id.identity_hash().unwrap();
112        let h2 = id.identity_hash().unwrap();
113        assert_eq!(h1, h2);
114        assert_eq!(h1.len(), 64); // SHA-256 hex
115    }
116
117    #[test]
118    fn serde_roundtrip() {
119        let id = CallerIdentity {
120            subject: "svc-agent".to_string(),
121            auth_method: AuthMethod::ApiKey {
122                key_name: "X-API-Key".to_string(),
123                key_hash: "deadbeef".to_string(),
124            },
125            verified: true,
126            tenant: None,
127            agent_id: Some("agent-42".to_string()),
128        };
129        let json = serde_json::to_string(&id).unwrap();
130        let back: CallerIdentity = serde_json::from_str(&json).unwrap();
131        assert_eq!(back.subject, "svc-agent");
132        assert_eq!(back.agent_id.as_deref(), Some("agent-42"));
133    }
134
135    #[test]
136    fn mtls_certificate_serde_roundtrip() {
137        let id = CallerIdentity {
138            subject: "CN=service.internal".to_string(),
139            auth_method: AuthMethod::MtlsCertificate {
140                subject_dn: "CN=service.internal,O=Acme".to_string(),
141                fingerprint: "abcdef1234567890".to_string(),
142            },
143            verified: true,
144            tenant: Some("acme-corp".to_string()),
145            agent_id: None,
146        };
147        let json = serde_json::to_string(&id).unwrap();
148        let back: CallerIdentity = serde_json::from_str(&json).unwrap();
149        assert_eq!(back.subject, "CN=service.internal");
150        assert!(back.verified);
151        assert_eq!(back.tenant.as_deref(), Some("acme-corp"));
152        match &back.auth_method {
153            AuthMethod::MtlsCertificate {
154                subject_dn,
155                fingerprint,
156            } => {
157                assert_eq!(subject_dn, "CN=service.internal,O=Acme");
158                assert_eq!(fingerprint, "abcdef1234567890");
159            }
160            other => panic!("expected MtlsCertificate, got {other:?}"),
161        }
162    }
163
164    #[test]
165    fn cookie_auth_method_serde_roundtrip() {
166        let id = CallerIdentity {
167            subject: "cookie-user".to_string(),
168            auth_method: AuthMethod::Cookie {
169                cookie_name: "session_id".to_string(),
170                cookie_hash: "cookiehash123".to_string(),
171            },
172            verified: false,
173            tenant: None,
174            agent_id: None,
175        };
176        let json = serde_json::to_string(&id).unwrap();
177        let back: CallerIdentity = serde_json::from_str(&json).unwrap();
178        match &back.auth_method {
179            AuthMethod::Cookie {
180                cookie_name,
181                cookie_hash,
182            } => {
183                assert_eq!(cookie_name, "session_id");
184                assert_eq!(cookie_hash, "cookiehash123");
185            }
186            other => panic!("expected Cookie, got {other:?}"),
187        }
188    }
189
190    #[test]
191    fn different_identities_produce_different_hashes() {
192        let id1 = CallerIdentity {
193            subject: "user-a".to_string(),
194            auth_method: AuthMethod::Bearer {
195                token_hash: "hash1".to_string(),
196            },
197            verified: true,
198            tenant: None,
199            agent_id: None,
200        };
201        let id2 = CallerIdentity {
202            subject: "user-b".to_string(),
203            auth_method: AuthMethod::Bearer {
204                token_hash: "hash2".to_string(),
205            },
206            verified: true,
207            tenant: None,
208            agent_id: None,
209        };
210        let h1 = id1.identity_hash().unwrap();
211        let h2 = id2.identity_hash().unwrap();
212        assert_ne!(h1, h2);
213    }
214
215    #[test]
216    fn anonymous_identity_serde_omits_optional_fields() {
217        let id = CallerIdentity::anonymous();
218        let json = serde_json::to_string(&id).unwrap();
219        // tenant and agent_id should be skipped because of skip_serializing_if
220        assert!(!json.contains("tenant"));
221        assert!(!json.contains("agent_id"));
222    }
223}