forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
5.4 KiB
1//! Basic-auth parsing and personal-access-token resolution for git over smart HTTP.
2//! Only personal access tokens are accepted as the Basic password; account passwords are never
3//! accepted over git HTTP. Tokens are never logged.
4
5use base64::prelude::{BASE64_STANDARD, Engine};
6use forge_core::{User, token::hash_token};
7use http::{HeaderMap, header::AUTHORIZATION};
8
9use crate::access::AccessError;
10use crate::state::AppState;
11
12/// Usernames that stand in for "no particular user" when a token is the password.
13const TOKEN_ONLY_USERNAMES: [&str; 2] = ["x-access-token", "oauth2"];
14
15/// Parses `Authorization: Basic <base64(user:password)>` into (username, password).
16/// The scheme is matched case-insensitively. Anything else returns `None`.
17pub fn parse_basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
18 let value = headers.get(AUTHORIZATION)?.to_str().ok()?;
19 let (scheme, encoded) = value.split_once(' ')?;
20 if !scheme.eq_ignore_ascii_case("basic") || encoded.is_empty() {
21 return None;
22 }
23 let decoded = BASE64_STANDARD.decode(encoded).ok()?;
24 let text = String::from_utf8(decoded).ok()?;
25 let (username, password) = text.split_once(':')?;
26 Some((username.to_owned(), password.to_owned()))
27}
28
29/// Resolves the actor from a personal access token sent as the Basic password.
30///
31/// - No `Authorization` header: `Ok(None)` (anonymous).
32/// - A header that is not usable Basic auth, an unknown or expired token, a token whose user is
33/// gone, or a username that matches neither the user nor a token-only name: `Err(Unauthorized)`.
34/// - Otherwise the token's `last_used_at` is touched and the user is returned.
35pub async fn user_from_basic(
36 state: &AppState,
37 headers: &HeaderMap,
38) -> Result<Option<User>, AccessError> {
39 if !headers.contains_key(AUTHORIZATION) {
40 return Ok(None);
41 }
42 let Some((username, token)) = parse_basic_auth(headers) else {
43 return Err(AccessError::Unauthorized);
44 };
45
46 let token_hash = hash_token(&token);
47 let access = state
48 .store
49 .token_by_hash(&token_hash)
50 .await
51 .map_err(internal)?
52 .ok_or(AccessError::Unauthorized)?;
53 // An organization never authenticates, so a token that names one is refused.
54 let user = state
55 .store
56 .user_by_id(&access.user_id)
57 .await
58 .map_err(internal)?
59 .filter(|user| !user.is_org())
60 .ok_or(AccessError::Unauthorized)?;
61
62 let username_ok = username.eq_ignore_ascii_case(&user.username)
63 || TOKEN_ONLY_USERNAMES
64 .iter()
65 .any(|name| username.eq_ignore_ascii_case(name));
66 if !username_ok {
67 return Err(AccessError::Unauthorized);
68 }
69
70 state
71 .store
72 .touch_token(&access.id)
73 .await
74 .map_err(internal)?;
75 Ok(Some(user))
76}
77
78fn internal(e: forge_core::Error) -> AccessError {
79 AccessError::Internal(e.to_string())
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use http::HeaderValue;
86
87 fn headers_with(value: &str) -> HeaderMap {
88 let mut headers = HeaderMap::new();
89 headers.insert(
90 AUTHORIZATION,
91 HeaderValue::from_str(value).expect("valid header"),
92 );
93 headers
94 }
95
96 fn basic(plain: &str) -> String {
97 format!("Basic {}", BASE64_STANDARD.encode(plain))
98 }
99
100 #[test]
101 fn parses_user_and_token() {
102 let headers = headers_with(&basic("alice:forge_abc"));
103 assert_eq!(
104 parse_basic_auth(&headers),
105 Some(("alice".to_owned(), "forge_abc".to_owned()))
106 );
107 }
108
109 #[test]
110 fn scheme_is_case_insensitive() {
111 let value = format!("bAsIc {}", BASE64_STANDARD.encode("alice:t"));
112 let headers = headers_with(&value);
113 assert_eq!(
114 parse_basic_auth(&headers),
115 Some(("alice".into(), "t".into()))
116 );
117 }
118
119 #[test]
120 fn password_may_contain_colons() {
121 let headers = headers_with(&basic("alice:a:b:c"));
122 assert_eq!(
123 parse_basic_auth(&headers),
124 Some(("alice".into(), "a:b:c".into()))
125 );
126 }
127
128 #[test]
129 fn empty_password_is_parsed_but_user_is_not_resolved_here() {
130 let headers = headers_with(&basic("alice:"));
131 assert_eq!(
132 parse_basic_auth(&headers),
133 Some(("alice".into(), String::new()))
134 );
135 }
136
137 #[test]
138 fn rejects_wrong_scheme() {
139 let value = format!("Bearer {}", BASE64_STANDARD.encode("alice:t"));
140 assert_eq!(parse_basic_auth(&headers_with(&value)), None);
141 }
142
143 #[test]
144 fn rejects_missing_colon() {
145 assert_eq!(parse_basic_auth(&headers_with(&basic("alicetoken"))), None);
146 }
147
148 #[test]
149 fn rejects_bad_base64() {
150 assert_eq!(
151 parse_basic_auth(&headers_with("Basic !!!not-base64!!!")),
152 None
153 );
154 }
155
156 #[test]
157 fn rejects_extra_spaces_and_empty_value() {
158 let encoded = BASE64_STANDARD.encode("alice:t");
159 assert_eq!(
160 parse_basic_auth(&headers_with(&format!("Basic {encoded}"))),
161 None
162 );
163 assert_eq!(parse_basic_auth(&headers_with("Basic ")), None);
164 assert_eq!(parse_basic_auth(&HeaderMap::new()), None);
165 }
166
167 #[test]
168 fn rejects_non_utf8_credentials() {
169 let encoded = BASE64_STANDARD.encode([0xff_u8, 0xfe, b':', b'x']);
170 assert_eq!(
171 parse_basic_auth(&headers_with(&format!("Basic {encoded}"))),
172 None
173 );
174 }
175}