Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! HTTP Basic authentication for Git clients (spec §6).
2//!
3//! "Git over HTTPS uses Basic auth: username is the user handle, password is a
4//! personal access token."
5//!
6//! The handle is accepted but not trusted for identity — the token alone
7//! determines who the caller is. A token that belongs to a different user than
8//! the supplied handle still authenticates as its real owner rather than
9//! being rejected, because the handle is decoration in the credential helper
10//! and users routinely have it stale.
11
12use axum::http::HeaderMap;
13use base64::Engine;
14use df_db::models::User;
15use uuid::Uuid;
16
17use crate::error::AppResult;
18use crate::state::AppState;
19
20/// Decode `Authorization: Basic …` into (username, password).
21fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> {
22 let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
23 let encoded = raw.strip_prefix("Basic ").or_else(|| raw.strip_prefix("basic "))?;
24
25 let decoded = base64::engine::general_purpose::STANDARD
26 .decode(encoded.trim())
27 .ok()?;
28 let text = String::from_utf8(decoded).ok()?;
29
30 // The password may itself contain a colon, so split only on the first.
31 let (user, pass) = text.split_once(':')?;
32 Some((user.to_string(), pass.to_string()))
33}
34
35/// Authenticate a Git request, if credentials were supplied.
36///
37/// Returns `Ok(None)` for an anonymous request — which is legitimate for
38/// reading a public repository — and for credentials that do not verify. The
39/// caller turns that into a challenge or a 404 as appropriate; it never
40/// distinguishes "no credentials" from "bad credentials" to the client.
41pub async fn authenticate(state: &AppState, headers: &HeaderMap) -> AppResult<Option<User>> {
42 let Some((handle, token)) = parse_basic(headers) else {
43 return Ok(None);
44 };
45
46 let Some(user_id) = df_auth::tokens::verify(&state.db, &token).await? else {
47 tracing::info!(%handle, "git authentication failed: token did not verify");
48 return Ok(None);
49 };
50
51 let user = sqlx::query_as::<_, User>(
52 "SELECT id, subject, handle, display_name, email, avatar_url, is_admin, created_at
53 FROM users WHERE id = $1",
54 )
55 .bind(user_id)
56 .fetch_optional(&state.db)
57 .await?;
58
59 if let Some(u) = &user {
60 if !u.handle.eq_ignore_ascii_case(&handle) {
61 // Not an error: credential helpers cache stale usernames. The token
62 // is the authority.
63 tracing::debug!(
64 supplied = %handle,
65 actual = %u.handle,
66 "git basic-auth handle does not match the token owner; using the token owner"
67 );
68 }
69 }
70
71 Ok(user)
72}
73
74/// The authenticated user's id, for attributing a push.
75pub async fn authenticated_user_id(state: &AppState, headers: &HeaderMap) -> Option<Uuid> {
76 authenticate(state, headers).await.ok().flatten().map(|u| u.id)
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use axum::http::HeaderValue;
83
84 fn headers_with(value: &str) -> HeaderMap {
85 let mut h = HeaderMap::new();
86 h.insert(
87 axum::http::header::AUTHORIZATION,
88 HeaderValue::from_str(value).unwrap(),
89 );
90 h
91 }
92
93 fn basic(user: &str, pass: &str) -> String {
94 format!(
95 "Basic {}",
96 base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}"))
97 )
98 }
99
100 #[test]
101 fn parses_ordinary_credentials() {
102 let h = headers_with(&basic("snow", "dgf_abc123"));
103 assert_eq!(
104 parse_basic(&h),
105 Some(("snow".into(), "dgf_abc123".into()))
106 );
107 }
108
109 #[test]
110 fn a_password_may_contain_colons() {
111 // Splitting on the last colon, or on all of them, would corrupt tokens.
112 let h = headers_with(&basic("snow", "a:b:c"));
113 assert_eq!(parse_basic(&h), Some(("snow".into(), "a:b:c".into())));
114 }
115
116 #[test]
117 fn an_empty_username_is_still_parsed() {
118 // Some credential helpers send only a token, with an empty username.
119 let h = headers_with(&basic("", "dgf_token"));
120 assert_eq!(parse_basic(&h), Some(("".into(), "dgf_token".into())));
121 }
122
123 #[test]
124 fn missing_or_malformed_headers_yield_none() {
125 assert_eq!(parse_basic(&HeaderMap::new()), None);
126 assert_eq!(parse_basic(&headers_with("Bearer sometoken")), None);
127 assert_eq!(parse_basic(&headers_with("Basic !!!not-base64!!!")), None);
128 // Valid base64, but no colon separator.
129 let no_colon = base64::engine::general_purpose::STANDARD.encode("nocolon");
130 assert_eq!(parse_basic(&headers_with(&format!("Basic {no_colon}"))), None);
131 }
132
133 #[test]
134 fn the_scheme_match_is_case_insensitive() {
135 let encoded = base64::engine::general_purpose::STANDARD.encode("a:b");
136 assert!(parse_basic(&headers_with(&format!("basic {encoded}"))).is_some());
137 }
138
139 #[test]
140 fn non_utf8_credentials_are_rejected_rather_than_panicking() {
141 let encoded = base64::engine::general_purpose::STANDARD.encode([0xff, 0xfe, 0x3a, 0x61]);
142 assert_eq!(parse_basic(&headers_with(&format!("Basic {encoded}"))), None);
143 }
144}

144 lines · Rust