diff --git a/core/src/auth.rs b/core/src/auth.rs index c26c4449..076fbf44 100644 --- a/core/src/auth.rs +++ b/core/src/auth.rs @@ -72,6 +72,54 @@ impl HTTPAuthentication for ForceHTTPBasicAuth { } } +/// Authentication policy that *always* includes a given header +#[derive(Debug, Clone)] +struct HeaderAuth { + pub header: String, + pub value: String, +} + +impl HTTPAuthentication for HeaderAuth { + async fn request_with_authentication( + &self, + request: RequestBuilder, + _renew_request: &F, + ) -> Result + where + F: Fn(&ClientWithMiddleware) -> RequestBuilder + 'static, + { + request.header(&self.header, &self.value).send().await + } +} + +/// Authentication policy that *always* includes a bearer token +#[derive(Debug, Clone)] +pub struct ForceBearerAuth(HeaderAuth); + +impl ForceBearerAuth { + pub fn new>(token: S) -> ForceBearerAuth { + ForceBearerAuth(HeaderAuth { + header: "Authorization".to_string(), + value: format!("Bearer {}", token.as_ref()), + }) + } +} + +impl HTTPAuthentication for ForceBearerAuth { + async fn request_with_authentication( + &self, + request: RequestBuilder, + renew_request: &F, + ) -> Result + where + F: Fn(&ClientWithMiddleware) -> RequestBuilder + 'static, + { + self.0 + .request_with_authentication(request, renew_request) + .await + } +} + /// First tries `Higher` priority authentication and then the /// `Lower` priority one in case the first request results in /// a response in the 4xx range. @@ -307,6 +355,36 @@ impl HTTPAuthe } } +#[derive(Debug, Clone)] +pub enum StandardInnerAuthentication { + HTTPBasicAuth(ForceHTTPBasicAuth), + BearerAuth(ForceBearerAuth), +} + +impl HTTPAuthentication for StandardInnerAuthentication { + async fn request_with_authentication( + &self, + request: RequestBuilder, + renew_request: &F, + ) -> Result + where + F: Fn(&ClientWithMiddleware) -> RequestBuilder + 'static, + { + match self { + StandardInnerAuthentication::HTTPBasicAuth(inner) => { + inner + .request_with_authentication(request, renew_request) + .await + } + StandardInnerAuthentication::BearerAuth(inner) => { + inner + .request_with_authentication(request, renew_request) + .await + } + } + } +} + /// Standard HTTP authentication policy where a restricted set of domains/paths have /// BasicAuth username/password pairs specified, but they are sent only in response to a /// 4xx status code. @@ -316,7 +394,7 @@ pub type StandardHTTPAuthentication = RestrictAuthentication< Unauthenticated, // ... but send username/password in response to 4xx. // FIXME: Replace by a more general type as more authentication schemes are added - ForceHTTPBasicAuth, + StandardInnerAuthentication, >, // For all other domains use unauthenticated access. Unauthenticated, @@ -325,7 +403,7 @@ pub type StandardHTTPAuthentication = RestrictAuthentication< /// Utility to simplify construction of `StandardHTTPAuthentication` #[derive(Debug, Default, Clone)] pub struct StandardHTTPAuthenticationBuilder { - partial: GlobMapBuilder>, + partial: GlobMapBuilder>, } impl StandardHTTPAuthenticationBuilder { @@ -350,10 +428,20 @@ impl StandardHTTPAuthenticationBuilder { globstr, SequenceAuthentication { higher: Unauthenticated {}, - lower: ForceHTTPBasicAuth { + lower: StandardInnerAuthentication::HTTPBasicAuth(ForceHTTPBasicAuth { username: username.as_ref().to_string(), password: password.as_ref().to_string(), - }, + }), + }, + ); + } + + pub fn add_bearer_auth, T: AsRef>(&mut self, globstr: S, token: T) { + self.partial.add( + globstr, + SequenceAuthentication { + higher: Unauthenticated {}, + lower: StandardInnerAuthentication::BearerAuth(ForceBearerAuth::new(token)), }, ); } diff --git a/core/src/project/reqwest_src.rs b/core/src/project/reqwest_src.rs index f4403195..da000fef 100644 --- a/core/src/project/reqwest_src.rs +++ b/core/src/project/reqwest_src.rs @@ -76,12 +76,12 @@ impl ReqwestSrcProjectAsync { // .header(reqwest::header::ACCEPT, "application/json") // } - pub fn reqwest_src>( - &self, - path: P, - ) -> reqwest_middleware::RequestBuilder { - self.client.get(self.src_url(path)) - } + // pub fn reqwest_src>( + // &self, + // path: P, + // ) -> reqwest_middleware::RequestBuilder { + // self.client.get(self.src_url(path)) + // } } #[derive(Error, Debug)] @@ -164,11 +164,13 @@ impl ProjectReadAsync for ReqwestSrcProjectAsync Result, Self::Error> { use futures::StreamExt as _; + let this_url = self.src_url(path); + let resp = self - .reqwest_src(&path) - .send() + .auth_policy + .with_authentication(&self.client, &move |client| client.get(this_url.clone())) .await - .map_err(|e| ReqwestSrcError::Reqwest(self.src_url(&path).into(), e))?; + .map_err(|e| ReqwestSrcError::Reqwest(self.meta_url().into(), e))?; if resp.status().is_success() { Ok(resp diff --git a/docs/src/authentication.md b/docs/src/authentication.md index 41ed2605..ad4e0675 100644 --- a/docs/src/authentication.md +++ b/docs/src/authentication.md @@ -4,16 +4,18 @@ Project indices and remotely stored project KPARs (or sources) may require authe to get authorised access. Sysand currently supports this for: - HTTP(S) using the [basic access authentication scheme](https://en.wikipedia.org/wiki/Basic_access_authentication) +- HTTP(S) using (fixed) bearer tokens (used by, for example, private GitLab pages) Support is planned for: -- HTTP(S) with digest access, (fixed) bearer token, and OAuth2 device authentication +- HTTP(S) with digest access and OAuth2 device authentication - Git with private-key and basic access authentication ## Configuring At the time of writing, authentication can only be configured through environment variables. -Providing credentials is done by setting environment variables following the pattern + +Providing credentials for the Basic authentication scheme is done by setting environment variables following the pattern ```text SYSAND_CRED_ = @@ -47,3 +49,12 @@ Credentials will *only* be sent to URLs matching the pattern, and even then only unauthenticated response produces a status in the 4xx range. If multiple patterns match, they will be tried in an arbitrary order, after the initial unauthenticated attempt, until one results in a response not in the 4xx range. + +Authentication by a (fixed) bearer token works similarly, using the pattern +```text +SYSAND_CRED_ = +SYSAND_CRED__BEARER_TOKEN = +``` + +With the above the Sysand client will send `Authorization: Bearer ` +in response to 4xx statuses when accessing URLs maching ``. diff --git a/sysand/src/lib.rs b/sysand/src/lib.rs index 54aa3904..89e6f55b 100644 --- a/sysand/src/lib.rs +++ b/sysand/src/lib.rs @@ -168,9 +168,10 @@ pub fn run_cli(args: cli::Args) -> Result<()> { // FIXME: This is a temporary implementation to provide credentials until // https://github.com/sensmetry/sysand/pull/157 // gets merged. - let mut basic_auth_patterns = HashMap::new(); + let mut auth_patterns = HashMap::new(); let mut basic_auth_users = HashMap::new(); let mut basic_auth_passwords = HashMap::new(); + let mut bearer_auth_tokens = HashMap::new(); for (key, value) in std::env::vars() { if let Some(key_rest) = key.strip_prefix("SYSAND_CRED_") { @@ -178,42 +179,71 @@ pub fn run_cli(args: cli::Args) -> Result<()> { basic_auth_users.insert(key_name.to_owned(), value); } else if let Some(key_name) = key_rest.strip_suffix("_BASIC_PASS") { basic_auth_passwords.insert(key_name.to_owned(), value); + } else if let Some(key_name) = key_rest.strip_suffix("_BEARER_TOKEN") { + bearer_auth_tokens.insert(key_name.to_owned(), value); } else { - basic_auth_patterns.insert(key_rest.to_owned(), value); + auth_patterns.insert(key_rest.to_owned(), value); } } } let mut basic_auth_pattern_names = HashSet::new(); for x in [ - &basic_auth_patterns, + &auth_patterns, &basic_auth_users, &basic_auth_passwords, + &bearer_auth_tokens, ] { for k in x.keys() { basic_auth_pattern_names.insert(k); } } - let mut basic_auths_builder: StandardHTTPAuthenticationBuilder = + let mut auths_builder: StandardHTTPAuthenticationBuilder = StandardHTTPAuthenticationBuilder::new(); for k in basic_auth_pattern_names { match ( - basic_auth_patterns.get(k), + auth_patterns.get(k), basic_auth_users.get(k), basic_auth_passwords.get(k), + bearer_auth_tokens.get(k), ) { - (Some(pattern), Some(username), Some(password)) => { - basic_auths_builder.add_basic_auth(pattern, username, password); - } - _ => { + (Some(_), None, None, None) => { anyhow::bail!( - "Please specify all of SYSAND_CRED_{k}, SYSAND_CRED_{k}_BASIC_USER, SYSAND_CRED_{k}_BASIC_PASS" + "SYSAND_CRED_{k} has no matching authentication scheme, please specify SYSAND_CRED_{k}_BASIC_USER/SYSAND_CRED_{k}_BASIC_PASS or SYSAND_CRED_{k}_BEARER_TOKEN" ); } + (Some(pattern), maybe_username, maybe_password, maybe_token) => { + let mut matched_schemes = 0; + + match (maybe_username, maybe_password) { + (Some(username), Some(password)) => { + matched_schemes += 1; + auths_builder.add_basic_auth(pattern, username, password) + } + (None, None) => {} + (_, _) => { + anyhow::bail!( + "Please specify both (or neither) of SYSAND_CRED_{k}_BASIC_USER and SYSAND_CRED_{k}_BASIC_PASS" + ); + } + } + + if let Some(token) = maybe_token { + matched_schemes += 1; + auths_builder.add_bearer_auth(pattern, token); + } + + if matched_schemes > 1 { + log::warn!("SYSAND_CRED_{k} has multiple authentication schemes!"); + } + } + (None, _, _, _) => { + anyhow::bail!("please specify URL pattern SYSAND_CRED_{k} for credential"); + } } } - let basic_auth_policy = Arc::new(basic_auths_builder.build()?); + let basic_auth_policy = Arc::new(auths_builder.build()?); match args.command { cli::Command::Init { diff --git a/sysand/tests/cli_info.rs b/sysand/tests/cli_info.rs index 486ca985..f2f28b4f 100644 --- a/sysand/tests/cli_info.rs +++ b/sysand/tests/cli_info.rs @@ -207,6 +207,89 @@ fn info_basic_http_url_noauth() -> Result<(), Box> { Ok(()) } +#[test] +fn info_basic_http_url_irrelevant_auth() -> Result<(), Box> { + let mut server = mockito::Server::new(); + + let git_mock = server + .mock("GET", "/info/refs?service=git-upload-pack") + .with_status(404) + .expect_at_most(2) // TODO: Reduce this to 1 + .create(); + + let kpar_range_probe = server + .mock("HEAD", "/") + .with_status(404) + .expect_at_most(1) + .create(); + + let kpar_download_try = server + .mock("GET", "/") + .with_status(404) + .expect_at_most(1) + .create(); + + let info_mock_head = server + .mock("HEAD", "/.project.json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"name":"info_basic_http_url","version":"1.2.3","usage":[]}"#) + .expect_at_most(1) // TODO: Reduce this + .create(); + + let info_mock = server + .mock("GET", "/.project.json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"name":"info_basic_http_url","version":"1.2.3","usage":[]}"#) + .expect_at_most(3) // TODO: Reduce this to 1 + .create(); + + let meta_mock_head = server + .mock("HEAD", "/.meta.json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#) + .expect_at_most(1) + .create(); + + let meta_mock = server + .mock("GET", "/.meta.json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#) + .expect_at_most(3) // TODO: Reduce this to 1 + .create(); + + let (_, _, out) = run_sysand_with( + ["info", "--iri", &server.url()], + None, + &IndexMap::from([ + ("SYSAND_CRED_TEST", "http://irrelevant.example.com:*/**"), + ("SYSAND_CRED_TEST_BASIC_USER", "user_1234"), + ("SYSAND_CRED_TEST_BASIC_PASS", "pass_4321"), + ]), + )?; + + out.assert() + .success() + .stdout(predicate::str::contains("Name: info_basic_http_url")) + .stdout(predicate::str::contains("Version: 1.2.3")); + + git_mock.assert(); + + info_mock_head.assert(); + meta_mock_head.assert(); + + kpar_range_probe.assert(); + kpar_download_try.assert(); + + info_mock.assert(); + meta_mock.assert(); + + Ok(()) +} + #[test] fn info_basic_http_url_auth() -> Result<(), Box> { let mut server = mockito::Server::new(); @@ -361,6 +444,159 @@ fn info_basic_http_url_auth() -> Result<(), Box> { Ok(()) } +#[test] +fn info_bearer_http_url_auth() -> Result<(), Box> { + let mut server = mockito::Server::new(); + + let git_mock = server + .mock("GET", "/info/refs?service=git-upload-pack") + .match_header("authorization", Matcher::Missing) + .with_status(404) + .expect(2) // TODO: Reduce this to 1 + .create(); + + // let kpar_range_probe = server + // .mock("HEAD", "/") + // .match_header("authorization", Matcher::Missing) + // .with_status(404) + // .expect(1) + // .create(); + + let kpar_download_try = server + .mock("GET", "/") + .match_header("authorization", Matcher::Missing) + .with_status(404) + .expect(1) + .create(); + + let kpar_download_try_auth = server + .mock("GET", "/") + .match_header( + "authorization", + Matcher::Exact("Bearer this_is_a_token".to_string()), + ) + .with_status(404) + .expect(1) + .create(); + + let info_mock_head = server + .mock("HEAD", "/.project.json") + .match_header("authorization", Matcher::Missing) + .with_status(404) + .with_header("content-type", "application/json") + .with_body(r#"{"name":"info_basic_http_url","version":"1.2.3","usage":[]}"#) + .expect(1) // TODO: Reduce this + .create(); + + let info_mock_head_auth = server + .mock("HEAD", "/.project.json") + .match_header( + "authorization", + Matcher::Exact("Bearer this_is_a_token".to_string()), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"name":"info_basic_http_url","version":"1.2.3","usage":[]}"#) + .expect(1) // TODO: Reduce this + .create(); + + let info_mock = server + .mock("GET", "/.project.json") + .match_header("authorization", Matcher::Missing) + .with_status(404) + .with_header("content-type", "application/json") + .with_body(r#"{"name":"info_basic_http_url","version":"1.2.3","usage":[]}"#) + .expect(3) // TODO: Reduce this to 1 + .create(); + + let info_mock_auth = server + .mock("GET", "/.project.json") + .match_header( + "authorization", + Matcher::Exact("Bearer this_is_a_token".to_string()), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"name":"info_basic_http_url","version":"1.2.3","usage":[]}"#) + .expect(3) // TODO: Reduce this to 1 + .create(); + + let meta_mock_head = server + .mock("HEAD", "/.meta.json") + .match_header("authorization", Matcher::Missing) + .with_status(404) + .with_header("content-type", "application/json") + .with_body(r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#) + .expect(1) + .create(); + + let meta_mock_head_auth = server + .mock("HEAD", "/.meta.json") + .match_header( + "authorization", + Matcher::Exact("Bearer this_is_a_token".to_string()), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#) + .expect(1) + .create(); + + let meta_mock = server + .mock("GET", "/.meta.json") + .with_status(404) + .match_header("authorization", Matcher::Missing) + .with_header("content-type", "application/json") + .with_body(r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#) + .expect(3) // TODO: Reduce this to 1 + .create(); + + let meta_mock_auth = server + .mock("GET", "/.meta.json") + .with_status(200) + .match_header( + "authorization", + Matcher::Exact("Bearer this_is_a_token".to_string()), + ) + .with_header("content-type", "application/json") + .with_body(r#"{"index":{},"created":"0000-00-00T00:00:00.123456789Z"}"#) + .expect(3) // TODO: Reduce this to 1 + .create(); + + let (_, _, out) = run_sysand_with( + ["info", "--iri", &server.url()], + None, + &IndexMap::from([ + ("SYSAND_CRED_TEST", "http://127.0.0.1:*/**"), + ("SYSAND_CRED_TEST_BEARER_TOKEN", "this_is_a_token"), + ]), + )?; + + out.assert() + .success() + .stdout(predicate::str::contains("Name: info_basic_http_url")) + .stdout(predicate::str::contains("Version: 1.2.3")); + + git_mock.assert(); + + info_mock_head.assert(); + info_mock_head_auth.assert(); + meta_mock_head.assert(); + meta_mock_head_auth.assert(); + + // kpar_range_probe.assert(); + kpar_download_try.assert(); + kpar_download_try_auth.assert(); + + info_mock.assert(); + info_mock_auth.assert(); + + meta_mock.assert(); + meta_mock_auth.assert(); + + Ok(()) +} + // #[test] // fn info_non_ranged_http_kpar() -> Result<(), Box> { // let buf = {