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    use chio_test_support::prelude::*;
93
94    #[test]
95    fn anonymous_identity() {
96        let id = CallerIdentity::anonymous();
97        assert_eq!(id.subject, "anonymous");
98        assert!(!id.verified);
99        assert!(matches!(id.auth_method, AuthMethod::Anonymous));
100    }
101
102    #[test]
103    fn identity_hash_deterministic() {
104        let id = CallerIdentity {
105            subject: "user-123".to_string(),
106            auth_method: AuthMethod::Bearer {
107                token_hash: "abc123".to_string(),
108            },
109            verified: true,
110            tenant: Some("acme".to_string()),
111            agent_id: None,
112        };
113        let h1 = id.identity_hash().test_unwrap();
114        let h2 = id.identity_hash().test_unwrap();
115        assert_eq!(h1, h2);
116        assert_eq!(h1.len(), 64); // SHA-256 hex
117    }
118
119    #[test]
120    fn serde_roundtrip() {
121        let id = CallerIdentity {
122            subject: "svc-agent".to_string(),
123            auth_method: AuthMethod::ApiKey {
124                key_name: "X-API-Key".to_string(),
125                key_hash: "deadbeef".to_string(),
126            },
127            verified: true,
128            tenant: None,
129            agent_id: Some("agent-42".to_string()),
130        };
131        let json = serde_json::to_string(&id).test_unwrap();
132        let back: CallerIdentity = serde_json::from_str(&json).test_unwrap();
133        assert_eq!(back.subject, "svc-agent");
134        assert_eq!(back.agent_id.as_deref(), Some("agent-42"));
135    }
136
137    #[test]
138    fn mtls_certificate_serde_roundtrip() {
139        let id = CallerIdentity {
140            subject: "CN=service.internal".to_string(),
141            auth_method: AuthMethod::MtlsCertificate {
142                subject_dn: "CN=service.internal,O=Acme".to_string(),
143                fingerprint: "abcdef1234567890".to_string(),
144            },
145            verified: true,
146            tenant: Some("acme-corp".to_string()),
147            agent_id: None,
148        };
149        let json = serde_json::to_string(&id).test_unwrap();
150        let back: CallerIdentity = serde_json::from_str(&json).test_unwrap();
151        assert_eq!(back.subject, "CN=service.internal");
152        assert!(back.verified);
153        assert_eq!(back.tenant.as_deref(), Some("acme-corp"));
154        match &back.auth_method {
155            AuthMethod::MtlsCertificate {
156                subject_dn,
157                fingerprint,
158            } => {
159                assert_eq!(subject_dn, "CN=service.internal,O=Acme");
160                assert_eq!(fingerprint, "abcdef1234567890");
161            }
162            other => panic!("expected MtlsCertificate, got {other:?}"),
163        }
164    }
165
166    #[test]
167    fn cookie_auth_method_serde_roundtrip() {
168        let id = CallerIdentity {
169            subject: "cookie-user".to_string(),
170            auth_method: AuthMethod::Cookie {
171                cookie_name: "session_id".to_string(),
172                cookie_hash: "cookiehash123".to_string(),
173            },
174            verified: false,
175            tenant: None,
176            agent_id: None,
177        };
178        let json = serde_json::to_string(&id).test_unwrap();
179        let back: CallerIdentity = serde_json::from_str(&json).test_unwrap();
180        match &back.auth_method {
181            AuthMethod::Cookie {
182                cookie_name,
183                cookie_hash,
184            } => {
185                assert_eq!(cookie_name, "session_id");
186                assert_eq!(cookie_hash, "cookiehash123");
187            }
188            other => panic!("expected Cookie, got {other:?}"),
189        }
190    }
191
192    #[test]
193    fn different_identities_produce_different_hashes() {
194        let id1 = CallerIdentity {
195            subject: "user-a".to_string(),
196            auth_method: AuthMethod::Bearer {
197                token_hash: "hash1".to_string(),
198            },
199            verified: true,
200            tenant: None,
201            agent_id: None,
202        };
203        let id2 = CallerIdentity {
204            subject: "user-b".to_string(),
205            auth_method: AuthMethod::Bearer {
206                token_hash: "hash2".to_string(),
207            },
208            verified: true,
209            tenant: None,
210            agent_id: None,
211        };
212        let h1 = id1.identity_hash().test_unwrap();
213        let h2 = id2.identity_hash().test_unwrap();
214        assert_ne!(h1, h2);
215    }
216
217    #[test]
218    fn anonymous_identity_serde_omits_optional_fields() {
219        let id = CallerIdentity::anonymous();
220        let json = serde_json::to_string(&id).test_unwrap();
221        // tenant and agent_id should be skipped because of skip_serializing_if
222        assert!(!json.contains("tenant"));
223        assert!(!json.contains("agent_id"));
224    }
225}