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.rs7.1 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/// Whether the caller offered a credential at all.
25///
26/// The read surfaces that permit anonymous access need to tell "no credential" from
27/// "a credential that did not work". Both resolve to `Unauthorized`, but they must
28/// be handled differently: falling back to anonymous on a rejected token turns an
29/// expired credential into a silent success on public repositories and a bare 404 on
30/// private ones, which is the least debuggable failure a token can have.
31pub fn presented(headers: &HeaderMap) -> bool {
32 !matches!(extract(headers), Credential::None)
33}
34
35/// Read a credential from the request headers.
36pub fn extract(headers: &HeaderMap) -> Credential {
37 let Some(raw) = headers.get("authorization").and_then(|v| v.to_str().ok()) else {
38 return Credential::None;
39 };
40
41 if let Some(token) = strip_scheme(raw, "bearer ") {
42 return if token.is_empty() {
43 Credential::None
44 } else {
45 Credential::Token(token.to_string())
46 };
47 }
48
49 if let Some(encoded) = strip_scheme(raw, "basic ") {
50 let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded.trim()) else {
51 return Credential::None;
52 };
53 let Ok(pair) = String::from_utf8(decoded) else {
54 return Credential::None;
55 };
56 // The username is ignored: `git` sends whatever the credential helper stored
57 // and only the password is the secret.
58 let password = pair.split_once(':').map(|(_, p)| p).unwrap_or("");
59 return if password.is_empty() {
60 Credential::None
61 } else {
62 Credential::Token(password.to_string())
63 };
64 }
65
66 Credential::None
67}
68
69/// Resolve a caller in tenant mode, from the control plane's signed assertion.
70///
71/// A tenant trusts nothing a client sends: the account comes from an assertion the
72/// control plane signed for *this* request. A token presented directly to a tenant
73/// is ignored, because a tenant has no token store and no way to check one.
74pub fn resolve_assertion(
75 verifier: &crate::account::assertion::Verifier_,
76 headers: &hyper::HeaderMap,
77 method: &str,
78 path: &str,
79) -> Result<Identity> {
80 let header = headers
81 .get(crate::account::assertion::header())
82 .and_then(|v| v.to_str().ok())
83 .ok_or(Error::Unauthorized)?;
84
85 let claims = verifier.verify(header, method, path)?;
86 let account = crate::git::validate::name(&claims.account)?;
87
88 // The control plane vouches for the account, not for a scope: a tenant serves
89 // exactly one account and everything reaching it is that account acting on its
90 // own repositories.
91 Ok(Identity {
92 account,
93 token_id: format!("assertion:{}", &claims.nonce[..8.min(claims.nonce.len())]),
94 scopes: vec![crate::account::Scope::Admin],
95 repos: Vec::new(),
96 })
97}
98
99/// Resolve a credential against the local token file.
100pub fn resolve_local(tokens: &TokenFile, credential: &Credential) -> Result<Identity> {
101 let Credential::Token(secret) = credential else {
102 return Err(Error::Unauthorized);
103 };
104 let record = tokens
105 .resolve(secret, now_secs())
106 .ok_or(Error::Unauthorized)?;
107 Identity::from_token(record)
108}
109
110/// Case-insensitive scheme match, returning the remainder.
111fn strip_scheme<'a>(header: &'a str, scheme: &str) -> Option<&'a str> {
112 if header.len() < scheme.len() {
113 return None;
114 }
115 let (head, rest) = header.split_at(scheme.len());
116 head.eq_ignore_ascii_case(scheme).then_some(rest)
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::account::token::{mint, Scope};
123
124 fn headers_with(value: &str) -> HeaderMap {
125 let mut headers = HeaderMap::new();
126 headers.insert("authorization", value.parse().unwrap());
127 headers
128 }
129
130 #[test]
131 fn reads_a_bearer_token() {
132 let credential = extract(&headers_with("Bearer sk_abc"));
133 assert_eq!(credential, Credential::Token("sk_abc".into()));
134 }
135
136 #[test]
137 fn the_scheme_match_is_case_insensitive() {
138 assert_eq!(
139 extract(&headers_with("bearer sk_abc")),
140 Credential::Token("sk_abc".into())
141 );
142 }
143
144 #[test]
145 fn reads_the_password_from_basic_and_ignores_the_username() {
146 let encoded = base64::engine::general_purpose::STANDARD.encode("anything:sk_abc");
147 let credential = extract(&headers_with(&format!("Basic {encoded}")));
148 assert_eq!(
149 credential,
150 Credential::Token("sk_abc".into()),
151 "git sends an arbitrary username; only the password is the secret"
152 );
153 }
154
155 #[test]
156 fn a_basic_password_containing_a_colon_survives_intact() {
157 let encoded = base64::engine::general_purpose::STANDARD.encode("user:sk_a:b:c");
158 assert_eq!(
159 extract(&headers_with(&format!("Basic {encoded}"))),
160 Credential::Token("sk_a:b:c".into())
161 );
162 }
163
164 #[test]
165 fn malformed_and_absent_credentials_resolve_to_none() {
166 assert_eq!(extract(&HeaderMap::new()), Credential::None);
167 assert_eq!(extract(&headers_with("Bearer ")), Credential::None);
168 assert_eq!(
169 extract(&headers_with("Basic !!!not-base64")),
170 Credential::None
171 );
172 assert_eq!(extract(&headers_with("Digest abc")), Credential::None);
173 let empty_password = base64::engine::general_purpose::STANDARD.encode("user:");
174 assert_eq!(
175 extract(&headers_with(&format!("Basic {empty_password}"))),
176 Credential::None
177 );
178 }
179
180 #[test]
181 fn an_unknown_token_is_unauthorized_not_an_anonymous_identity() {
182 let tokens = TokenFile::default();
183 let err = resolve_local(&tokens, &Credential::Token("sk_nope".into())).unwrap_err();
184 assert_eq!(err.status(), 401);
185 }
186
187 #[test]
188 fn a_valid_token_resolves_to_its_account() {
189 let (secret, record) = mint("alice", "laptop", vec![Scope::RepoWrite], vec![], None);
190 let tokens = TokenFile {
191 tokens: vec![record],
192 };
193 let identity = resolve_local(&tokens, &Credential::Token(secret)).unwrap();
194 assert_eq!(identity.account.as_str(), "alice");
195 }
196
197 #[test]
198 fn a_missing_credential_is_unauthorized() {
199 let tokens = TokenFile::default();
200 assert_eq!(
201 resolve_local(&tokens, &Credential::None)
202 .unwrap_err()
203 .status(),
204 401
205 );
206 }
207}