1//! Who may come in: nobody Cloudflare Access has not signed in. 2//! 3//! Access stands in front of a site and adds a signed token to every request it lets through 4//! (`Cf-Access-Jwt-Assertion`). Trusting that Access is there would be enough only while nothing else 5//! leads to the Worker, so the Worker checks the token itself: signed by this team's keys (RS256), for 6//! this application (`aud`), from this team (`iss`), not yet expired. Anything short of that is refused, 7//! and a Worker with no team or audience configured refuses everyone. 8//! 9//! Pure Rust and no runtime, so it builds for wasm. Fetching the team's keys (`certs`) is the adapter's. 10 11use base64::Engine; 12use base64::engine::general_purpose::URL_SAFE_NO_PAD; 13use rsa::pkcs1v15::Pkcs1v15Sign; 14use rsa::sha2::{Digest, Sha256}; 15use rsa::{BigUint, RsaPublicKey}; 16use serde::Deserialize; 17 18/// The header Access puts its token in. 19pub const HEADER: &str = "cf-access-jwt-assertion"; 20 21/// One of the team's public keys, as its certs address lists them. 22#[derive(Clone, Debug, Deserialize)] 23pub struct Key { 24 pub kid: String, 25 pub n: String, 26 pub e: String, 27} 28 29#[derive(Deserialize)] 30pub struct Keys { 31 pub keys: Vec<Key>, 32} 33 34#[derive(Deserialize)] 35struct Head { 36 alg: String, 37 kid: String, 38} 39 40/// `aud` is a list in Access's tokens, and a single string in the standard. 41#[derive(Deserialize)] 42#[serde(untagged)] 43enum Audience { 44 One(String), 45 Many(Vec<String>), 46} 47 48#[derive(Deserialize)] 49struct Claims { 50 aud: Audience, 51 iss: String, 52 exp: f64, 53 #[serde(default)] 54 email: String, 55 /// A service token has no email; Access names it by its client id. 56 #[serde(default)] 57 common_name: String, 58} 59 60/// Why a request was not let in. Closed, so a caller cannot invent a reason and a test can name each. 61#[derive(Clone, Copy, Debug, PartialEq, Eq)] 62pub enum Refused { 63 NotConfigured, 64 NotAToken, 65 WrongAlgorithm, 66 UnknownKey, 67 BadKey, 68 BadSignature, 69 OtherApplication, 70 OtherTeam, 71 Expired, 72} 73 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 {} 91 92/// Where the team's keys are read from. 93pub fn certs(team: &str) -> String { 94 format!("{}/cdn-cgi/access/certs", issuer(team)) 95} 96 97fn issuer(team: &str) -> String { 98 format!("https://{team}.cloudflareaccess.com") 99} 100 101/// The kid a token says it was signed with, to find the key by. Says nothing 102/// about whether the token is good. 103pub fn kid(token: &str) -> Option<String> { 104 let head = token.split('.').next()?; 105 serde_json::from_slice::<Head>(&URL_SAFE_NO_PAD.decode(head).ok()?).ok().map(|head| head.kid) 106} 107 108/// The email of whoever Access signed in, if `token` is this team's, for 109/// 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} 145 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}