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

+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());