Skip to main content

chio_tower/
identity.rs

1//! Caller identity extraction from HTTP requests.
2
3use chio_http_core::{AuthMethod, CallerIdentity};
4
5/// Function that extracts caller identity from HTTP request headers.
6pub type IdentityExtractor = fn(&http::HeaderMap) -> CallerIdentity;
7
8/// Extract caller identity from HTTP headers.
9///
10/// Checks in order:
11/// 1. Authorization: Bearer <token>
12/// 2. X-API-Key header
13/// 3. Cookie header
14/// 4. Anonymous fallback
15pub fn extract_identity(headers: &http::HeaderMap) -> CallerIdentity {
16    // 1. Bearer token
17    if let Some(auth) = headers.get(http::header::AUTHORIZATION) {
18        if let Ok(auth_str) = auth.to_str() {
19            if let Some(token) = auth_str
20                .strip_prefix("Bearer ")
21                .and_then(valid_secret_value)
22            {
23                let token_hash = chio_http_core::sha256_hex(token.as_bytes());
24                let subject = format!("bearer:{}", &token_hash[..16]);
25                return CallerIdentity {
26                    subject,
27                    auth_method: AuthMethod::Bearer { token_hash },
28                    verified: false,
29                    tenant: None,
30                    agent_id: None,
31                };
32            }
33        }
34    }
35
36    // 2. API key
37    for key_header in &["x-api-key", "X-Api-Key", "X-API-Key"] {
38        if let Some(key_value) = headers.get(*key_header) {
39            if let Ok(key_str) = key_value.to_str().map(valid_secret_value) {
40                let Some(key_str) = key_str else {
41                    continue;
42                };
43                let key_hash = chio_http_core::sha256_hex(key_str.as_bytes());
44                let subject = format!("apikey:{}", &key_hash[..16]);
45                return CallerIdentity {
46                    subject,
47                    auth_method: AuthMethod::ApiKey {
48                        key_name: key_header.to_string(),
49                        key_hash,
50                    },
51                    verified: false,
52                    tenant: None,
53                    agent_id: None,
54                };
55            }
56        }
57    }
58
59    // 3. Cookie
60    if let Some(cookie) = headers.get(http::header::COOKIE) {
61        if let Ok(cookie_str) = cookie.to_str() {
62            if let Some(first) = cookie_str.split(';').next() {
63                let parts: Vec<&str> = first.splitn(2, '=').collect();
64                if parts.len() == 2 {
65                    let cookie_name = parts[0].trim().to_string();
66                    let cookie_value = parts[1].trim();
67                    if !cookie_value.is_empty() {
68                        let cookie_hash = chio_http_core::sha256_hex(cookie_value.as_bytes());
69                        let subject = format!("cookie:{}", &cookie_hash[..16]);
70                        return CallerIdentity {
71                            subject,
72                            auth_method: AuthMethod::Cookie {
73                                cookie_name,
74                                cookie_hash,
75                            },
76                            verified: false,
77                            tenant: None,
78                            agent_id: None,
79                        };
80                    }
81                }
82            }
83        }
84    }
85
86    // 4. Anonymous
87    CallerIdentity::anonymous()
88}
89
90fn valid_secret_value(value: &str) -> Option<&str> {
91    if value.trim().is_empty() || value.trim() != value {
92        None
93    } else {
94        Some(value)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn extract_bearer() {
104        let mut headers = http::HeaderMap::new();
105        headers.insert(
106            http::header::AUTHORIZATION,
107            http::HeaderValue::from_static("Bearer my-test-token"),
108        );
109        let caller = extract_identity(&headers);
110        assert!(caller.subject.starts_with("bearer:"));
111        assert!(matches!(caller.auth_method, AuthMethod::Bearer { .. }));
112        assert!(!caller.verified);
113    }
114
115    #[test]
116    fn extract_api_key() {
117        let mut headers = http::HeaderMap::new();
118        headers.insert("x-api-key", http::HeaderValue::from_static("secret-key"));
119        let caller = extract_identity(&headers);
120        assert!(caller.subject.starts_with("apikey:"));
121        assert!(matches!(caller.auth_method, AuthMethod::ApiKey { .. }));
122    }
123
124    #[test]
125    fn extract_anonymous() {
126        let headers = http::HeaderMap::new();
127        let caller = extract_identity(&headers);
128        assert_eq!(caller.subject, "anonymous");
129        assert!(matches!(caller.auth_method, AuthMethod::Anonymous));
130    }
131
132    #[test]
133    fn extract_cookie_identity() {
134        let mut headers = http::HeaderMap::new();
135        headers.insert(
136            http::header::COOKIE,
137            http::HeaderValue::from_static("session_id=abc123xyz"),
138        );
139        let caller = extract_identity(&headers);
140        assert!(caller.subject.starts_with("cookie:"));
141        match &caller.auth_method {
142            AuthMethod::Cookie {
143                cookie_name,
144                cookie_hash,
145            } => {
146                assert_eq!(cookie_name, "session_id");
147                assert_eq!(cookie_hash.len(), 64); // SHA-256 hex
148            }
149            other => panic!("expected Cookie, got {other:?}"),
150        }
151    }
152
153    #[test]
154    fn bearer_takes_precedence_over_cookie() {
155        let mut headers = http::HeaderMap::new();
156        headers.insert(
157            http::header::AUTHORIZATION,
158            http::HeaderValue::from_static("Bearer my-token"),
159        );
160        headers.insert(
161            http::header::COOKIE,
162            http::HeaderValue::from_static("session_id=abc123"),
163        );
164        let caller = extract_identity(&headers);
165        // Bearer should take precedence
166        assert!(caller.subject.starts_with("bearer:"));
167    }
168
169    #[test]
170    fn non_bearer_authorization_falls_through() {
171        let mut headers = http::HeaderMap::new();
172        headers.insert(
173            http::header::AUTHORIZATION,
174            http::HeaderValue::from_static("Basic dXNlcjpwYXNz"),
175        );
176        let caller = extract_identity(&headers);
177        // Basic auth is not recognized, should fall through to anonymous
178        assert_eq!(caller.subject, "anonymous");
179    }
180
181    #[test]
182    fn blank_bearer_token_falls_through() {
183        let mut headers = http::HeaderMap::new();
184        headers.insert(
185            http::header::AUTHORIZATION,
186            http::HeaderValue::from_static("Bearer    "),
187        );
188        let caller = extract_identity(&headers);
189        assert_eq!(caller.subject, "anonymous");
190        assert!(matches!(caller.auth_method, AuthMethod::Anonymous));
191    }
192
193    #[test]
194    fn blank_api_key_falls_through() {
195        let mut headers = http::HeaderMap::new();
196        headers.insert("x-api-key", http::HeaderValue::from_static("   "));
197        let caller = extract_identity(&headers);
198        assert_eq!(caller.subject, "anonymous");
199        assert!(matches!(caller.auth_method, AuthMethod::Anonymous));
200    }
201
202    #[test]
203    fn empty_cookie_value_falls_through() {
204        let mut headers = http::HeaderMap::new();
205        headers.insert(
206            http::header::COOKIE,
207            http::HeaderValue::from_static("session_id="),
208        );
209        let caller = extract_identity(&headers);
210        // Empty cookie value should not be used
211        assert_eq!(caller.subject, "anonymous");
212    }
213}