feat(auth): verify Cloudflare Access tokens for Caddy forward auth

This commit is contained in:
lda committed 2026-10-13 01:11:04 +07:00 Verified
1 parent 4e290e6de8
commit e74b3350f7
13 files changed
+1216 -15

No files matched your search

Generated
+88 -2
View File
@@ -142,6 +142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
"zeroize",
]
@@ -1013,6 +1014,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-cache-semantics"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0603b99309a1e404c2c0945650c0f775b50bb72ac90ae570cb93f9ff05d9efe7"
dependencies = [
"http",
"reqwest",
"time",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
@@ -1391,6 +1403,24 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "10.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
dependencies = [
"aws-lc-rs",
"base64",
"getrandom 0.2.17",
"js-sys",
"pem",
"serde",
"serde_json",
"signature",
"simple_asn1",
"zeroize",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1653,6 +1683,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -1854,6 +1894,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -2278,7 +2328,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"untrusted 0.9.0",
"windows-sys 0.52.0",
]
@@ -2417,7 +2467,7 @@ dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
"untrusted 0.9.0",
]
[[package]]
@@ -2713,6 +2763,18 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -3575,6 +3637,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -3704,15 +3772,19 @@ dependencies = [
"axum",
"clap",
"futures-util",
"http-cache-semantics",
"humantime-serde",
"jsonwebtoken",
"macaddr",
"nix 0.31.2",
"reqwest",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.18",
"tokio",
"toml",
"tower",
"tower-http",
"tracing",
"uuid",
@@ -4517,6 +4589,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerotrie"
+9 -8
View File
@@ -2,9 +2,11 @@
#
# Security model:
# - Public endpoints for agents: health, enrollment, control WS, and scoped terminal relay WS
# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers)
# - Private admin surface: root, /ui/* and /api/v1/control/* (verified Access JWT)
# Configure CC's [access] section and loopback bind before enabling this template.
# Never expose /internal/auth/verify through this virtual host.
#
# Replace cp.example.com with your public control-plane domain.
# Replace wakey.ldlda.com and the loopback upstream port for your deployment.
wakey.ldlda.com {
encode zstd gzip
@@ -15,15 +17,14 @@ wakey.ldlda.com {
reverse_proxy 127.0.0.1:6767
}
# Admin surface requires Cloudflare Access headers.
@admin path /ui* /api/v1/control/*
@cf_access header_regexp CFJWT Cf-Access-Jwt-Assertion .+
# Applies to HTTP requests and operator WebSocket upgrades, not WS frames.
@admin path / /ui* /api/v1/control/*
handle @admin {
handle @cf_access {
reverse_proxy 127.0.0.1:6767
forward_auth 127.0.0.1:6767 {
uri /internal/auth/verify
}
respond "forbidden" 403
reverse_proxy 127.0.0.1:6767
}
# Deny unknown paths by default.
+118
View File
@@ -0,0 +1,118 @@
# Origin Authentication With Caddy
Cloudflare Access protects the operator surface at the edge. An origin reachable
directly must also verify Access tokens: a nonempty assertion header is not
authentication. A read-only probe through `mails.ldlda.com`, preserving Wakey's
TLS hostname and HTTP host, returned 403 without a header and 200 with a forged
header on the same origin IP. Do not retain the old header-presence matcher.
## Configure CC
Add this to the deployed CC configuration, using your protected application's
settings, not the bypass application's audience:
```toml
bind = "127.0.0.1:23501"
[access]
issuer = "https://ldlda.cloudflareaccess.com"
audience = "0c8ca0bce40a48ddc5bc065cea8390eaddd75a84458f147bbe249fcc57c3d781"
# Optional; defaults to the issuer's /cdn-cgi/access/certs endpoint.
# jwks_url = "https://ldlda.cloudflareaccess.com/cdn-cgi/access/certs"
```
Issuer and key URLs must use HTTPS without user credentials, query or fragment.
The issuer is an origin without a trailing slash; it must exactly match the JWT
issuer. The audience is not a secret. URLs come only from trusted configuration,
never from an incoming token. `init-config --from-config` preserves `[access]`.
CC requires a loopback bind when `[access]` is configured. With no `[access]`,
the verifier returns 401; existing local development routes remain unchanged.
Changes to Access configuration require a daemon restart, not a state reload.
## Configure Caddy
Use [the Caddy template](../deploy/control-plane.Caddyfile), replacing the domain
and every upstream port with your deployment values. Its protected handler calls
CC's `GET /internal/auth/verify` through `forward_auth`, then proxies the original
request only on success. No additional service or custom Caddy module is needed.
Protect `/`, `/ui*` and `/api/v1/control/*`. This includes operator WebSocket
handshakes; established WebSocket frames are not individually authenticated and
token expiry does not automatically disconnect an existing WebSocket.
Keep `/healthz`, `/api/v1/agents/enroll`, `/api/v1/agent/ws` and
`/api/v1/agent/terminals/*` outside Access verification. They retain Wakey's own
agent/scoped-relay authentication. Cloudflare's bypass application should cover
these paths; terminal child paths must be included. If using HTTP ACME challenges,
ensure `/.well-known/acme-challenge/*` is exempt at Cloudflare; Caddy handles its
own active challenges, not CC.
Do not proxy `/internal/auth/verify` publicly and do not put `forward_auth` in
front of the verifier itself. Do not replace verification with a static header
that Caddy adds to every request. Caddy owns enforcement: direct access to the
CC port would bypass it, so loopback binding and host access controls matter.
## Verification And Cache
The verifier accepts only RS256, verifies signatures, and requires matching
issuer/audience plus expiry. A future `nbf` also rejects the token. No clock-skew
leeway is applied; keep the host clock synchronized. Duplicate assertion headers
and assertions over 16 KiB are rejected. No tokens, claims or upstream bodies
are logged.
Responses have no identity payload:
| Status | Meaning |
| --- | --- |
| 204 | Valid protected-application token |
| 401 | Missing/invalid token, unknown key after successful key retrieval, or unconfigured verifier |
| 503 | Required signing keys cannot be obtained safely |
CC reuses one HTTP client and stores parsed keys with their HTTP cache policy.
`http-cache-semantics` determines freshness from the issuer's `Cache-Control`,
`Age`, `Date` and `Expires`, rather than a built-in key TTL. For example,
`public, max-age=14400, must-revalidate, no-transform` permits four hours of
freshness minus the response's existing age; `must-revalidate` forbids stale
reuse, not every fresh reuse. No heuristic lifetime is invented when freshness
is unspecified. A `no-cache` response requires revalidation before reuse;
`no-store` retains neither keys nor policy after serving the fetching request.
Fresh matching keys verify locally. Stale policies produce conditional requests
using ETag or Last-Modified when available. A matching 304 renews the policy and
reuses parsed keys without downloading/parsing a body; mismatched or unsolicited
304 responses fail closed. An unknown key ID forces conditional revalidation
even when the stored response remains fresh, to discover rotation.
Fetches are single-flight and limited to once per minute, including failures.
Rotation immediately after a fetch may therefore take up to a minute to become
available. If the issuer requires revalidation on every use (or allows no
storage), subsequent callers inside that cooldown receive 503 rather than reuse
keys contrary to the HTTP policy. Successful 200 refresh replaces the entire
set, including removing all keys for a valid empty set. Failed refresh does not
extend freshness: matching keys may be used only while the stored policy still
permits reuse. There is no indefinitely stale fallback, including on outages.
Fetches have a five-second timeout, no redirects, a 256 KiB body limit and at
most 64 keys. These limits are internal safeguards, not new config knobs.
## Rollout
1. Install the new CC binary, add `[access]`, and restart CC. Check the loopback
verifier returns 401 for an absent or forged assertion.
2. Update Caddy from the template and validate its configuration before reload.
The repo template uses port 6767; this deployment uses 23501.
3. Confirm a real Access login can use the UI/control APIs and operator terminal
WebSocket. Confirm agent connections and enrollment still work.
4. Repeat a direct-origin request with Wakey's Host/SNI. Both absent and forged
assertions must return 401, not the UI. Verify the internal endpoint itself
returns 404 through the public virtual host.
Do not roll back to header-only acceptance on a verification failure. A UI
build or a CC restart alone does not update the deployed Caddy policy.
References: [Cloudflare token validation](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/)
and [Caddy forward_auth](https://caddyserver.com/docs/caddyfile/directives/forward_auth).
HTTP policy is provided by [http-cache-semantics](https://docs.rs/http-cache-semantics/latest/http_cache_semantics/struct.CachePolicy.html);
only the single-resource storage, bounded fetch and anti-hammering policy are
application-owned. No general-purpose disk or Moka response cache is needed.
+5 -4
View File
@@ -89,10 +89,11 @@ firmware actually exposes before making any of them an agent requirement.
## Origin Authentication
- [ ] Replace Caddy's nonempty `Cf-Access-Jwt-Assertion` matcher: header presence does not authenticate a request that reaches the origin directly. The `lb.ldlda.com` probe reached Cloudflare and redirected to Access login, so a direct-origin bypass was not reproduced; the matcher remains insufficient.
- [ ] Add a CC-owned, loopback-accessible `/internal/auth/verify` endpoint for Caddy `forward_auth`. Validate the Access JWT signature, permitted algorithm, exact team issuer, protected Wakey application AUD and expiry using a standard JWT library. Cache signing keys with bounded fetches/timeouts and rate-limited refreshes; arbitrary token key IDs must not trigger unlimited upstream requests. Return 204 for valid credentials, 401 for missing/invalid credentials, and 503 when verification cannot be completed safely. Never log tokens.
- [ ] Apply `forward_auth` to `/`, `/ui*` and `/api/v1/control/*`, including operator WebSocket handshakes. Keep health, agent enrollment/WS/terminal relay and ACME challenge paths outside Access verification; agent endpoints retain their own authentication. Do not accept the bypass application's AUD as an operator credential or expose the internal verifier through public Caddy routes. Keep CC bound to loopback: Caddy owns enforcement, so direct CC access would bypass it.
- [ ] Test forged/expired/wrong-audience tokens, signing-key rotation and unavailable key retrieval, protected HTTP/WS rejection, and unaffected public agent routes before deployment. Validate the Caddy configuration and test a real direct-origin connection as well as the Cloudflare-facing hostname. Authentication failures must never fall back to header-only acceptance.
- [x] Replace the nonempty assertion matcher in the Caddy template with `forward_auth`. The initial `lb.ldlda.com` probe reached Cloudflare; the later `mails.ldlda.com` probe reached an origin and confirmed 403 without an assertion versus 200 with a forged assertion on the same IP. This is a reproduced bypass, not just a theoretical concern; production rollout remains required.
- [x] Add CC's `/internal/auth/verify` endpoint with configured issuer/audience/key URL, RS256 signature and required claim validation, bounded cached key retrieval, single-flight refresh and outage cooldown. Return 204/401/503 without exposing tokens or identity data. Configured verification requires loopback binding, and `init-config --from-config` preserves it. See [origin authentication](origin-auth.md).
- [x] Cover forged/expired/wrong-audience/missing-claim tokens, key rotation/removal (including an empty set), duplicate assertions, unavailable/malformed/oversized key responses, cooldown and concurrent fetches. Router coverage verifies the oracle is separate from public/control handlers; actual Caddy enforcement is deployment-owned and still requires validation.
- [x] Use `http-cache-semantics` for issuer-supplied HTTP freshness instead of a hardcoded signing-key TTL. Preserve bounded streaming reads, force revalidation on unknown key IDs, and handle ETag/Last-Modified 304 responses without reparsing a body. Tests cover fresh reuse, Age, no-cache/no-store, unspecified freshness, conditional rotation and invalid 304 rejection; no sleeps required.
- [ ] Deploy the new CC verifier and Caddy policy for root/UI/control HTTP and operator WS handshakes. Keep public agent paths outside Access checks and the verifier unexposed publicly. Validate Caddy configuration, real Access login, agent connectivity, protected WebSocket rejection, and forged-header direct-origin rejection. Authentication failures must never fall back to header-only acceptance.
## Multi-CC Planning
+7
View File
@@ -10,6 +10,8 @@ axum = { version = "0.8", features = ["ws", "json"] }
clap = { version = "4", features = ["derive"] }
futures-util = "0.3"
humantime-serde = "1"
http-cache-semantics = { version = "3", default-features = false, features = ["reqwest"] }
jsonwebtoken = { version = "10.3", default-features = false, features = ["aws_lc_rs"] }
macaddr = { workspace = true }
nix = { version = "0.31", default-features = false, features = [
"signal",
@@ -21,6 +23,7 @@ reqwest = { version = "0.13", default-features = false, features = [
] }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = "2"
sqlx = { version = "0.8", features = [
"runtime-tokio",
@@ -40,3 +43,7 @@ wakey-core = { path = "../wakey-core", registry = "gitea", version = "0", featur
[target.'cfg(target_os = "linux")'.dependencies]
wakey-linux = { path = "../wakey-linux", registry = "gitea", version = "0", features = ["telemetry"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
jsonwebtoken = { version = "10.3", default-features = false, features = ["use_pem", "aws_lc_rs"] }
+14
View File
@@ -107,6 +107,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
super::resolve::validate_device_retention(device_retention)?;
let body = FileConfig {
access: base.access,
data_dir: Some(data_dir),
bind: Some(bind.to_string()),
public_url: Some(public_url),
@@ -245,6 +246,11 @@ pid_file = "old.pid"
ui_dist_dir = "/opt/wakey/ui"
bootstrap_enroll_tokens = ["old-token"]
[access]
issuer = "https://access.example"
audience = "protected-app"
jwks_url = "https://keys.example/custom"
[telemetry]
otlp_endpoint = "http://otel.example"
authorization_env = "WAKEY_OTLP_AUTHORIZATION"
@@ -293,6 +299,14 @@ json_logs = true
);
assert!(parsed.get("command_timeout_ms").is_none());
assert_eq!(converted.device_retention, Some(std::time::Duration::ZERO));
assert_eq!(
converted.access,
Some(crate::config::AccessConfig {
issuer: "https://access.example".into(),
audience: "protected-app".into(),
jwks_url: Some("https://keys.example/custom".into()),
})
);
assert!(parsed.get("enroll_token_ttl_seconds").is_none());
assert_eq!(
parsed["state_file"].as_str(),
+1 -1
View File
@@ -8,4 +8,4 @@ pub use resolve::{
resolve_revoke_agent_settings, resolve_revoke_enroll_token_settings,
resolve_state_stats_settings,
};
pub use types::{DaemonConfig, TelemetryConfig};
pub use types::{AccessConfig, DaemonConfig, TelemetryConfig};
@@ -105,6 +105,7 @@ impl DaemonConfig {
ui_dist_dir,
bootstrap_enroll_tokens,
telemetry,
access: file.access,
})
}
}
+13
View File
@@ -22,6 +22,17 @@ pub struct DaemonConfig {
pub ui_dist_dir: PathBuf,
pub bootstrap_enroll_tokens: Vec<String>,
pub telemetry: TelemetryConfig,
pub access: Option<AccessConfig>,
}
/// Trusted origin-verifier settings; no URL is taken from a JWT header or claim.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AccessConfig {
pub issuer: String,
pub audience: String,
/// Omission uses the Cloudflare Access certs path beneath the issuer.
pub jwks_url: Option<String>,
}
/// TOML DTO for both loading partial files and generating complete files.
@@ -46,6 +57,7 @@ pub(crate) struct FileConfig {
#[serde(alias = "enroll_tokens")]
pub(crate) bootstrap_enroll_tokens: Option<Vec<String>>,
pub(crate) telemetry: Option<FileTelemetryConfig>,
pub(crate) access: Option<AccessConfig>,
}
pub(crate) const DURATION_KEYS: &[LegacyDuration] = &[
@@ -103,6 +115,7 @@ pub(crate) static DEFAULT_CONFIG: LazyLock<DaemonConfig> = LazyLock::new(|| Daem
pid_file: "wakey-control-plane.pid".into(),
ui_dist_dir: "ui/dist".into(),
bootstrap_enroll_tokens: Vec::new(),
access: None,
telemetry: TelemetryConfig {
console_filter: None,
otlp_log_filter: None,
+337
View File
@@ -0,0 +1,337 @@
//! Origin authentication oracle for Caddy, not middleware on the control API.
//! Enforcement belongs to the proxy; the daemon must remain loopback-only.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result, ensure};
use axum::{
Extension, Router,
http::{HeaderMap, StatusCode},
routing::get,
};
use http_cache_semantics::{AfterResponse, BeforeRequest, CacheOptions, CachePolicy};
use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse};
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
use reqwest::{Client, Url};
use tokio::sync::Mutex;
use tokio::time::Instant;
use super::AppState;
use crate::config::AccessConfig;
const REFRESH_COOLDOWN: Duration = Duration::from_secs(60);
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_JWKS_BYTES: usize = 256 * 1024;
const MAX_TOKEN_BYTES: usize = 16 * 1024;
const MAX_KEYS: usize = 64;
// This is a private, single-resource cache. Do not invent a freshness lifetime
// when the issuer supplies none, or override its no-cache/no-store directives.
const CACHE_OPTIONS: CacheOptions = CacheOptions {
shared: false,
cache_heuristic: 0.0,
immutable_min_time_to_live: Duration::ZERO,
ignore_cargo_cult: false,
};
/// Absent configuration deliberately leaves the oracle denying every request.
pub(super) fn routes(config: Option<&AccessConfig>, bind: SocketAddr) -> Result<Router<AppState>> {
let verifier = config
.map(|config| {
ensure!(
bind.ip().is_loopback(),
"Access verification requires a loopback bind; Caddy owns enforcement"
);
Verifier::new(config).map(Arc::new)
})
.transpose()?;
Ok(verifier_routes(verifier))
}
fn verifier_routes<S: Clone + Send + Sync + 'static>(verifier: Option<Arc<Verifier>>) -> Router<S> {
Router::new()
.route("/internal/auth/verify", get(verify))
.layer(Extension(verifier))
}
async fn verify(
Extension(verifier): Extension<Option<Arc<Verifier>>>,
headers: HeaderMap,
) -> StatusCode {
let Some(verifier) = verifier else {
return StatusCode::UNAUTHORIZED;
};
let mut values = headers.get_all("cf-access-jwt-assertion").iter();
let Some(token) = values.next().and_then(|value| value.to_str().ok()) else {
return StatusCode::UNAUTHORIZED;
};
if values.next().is_some() {
return StatusCode::UNAUTHORIZED;
}
match verifier.validate(token).await {
Ok(()) => StatusCode::NO_CONTENT,
Err(VerifyError::Invalid) => StatusCode::UNAUTHORIZED,
Err(VerifyError::Unavailable) => StatusCode::SERVICE_UNAVAILABLE,
}
}
struct Verifier {
client: Client,
jwks_url: Url,
validation: Validation,
cache: Mutex<KeyCache>,
}
#[derive(Default)]
struct KeyCache {
keys: HashMap<String, DecodingKey>,
policy: Option<CachePolicy>,
attempted_at: Option<Instant>,
last_fetch_failed: bool,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
enum VerifyError {
#[error("invalid Access assertion")]
Invalid,
#[error("Access signing keys unavailable")]
Unavailable,
}
impl Verifier {
fn new(config: &AccessConfig) -> Result<Self> {
let issuer = trusted_url(&config.issuer)?;
ensure!(
issuer.path() == "/" && !config.issuer.ends_with('/'),
"Access issuer must be an HTTPS origin without a trailing slash"
);
ensure!(
!config.audience.trim().is_empty(),
"Access audience must not be empty"
);
let jwks_url = match &config.jwks_url {
Some(url) => trusted_url(url)?,
None => issuer.join("/cdn-cgi/access/certs")?,
};
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[&config.issuer]);
validation.set_audience(&[&config.audience]);
validation.set_required_spec_claims(&["exp", "iss", "aud"]);
validation.validate_nbf = true;
validation.leeway = 0;
Ok(Self {
client: Client::builder()
.timeout(FETCH_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()?,
jwks_url,
validation,
cache: Mutex::new(KeyCache::default()),
})
}
async fn validate(&self, token: &str) -> Result<(), VerifyError> {
if token.len() > MAX_TOKEN_BYTES {
return Err(VerifyError::Invalid);
}
// Only kid selects a cached key. Token-supplied URLs never drive I/O.
let header = decode_header(token).map_err(|_| VerifyError::Invalid)?;
if header.alg != Algorithm::RS256 {
return Err(VerifyError::Invalid);
}
let kid = header
.kid
.filter(|kid| !kid.is_empty() && kid.len() <= 256)
.ok_or(VerifyError::Invalid)?;
let key = self.key(&kid).await?;
decode::<serde_json::Value>(token, &key, &self.validation)
.map(|_| ())
.map_err(|_| VerifyError::Invalid)
}
async fn key(&self, kid: &str) -> Result<DecodingKey, VerifyError> {
// Single-flight refresh with a bounded HTTP fetch. The cooldown is
// global, not an attacker-controlled per-kid cache, including outages.
let mut cache = self.cache.lock().await;
let mut request = self
.client
.get(self.jwks_url.clone())
.build()
.map_err(|_| VerifyError::Unavailable)?;
let now = Instant::now();
let fresh = cache.policy.as_ref().is_some_and(|policy| {
matches!(
policy.before_request(&request, SystemTime::now()),
BeforeRequest::Fresh(_)
)
});
if fresh && let Some(key) = cache.keys.get(kid) {
return Ok(key.clone());
}
if cache
.attempted_at
.is_none_or(|at| now.duration_since(at) >= REFRESH_COOLDOWN)
{
cache.attempted_at = Some(now);
// A missing kid must bypass a still-fresh response cache to notice
// rotation. The HTTP library adds ETag/Last-Modified validators.
request.headers_mut().insert(
reqwest::header::CACHE_CONTROL,
reqwest::header::HeaderValue::from_static("no-cache"),
);
if let Some(policy) = &cache.policy
&& let BeforeRequest::Stale {
request: conditional,
..
} = policy.before_request(&request, SystemTime::now())
{
*request.headers_mut() = conditional.headers;
}
match self.fetch_keys(&request, &cache).await {
Ok((keys, policy)) => {
let selected = keys.get(kid).cloned().ok_or(VerifyError::Invalid);
if policy.is_storable() {
cache.keys = keys;
cache.policy = Some(policy);
} else {
// no-store permits this response's use for this request,
// but neither keys nor HTTP metadata may be retained.
cache.keys.clear();
cache.policy = None;
}
cache.last_fetch_failed = false;
return selected;
}
Err(_) => {
// Never include upstream bodies, tokens or claims in logs.
tracing::warn!(
"Access signing-key refresh failed; verification fails closed when no fresh matching key is available"
);
cache.last_fetch_failed = true;
}
}
}
if !fresh || cache.last_fetch_failed {
Err(VerifyError::Unavailable)
} else {
Err(VerifyError::Invalid)
}
}
async fn fetch_keys(
&self,
request: &reqwest::Request,
cached: &KeyCache,
) -> Result<(HashMap<String, DecodingKey>, CachePolicy)> {
let mut response = self
.client
.execute(
request
.try_clone()
.context("JWKS request cannot be cloned")?,
)
.await?
.error_for_status()?;
let received_at = SystemTime::now();
if response.status() == StatusCode::NOT_MODIFIED {
ensure!(
request
.headers()
.contains_key(reqwest::header::IF_NONE_MATCH)
|| request
.headers()
.contains_key(reqwest::header::IF_MODIFIED_SINCE),
"unsolicited JWKS 304"
);
let policy = cached
.policy
.as_ref()
.context("JWKS 304 without stored keys")?;
return match policy.after_response(request, &response, received_at) {
AfterResponse::NotModified(policy, _) => Ok((cached.keys.clone(), policy)),
AfterResponse::Modified(_, _) => {
anyhow::bail!("JWKS 304 did not match stored validators")
}
};
}
ensure!(
response.status() == StatusCode::OK,
"JWKS must return 200 or a matching 304"
);
let policy = CachePolicy::new_options(request, &response, received_at, CACHE_OPTIONS);
ensure!(
response
.content_length()
.is_none_or(|len| len <= MAX_JWKS_BYTES as u64),
"JWKS too large"
);
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await? {
ensure!(chunk.len() <= MAX_JWKS_BYTES - body.len(), "JWKS too large");
body.extend_from_slice(&chunk);
}
Ok((parse_keys(&body)?, policy))
}
}
fn trusted_url(value: &str) -> Result<Url> {
let url = Url::parse(value).context("invalid Access URL")?;
ensure!(
url.scheme() == "https"
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
"Access URLs require HTTPS without credentials, query or fragment"
);
Ok(url)
}
fn parse_keys(body: &[u8]) -> Result<HashMap<String, DecodingKey>> {
let jwks: JwkSet = serde_json::from_slice(body)?;
ensure!(jwks.keys.len() <= MAX_KEYS, "too many signing keys");
let mut keys = HashMap::new();
for jwk in jwks.keys {
if !matches!(jwk.algorithm, AlgorithmParameters::RSA(_))
|| jwk
.common
.key_algorithm
.is_some_and(|alg| alg != KeyAlgorithm::RS256)
|| jwk
.common
.public_key_use
.as_ref()
.is_some_and(|usage| *usage != PublicKeyUse::Signature)
|| jwk
.common
.key_operations
.as_ref()
.is_some_and(|ops| !ops.contains(&KeyOperations::Verify))
{
continue;
}
let Some(kid) = jwk
.common
.key_id
.as_ref()
.filter(|kid| !kid.is_empty() && kid.len() <= 256)
else {
continue;
};
let key = DecodingKey::from_jwk(&jwk)?;
ensure!(
keys.insert(kid.clone(), key).is_none(),
"duplicate signing key ID"
);
}
// A valid empty set revokes every cached key. Do not treat it as a fetch
// failure and preserve keys that the issuer has deliberately removed.
Ok(keys)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpN0wYAVcYDhNr
MNgF22jtk0uiZZQQl4K+uM1S3ivY3sQDjZj3y6icwR5MhYHOtCW2+EY/2IctpEcd
/3li5OOna2RKDdTpshw/mE3Q4NYZyk8JCAoIY+J9yzQsBdTjKbSUu3tMFxQb89ap
baAooZ5TpnZ3ZVZUN1SQjYq5my8sZAkO9JOOMHnPx/5Ba5Z4IT9DX8hzlgG+e1F3
lW3XpjTt3xgGp3ZWNUU6hA2dnwHrkvmy91U1ojC0Bow2ySL0hbgHnFwFNpsx5wFs
Mum4/LI+ZA9yaWKdbPKXx7W0g+x0ihqyq/K00z/KE4zIIENS+hNv/Ko7nMB0S/s2
gkdmgiEjAgMBAAECggEAE0olR6lDczpg4VEYlxZWGvBgaLCj/9OIXn3KPijrHlZK
nvhnGHxOtMvY9zJ9mSwfuStf4AsADJETtao3/VuH+L+nwj9bEZGHDILWqrnZU5L5
49bW70eU+++2+1EQ3ce/zk1O9rNnd0ZFJrGYq85nXN7MK2Z8awOXBWwf2nJZHs9Q
QYM+LFiLDqkT+sTGIwV8mNwVN+S4tKEyxPWuFmGU649nQL6LAy/NJKtH+6C7feEa
pdIoUZ6TT9QvR2kVC5XTaDeLGf1uwgzFBkB9hZoFMBzh8Bx1k0p9WkA1ViJkSzXc
T38deIES5RUid0/GVxOKZkuYk7jnoLWMxK8T5SzeWQKBgQDg3VOyPy0ykD+YtgbL
4DWrBW/kregMfximpESEL7yP+XAKv6vYox4oGDncoX7g48C6K6c1kSdlGb0We1nA
md+TraIQjDWv9Zyfr7FeiD8739xJGz8T7Y0TU7sdd1EYz5fUkURLqzkX9Tpy4+Ia
mXSUH4jfyjMWVghhe9E2rJmUawKBgQDApWvHFIp2nWDvErE/xPQnMCTxc3ndGbfi
1CszYlQcm+C57KhMprWUbDSXrGzqlqn75npdIsXXbY1SORb44antwHzofJyhE9aA
RGPDKtym9d8/+zy7DtYfw7IlIDZtXFpgCQPtKPXVZ0v3gE0/kYfeRcoyyQnsT8jd
MqiX0zMUKQKBgEf+tQhx78+rnaEQTrbB74z1LbEi+RPfMY5RM9q6yf2y2GuomKXA
/CvMLeJ1FoF8ij1RMrUU528YQZFMJx1iZtIKmsP/kETpN59tCjYBG3XZI9eIULbZ
rPyOJ2+0vxNZi/w0EpDDnoEMNA2K5t6rWUb/nbxBKgAxxXcoxIBj3LxHAoGAJYFP
DVATcOpMz96MwYR/oA2Ac71ukTwLj86MppFTHMrAIpJPZbTjDwVN6+V2wukYKOGD
GxTE/Z7nb4WL0mJkqdPavYxdFgL9tLUb0whKMvsVPqiT9zr5Nu/rg8zUq8/aDG5U
5FwgGC7nHEVPRLv5fq2Jov9+vhH+YgoVxpEolZkCgYAgZ+PdXcm1XkwioHlBCfOH
ilXAaubbb1SZtrodabplqut8ox9oq9RL/Fa549IPIB5e+SSAU3S/al21/0+bxs8C
QyoWII8ayb7vgZ46TBuaaZVT4Oxd83GQgc4Az91m7UZIgxhbS/3os1tqXJqQKujt
Wy7tHJh9RC/z2+kFSog3tA==
-----END PRIVATE KEY-----
@@ -0,0 +1,590 @@
use super::*;
use axum::body::Body;
use axum::http::{HeaderValue, Request};
use jsonwebtoken::jwk::Jwk;
use jsonwebtoken::{EncodingKey, Header, encode};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::RwLock;
use tower::ServiceExt;
// Public test-only private key, never used as a deployment credential.
const TEST_KEY: &[u8] = include_bytes!("test-key.pem");
const ISSUER: &str = "https://access.example";
const AUDIENCE: &str = "wakey-test";
fn config() -> AccessConfig {
AccessConfig {
issuer: ISSUER.into(),
audience: AUDIENCE.into(),
jwks_url: None,
}
}
fn encoding_key() -> EncodingKey {
EncodingKey::from_rsa_pem(TEST_KEY).unwrap()
}
fn jwks(kid: &str) -> Value {
let mut jwk = Jwk::from_encoding_key(&encoding_key(), Algorithm::RS256).unwrap();
jwk.common.key_id = Some(kid.into());
json!({"keys": [jwk]})
}
fn claims() -> Value {
// Fixed dates make boundary tests independent of scheduling delays.
json!({"iss": ISSUER, "aud": [AUDIENCE], "exp": 4102444800u64})
}
fn token(kid: &str, claims: &Value) -> String {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some(kid.into());
encode(&header, claims, &encoding_key()).unwrap()
}
async fn cached() -> Arc<Verifier> {
let verifier = Arc::new(Verifier::new(&config()).unwrap());
let mut cache = verifier.cache.lock().await;
cache.keys = parse_keys(&serde_json::to_vec(&jwks("one")).unwrap()).unwrap();
cache.policy = Some(test_policy(
&verifier,
"public, max-age=14400, must-revalidate",
None,
));
cache.attempted_at = Some(Instant::now());
drop(cache);
verifier
}
fn test_policy(verifier: &Verifier, cache_control: &str, etag: Option<&str>) -> CachePolicy {
let request = verifier
.client
.get(verifier.jwks_url.clone())
.build()
.unwrap();
let mut response = axum::http::Response::builder().header("cache-control", cache_control);
if let Some(etag) = etag {
response = response.header("etag", etag);
}
CachePolicy::new_options(
&request,
&response.body(()).unwrap(),
SystemTime::now(),
CACHE_OPTIONS,
)
}
async fn status(app: Router, assertion: Option<&str>) -> StatusCode {
let mut request = Request::builder().uri("/internal/auth/verify");
if let Some(assertion) = assertion {
request = request.header("cf-access-jwt-assertion", assertion);
}
app.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap()
.status()
}
#[tokio::test]
async fn oracle_accepts_only_valid_application_tokens() {
let verifier = cached().await;
let app = verifier_routes(Some(verifier));
assert_eq!(
status(app.clone(), Some(&token("one", &claims()))).await,
StatusCode::NO_CONTENT
);
assert_eq!(status(app.clone(), None).await, StatusCode::UNAUTHORIZED);
assert_eq!(
status(app.clone(), Some("wakey-invalid-test")).await,
StatusCode::UNAUTHORIZED
);
for (field, value) in [
("iss", json!("https://other.example")),
("aud", json!(["bypass-app"])),
("exp", json!(1)),
("nbf", json!(4102444800u64)),
] {
let mut invalid = claims();
invalid[field] = value;
assert_eq!(
status(app.clone(), Some(&token("one", &invalid))).await,
StatusCode::UNAUTHORIZED,
"{field}"
);
}
for field in ["iss", "aud", "exp"] {
let mut invalid = claims();
invalid.as_object_mut().unwrap().remove(field);
assert_eq!(
status(app.clone(), Some(&token("one", &invalid))).await,
StatusCode::UNAUTHORIZED,
"missing {field}"
);
}
let mut forged = token("one", &claims()).into_bytes();
let signature_start = forged.iter().rposition(|byte| *byte == b'.').unwrap() + 1;
forged[signature_start] = if forged[signature_start] == b'A' {
b'B'
} else {
b'A'
};
assert_eq!(
status(app.clone(), Some(std::str::from_utf8(&forged).unwrap())).await,
StatusCode::UNAUTHORIZED
);
let mut header = Header::new(Algorithm::HS256);
header.kid = Some("one".into());
let hmac = encode(
&header,
&claims(),
&EncodingKey::from_secret(b"not-an-rsa-key"),
)
.unwrap();
assert_eq!(
status(app.clone(), Some(&hmac)).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status(app.clone(), Some(&token("unknown", &claims()))).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status(app.clone(), Some(&"a".repeat(MAX_TOKEN_BYTES + 1))).await,
StatusCode::UNAUTHORIZED
);
let mut request = Request::builder()
.uri("/internal/auth/verify")
.body(Body::empty())
.unwrap();
let valid = token("one", &claims());
request.headers_mut().append(
"cf-access-jwt-assertion",
HeaderValue::from_str(&valid).unwrap(),
);
request
.headers_mut()
.append("cf-access-jwt-assertion", HeaderValue::from_static("fake"));
assert_eq!(
app.oneshot(request).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
status(verifier_routes(None), Some(&valid)).await,
StatusCode::UNAUTHORIZED
);
}
#[test]
fn trusted_configuration_rejects_unsafe_urls_and_non_loopback_bind() {
assert_eq!(
Verifier::new(&config()).unwrap().jwks_url.as_str(),
"https://access.example/cdn-cgi/access/certs"
);
let mut explicit = config();
explicit.jwks_url = Some("https://keys.example/custom".into());
assert_eq!(
Verifier::new(&explicit).unwrap().jwks_url.as_str(),
"https://keys.example/custom"
);
for url in [
"http://access.example",
"https://user:[email protected]",
"https://access.example?q=1",
"https://access.example/#fragment",
"not a url",
] {
assert!(trusted_url(url).is_err(), "{url}");
}
let mut invalid = config();
invalid.audience.clear();
assert!(Verifier::new(&invalid).is_err());
invalid = config();
invalid.issuer.push_str("/wrong-path");
assert!(Verifier::new(&invalid).is_err());
assert!(routes(Some(&config()), "0.0.0.0:8080".parse().unwrap()).is_err());
assert!(routes(Some(&config()), "[::1]:8080".parse().unwrap()).is_ok());
}
#[test]
fn signing_key_set_rejects_duplicates_and_unusable_keys() {
let key = jwks("one")["keys"][0].clone();
assert!(parse_keys(br#"{"keys": []}"#).unwrap().is_empty());
for set in [
json!({"keys": [key.clone(), key.clone()]}),
json!({"keys": vec![key.clone(); MAX_KEYS + 1]}),
] {
assert!(parse_keys(&serde_json::to_vec(&set).unwrap()).is_err());
}
for (field, value) in [
("use", json!("enc")),
("alg", json!("RS512")),
("key_ops", json!(["encrypt"])),
] {
let mut invalid = key.clone();
invalid[field] = value;
assert!(
parse_keys(&serde_json::to_vec(&json!({"keys": [invalid]})).unwrap())
.unwrap()
.is_empty()
);
}
}
#[tokio::test]
async fn key_fetch_is_single_flight_rotates_and_fails_closed() {
let hits = Arc::new(AtomicUsize::new(0));
let reply = Arc::new(RwLock::new((
StatusCode::OK,
serde_json::to_string(&jwks("one")).unwrap(),
)));
let app = Router::new().route(
"/keys",
get({
let hits = hits.clone();
let reply = reply.clone();
move || {
let hits = hits.clone();
let reply = reply.clone();
async move {
hits.fetch_add(1, Ordering::Relaxed);
let (status, body) = reply.read().await.clone();
(
status,
[("cache-control", "public, max-age=14400, must-revalidate")],
body,
)
}
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let mut verifier = Verifier::new(&config()).unwrap();
// HTTP is allowed only in this in-process fixture, never in loaded config.
verifier.jwks_url = Url::parse(&format!("http://{address}/keys")).unwrap();
let verifier = Arc::new(verifier);
let valid = token("one", &claims());
let (first, second) = tokio::join!(verifier.validate(&valid), verifier.validate(&valid));
assert!(first.is_ok() && second.is_ok());
assert_eq!(hits.load(Ordering::Relaxed), 1);
for kid in ["random-1", "random-2"] {
assert_eq!(
verifier.validate(&token(kid, &claims())).await,
Err(VerifyError::Invalid)
);
}
assert_eq!(hits.load(Ordering::Relaxed), 1);
*reply.write().await = (StatusCode::OK, serde_json::to_string(&jwks("two")).unwrap());
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
assert!(verifier.validate(&token("two", &claims())).await.is_ok());
assert_eq!(verifier.validate(&valid).await, Err(VerifyError::Invalid));
assert_eq!(hits.load(Ordering::Relaxed), 2);
// A failed refresh cannot authorize unknown keys or expired cached keys.
*reply.write().await = (StatusCode::SERVICE_UNAVAILABLE, "unavailable".into());
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
assert_eq!(
verifier.validate(&valid).await,
Err(VerifyError::Unavailable)
);
assert!(verifier.validate(&token("two", &claims())).await.is_ok());
verifier.cache.lock().await.policy =
Some(test_policy(&verifier, "max-age=0, must-revalidate", None));
assert_eq!(
verifier.validate(&token("two", &claims())).await,
Err(VerifyError::Unavailable)
);
assert_eq!(hits.load(Ordering::Relaxed), 3);
// Oversized and malformed responses also fail closed, with bounded retries.
for body in ["x".repeat(MAX_JWKS_BYTES + 1), "not json".into()] {
*reply.write().await = (StatusCode::OK, body);
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
assert_eq!(
verifier.validate(&valid).await,
Err(VerifyError::Unavailable)
);
}
assert_eq!(
status(verifier_routes(Some(verifier.clone())), Some(&valid)).await,
StatusCode::SERVICE_UNAVAILABLE
);
// A successful empty set must revoke previously cached keys immediately.
*reply.write().await = (StatusCode::OK, r#"{"keys": []}"#.into());
verifier.cache.lock().await.attempted_at = Some(Instant::now() - REFRESH_COOLDOWN);
assert_eq!(
verifier.validate(&token("two", &claims())).await,
Err(VerifyError::Invalid)
);
assert!(verifier.cache.lock().await.keys.is_empty());
server.abort();
let _ = server.await;
}
struct KeyOrigin {
verifier: Arc<Verifier>,
reply: Arc<RwLock<(StatusCode, HeaderMap, String)>>,
requests: Arc<Mutex<Vec<HeaderMap>>>,
server: tokio::task::JoinHandle<()>,
}
impl KeyOrigin {
async fn new(headers: &[(&str, &str)]) -> Self {
let reply = Arc::new(RwLock::new((
StatusCode::OK,
HeaderMap::new(),
String::new(),
)));
let requests = Arc::new(Mutex::new(Vec::new()));
let app = Router::new().route(
"/keys",
get({
let reply = reply.clone();
let requests = requests.clone();
move |headers: HeaderMap| {
let reply = reply.clone();
let requests = requests.clone();
async move {
requests.lock().await.push(headers);
reply.read().await.clone()
}
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let mut verifier = Verifier::new(&config()).unwrap();
verifier.jwks_url = Url::parse(&format!("http://{address}/keys")).unwrap();
let origin = Self {
verifier: Arc::new(verifier),
reply,
requests,
server,
};
origin
.respond(
StatusCode::OK,
serde_json::to_string(&jwks("one")).unwrap(),
headers,
)
.await;
origin
}
async fn respond(&self, status: StatusCode, body: String, headers: &[(&str, &str)]) {
let mut response = axum::http::Response::builder().status(status);
for (name, value) in headers {
response = response.header(*name, *value);
}
let (parts, body) = response.body(body).unwrap().into_parts();
*self.reply.write().await = (status, parts.headers, body);
}
async fn allow_refresh(&self) {
self.verifier.cache.lock().await.attempted_at = None;
}
async fn stop(self) {
self.server.abort();
let _ = self.server.await;
}
}
#[tokio::test]
async fn http_cache_reuses_fresh_keys_revalidates_etags_and_forces_rotation() {
let origin = KeyOrigin::new(&[
(
"cache-control",
"public, max-age=14400, must-revalidate, no-transform",
),
("etag", "\"v1\""),
])
.await;
let one = token("one", &claims());
for _ in 0..2 {
assert!(origin.verifier.validate(&one).await.is_ok());
}
assert_eq!(origin.requests.lock().await.len(), 1);
// Expire the policy directly; no wall-clock sleeps or delayed retries.
origin.verifier.cache.lock().await.policy = Some(test_policy(
&origin.verifier,
"max-age=0, must-revalidate",
Some("\"v1\""),
));
origin.allow_refresh().await;
origin
.respond(
StatusCode::NOT_MODIFIED,
String::new(),
&[
("etag", "\"v1\""),
("cache-control", "public, max-age=14400, must-revalidate"),
],
)
.await;
for _ in 0..2 {
assert!(origin.verifier.validate(&one).await.is_ok());
}
assert_eq!(origin.requests.lock().await.len(), 2);
assert_eq!(origin.requests.lock().await[1]["if-none-match"], "\"v1\"");
// An unknown kid forces revalidation even though the HTTP entry is fresh.
origin.allow_refresh().await;
origin
.respond(
StatusCode::OK,
serde_json::to_string(&jwks("two")).unwrap(),
&[
("etag", "\"v2\""),
("cache-control", "public, max-age=14400, must-revalidate"),
],
)
.await;
assert!(
origin
.verifier
.validate(&token("two", &claims()))
.await
.is_ok()
);
assert_eq!(origin.requests.lock().await.len(), 3);
assert_eq!(origin.requests.lock().await[2]["cache-control"], "no-cache");
assert_eq!(origin.requests.lock().await[2]["if-none-match"], "\"v1\"");
assert_eq!(
origin.verifier.validate(&one).await,
Err(VerifyError::Invalid)
);
// A mismatched 304 must not renew trust in the stored keys.
origin.verifier.cache.lock().await.policy = Some(test_policy(
&origin.verifier,
"max-age=0, must-revalidate",
Some("\"v2\""),
));
origin.allow_refresh().await;
origin
.respond(
StatusCode::NOT_MODIFIED,
String::new(),
&[("etag", "\"wrong\""), ("cache-control", "max-age=14400")],
)
.await;
assert_eq!(
origin.verifier.validate(&token("two", &claims())).await,
Err(VerifyError::Unavailable)
);
origin.stop().await;
}
#[tokio::test]
async fn http_cache_honors_age_no_cache_and_no_store() {
for (cache_control, age, stores) in [
("no-store", "0", false),
("no-cache", "0", true),
("public, max-age=14400, must-revalidate", "14401", true),
("", "0", true),
] {
let origin = KeyOrigin::new(&[("cache-control", cache_control), ("age", age)]).await;
let one = token("one", &claims());
assert!(origin.verifier.validate(&one).await.is_ok());
assert_eq!(origin.verifier.cache.lock().await.policy.is_some(), stores);
if !stores {
assert!(origin.verifier.cache.lock().await.keys.is_empty());
}
// A successful origin response can serve the requesting caller, but
// an HTTP-stale/non-storable response cannot authorize later requests.
assert_eq!(
origin.verifier.validate(&one).await,
Err(VerifyError::Unavailable)
);
assert_eq!(origin.requests.lock().await.len(), 1);
origin.allow_refresh().await;
assert!(origin.verifier.validate(&one).await.is_ok());
assert_eq!(origin.requests.lock().await.len(), 2);
origin.stop().await;
}
}
#[tokio::test]
async fn http_cache_revalidates_last_modified_and_rejects_unsolicited_304() {
let modified = "Wed, 01 Jan 2025 00:00:00 GMT";
let origin = KeyOrigin::new(&[
("cache-control", "max-age=0, must-revalidate"),
("last-modified", modified),
])
.await;
let one = token("one", &claims());
assert!(origin.verifier.validate(&one).await.is_ok());
origin.allow_refresh().await;
origin
.respond(
StatusCode::NOT_MODIFIED,
String::new(),
&[
("last-modified", modified),
("cache-control", "max-age=14400"),
],
)
.await;
assert!(origin.verifier.validate(&one).await.is_ok());
assert_eq!(
origin.requests.lock().await[1]["if-modified-since"],
modified
);
origin.stop().await;
let origin = KeyOrigin::new(&[]).await;
origin
.respond(StatusCode::NOT_MODIFIED, String::new(), &[])
.await;
assert_eq!(
origin.verifier.validate(&one).await,
Err(VerifyError::Unavailable)
);
assert!(origin.verifier.cache.lock().await.policy.is_none());
origin.stop().await;
}
#[tokio::test]
async fn verifier_routes_do_not_gate_agent_or_control_handlers() {
let dir = std::env::temp_dir().join(format!("wakey-access-routes-{}", uuid::Uuid::new_v4()));
let store = crate::state::Store::load_or_init(
&dir.join("state.sqlite3"),
vec![],
Duration::from_secs(60),
)
.await
.unwrap();
let state = AppState {
store: Arc::new(store),
sessions: Arc::new(RwLock::new(HashMap::new())),
pending: Arc::new(Mutex::new(HashMap::new())),
public_url: "http://localhost".into(),
command_timeout: Duration::from_secs(30),
enroll_token_ttl: Duration::from_secs(60),
terminals: super::super::terminals::TerminalRegistry::new(),
alerts: crate::api::AlertsHub::new(),
fleet: Arc::new(crate::api::FleetCache::default()),
};
let app = Router::new()
.merge(routes(Some(&config()), "127.0.0.1:8080".parse().unwrap()).unwrap())
.merge(super::super::public_api_routes(dir.clone()))
.merge(super::super::control_api_routes())
.with_state(state);
for (path, expected) in [
("/healthz", StatusCode::OK),
("/api/v1/control/agents", StatusCode::OK),
("/internal/auth/verify", StatusCode::UNAUTHORIZED),
] {
let response = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), expected, "{path}");
}
drop(app);
std::fs::remove_dir_all(dir).unwrap();
}
+5
View File
@@ -23,6 +23,7 @@ use crate::config;
use crate::state;
use crate::ws;
mod access;
mod admin;
mod fleet_metrics;
mod process;
@@ -177,6 +178,9 @@ fn control_api_routes() -> Router<AppState> {
/// Starts the control-plane HTTP and websocket surfaces and manages daemon lifecycle hooks.
pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
// The verifier delegates enforcement to Caddy. Do not expose the other
// handlers directly when that boundary is configured.
let access_routes = access::routes(daemon.access.as_ref(), daemon.bind)?;
write_pid_file(&daemon.pid_file)?;
info!(pid_file = %daemon.pid_file.display(), "wrote control-plane pid file");
@@ -207,6 +211,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
// - public_api_routes: intended internet-facing agent endpoints
// - control_api_routes: intended admin-only endpoints behind Access
let app = Router::new()
.merge(access_routes)
.merge(public_api_routes(daemon.ui_dist_dir.clone()))
.merge(control_api_routes())
.with_state(app_state.clone());