zuka
zuka/src/http/auth.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/http/auth.rs
RSauth.rs6.6 KBDownload
1// Credential extraction (SPEC §2.3, §5.3).
2//
3// Two wire forms, one resolution path: `Authorization: Bearer <token>` for REST and
4// MCP, and HTTP Basic for git over HTTPS — where the username is ignored and the
5// password is the same token, because that is what `git` sends and what every
6// credential helper already stores.
7//
8// A rejected credential is 401 and an unreachable auth service is 502. There is no
9// silent fallback to a lesser identity.
10
11use crate::account::token::{now_secs, TokenFile};
12use crate::account::Identity;
13use crate::error::{Error, Result};
14use base64::Engine;
15use hyper::HeaderMap;
16
17/// The credential presented, before it is resolved to an identity.
18#[derive(Debug, PartialEq, Eq)]
19pub enum Credential {
20 Token(String),
21 None,
22}
23
24/// Read a credential from the request headers.
25pub fn extract(headers: &HeaderMap) -> Credential {
26 let Some(raw) = headers.get("authorization").and_then(|v| v.to_str().ok()) else {
27 return Credential::None;
28 };
29
30 if let Some(token) = strip_scheme(raw, "bearer ") {
31 return if token.is_empty() {
32 Credential::None
33 } else {
34 Credential::Token(token.to_string())
35 };
36 }
37
38 if let Some(encoded) = strip_scheme(raw, "basic ") {
39 let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded.trim()) else {
40 return Credential::None;
41 };
42 let Ok(pair) = String::from_utf8(decoded) else {
43 return Credential::None;
44 };
45 // The username is ignored: `git` sends whatever the credential helper stored
46 // and only the password is the secret.
47 let password = pair.split_once(':').map(|(_, p)| p).unwrap_or("");
48 return if password.is_empty() {
49 Credential::None
50 } else {
51 Credential::Token(password.to_string())
52 };
53 }
54
55 Credential::None
56}
57
58/// Resolve a caller in tenant mode, from the control plane's signed assertion.
59///
60/// A tenant trusts nothing a client sends: the account comes from an assertion the
61/// control plane signed for *this* request. A token presented directly to a tenant
62/// is ignored, because a tenant has no token store and no way to check one.
63pub fn resolve_assertion(
64 verifier: &crate::account::assertion::Verifier_,
65 headers: &hyper::HeaderMap,
66 method: &str,
67 path: &str,
68) -> Result<Identity> {
69 let header = headers
70 .get(crate::account::assertion::header())
71 .and_then(|v| v.to_str().ok())
72 .ok_or(Error::Unauthorized)?;
73
74 let claims = verifier.verify(header, method, path)?;
75 let account = crate::git::validate::name(&claims.account)?;
76
77 // The control plane vouches for the account, not for a scope: a tenant serves
78 // exactly one account and everything reaching it is that account acting on its
79 // own repositories.
80 Ok(Identity {
81 account,
82 token_id: format!("assertion:{}", &claims.nonce[..8.min(claims.nonce.len())]),
83 scopes: vec![crate::account::Scope::Admin],
84 repos: Vec::new(),
85 })
86}
87
88/// Resolve a credential against the local token file.
89pub fn resolve_local(tokens: &TokenFile, credential: &Credential) -> Result<Identity> {
90 let Credential::Token(secret) = credential else {
91 return Err(Error::Unauthorized);
92 };
93 let record = tokens
94 .resolve(secret, now_secs())
95 .ok_or(Error::Unauthorized)?;
96 Identity::from_token(record)
97}
98
99/// Case-insensitive scheme match, returning the remainder.
100fn strip_scheme<'a>(header: &'a str, scheme: &str) -> Option<&'a str> {
101 if header.len() < scheme.len() {
102 return None;
103 }
104 let (head, rest) = header.split_at(scheme.len());
105 head.eq_ignore_ascii_case(scheme).then_some(rest)
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use crate::account::token::{mint, Scope};
112
113 fn headers_with(value: &str) -> HeaderMap {
114 let mut headers = HeaderMap::new();
115 headers.insert("authorization", value.parse().unwrap());
116 headers
117 }
118
119 #[test]
120 fn reads_a_bearer_token() {
121 let credential = extract(&headers_with("Bearer sk_abc"));
122 assert_eq!(credential, Credential::Token("sk_abc".into()));
123 }
124
125 #[test]
126 fn the_scheme_match_is_case_insensitive() {
127 assert_eq!(
128 extract(&headers_with("bearer sk_abc")),
129 Credential::Token("sk_abc".into())
130 );
131 }
132
133 #[test]
134 fn reads_the_password_from_basic_and_ignores_the_username() {
135 let encoded = base64::engine::general_purpose::STANDARD.encode("anything:sk_abc");
136 let credential = extract(&headers_with(&format!("Basic {encoded}")));
137 assert_eq!(
138 credential,
139 Credential::Token("sk_abc".into()),
140 "git sends an arbitrary username; only the password is the secret"
141 );
142 }
143
144 #[test]
145 fn a_basic_password_containing_a_colon_survives_intact() {
146 let encoded = base64::engine::general_purpose::STANDARD.encode("user:sk_a:b:c");
147 assert_eq!(
148 extract(&headers_with(&format!("Basic {encoded}"))),
149 Credential::Token("sk_a:b:c".into())
150 );
151 }
152
153 #[test]
154 fn malformed_and_absent_credentials_resolve_to_none() {
155 assert_eq!(extract(&HeaderMap::new()), Credential::None);
156 assert_eq!(extract(&headers_with("Bearer ")), Credential::None);
157 assert_eq!(
158 extract(&headers_with("Basic !!!not-base64")),
159 Credential::None
160 );
161 assert_eq!(extract(&headers_with("Digest abc")), Credential::None);
162 let empty_password = base64::engine::general_purpose::STANDARD.encode("user:");
163 assert_eq!(
164 extract(&headers_with(&format!("Basic {empty_password}"))),
165 Credential::None
166 );
167 }
168
169 #[test]
170 fn an_unknown_token_is_unauthorized_not_an_anonymous_identity() {
171 let tokens = TokenFile::default();
172 let err = resolve_local(&tokens, &Credential::Token("sk_nope".into())).unwrap_err();
173 assert_eq!(err.status(), 401);
174 }
175
176 #[test]
177 fn a_valid_token_resolves_to_its_account() {
178 let (secret, record) = mint("alice", "laptop", vec![Scope::RepoWrite], vec![], None);
179 let tokens = TokenFile {
180 tokens: vec![record],
181 };
182 let identity = resolve_local(&tokens, &Credential::Token(secret)).unwrap();
183 assert_eq!(identity.account.as_str(), "alice");
184 }
185
186 #[test]
187 fn a_missing_credential_is_unauthorized() {
188 let tokens = TokenFile::default();
189 assert_eq!(
190 resolve_local(&tokens, &Credential::None)
191 .unwrap_err()
192 .status(),
193 401
194 );
195 }
196}