Skip to content

Commit

Permalink
Merge pull request #2330 from jarhodes314/docs/c8y-auth-proxy
Browse files Browse the repository at this point in the history
Add reference documentation for `c8y_auth_proxy`
  • Loading branch information
jarhodes314 authored Oct 16, 2023
2 parents 0047f08 + cfba699 commit dc88562
Show file tree
Hide file tree
Showing 2 changed files with 141 additions and 8 deletions.
116 changes: 108 additions & 8 deletions crates/extensions/c8y_auth_proxy/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ async fn respond_to(
Some(Path(p)) => p.as_str(),
None => "",
};
let auth: fn(reqwest::RequestBuilder, &str) -> reqwest::RequestBuilder =
if headers.contains_key("Authorization") {
|req, _token| req
} else {
|req, token| req.bearer_auth(token)
};

// Cumulocity revokes the device token if we access parts of the frontend UI,
// so deny requests to these proactively
Expand All @@ -138,23 +144,39 @@ async fn respond_to(

let client = reqwest::Client::new();
let (body, body_clone) = small_body.try_clone();
let send_request = |body, token| {
client
.request(method.to_owned(), destination.clone())
.headers(headers.clone())
if body_clone.is_none() {
let destination = format!("{host}/tenant/currentTenant");
let response = client
.head(&destination)
.bearer_auth(&token)
.body(body)
.send()
.await
.into_diagnostic()
.wrap_err_with(|| format!("making HEAD request to {destination}"))?;
if response.status() == StatusCode::UNAUTHORIZED {
token = retrieve_token.not_matching(Some(&token)).await;
}
}

let send_request = |body, token: &str| {
auth(
client
.request(method.to_owned(), &destination)
.headers(headers.clone()),
token,
)
.body(body)
.send()
};
let mut res = send_request(body, token.clone())
let mut res = send_request(body, &token)
.await
.into_diagnostic()
.wrap_err_with(|| format!("making proxied request to {destination}"))?;

if res.status() == StatusCode::UNAUTHORIZED {
token = retrieve_token.not_matching(Some(&token)).await;
if let Some(body) = body_clone {
res = send_request(Body::from(body), token.clone())
res = send_request(Body::from(body), &token)
.await
.into_diagnostic()
.wrap_err_with(|| format!("making proxied request to {destination}"))?;
Expand Down Expand Up @@ -231,6 +253,18 @@ mod tests {
assert_eq!(res.status(), 404);
}

#[tokio::test]
async fn responds_with_bad_gateway_on_connection_error() {
let _ = env_logger::try_init();

let port = start_server_at_url(Arc::from("127.0.0.1:0"), vec!["test-token"]);

let res = reqwest::get(format!("http://localhost:{port}/c8y/not-a-known-url"))
.await
.unwrap();
assert_eq!(res.status(), 502);
}

#[tokio::test]
async fn sends_query_string_from_original_request() {
let _ = env_logger::try_init();
Expand All @@ -251,6 +285,30 @@ mod tests {
assert_eq!(res.status(), 200);
}

#[tokio::test]
async fn uses_authorization_header_passed_by_user_if_one_is_provided() {
let _ = env_logger::try_init();
let mut server = mockito::Server::new();
let _mock = server
.mock("GET", "/inventory/managedObjects")
.match_header("authorization", "Basic dGVzdDp0ZXN0")
.with_status(200)
.create();

let port = start_server(&server, vec!["test-token"]);

let client = reqwest::Client::new();
let res = client
.get(format!(
"http://localhost:{port}/c8y/inventory/managedObjects"
))
.basic_auth("test", Some("test"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
}

#[tokio::test]
async fn retries_requests_with_small_bodies() {
let _ = env_logger::try_init();
Expand Down Expand Up @@ -284,6 +342,41 @@ mod tests {
assert_eq!(res.bytes().await.unwrap(), Bytes::from("Some response"));
}

#[tokio::test]
async fn regenerates_token_proactively_if_the_request_cannot_be_retried() {
let _ = env_logger::try_init();
let mut server = mockito::Server::new();
let head_request = server
.mock("HEAD", "/tenant/currentTenant")
.match_header("Authorization", "Bearer old-token")
.with_status(401)
.create();
let _mock = server
.mock("PUT", "/hello")
.match_header("Authorization", "Bearer test-token")
.match_body("A body")
.with_body("Some response")
.with_status(200)
.create();

let port = start_server(&server, vec!["old-token", "test-token"]);

let client = reqwest::Client::new();
let body = "A body";
let res = client
.put(format!("http://localhost:{port}/c8y/hello"))
.body(reqwest::Body::wrap_stream(futures::stream::once(
futures::future::ready(Ok::<_, std::convert::Infallible>(body)),
)))
.send()
.await
.unwrap();

head_request.assert();
assert_eq!(res.status(), 200);
assert_eq!(res.bytes().await.unwrap(), Bytes::from("Some response"));
}

#[tokio::test]
async fn retries_get_request_on_401() {
let _ = env_logger::try_init();
Expand Down Expand Up @@ -311,10 +404,17 @@ mod tests {
}

fn start_server(server: &mockito::Server, tokens: Vec<impl Into<Cow<'static, str>>>) -> u16 {
start_server_at_url(server.url().into(), tokens)
}

fn start_server_at_url(
target_host: Arc<str>,
tokens: Vec<impl Into<Cow<'static, str>>>,
) -> u16 {
let mut retriever = IterJwtRetriever::builder(tokens);
for port in 3000..3100 {
let state = AppState {
target_host: server.url().into(),
target_host: target_host.clone(),
token_manager: TokenManager::new(JwtRetriever::new("TEST => JWT", &mut retriever))
.shared(),
};
Expand Down
33 changes: 33 additions & 0 deletions docs/src/references/tedge-cumulocity-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: Thin Edge Cumulocity HTTP Proxy
tags: [ Reference, HTTP, Cumulocity ]
sidebar_position: 12
---

# Thin Edge Cumulocity Proxy

The `tedge-mapper` (when running in `c8y` mode) hosts a proxy server to access the Cumulocity HTTP API from the
thin-edge device.
It automatically handles authenticating with a JWT, avoiding the need for clients to support MQTT to retrieve this
information.

The API can be accessed at `http://{ip}:8001/c8y/{c8y-endpoint}`. `ip` is configured using the `c8y.proxy.bind.address`
configuration
(the default value is `127.0.0.1`) and the port can be changed by setting `c8y.proxy.bind.port`.

For example, you can access the current tenant information
at [http://127.0.0.1:8001/c8y/tenant/currentTenant](http://127.0.0.1:8001/c8y/tenant/currentTenant)
from the machine running `tedge-mapper`.
The server supports all public REST APIs of Cumulocity, and all possible request methods
(e.g. `HEAD`/`GET`/`PUT`/`PATCH`/`DELETE`).
There is no need to provide an `Authorization` header (or any other authentication method) when accessing the API.
If an `Authorization` header is provided, this will be used to authenticate the request instead of the device JWT.

At the time of writing, this service is unauthenticated and does not support incoming HTTPS connections
(when the request is forwarded to Cumulocity, however, this will use HTTPS).
Due to the underlying JWT handling in Cumulocity, requests to the proxy API are occasionally spuriously rejected with
a `401 Not Authorized` status code.
The proxy server currently forwards this response directly to the client, as well as all other errors responses from
Cumulocity.
If there is an error connecting to Cumulocity to make the request, a plain text response with the status
code `502 Bad Gateway` will be returned.

1 comment on commit dc88562

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robot Results

✅ Passed ❌ Failed ⏭️ Skipped Total Pass % ⏱️ Duration
319 0 3 319 100 59m12.055s

Please sign in to comment.