Who may come in: nobody Cloudflare Access has not signed in.
Access stands in front of a site and adds a signed token to every request it lets through
(Cf-Access-Jwt-Assertion). Trusting that Access is there would be enough only while nothing else
leads to the Worker, so the Worker checks the token itself: signed by this team's keys (RS256), for
this application (aud), from this team (iss), not yet expired. Anything short of that is refused,
and a Worker with no team or audience configured refuses everyone.
Pure Rust and no runtime, so it builds for wasm. Fetching the team's keys (certs) is the adapter's.
The header Access puts its token in.
19pub const HEADER: &str = "cf-access-jwt-assertion";
One of the team's public keys, as its certs address lists them.
aud is a list in Access's tokens, and a single string in the standard.
A service token has no email; Access names it by its client id.
Why a request was not let in. Closed, so a caller cannot invent a reason and a test can name each.
74impl std::fmt::Display for Refused { 75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 76 f.write_str(match self { 77 Refused::NotConfigured => "no Access team or audience is configured", 78 Refused::NotAToken => "not a token", 79 Refused::WrongAlgorithm => "not signed the way Access signs", 80 Refused::UnknownKey => "signed by a key this team does not have", 81 Refused::BadKey => "the team's key is not a key", 82 Refused::BadSignature => "the signature is not this team's", 83 Refused::OtherApplication => "for another application", 84 Refused::OtherTeam => "from another team", 85 Refused::Expired => "expired", 86 }) 87 } 88} 89 90impl std::error::Error for Refused {}
Where the team's keys are read from.
The kid a token says it was signed with, to find the key by. Says nothing about whether the token is good.
The email of whoever Access signed in, if token is this team's, for
this application, in date, and signed by one of keys.
110pub fn admitted(token: &str, keys: &[Key], team: &str, audience: &str, now_s: f64) -> Result<String, Refused> { 111 if team.is_empty() || audience.is_empty() { 112 return Err(Refused::NotConfigured); 113 } 114 let mut parts = token.split('.'); 115 let (Some(head), Some(body), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { 116 return Err(Refused::NotAToken); 117 }; 118 let decoded = |part: &str| URL_SAFE_NO_PAD.decode(part).map_err(|_| Refused::NotAToken); 119 let head_json: Head = serde_json::from_slice(&decoded(head)?).map_err(|_| Refused::NotAToken)?; 120 if head_json.alg != "RS256" { 121 return Err(Refused::WrongAlgorithm); 122 } 123 let key = keys.iter().find(|key| key.kid == head_json.kid).ok_or(Refused::UnknownKey)?; 124 let number = |part: &str| decoded(part).map(|bytes| BigUint::from_bytes_be(&bytes)); 125 let public = RsaPublicKey::new(number(&key.n)?, number(&key.e)?).map_err(|_| Refused::BadKey)?; 126 let signed = Sha256::digest(format!("{head}.{body}").as_bytes()); 127 public.verify(Pkcs1v15Sign::new::<Sha256>(), &signed, &decoded(signature)?).map_err(|_| Refused::BadSignature)?; 128 // Only now is what the token says worth reading. 129 let claims: Claims = serde_json::from_slice(&decoded(body)?).map_err(|_| Refused::NotAToken)?; 130 let ours = match &claims.aud { 131 Audience::One(one) => one == audience, 132 Audience::Many(many) => many.iter().any(|one| one == audience), 133 }; 134 if !ours { 135 return Err(Refused::OtherApplication); 136 } 137 if claims.iss != issuer(team) { 138 return Err(Refused::OtherTeam); 139 } 140 if claims.exp <= now_s { 141 return Err(Refused::Expired); 142 } 143 Ok(if claims.email.is_empty() { claims.common_name } else { claims.email }) 144}
146#[cfg(test)] 147mod tests { 148 use super::*; 149 use rsa::RsaPrivateKey; 150 use rsa::traits::PublicKeyParts; 151 152 const TEAM: &str = "deizel"; 153 const AUD: &str = "0123456789abcdef"; 154 155 struct Signer { 156 private: RsaPrivateKey, 157 key: Key, 158 } 159 160 fn signer(kid: &str) -> Signer { 161 let private = RsaPrivateKey::new(&mut rand::thread_rng(), 1024).unwrap(); 162 let encoded = |number: &BigUint| URL_SAFE_NO_PAD.encode(number.to_bytes_be()); 163 let key = Key { kid: kid.into(), n: encoded(private.n()), e: encoded(private.e()) }; 164 Signer { private, key } 165 } 166 167 fn token(signer: &Signer, alg: &str, claims: serde_json::Value) -> String { 168 let head = URL_SAFE_NO_PAD.encode(serde_json::json!({ "alg": alg, "kid": signer.key.kid }).to_string()); 169 let body = URL_SAFE_NO_PAD.encode(claims.to_string()); 170 let signed = Sha256::digest(format!("{head}.{body}").as_bytes()); 171 let signature = signer.private.sign(Pkcs1v15Sign::new::<Sha256>(), &signed).unwrap(); 172 format!("{head}.{body}.{}", URL_SAFE_NO_PAD.encode(signature)) 173 } 174 175 fn claims(aud: &str, iss: &str, exp: f64) -> serde_json::Value { 176 serde_json::json!({ "aud": [aud], "iss": iss, "exp": exp, "email": "owner@example.com" }) 177 } 178 179 #[test] 180 fn only_this_teams_token_for_this_application_in_date_gets_in() { 181 let ours = signer("a"); 182 let keys = [ours.key.clone()]; 183 let iss = "https://deizel.cloudflareaccess.com"; 184 let good = token(&ours, "RS256", claims(AUD, iss, 2000.0)); 185 assert_eq!(admitted(&good, &keys, TEAM, AUD, 1000.0).as_deref(), Ok("owner@example.com")); 186 assert_eq!(kid(&good).as_deref(), Some("a")); 187 188 // Expired, for another application, from another team. 189 assert_eq!(admitted(&good, &keys, TEAM, AUD, 2000.0), Err(Refused::Expired)); 190 assert_eq!(admitted(&token(&ours, "RS256", claims("other", iss, 2000.0)), &keys, TEAM, AUD, 1000.0), Err(Refused::OtherApplication)); 191 assert_eq!(admitted(&token(&ours, "RS256", claims(AUD, "https://evil.cloudflareaccess.com", 2000.0)), &keys, TEAM, AUD, 1000.0), Err(Refused::OtherTeam)); 192 193 // Signed by someone else, even under our key's name. 194 let theirs = signer("a"); 195 assert_eq!(admitted(&token(&theirs, "RS256", claims(AUD, iss, 2000.0)), &keys, TEAM, AUD, 1000.0), Err(Refused::BadSignature)); 196 assert_eq!(admitted(&token(&signer("b"), "RS256", claims(AUD, iss, 2000.0)), &keys, TEAM, AUD, 1000.0), Err(Refused::UnknownKey)); 197 198 // A token whose body was changed after signing. 199 let mut parts: Vec<String> = good.split('.').map(str::to_owned).collect(); 200 parts[1] = URL_SAFE_NO_PAD.encode(claims(AUD, iss, 9e9).to_string()); 201 assert_eq!(admitted(&parts.join("."), &keys, TEAM, AUD, 1000.0), Err(Refused::BadSignature)); 202 203 // No signature at all, and the `none` trick. 204 assert_eq!(admitted("", &keys, TEAM, AUD, 1000.0), Err(Refused::NotAToken)); 205 assert_eq!(admitted(&token(&ours, "none", claims(AUD, iss, 2000.0)), &keys, TEAM, AUD, 1000.0), Err(Refused::WrongAlgorithm)); 206 } 207 208 #[test] 209 fn a_worker_with_nothing_configured_lets_nobody_in() { 210 let ours = signer("a"); 211 let good = token(&ours, "RS256", claims("", "https://.cloudflareaccess.com", 2000.0)); 212 assert_eq!(admitted(&good, &[ours.key.clone()], "", "", 1000.0), Err(Refused::NotConfigured)); 213 assert_eq!(admitted(&good, &[ours.key.clone()], TEAM, "", 1000.0), Err(Refused::NotConfigured)); 214 } 215}