docker_registry/v2/
auth.rs

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/// Represents all supported authentication schemes and is stored by `Client`.
14#[derive(Debug, Clone)]
15pub enum Auth {
16  Bearer(BearerAuth),
17  Basic(BasicAuth),
18}
19
20impl Auth {
21  /// Add authentication headers to a request builder.
22  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/// Used for Bearer HTTP Authentication.
31#[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/// Used to support different response schemas of Bearer HTTP Authentication
40#[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    // mask the token before logging it
104    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/// Used for Basic HTTP Authentication.
117#[derive(Debug, Clone)]
118pub struct BasicAuth {
119  user: String,
120  password: Option<String>,
121}
122
123/// Structured representation for the content of the authentication response header.
124#[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  /// Create a `WwwAuthenticateHeaderContent` by parsing a `HeaderValue` instance.
154  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    // This regex will result in multiple captures which will contain one key-value pair each.
158    // The first capture will be the only one with the "method" group set.
159    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    // Deserialize the content
197    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/// Structured content for the Bearer authentication response header.
212#[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/// Structured content for the Basic authentication response header.
245#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
246pub(crate) struct WwwAuthenticateHeaderContentBasic {
247  realm: String,
248}
249
250impl Client {
251  /// Make a request and return the response's www authentication header.
252  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  /// Perform registry authentication and return the authenticated client.
268  ///
269  /// If Bearer authentication is used the returned client will be authorized for the requested scopes.
270  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  /// Check whether the client can successfully make requests to the registry.
305  ///
306  /// This could be due to granted anonymous access or valid credentials.
307  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  // Testing for this situation to work:
380  // [TRACE docker_registry::v2::auth] Sending request to 'https://localhost:5000/v2/'
381  // [TRACE docker_registry::v2::auth] GET 'Response { url: "https://localhost:5000/v2/", status: 401, headers: {"content-type": "application/json; charset=utf-8", "docker-distribution-api-version": "registry/2.0", "www-authenticate": "Basic realm=\"Registry\"", "x-content-type-options": "nosniff", "date": "Thu, 18 Jun 2020 09:04:24 GMT", "content-length": "87"} }'
382  // [TRACE docker_registry::v2::auth] GET 'https://localhost:5000/v2/' status: 401
383  // [TRACE docker_registry::v2::auth] Token provider: Registry
384  // [TRACE docker_registry::v2::auth] login: token endpoint:
385  // Registry&scope=repository:cincinnati-ci/ocp-release-dev:pull [ERROR graph_builder::graph] failed to fetch all
386  // release metadata [ERROR graph_builder::graph] failed to parse url from string
387  // 'Registry&scope=repository:cincinnati-ci/ocp-release-dev:pull': relative URL without a base
388  #[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  // The following test checks the url construction within the 'auth_ep'
415  // method of WwwAuthenticateHeaderContentBearer.
416  // Tests that the result is correctly parsed by Url::parse and that the
417  // scopes in the query string are as expected in three situations.
418  // Tests combination of scopes with service query param also.
419  #[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    // build list of expected headers
438    let mut expected_headers: Vec<(String, String)> =
439      scopes.iter().map(|a| ("scope".to_owned(), a.to_string())).collect();
440    // first one is the service header if specified
441    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}