swarm-queue-client: audience-scoped tokens + a blocking mint for a non-reactor caller
This commit is contained in:
parent
2aa33f12d2
commit
e8584e595b
3 changed files with 179 additions and 21 deletions
|
|
@ -98,7 +98,7 @@ impl AuthBridge {
|
||||||
// configured CA, if any) — deliberately not `self.http`, which is
|
// configured CA, if any) — deliberately not `self.http`, which is
|
||||||
// the bridge's own client and has nothing to do with authelia's
|
// the bridge's own client and has nothing to do with authelia's
|
||||||
// token endpoint's trust anchors.
|
// token endpoint's trust anchors.
|
||||||
let token = swarm_queue_client::mint_token_for(&self.queue_cfg)
|
let token = swarm_queue_client::mint_token_for(&self.queue_cfg, None)
|
||||||
.await
|
.await
|
||||||
.context("minting a bearer token for swarm-authelia-bridge")?;
|
.context("minting a bearer token for swarm-authelia-bridge")?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,16 @@ notices = ["async-nats/jetstream"]
|
||||||
# Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature
|
# Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature
|
||||||
# above - the connect itself needs none of them.
|
# above - the connect itself needs none of them.
|
||||||
async-nats.workspace = true
|
async-nats.workspace = true
|
||||||
reqwest.workspace = true
|
# `blocking` on top of the workspace default (`form`/`json`/`rustls`) —
|
||||||
|
# `mint_token_for_blocking` needs `reqwest::blocking::Client` for a caller
|
||||||
|
# with no tokio reactor to `.await` an async request on (an OTLP exporter's
|
||||||
|
# `HttpClient` impl, see that function's doc). Declared here rather than
|
||||||
|
# left to arrive transitively from a consumer that happens to pull in
|
||||||
|
# `reqwest`'s blocking feature some other way — this crate already has a
|
||||||
|
# recorded case of exactly that kind of accidental compile (see the `kv`
|
||||||
|
# feature's comment above), and `cargo check -p swarm-queue-client` alone
|
||||||
|
# must not depend on what else is in the build.
|
||||||
|
reqwest = { workspace = true, features = ["blocking"] }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
# A library, so its errors are a matchable enum rather than an opaque
|
# A library, so its errors are a matchable enum rather than an opaque
|
||||||
|
|
|
||||||
|
|
@ -316,7 +316,11 @@ impl QueueConfig {
|
||||||
/// authenticates as itself. Authelia refuses the `openid` scope for this grant
|
/// authenticates as itself. Authelia refuses the `openid` scope for this grant
|
||||||
/// (a machine client receives an access token and never an id-token), so no
|
/// (a machine client receives an access token and never an id-token), so no
|
||||||
/// scope is requested.
|
/// scope is requested.
|
||||||
async fn mint_token(http: &reqwest::Client, cfg: &QueueConfig) -> Result<CachedToken, Error> {
|
async fn mint_token(
|
||||||
|
http: &reqwest::Client,
|
||||||
|
cfg: &QueueConfig,
|
||||||
|
audience: Option<&str>,
|
||||||
|
) -> Result<CachedToken, Error> {
|
||||||
// Read per call rather than caching: the file is small, and a cached
|
// Read per call rather than caching: the file is small, and a cached
|
||||||
// secret would survive a rotation that the operator believes took effect.
|
// secret would survive a rotation that the operator believes took effect.
|
||||||
let secret = tokio::fs::read_to_string(&cfg.client_secret_file)
|
let secret = tokio::fs::read_to_string(&cfg.client_secret_file)
|
||||||
|
|
@ -326,19 +330,35 @@ async fn mint_token(http: &reqwest::Client, cfg: &QueueConfig) -> Result<CachedT
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let response = token_request(http, cfg, secret.trim())
|
let response = token_request(http, cfg, secret.trim(), audience)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(Error::TokenRequest)?;
|
.map_err(Error::TokenRequest)?;
|
||||||
|
|
||||||
// Why the body and not just the status: see `Error::TokenRefused`.
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
parse_token_response(status, &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a raw token-endpoint response into a [`CachedToken`] — the half of
|
||||||
|
/// [`mint_token`] that is pure and therefore shared with the blocking sibling
|
||||||
|
/// below: neither the success/failure check nor the JSON shape cares which
|
||||||
|
/// HTTP client fetched the bytes.
|
||||||
|
///
|
||||||
|
/// Takes the body already read out, not the response itself: `reqwest`'s
|
||||||
|
/// blocking and async response types have no common trait for that, and
|
||||||
|
/// forcing one here would mean this function is not actually callable from
|
||||||
|
/// both callers, which defeats the reason it exists.
|
||||||
|
fn parse_token_response(status: reqwest::StatusCode, body: &str) -> Result<CachedToken, Error> {
|
||||||
|
// Why the body and not just the status: see `Error::TokenRefused`.
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(Error::TokenRefused { status, body });
|
return Err(Error::TokenRefused {
|
||||||
|
status,
|
||||||
|
body: body.to_owned(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: TokenResponse = serde_json::from_str(&body).map_err(Error::TokenResponse)?;
|
let parsed: TokenResponse = serde_json::from_str(body).map_err(Error::TokenResponse)?;
|
||||||
Ok(CachedToken {
|
Ok(CachedToken {
|
||||||
token: parsed.access_token,
|
token: parsed.access_token,
|
||||||
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(parsed.expires_in),
|
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(parsed.expires_in),
|
||||||
|
|
@ -364,14 +384,45 @@ async fn mint_token(http: &reqwest::Client, cfg: &QueueConfig) -> Result<CachedT
|
||||||
///
|
///
|
||||||
/// Split out of [`mint_token`] so the request's *shape* is testable without a
|
/// Split out of [`mint_token`] so the request's *shape* is testable without a
|
||||||
/// running identity provider — see the tests at the bottom of this file.
|
/// running identity provider — see the tests at the bottom of this file.
|
||||||
|
///
|
||||||
|
/// `audience` is RFC 8707 resource-indicator territory, and it is opt-in:
|
||||||
|
/// omitted, this identity gets whatever audience authelia defaults a
|
||||||
|
/// scopeless `client_credentials` grant to (the queue connection's own
|
||||||
|
/// case — it has always worked without asking). A caller proving this
|
||||||
|
/// identity to a SPECIFIC audience-checked receiver — the swarm-otel
|
||||||
|
/// `oidc/swarm` authenticator being the first one — has to ask for it by
|
||||||
|
/// name, the same way `swarm-otel.nix`'s own prometheus scrape config
|
||||||
|
/// already does per target (`endpoint_params.audience`): a token minted
|
||||||
|
/// without asking carries `aud: []`, and an audience-checked receiver
|
||||||
|
/// refuses that just as readily as the wrong one.
|
||||||
fn token_request(
|
fn token_request(
|
||||||
http: &reqwest::Client,
|
http: &reqwest::Client,
|
||||||
cfg: &QueueConfig,
|
cfg: &QueueConfig,
|
||||||
secret: &str,
|
secret: &str,
|
||||||
|
audience: Option<&str>,
|
||||||
) -> reqwest::RequestBuilder {
|
) -> reqwest::RequestBuilder {
|
||||||
|
let mut form = vec![("grant_type", "client_credentials")];
|
||||||
|
if let Some(audience) = audience {
|
||||||
|
form.push(("audience", audience));
|
||||||
|
}
|
||||||
http.post(&cfg.token_endpoint)
|
http.post(&cfg.token_endpoint)
|
||||||
.basic_auth(&cfg.client_id, Some(secret))
|
.basic_auth(&cfg.client_id, Some(secret))
|
||||||
.form(&[("grant_type", "client_credentials")])
|
.form(&form)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read + parse `path` as a PEM root certificate. Shared by the async and
|
||||||
|
/// blocking HTTP client builders below — `reqwest::Certificate::from_pem`
|
||||||
|
/// takes the same bytes regardless of which client type ends up trusting
|
||||||
|
/// it, so the CA loading has no reason to exist twice.
|
||||||
|
fn load_ca_cert(path: &std::path::Path) -> Result<reqwest::Certificate, Error> {
|
||||||
|
let pem = std::fs::read(path).map_err(|source| Error::CaFile {
|
||||||
|
path: path.display().to_string(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
reqwest::Certificate::from_pem(&pem).map_err(|source| Error::CaParse {
|
||||||
|
path: path.display().to_string(),
|
||||||
|
source,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the HTTP client used to reach `cfg.token_endpoint`, trusting
|
/// Build the HTTP client used to reach `cfg.token_endpoint`, trusting
|
||||||
|
|
@ -382,15 +433,7 @@ fn token_request(
|
||||||
fn build_http_client(cfg: &QueueConfig) -> Result<reqwest::Client, Error> {
|
fn build_http_client(cfg: &QueueConfig) -> Result<reqwest::Client, Error> {
|
||||||
let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10));
|
let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10));
|
||||||
if let Some(path) = &cfg.ca_file {
|
if let Some(path) = &cfg.ca_file {
|
||||||
let pem = std::fs::read(path).map_err(|source| Error::CaFile {
|
builder = builder.add_root_certificate(load_ca_cert(path)?);
|
||||||
path: path.display().to_string(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let cert = reqwest::Certificate::from_pem(&pem).map_err(|source| Error::CaParse {
|
|
||||||
path: path.display().to_string(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
builder = builder.add_root_certificate(cert);
|
|
||||||
}
|
}
|
||||||
builder.build().map_err(Error::HttpClient)
|
builder.build().map_err(Error::HttpClient)
|
||||||
}
|
}
|
||||||
|
|
@ -408,9 +451,70 @@ fn build_http_client(cfg: &QueueConfig) -> Result<reqwest::Client, Error> {
|
||||||
/// outside that loop (e.g. `swarm-controller::auth`'s bridge client) mints
|
/// outside that loop (e.g. `swarm-controller::auth`'s bridge client) mints
|
||||||
/// per call, same as this crate did before the reconnect-storm fix added the
|
/// per call, same as this crate did before the reconnect-storm fix added the
|
||||||
/// cache.
|
/// cache.
|
||||||
pub async fn mint_token_for(cfg: &QueueConfig) -> Result<String, Error> {
|
///
|
||||||
|
/// `audience` is passed straight to [`token_request`] — see its doc for why
|
||||||
|
/// it is optional and when a caller needs it. `None` reproduces this
|
||||||
|
/// function's behaviour before the parameter existed, so every caller from
|
||||||
|
/// before that added it (the queue connect path, `auth.rs`'s bridge client)
|
||||||
|
/// is unaffected.
|
||||||
|
pub async fn mint_token_for(cfg: &QueueConfig, audience: Option<&str>) -> Result<String, Error> {
|
||||||
let http = build_http_client(cfg)?;
|
let http = build_http_client(cfg)?;
|
||||||
Ok(mint_token(&http, cfg).await?.token)
|
Ok(mint_token(&http, cfg, audience).await?.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking sibling of [`mint_token_for`], for a caller with no tokio
|
||||||
|
/// reactor to `.await` on.
|
||||||
|
///
|
||||||
|
/// That caller is real, not hypothetical: an OTLP exporter's `HttpClient`
|
||||||
|
/// implementation runs on the `PeriodicReader`'s own thread, which this
|
||||||
|
/// workspace deliberately keeps reactor-free (`reqwest-blocking-client` was
|
||||||
|
/// chosen over the async client for exactly that reason — see
|
||||||
|
/// `swarm-controller::vcs_metrics`'s module doc). Calling the async
|
||||||
|
/// [`mint_token_for`] from there would need a runtime that thread does not
|
||||||
|
/// have; this function uses `reqwest::blocking::Client` end to end instead,
|
||||||
|
/// so it never needs one.
|
||||||
|
///
|
||||||
|
/// Same request shape and same no-caching behaviour as [`mint_token_for`] —
|
||||||
|
/// see that function's doc for why both of those are the right call here.
|
||||||
|
pub fn mint_token_for_blocking(cfg: &QueueConfig, audience: Option<&str>) -> Result<String, Error> {
|
||||||
|
let http = build_blocking_http_client(cfg)?;
|
||||||
|
|
||||||
|
// Read per call rather than caching — see `mint_token`'s identical
|
||||||
|
// comment on the async path; the reasoning does not change with the
|
||||||
|
// client type.
|
||||||
|
let secret =
|
||||||
|
std::fs::read_to_string(&cfg.client_secret_file).map_err(|source| Error::ClientSecret {
|
||||||
|
path: cfg.client_secret_file.display().to_string(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut form = vec![("grant_type", "client_credentials")];
|
||||||
|
if let Some(audience) = audience {
|
||||||
|
form.push(("audience", audience));
|
||||||
|
}
|
||||||
|
let response = http
|
||||||
|
.post(&cfg.token_endpoint)
|
||||||
|
.basic_auth(&cfg.client_id, Some(secret.trim()))
|
||||||
|
.form(&form)
|
||||||
|
.send()
|
||||||
|
.map_err(Error::TokenRequest)?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().unwrap_or_default();
|
||||||
|
Ok(parse_token_response(status, &body)?.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking counterpart to [`build_http_client`] — same CA-trust logic
|
||||||
|
/// (via the shared [`load_ca_cert`]), a `reqwest::blocking::Client` instead
|
||||||
|
/// of the async one. See [`mint_token_for_blocking`] for why a caller needs
|
||||||
|
/// this rather than the async builder.
|
||||||
|
fn build_blocking_http_client(cfg: &QueueConfig) -> Result<reqwest::blocking::Client, Error> {
|
||||||
|
let mut builder =
|
||||||
|
reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(10));
|
||||||
|
if let Some(path) = &cfg.ca_file {
|
||||||
|
builder = builder.add_root_certificate(load_ca_cert(path)?);
|
||||||
|
}
|
||||||
|
builder.build().map_err(Error::HttpClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fail fast unless the client is actually connected.
|
/// Fail fast unless the client is actually connected.
|
||||||
|
|
@ -487,7 +591,7 @@ pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> {
|
||||||
let token = if let Some(token) = reuse {
|
let token = if let Some(token) = reuse {
|
||||||
token
|
token
|
||||||
} else {
|
} else {
|
||||||
let minted = mint_token(&http, &cfg)
|
let minted = mint_token(&http, &cfg, None)
|
||||||
.await
|
.await
|
||||||
// The callback's error type carries a string, so the
|
// The callback's error type carries a string, so the
|
||||||
// source chain would be lost; flatten it rather than
|
// source chain would be lost; flatten it rather than
|
||||||
|
|
@ -631,7 +735,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_token_request_authenticates_with_http_basic() {
|
fn the_token_request_authenticates_with_http_basic() {
|
||||||
let req = token_request(&offline_client(), &token_cfg(), "s3cret")
|
let req = token_request(&offline_client(), &token_cfg(), "s3cret", None)
|
||||||
.build()
|
.build()
|
||||||
.expect("the token request must build");
|
.expect("the token request must build");
|
||||||
|
|
||||||
|
|
@ -667,4 +771,49 @@ mod tests {
|
||||||
"the client id belongs in the Basic credentials, got: {body}"
|
"the client id belongs in the Basic credentials, got: {body}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The queue/bridge shape (`audience: None`) must stay unchanged by the
|
||||||
|
/// parameter's addition — no `audience` field appears in the body at
|
||||||
|
/// all, not even empty.
|
||||||
|
#[test]
|
||||||
|
fn no_audience_means_no_audience_field() {
|
||||||
|
let req = token_request(&offline_client(), &token_cfg(), "s3cret", None)
|
||||||
|
.build()
|
||||||
|
.expect("the token request must build");
|
||||||
|
let body = std::str::from_utf8(
|
||||||
|
req.body()
|
||||||
|
.and_then(reqwest::Body::as_bytes)
|
||||||
|
.expect("the request has an in-memory body"),
|
||||||
|
)
|
||||||
|
.expect("the body is utf-8");
|
||||||
|
assert!(
|
||||||
|
!body.contains("audience"),
|
||||||
|
"omitting the audience must not even send an empty field, got: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A caller that DOES ask for an audience gets it in the form body,
|
||||||
|
/// verbatim — this is the half `swarm-otel`'s `oidc/<owner>`
|
||||||
|
/// authenticators actually check.
|
||||||
|
#[test]
|
||||||
|
fn an_audience_is_sent_verbatim() {
|
||||||
|
let req = token_request(
|
||||||
|
&offline_client(),
|
||||||
|
&token_cfg(),
|
||||||
|
"s3cret",
|
||||||
|
Some("https://otel.example/swarm"),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
.expect("the token request must build");
|
||||||
|
let body = std::str::from_utf8(
|
||||||
|
req.body()
|
||||||
|
.and_then(reqwest::Body::as_bytes)
|
||||||
|
.expect("the request has an in-memory body"),
|
||||||
|
)
|
||||||
|
.expect("the body is utf-8");
|
||||||
|
assert!(
|
||||||
|
body.contains("audience=https%3A%2F%2Fotel.example%2Fswarm"),
|
||||||
|
"the requested audience must reach the form body, got: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue