1use std::convert::{TryFrom, TryInto};
2
3use log::{trace, warn};
4use regex_lite::Regex;
5use reqwest::{RequestBuilder, StatusCode, Url, header::HeaderValue};
6use serde::{Deserialize, Serialize};
7
8use crate::{
9 errors::{Error, Result},
10 v2::*,
11};
12
13#[derive(Debug, Clone)]
15pub enum Auth {
16 Bearer(BearerAuth),
17 Basic(BasicAuth),
18}
19
20impl Auth {
21 pub(crate) fn add_auth_headers(&self, request_builder: RequestBuilder) -> RequestBuilder {
23 match self {
24 Auth::Bearer(bearer_auth) => request_builder.bearer_auth(bearer_auth.token.clone()),
25 Auth::Basic(basic_auth) => request_builder.basic_auth(basic_auth.user.clone(), basic_auth.password.clone()),
26 }
27 }
28}
29
30#[derive(Debug, Clone, Default, Deserialize, Serialize)]
32pub struct BearerAuth {
33 token: String,
34 expires_in: Option<u32>,
35 issued_at: Option<String>,
36 refresh_token: Option<String>,
37}
38
39#[derive(Debug, Clone, Default, Deserialize)]
41pub struct MultiTokenBearerAuth {
42 token: Option<String>,
43 access_token: Option<String>,
44 expires_in: Option<u32>,
45 issued_at: Option<String>,
46 refresh_token: Option<String>,
47}
48
49impl TryFrom<MultiTokenBearerAuth> for BearerAuth {
50 type Error = Error;
51
52 fn try_from(value: MultiTokenBearerAuth) -> std::result::Result<Self, Error> {
53 let t = value.token.or(value.access_token).ok_or(Error::NoTokenReceived)?;
54
55 Ok(Self {
56 token: t,
57 expires_in: value.expires_in,
58 issued_at: value.issued_at,
59 refresh_token: value.refresh_token,
60 })
61 }
62}
63
64impl BearerAuth {
65 async fn try_from_header_content(
66 client: Client,
67 scopes: &[&str],
68 credentials: Option<(String, String)>,
69 bearer_header_content: WwwAuthenticateHeaderContentBearer,
70 ) -> Result<Self> {
71 let auth_ep = bearer_header_content.auth_ep(scopes);
72 trace!("authenticate: token endpoint: {auth_ep}");
73
74 let url = reqwest::Url::parse(&auth_ep)?;
75
76 let auth_req = {
77 Client {
78 auth: credentials.map(|(user, password)| {
79 Auth::Basic(BasicAuth {
80 user,
81 password: Some(password),
82 })
83 }),
84 ..client
85 }
86 }
87 .build_reqwest(Method::GET, url);
88
89 let r = auth_req.send().await?;
90 let status = r.status();
91 trace!("authenticate: got status {status}");
92 if status != StatusCode::OK {
93 return Err(Error::UnexpectedHttpStatus(status));
94 }
95
96 let bearer_auth: BearerAuth = r.json::<MultiTokenBearerAuth>().await?.try_into()?;
97
98 match bearer_auth.token.as_str() {
99 "unauthenticated" | "" => return Err(Error::InvalidAuthToken(bearer_auth.token)),
100 _ => {}
101 };
102
103 let chars_count = bearer_auth.token.chars().count();
105 let mask_start = std::cmp::min(1, chars_count - 1);
106 let mask_end = std::cmp::max(chars_count - 1, 1);
107 let mut masked_token = bearer_auth.token.clone();
108 masked_token.replace_range(mask_start..mask_end, &"*".repeat(mask_end - mask_start));
109
110 trace!("authenticate: got token: {masked_token:?}");
111
112 Ok(bearer_auth)
113 }
114}
115
116#[derive(Debug, Clone)]
118pub struct BasicAuth {
119 user: String,
120 password: Option<String>,
121}
122
123#[derive(Debug, PartialEq, Eq, Deserialize)]
125#[serde(rename_all(deserialize = "lowercase"))]
126pub(crate) enum WwwAuthenticateHeaderContent {
127 Bearer(WwwAuthenticateHeaderContentBearer),
128 Basic(WwwAuthenticateHeaderContentBasic),
129}
130
131const REGEX: &str = r#"(?x)\s*
132((?P<method>[A-Za-z]+)\s)?
133\s*
134(
135 (?P<key>[A-Za-z]+)
136 \s*
137 =
138 \s*
139 "(?P<value>[^"]+)"
140 \s*
141)
142"#;
143
144#[derive(Debug, thiserror::Error)]
145pub enum WwwHeaderParseError {
146 #[error("header value must conform to {}", REGEX)]
147 InvalidValue,
148 #[error("'method' field missing")]
149 FieldMethodMissing,
150}
151
152impl WwwAuthenticateHeaderContent {
153 pub(crate) fn from_www_authentication_header(header_value: HeaderValue) -> Result<Self> {
155 let header = String::from_utf8(header_value.as_bytes().to_vec())?;
156
157 let re = Regex::new(REGEX).expect("this static regex is valid");
160 let captures = re.captures_iter(&header).collect::<Vec<_>>();
161
162 let method = captures
163 .first()
164 .ok_or(WwwHeaderParseError::InvalidValue)?
165 .name("method")
166 .ok_or(WwwHeaderParseError::FieldMethodMissing)?
167 .as_str()
168 .to_lowercase();
169
170 let serialized_content = {
171 let serialized_captures = captures
172 .iter()
173 .filter_map(|capture| {
174 match (
175 capture.name("key").map(|n| n.as_str().to_lowercase()),
176 capture.name("value").map(|n| n.as_str().to_string()),
177 ) {
178 (Some(key), Some(value)) => Some(format!(
179 r#"{}: {}"#,
180 serde_json::Value::String(key),
181 serde_json::Value::String(value),
182 )),
183 _ => None,
184 }
185 })
186 .collect::<Vec<_>>()
187 .join(", ");
188
189 format!(
190 r#"{{ {}: {{ {} }} }}"#,
191 serde_json::Value::String(method),
192 serialized_captures
193 )
194 };
195
196 let mut unsupported_keys = std::collections::HashSet::new();
198 let content: WwwAuthenticateHeaderContent =
199 serde_ignored::deserialize(&mut serde_json::Deserializer::from_str(&serialized_content), |path| {
200 unsupported_keys.insert(path.to_string());
201 })?;
202
203 if !unsupported_keys.is_empty() {
204 warn!("skipping unrecognized keys in authentication header: {unsupported_keys:#?}");
205 }
206
207 Ok(content)
208 }
209}
210
211#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
213pub(crate) struct WwwAuthenticateHeaderContentBearer {
214 realm: String,
215 service: Option<String>,
216 scope: Option<String>,
217}
218
219impl WwwAuthenticateHeaderContentBearer {
220 fn auth_ep(&self, scopes: &[&str]) -> String {
221 let service = self
222 .service
223 .as_ref()
224 .map(|sv| format!("?service={sv}"))
225 .unwrap_or_default();
226
227 let scope = scopes.iter().enumerate().fold(String::new(), |acc, (i, &s)| {
228 let separator = if i > 0 { "&" } else { "" };
229 acc + separator + "scope=" + s
230 });
231
232 let scope_prefix = if scopes.is_empty() {
233 ""
234 } else if service.is_empty() {
235 "?"
236 } else {
237 "&"
238 };
239
240 format!("{}{}{}{}", self.realm, service, scope_prefix, scope)
241 }
242}
243
244#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
246pub(crate) struct WwwAuthenticateHeaderContentBasic {
247 realm: String,
248}
249
250impl Client {
251 async fn get_www_authentication_header(&self) -> Result<HeaderValue> {
253 let url = {
254 let ep = format!("{}/v2/", self.base_url.clone(),);
255 reqwest::Url::parse(&ep)?
256 };
257
258 let r = self.build_reqwest(Method::GET, url.clone()).send().await?;
259
260 trace!("GET '{}' status: {:?}", r.url(), r.status());
261 r.headers()
262 .get(reqwest::header::WWW_AUTHENTICATE)
263 .ok_or(Error::MissingAuthHeader("WWW-Authenticate"))
264 .map(ToOwned::to_owned)
265 }
266
267 pub async fn authenticate(mut self, scopes: &[&str]) -> Result<Self> {
271 let credentials = self.credentials.clone();
272
273 let client = Client {
274 auth: None,
275 ..self.clone()
276 };
277
278 let authentication_header = client.get_www_authentication_header().await?;
279 let auth = match WwwAuthenticateHeaderContent::from_www_authentication_header(authentication_header)? {
280 WwwAuthenticateHeaderContent::Basic(_) => {
281 let basic_auth = credentials
282 .map(|(user, password)| BasicAuth {
283 user,
284 password: Some(password),
285 })
286 .ok_or(Error::NoCredentials)?;
287
288 Auth::Basic(basic_auth)
289 }
290 WwwAuthenticateHeaderContent::Bearer(bearer_header_content) => {
291 let bearer_auth =
292 BearerAuth::try_from_header_content(client, scopes, credentials, bearer_header_content).await?;
293
294 Auth::Bearer(bearer_auth)
295 }
296 };
297
298 trace!("authenticate: login succeeded");
299 self.auth = Some(auth);
300
301 Ok(self)
302 }
303
304 pub async fn is_auth(&self) -> Result<bool> {
308 let url = {
309 let ep = format!("{}/v2/", self.base_url.clone(),);
310 Url::parse(&ep)?
311 };
312
313 let req = self.build_reqwest(Method::GET, url.clone());
314
315 trace!("Sending request to '{url}'");
316 let resp = req.send().await?;
317 trace!("GET '{resp:?}'");
318
319 let status = resp.status();
320 match status {
321 reqwest::StatusCode::OK => Ok(true),
322 reqwest::StatusCode::UNAUTHORIZED => Ok(false),
323 _ => Err(Error::UnexpectedHttpStatus(status)),
324 }
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use test_case::test_case;
331
332 use super::*;
333
334 #[test]
335 fn bearer_realm_parses_correctly() -> Result<()> {
336 let realm = "https://sat-r220-02.lab.eng.rdu2.redhat.com/v2/token";
337 let service = "sat-r220-02.lab.eng.rdu2.redhat.com";
338 let scope = "repository:registry:pull,push";
339
340 for header_value in [
341 HeaderValue::from_str(&format!(
342 r#"Bearer realm="{realm}",service="{service}",scope="{scope}""#
343 ))
344 .unwrap(),
345 HeaderValue::from_str(&format!(
346 r#"bearer realm="{realm}",service="{service}",scope="{scope}""#
347 ))
348 .unwrap(),
349 HeaderValue::from_str(&format!(
350 r#"BEARER realm="{realm}",service="{service}",scope="{scope}""#
351 ))
352 .unwrap(),
353 HeaderValue::from_str(&format!(
354 r#"Bearer Realm="{realm}",Service="{service}",Scope="{scope}""#
355 ))
356 .unwrap(),
357 HeaderValue::from_str(&format!(
358 r#"Bearer REALM="{realm}",SERVICE="{service}",SCOPE="{scope}""#
359 ))
360 .unwrap(),
361 ]
362 .iter()
363 {
364 let content = WwwAuthenticateHeaderContent::from_www_authentication_header(header_value.to_owned())?;
365
366 assert_eq!(
367 WwwAuthenticateHeaderContent::Bearer(WwwAuthenticateHeaderContentBearer {
368 realm: realm.to_string(),
369 service: Some(service.to_string()),
370 scope: Some(scope.to_string()),
371 }),
372 content
373 );
374 }
375
376 Ok(())
377 }
378
379 #[test]
389 fn basic_realm_parses_correctly() -> Result<()> {
390 let realm = "Registry realm";
391
392 for header_value in [
393 HeaderValue::from_str(&format!(r#"Basic realm="{realm}""#)).unwrap(),
394 HeaderValue::from_str(&format!(r#"basic realm="{realm}""#)).unwrap(),
395 HeaderValue::from_str(&format!(r#"BASIC realm="{realm}""#)).unwrap(),
396 HeaderValue::from_str(&format!(r#"Basic Realm="{realm}""#)).unwrap(),
397 HeaderValue::from_str(&format!(r#"Basic REALM="{realm}""#)).unwrap(),
398 ]
399 .iter()
400 {
401 let content = WwwAuthenticateHeaderContent::from_www_authentication_header(header_value.to_owned())?;
402
403 assert_eq!(
404 WwwAuthenticateHeaderContent::Basic(WwwAuthenticateHeaderContentBasic {
405 realm: realm.to_string(),
406 }),
407 content
408 );
409 }
410
411 Ok(())
412 }
413
414 #[test_case(&[], true; "Test with no scopes and with service")]
420 #[test_case(&["repository:test:pull"], true; "Test with single scope and service")]
421 #[test_case(&["repository:test:pull", "repository:example:pull,push", "repository:another:*"], false;
422 "Test with multiple scopes")]
423 fn bearer_auth_ep_scope_construction(scopes: &[&str], include_service: bool) {
424 let realm = "https://sat-r220-02.lab.eng.rdu2.redhat.com/v2/token";
425 let service = "sat-r220-02.lab.eng.rdu2.redhat.com";
426
427 let bearer_header_content = WwwAuthenticateHeaderContentBearer {
428 realm: realm.to_string(),
429 service: if include_service {
430 Some(service.to_string())
431 } else {
432 None
433 },
434 scope: None,
435 };
436
437 let mut expected_headers: Vec<(String, String)> =
439 scopes.iter().map(|a| ("scope".to_owned(), a.to_string())).collect();
440 if include_service {
442 expected_headers.insert(0, ("service".to_owned(), service.to_string()));
443 }
444
445 let result = bearer_header_content.auth_ep(scopes);
446 let url = Url::parse(&result).unwrap();
447
448 assert_eq!(url.query_pairs().into_owned().collect::<Vec<_>>(), expected_headers);
449 }
450}