Compare commits
6 changed files with 52 additions and 153 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, None, None)
|
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")?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ impl HttpClient for AuthenticatedHttpClient {
|
||||||
/// why this specific caller deliberately runs on a thread with nothing
|
/// why this specific caller deliberately runs on a thread with nothing
|
||||||
/// to yield to.
|
/// to yield to.
|
||||||
async fn send_bytes(&self, mut request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
|
async fn send_bytes(&self, mut request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
|
||||||
let token = mint_token_for_blocking(&self.cfg, Some(&self.audience), None)?;
|
let token = mint_token_for_blocking(&self.cfg, Some(&self.audience))?;
|
||||||
request.headers_mut().insert(
|
request.headers_mut().insert(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
http::HeaderValue::from_str(&format!("Bearer {token}"))?,
|
http::HeaderValue::from_str(&format!("Bearer {token}"))?,
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,6 @@ hold `authelia.bearer.authz`. Both are set in `swarm-authelia.nix`'s
|
||||||
`agentClients`; without them authelia answers `invalid_target` at the token
|
`agentClients`; without them authelia answers `invalid_target` at the token
|
||||||
endpoint, or the gateway answers 401 with no explanation.
|
endpoint, or the gateway answers 401 with no explanation.
|
||||||
|
|
||||||
⚠️ Registration is not issuance, so the token request _asks_ for both: the
|
|
||||||
audience and the scope are named in the `client_credentials` form, because a
|
|
||||||
client that is registered for a scope it does not request is handed a token
|
|
||||||
carrying none, and the gateway refuses that with the same bare 401.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Supplied by `nix/agent-modules/logs.nix`, which wraps the binary — the same
|
Supplied by `nix/agent-modules/logs.nix`, which wraps the binary — the same
|
||||||
|
|
|
||||||
|
|
@ -46,25 +46,13 @@ pub fn run(
|
||||||
format: Format,
|
format: Format,
|
||||||
out: &mut impl Write,
|
out: &mut impl Write,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Minted per invocation, with the query URL as the audience and the
|
// Minted per invocation, with the query URL as the audience. Both halves
|
||||||
// `authelia.bearer.authz` scope. All three are load-bearing: authelia
|
// are load-bearing: authelia refuses a token carrying no audience at the
|
||||||
// refuses a token carrying no audience at the authz endpoint the
|
// authz endpoint the gateway's `auth_request` calls, and the audience it
|
||||||
// gateway's `auth_request` calls, and the audience it checks is the URL
|
// checks is the URL being requested — so the string sent here and the
|
||||||
// being requested — so the string sent here and the string requested
|
// string requested below must be one binding, which is why `Config` holds
|
||||||
// below must be one binding, which is why `Config` holds exactly one.
|
// exactly one.
|
||||||
//
|
let token = swarm_queue_client::mint_token_for_blocking(&cfg.queue, Some(&cfg.query_url))
|
||||||
// The scope has to be ASKED for, not merely registered: the agent client
|
|
||||||
// is granted `authelia.bearer.authz` by the `agentClients` entry in
|
|
||||||
// `swarm-authelia.nix`, but an OAuth2 server issues no scope the client
|
|
||||||
// never requested, and a scopeless token is refused at the authz endpoint
|
|
||||||
// exactly as an unauthenticated one is — a bare nginx 401 with nothing in
|
|
||||||
// it that names the scope. `swarm-otel.nix` records the same failure
|
|
||||||
// against the collector's client, on the same string.
|
|
||||||
let token = swarm_queue_client::mint_token_for_blocking(
|
|
||||||
&cfg.queue,
|
|
||||||
Some(&cfg.query_url),
|
|
||||||
Some("authelia.bearer.authz"),
|
|
||||||
)
|
|
||||||
.map_err(|e| anyhow::anyhow!("{}", swarm_queue_client::chain(&e)))
|
.map_err(|e| anyhow::anyhow!("{}", swarm_queue_client::chain(&e)))
|
||||||
.context("minting an access token for the swarm log store")?;
|
.context("minting an access token for the swarm log store")?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,41 +48,6 @@ would publish it to anything that can read `/proc/<pid>/environ`. It is read
|
||||||
per token request rather than cached, so a rotation the operator believes took
|
per token request rather than cached, so a rotation the operator believes took
|
||||||
effect actually did.
|
effect actually did.
|
||||||
|
|
||||||
## Token request shape: HTTP Basic, `audience`, `scope`
|
|
||||||
|
|
||||||
`token_request` builds a `client_credentials` grant, authenticated with HTTP
|
|
||||||
Basic — **never** the form body. Both are legal OAuth 2.0
|
|
||||||
(`client_secret_basic` vs `client_secret_post`), but a client registration
|
|
||||||
names one, and authelia's default (and ours) is Basic. Sending the
|
|
||||||
credentials in the body once got every token request refused with `Client
|
|
||||||
authentication failed … the registered client is configured to only support
|
|
||||||
'client_secret_basic'`, which reached the operator as an endless `429`
|
|
||||||
because the retries tripped a rate limiter whose penalty grew faster than the
|
|
||||||
retry interval — the 429 arrived _before_ the credentials were ever
|
|
||||||
evaluated, so the line naming the real cause appeared once an hour. RFC 6749
|
|
||||||
§2.3.1 says clients SHOULD use Basic, both introspection callers in this
|
|
||||||
workspace already do, and a secret in a header is one fewer place for a proxy
|
|
||||||
to log it.
|
|
||||||
|
|
||||||
`audience` (RFC 8707 resource indicators) and `scope` are both opt-in;
|
|
||||||
`None` for either reproduces the request this crate sent before the
|
|
||||||
parameter existed. Omitted, `audience` gets whatever authelia defaults a
|
|
||||||
scopeless `client_credentials` grant to — the queue connection's own case,
|
|
||||||
which has always worked without asking. A caller proving this identity to a
|
|
||||||
specifically audience-checked receiver (the swarm-otel `oidc/swarm`
|
|
||||||
authenticator being the first one) has to ask 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. `scope` follows the same rule and travels with `audience`:
|
|
||||||
registration is not issuance, and the authorisation server grants no scope
|
|
||||||
the client never requested. A caller reaching a destination behind
|
|
||||||
authelia's `/api/authz/auth-request` needs `authelia.bearer.authz` here
|
|
||||||
however completely the client is registered for it — the failure
|
|
||||||
`swarm-otel.nix` records against its own client (a scopeless token refused
|
|
||||||
at introspection with "the requested scope is invalid, unknown, or
|
|
||||||
malformed") is the same one, one layer down.
|
|
||||||
|
|
||||||
## What this crate does not do
|
## What this crate does not do
|
||||||
|
|
||||||
It ends at a connected client. `jetstream`/`kv` are **off by default** — what a
|
It ends at a connected client. `jetstream`/`kv` are **off by default** — what a
|
||||||
|
|
|
||||||
|
|
@ -404,14 +404,12 @@ impl QueueConfig {
|
||||||
///
|
///
|
||||||
/// `client_credentials`, because there is no user here: the controller
|
/// `client_credentials`, because there is no user here: the controller
|
||||||
/// 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 a
|
/// (a machine client receives an access token and never an id-token), so no
|
||||||
/// caller passing `None` for `scope` requests none at all — see
|
/// scope is requested.
|
||||||
/// [`token_request`] for the one scope a caller does have to ask for.
|
|
||||||
async fn mint_token(
|
async fn mint_token(
|
||||||
http: &reqwest::Client,
|
http: &reqwest::Client,
|
||||||
cfg: &QueueConfig,
|
cfg: &QueueConfig,
|
||||||
audience: Option<&str>,
|
audience: Option<&str>,
|
||||||
scope: Option<&str>,
|
|
||||||
) -> Result<CachedToken, Error> {
|
) -> 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.
|
||||||
|
|
@ -422,7 +420,7 @@ async fn mint_token(
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let response = token_request(http, cfg, secret.trim(), audience, scope)
|
let response = token_request(http, cfg, secret.trim(), audience)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(Error::TokenRequest)?;
|
.map_err(Error::TokenRequest)?;
|
||||||
|
|
@ -460,32 +458,43 @@ fn parse_token_response(status: reqwest::StatusCode, body: &str) -> Result<Cache
|
||||||
/// Build the token request: `client_credentials`, authenticated with HTTP
|
/// Build the token request: `client_credentials`, authenticated with HTTP
|
||||||
/// Basic.
|
/// Basic.
|
||||||
///
|
///
|
||||||
/// 🩸 **The credentials go in the `Authorization` header, not the form
|
/// 🩸 **The credentials go in the `Authorization` header, not the form body.**
|
||||||
/// body.** A client registration names one flavor, and authelia's default
|
/// Both are legal OAuth 2.0 — `client_secret_basic` and `client_secret_post` —
|
||||||
/// (and ours) is Basic — the other one once produced an hour of misleading
|
/// but a client registration names *one*, and authelia's default (and ours) is
|
||||||
/// `429`s before the real cause (every request refused at the auth step)
|
/// Basic. Sending them in the body got every token request refused with
|
||||||
/// surfaced. `audience` and `scope` are both opt-in, RFC 8707 /
|
/// `Client authentication failed … the registered client is configured to only
|
||||||
/// registered-scope territory; `None` for either reproduces the request
|
/// support 'client_secret_basic'`, which reached the operator as an endless
|
||||||
/// this crate sent before the parameter existed. See the crate README's
|
/// `429` because the retries tripped a rate limiter whose penalty grew faster
|
||||||
/// "Token request shape" section for the incident, the RFC citations, and
|
/// than the retry interval. The 429 then arrived *before* the credentials were
|
||||||
/// why each parameter exists.
|
/// ever evaluated, so the one line naming the real cause appeared once an hour.
|
||||||
|
///
|
||||||
|
/// RFC 6749 §2.3.1 says clients SHOULD use Basic, both introspection callers in
|
||||||
|
/// this workspace already do, and a secret in a header is one fewer place for a
|
||||||
|
/// proxy to log it.
|
||||||
///
|
///
|
||||||
/// 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>,
|
audience: Option<&str>,
|
||||||
scope: Option<&str>,
|
|
||||||
) -> reqwest::RequestBuilder {
|
) -> reqwest::RequestBuilder {
|
||||||
let mut form = vec![("grant_type", "client_credentials")];
|
let mut form = vec![("grant_type", "client_credentials")];
|
||||||
if let Some(audience) = audience {
|
if let Some(audience) = audience {
|
||||||
form.push(("audience", audience));
|
form.push(("audience", audience));
|
||||||
}
|
}
|
||||||
if let Some(scope) = scope {
|
|
||||||
form.push(("scope", scope));
|
|
||||||
}
|
|
||||||
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(&form)
|
.form(&form)
|
||||||
|
|
@ -533,19 +542,15 @@ fn build_http_client(cfg: &QueueConfig) -> Result<reqwest::Client, Error> {
|
||||||
/// 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.
|
||||||
///
|
///
|
||||||
/// `audience` and `scope` are passed straight to the private `token_request`
|
/// `audience` is passed straight to the private `token_request` helper —
|
||||||
/// helper — see its doc for why each is optional and when a caller needs it.
|
/// see its doc for why it is optional and when a caller needs it. `None`
|
||||||
/// `None` for both reproduces this
|
/// reproduces this
|
||||||
/// function's behaviour before the parameters existed, so every caller from
|
/// function's behaviour before the parameter existed, so every caller from
|
||||||
/// before they were added (the queue connect path, `auth.rs`'s bridge client)
|
/// before that added it (the queue connect path, `auth.rs`'s bridge client)
|
||||||
/// is unaffected.
|
/// is unaffected.
|
||||||
pub async fn mint_token_for(
|
pub async fn mint_token_for(cfg: &QueueConfig, audience: Option<&str>) -> Result<String, Error> {
|
||||||
cfg: &QueueConfig,
|
|
||||||
audience: Option<&str>,
|
|
||||||
scope: Option<&str>,
|
|
||||||
) -> Result<String, Error> {
|
|
||||||
let http = build_http_client(cfg)?;
|
let http = build_http_client(cfg)?;
|
||||||
Ok(mint_token(&http, cfg, audience, scope).await?.token)
|
Ok(mint_token(&http, cfg, audience).await?.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blocking sibling of [`mint_token_for`], for a caller with no tokio
|
/// Blocking sibling of [`mint_token_for`], for a caller with no tokio
|
||||||
|
|
@ -562,11 +567,7 @@ pub async fn mint_token_for(
|
||||||
///
|
///
|
||||||
/// Same request shape and same no-caching behaviour as [`mint_token_for`] —
|
/// 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.
|
/// see that function's doc for why both of those are the right call here.
|
||||||
pub fn mint_token_for_blocking(
|
pub fn mint_token_for_blocking(cfg: &QueueConfig, audience: Option<&str>) -> Result<String, Error> {
|
||||||
cfg: &QueueConfig,
|
|
||||||
audience: Option<&str>,
|
|
||||||
scope: Option<&str>,
|
|
||||||
) -> Result<String, Error> {
|
|
||||||
let http = build_blocking_http_client(cfg)?;
|
let http = build_blocking_http_client(cfg)?;
|
||||||
|
|
||||||
// Read per call rather than caching — see `mint_token`'s identical
|
// Read per call rather than caching — see `mint_token`'s identical
|
||||||
|
|
@ -582,9 +583,6 @@ pub fn mint_token_for_blocking(
|
||||||
if let Some(audience) = audience {
|
if let Some(audience) = audience {
|
||||||
form.push(("audience", audience));
|
form.push(("audience", audience));
|
||||||
}
|
}
|
||||||
if let Some(scope) = scope {
|
|
||||||
form.push(("scope", scope));
|
|
||||||
}
|
|
||||||
let response = http
|
let response = http
|
||||||
.post(&cfg.token_endpoint)
|
.post(&cfg.token_endpoint)
|
||||||
.basic_auth(&cfg.client_id, Some(secret.trim()))
|
.basic_auth(&cfg.client_id, Some(secret.trim()))
|
||||||
|
|
@ -704,7 +702,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, None, None)
|
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
|
||||||
|
|
@ -848,7 +846,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", None, None)
|
let req = token_request(&offline_client(), &token_cfg(), "s3cret", None)
|
||||||
.build()
|
.build()
|
||||||
.expect("the token request must build");
|
.expect("the token request must build");
|
||||||
|
|
||||||
|
|
@ -885,12 +883,12 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The queue/bridge shape (`audience: None`, `scope: None`) must stay
|
/// The queue/bridge shape (`audience: None`) must stay unchanged by the
|
||||||
/// unchanged by the parameters' addition — no `audience` field appears
|
/// parameter's addition — no `audience` field appears in the body at
|
||||||
/// in the body at all, not even empty.
|
/// all, not even empty.
|
||||||
#[test]
|
#[test]
|
||||||
fn no_audience_means_no_audience_field() {
|
fn no_audience_means_no_audience_field() {
|
||||||
let req = token_request(&offline_client(), &token_cfg(), "s3cret", None, None)
|
let req = token_request(&offline_client(), &token_cfg(), "s3cret", None)
|
||||||
.build()
|
.build()
|
||||||
.expect("the token request must build");
|
.expect("the token request must build");
|
||||||
let body = std::str::from_utf8(
|
let body = std::str::from_utf8(
|
||||||
|
|
@ -915,7 +913,6 @@ mod tests {
|
||||||
&token_cfg(),
|
&token_cfg(),
|
||||||
"s3cret",
|
"s3cret",
|
||||||
Some("https://otel.example/swarm"),
|
Some("https://otel.example/swarm"),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
.expect("the token request must build");
|
.expect("the token request must build");
|
||||||
|
|
@ -930,50 +927,4 @@ mod tests {
|
||||||
"the requested audience must reach the form body, got: {body}"
|
"the requested audience must reach the form body, got: {body}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No scope asked for, no `scope` field — the queue connection and the
|
|
||||||
/// OTLP push both mint this way, and a `scope=` authelia has no mapping
|
|
||||||
/// for is refused rather than ignored.
|
|
||||||
#[test]
|
|
||||||
fn no_scope_means_no_scope_field() {
|
|
||||||
let req = token_request(&offline_client(), &token_cfg(), "s3cret", None, 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("scope"),
|
|
||||||
"omitting the scope must not even send an empty field, got: {body}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The bug behind the log store's 401: a client REGISTERED for
|
|
||||||
/// `authelia.bearer.authz` still receives a token carrying no scope
|
|
||||||
/// unless the request asks, and authelia's authz endpoint refuses that.
|
|
||||||
#[test]
|
|
||||||
fn a_scope_is_sent_verbatim() {
|
|
||||||
let req = token_request(
|
|
||||||
&offline_client(),
|
|
||||||
&token_cfg(),
|
|
||||||
"s3cret",
|
|
||||||
Some("https://logs.example/select/logsql/query"),
|
|
||||||
Some("authelia.bearer.authz"),
|
|
||||||
)
|
|
||||||
.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("scope=authelia.bearer.authz"),
|
|
||||||
"the requested scope must reach the form body, got: {body}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue