Skip to content

Commit db81d3a

Browse files
committed
fix: filter app catalog and support service tokens
1 parent 6dc0232 commit db81d3a

3 files changed

Lines changed: 209 additions & 35 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ Cloudflare Access puts a login screen in front of your apps and sends a signed J
1313
3. It verifies the JWT signature and issuer against your team's public keys and domain, requires a Cloudflare Access application token (`type: app`), and checks the token was issued for one of *your* applications (its `aud`).
1414
4. Valid → `200` and Traefik serves the request. Anything else → `403`.
1515

16+
Cloudflare Access identities may come from an interactive user (`email`) or a service token (`common_name`). Both are accepted after the same signature, issuer, application-token, expiry, and audience checks.
17+
1618
It keeps itself current in the background, so you don't restart it when things change in Cloudflare:
1719

1820
- **Public keys** refresh every 24 hours (Cloudflare rotates them).
1921
- **Application list** refreshes every hour (so new apps start working on their own).
2022

2123
Nothing is stored on disk and no state is kept between requests.
2224

23-
On startup, the service loads both the signing keys and the complete filtered application list before opening its listening port. If either initial fetch fails, startup fails instead of briefly serving an empty configuration. Later refresh failures keep the last complete version.
25+
On startup, the service loads both the signing keys and the complete self-hosted application list before opening its listening port. If either initial fetch fails, startup fails instead of briefly serving an empty configuration. Later refresh failures keep the last complete version.
2426

2527
## Quick start
2628

cloudflare-authenticator/src/lib.rs

Lines changed: 144 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,34 @@ pub struct Authenticator {
5050

5151
#[derive(Debug, Serialize, Deserialize)]
5252
pub struct Claims {
53-
aud: Vec<String>,
54-
email: String,
53+
aud: Audience,
54+
email: Option<String>,
55+
common_name: Option<String>,
5556
exp: usize,
5657
iat: usize,
57-
nbf: usize,
58+
nbf: Option<usize>,
5859
iss: String,
5960
#[serde(rename = "type")] // use is a reserved keyword in Rust, so we will rename it
6061
type_: String,
61-
identity_nonce: String,
62+
identity_nonce: Option<String>,
6263
sub: String,
63-
country: String,
64+
country: Option<String>,
65+
}
66+
67+
#[derive(Debug, Serialize, Deserialize)]
68+
#[serde(untagged)]
69+
pub enum Audience {
70+
One(String),
71+
Many(Vec<String>),
72+
}
73+
74+
impl Audience {
75+
pub fn as_slice(&self) -> &[String] {
76+
match self {
77+
Self::One(audience) => std::slice::from_ref(audience),
78+
Self::Many(audiences) => audiences,
79+
}
80+
}
6481
}
6582

6683
#[derive(Error, Debug)]
@@ -75,6 +92,8 @@ pub enum ValidationError {
7592
InvalidToken,
7693
#[error("Token is not a Cloudflare Access application token")]
7794
InvalidTokenType,
95+
#[error("Token has no user or service identity")]
96+
MissingIdentity,
7897
#[error("No matching AUD found")]
7998
NoAudMatch,
8099
#[error("Certificate not found")]
@@ -107,7 +126,7 @@ impl ValidationError {
107126
| Self::InvalidSigningKey
108127
| Self::FetchCertificatesFailed => "signing_key_unavailable",
109128
Self::NoAudMatch => "audience_mismatch",
110-
Self::InvalidToken => "malformed_token",
129+
Self::InvalidToken | Self::MissingIdentity => "malformed_token",
111130
Self::InvalidTokenType => "invalid_token_type",
112131
Self::JwtDecodingError(error) => match error.kind() {
113132
ErrorKind::ExpiredSignature => "expired_token",
@@ -176,6 +195,9 @@ impl Authenticator {
176195
if token_data.claims.type_ != ACCESS_APPLICATION_TOKEN_TYPE {
177196
return Err(ValidationError::InvalidTokenType);
178197
}
198+
if !token_data.claims.has_identity() {
199+
return Err(ValidationError::MissingIdentity);
200+
}
179201

180202
Ok(token_data)
181203
}
@@ -252,6 +274,15 @@ impl Authenticator {
252274
}
253275
}
254276

277+
impl Claims {
278+
fn has_identity(&self) -> bool {
279+
[&self.email, &self.common_name]
280+
.into_iter()
281+
.flatten()
282+
.any(|identity| !identity.trim().is_empty())
283+
}
284+
}
285+
255286
#[cfg(test)]
256287
mod tests {
257288
use super::*;
@@ -323,16 +354,17 @@ mod tests {
323354
// Issued an hour before it expires, so expired tokens stay coherent.
324355
let issued = exp.saturating_sub(3600);
325356
Claims {
326-
aud: vec![aud.to_string()],
327-
email: "user@example.com".to_string(),
357+
aud: Audience::Many(vec![aud.to_string()]),
358+
email: Some("user@example.com".to_string()),
359+
common_name: None,
328360
exp,
329361
iat: issued,
330-
nbf: issued,
362+
nbf: Some(issued),
331363
iss: issuer.to_string(),
332364
type_: ACCESS_APPLICATION_TOKEN_TYPE.to_string(),
333-
identity_nonce: "test-nonce".to_string(),
365+
identity_nonce: Some("test-nonce".to_string()),
334366
sub: "00000000-0000-0000-0000-000000000000".to_string(),
335-
country: "SG".to_string(),
367+
country: Some("SG".to_string()),
336368
}
337369
}
338370

@@ -358,7 +390,7 @@ mod tests {
358390
}
359391

360392
#[tokio::test]
361-
async fn accepts_a_validly_signed_token() {
393+
async fn accepts_a_user_identity_with_an_array_audience() {
362394
let mut server = mockito::Server::new_async().await;
363395
let auth = authenticator(&mut server, jwk(KID, "RS256")).await;
364396

@@ -372,8 +404,81 @@ mod tests {
372404
.await
373405
.expect("a correctly signed, unexpired, correctly-audienced token must verify");
374406

375-
assert_eq!(decoded.claims.email, "user@example.com");
376-
assert_eq!(decoded.claims.aud, vec![AUD.to_string()]);
407+
assert_eq!(decoded.claims.email.as_deref(), Some("user@example.com"));
408+
assert_eq!(decoded.claims.aud.as_slice(), &[AUD.to_string()]);
409+
}
410+
411+
#[tokio::test]
412+
async fn accepts_a_service_identity_with_a_scalar_audience_and_no_nbf() {
413+
let mut server = mockito::Server::new_async().await;
414+
let auth = authenticator(&mut server, jwk(KID, "RS256")).await;
415+
let mut payload = serde_json::to_value(claims(AUD, now() + 3600, &server.url()))
416+
.expect("serialize claims");
417+
let payload = payload
418+
.as_object_mut()
419+
.expect("claims serialize as an object");
420+
for claim in ["email", "nbf", "identity_nonce", "country"] {
421+
payload.remove(claim);
422+
}
423+
payload.insert("aud".into(), json!(AUD));
424+
payload.insert("common_name".into(), json!("automation-client"));
425+
let token = sign_payload(&payload, Algorithm::RS256, Some(KID));
426+
427+
let decoded = auth
428+
.decode(&token, vec![AUD.to_string()])
429+
.await
430+
.expect("a service-token identity should be accepted");
431+
432+
assert_eq!(decoded.claims.email, None);
433+
assert_eq!(
434+
decoded.claims.common_name.as_deref(),
435+
Some("automation-client")
436+
);
437+
assert_eq!(decoded.claims.nbf, None);
438+
assert_eq!(decoded.claims.aud.as_slice(), &[AUD.to_string()]);
439+
}
440+
441+
#[tokio::test]
442+
async fn accepts_a_user_identity_with_a_scalar_audience() {
443+
let mut server = mockito::Server::new_async().await;
444+
let auth = authenticator(&mut server, jwk(KID, "RS256")).await;
445+
let mut payload = serde_json::to_value(claims(AUD, now() + 3600, &server.url()))
446+
.expect("serialize claims");
447+
payload
448+
.as_object_mut()
449+
.expect("claims serialize as an object")
450+
.insert("aud".into(), json!(AUD));
451+
let token = sign_payload(&payload, Algorithm::RS256, Some(KID));
452+
453+
let decoded = auth
454+
.decode(&token, vec![AUD.to_string()])
455+
.await
456+
.expect("a scalar audience with a user identity should be accepted");
457+
458+
assert_eq!(decoded.claims.email.as_deref(), Some("user@example.com"));
459+
assert_eq!(decoded.claims.aud.as_slice(), &[AUD.to_string()]);
460+
}
461+
462+
#[tokio::test]
463+
async fn rejects_an_application_token_without_a_user_or_service_identity() {
464+
let mut server = mockito::Server::new_async().await;
465+
let auth = authenticator(&mut server, jwk(KID, "RS256")).await;
466+
let mut payload = serde_json::to_value(claims(AUD, now() + 3600, &server.url()))
467+
.expect("serialize claims");
468+
let payload = payload
469+
.as_object_mut()
470+
.expect("claims serialize as an object");
471+
payload.remove("email");
472+
payload.remove("common_name");
473+
let token = sign_payload(&payload, Algorithm::RS256, Some(KID));
474+
475+
let error = auth
476+
.decode(&token, vec![AUD.to_string()])
477+
.await
478+
.expect_err("a token without an identity must fail closed");
479+
480+
assert!(matches!(error, ValidationError::MissingIdentity));
481+
assert_eq!(error.reason_code(), "malformed_token");
377482
}
378483

379484
#[tokio::test]
@@ -598,7 +703,7 @@ mod tests {
598703
let mut server = mockito::Server::new_async().await;
599704
let auth = authenticator(&mut server, jwk(KID, "RS256")).await;
600705
let mut token_claims = claims(AUD, now() + 3600, &server.url());
601-
token_claims.nbf = now() + 120;
706+
token_claims.nbf = Some(now() + 120);
602707
let token = sign(&token_claims, Algorithm::RS256, Some(KID));
603708

604709
let error = auth
@@ -633,6 +738,30 @@ mod tests {
633738
assert_eq!(err.reason_code(), "audience_mismatch");
634739
}
635740

741+
#[tokio::test]
742+
async fn rejects_a_scalar_audience_that_is_not_in_the_catalog() {
743+
let mut server = mockito::Server::new_async().await;
744+
let auth = authenticator(&mut server, jwk(KID, "RS256")).await;
745+
let mut payload =
746+
serde_json::to_value(claims("someone-elses-aud", now() + 3600, &server.url()))
747+
.expect("serialize claims");
748+
payload
749+
.as_object_mut()
750+
.expect("claims serialize as an object")
751+
.insert("aud".into(), json!("someone-elses-aud"));
752+
let token = sign_payload(&payload, Algorithm::RS256, Some(KID));
753+
754+
let error = auth
755+
.decode(&token, vec![AUD.to_string()])
756+
.await
757+
.expect_err("a non-matching scalar audience must fail closed");
758+
759+
assert!(
760+
matches!(&error, ValidationError::JwtDecodingError(error) if matches!(error.kind(), ErrorKind::InvalidAudience))
761+
);
762+
assert_eq!(error.reason_code(), "audience_mismatch");
763+
}
764+
636765
#[tokio::test]
637766
async fn rejects_a_token_signed_by_an_unknown_key() {
638767
let mut server = mockito::Server::new_async().await;

0 commit comments

Comments
 (0)