1use std::sync::{Arc, Mutex};
17use std::time::{Duration, Instant};
18
19use anyhow::{Context, Result, bail, ensure};
20use aws_lc_rs::signature::{RSA_PKCS1_2048_8192_SHA256, RsaPublicKeyComponents};
21use base64::Engine as _;
22use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
23use p256::ecdsa::signature::Signer;
24use p256::ecdsa::{Signature, SigningKey};
25use p256::pkcs8::DecodePrivateKey;
26use serde::Deserialize;
27use serde_json::{Value, json};
28use sha2::{Digest, Sha256};
29
30use crate::config::Config;
31
32const KEYS_LIFE: Duration = Duration::from_hours(1);
34const SKEW_SECS: i64 = 120;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct AppleId {
40 pub sub: String,
42 pub client_id: String,
44 pub email: Option<String>,
45 pub private_email: bool,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50struct Jwk {
51 kid: String,
52 #[serde(default)]
53 kty: String,
54 n: String,
55 e: String,
56}
57
58#[derive(Deserialize)]
59struct Keys {
60 keys: Vec<Jwk>,
61}
62
63pub struct Apple {
64 key: SigningKey,
65 key_id: String,
66 team_id: String,
67 client_ids: Vec<String>,
68 services_id: Option<String>,
70 url: String,
73 http: reqwest::Client,
74 keys: Mutex<Option<(Arc<Vec<Jwk>>, Instant)>>,
75}
76
77impl Apple {
78 pub fn from_config(cfg: &Config) -> Result<Option<Arc<Apple>>> {
80 let (Some(key), Some(key_id)) = (&cfg.apple_key, &cfg.apple_key_id) else {
81 return Ok(None);
82 };
83 let team_id = cfg
84 .apple_team_id
85 .clone()
86 .or_else(|| cfg.apns_team_id.clone())
87 .context("Sign in with Apple needs CHESS_APPLE_TEAM_ID (or CHESS_APNS_TEAM_ID)")?;
88 let pem = if key.contains("BEGIN PRIVATE KEY") {
89 key.clone()
90 } else {
91 std::fs::read_to_string(key).with_context(|| format!("reading CHESS_APPLE_KEY {key}"))?
92 };
93 let key = SigningKey::from_pkcs8_pem(&pem).map_err(|e| anyhow::anyhow!("CHESS_APPLE_KEY: {e}"))?;
94 let mut client_ids: Vec<String> =
95 cfg.apple_client_ids.iter().map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect();
96 if client_ids.is_empty() {
97 client_ids.push(cfg.apns_topic.clone());
98 }
99 let services_id =
100 cfg.apple_services_id.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned);
101 if let Some(s) = &services_id
102 && !client_ids.contains(s)
103 {
104 client_ids.push(s.clone());
105 }
106 let http = reqwest::Client::builder().timeout(Duration::from_secs(10)).build()?;
107 Ok(Some(Arc::new(Apple {
108 key,
109 key_id: key_id.clone(),
110 team_id,
111 client_ids,
112 services_id,
113 url: cfg.apple_url.trim_end_matches('/').to_owned(),
114 http,
115 keys: Mutex::new(None),
116 })))
117 }
118
119 pub fn services_id(&self) -> Option<&str> {
121 self.services_id.as_deref()
122 }
123
124 pub fn authorize_url(&self, redirect: &str, state: &str, nonce: &str) -> Option<String> {
127 let client = self.services_id.as_deref()?;
128 let mut u = url::Url::parse(&format!("{}/auth/authorize", self.url)).ok()?;
129 u.query_pairs_mut()
130 .append_pair("response_type", "code id_token")
131 .append_pair("response_mode", "form_post")
132 .append_pair("client_id", client)
133 .append_pair("redirect_uri", redirect)
134 .append_pair("scope", "name email")
135 .append_pair("state", state)
136 .append_pair("nonce", &nonce_hash(nonce));
137 Some(u.into())
138 }
139
140 pub async fn verify(&self, token: &str, nonce: &str) -> Result<AppleId> {
143 let claims = self.signed_by_apple(token).await?;
144 check_claims(&claims, &self.url, &self.client_ids, nonce, chrono::Utc::now().timestamp())
145 }
146
147 async fn signed_by_apple(&self, token: &str) -> Result<Value> {
149 let (kid, signed, sig, claims) = split(token)?;
150 let keys = self.keys(Some(&kid)).await?;
151 let jwk = keys.iter().find(|k| k.kid == kid).context("Apple doesn't know the token's key")?;
152 ensure!(jwk.kty.is_empty() || jwk.kty == "RSA", "Apple's key isn't RSA");
153 let n = B64.decode(&jwk.n).context("Apple's key")?;
154 let e = B64.decode(&jwk.e).context("Apple's key")?;
155 RsaPublicKeyComponents { n: &n[..], e: &e[..] }
156 .verify(&RSA_PKCS1_2048_8192_SHA256, signed.as_bytes(), &sig)
157 .map_err(|_| anyhow::anyhow!("the token's signature is wrong"))?;
158 Ok(claims)
159 }
160
161 pub async fn notification(&self, payload: &str) -> Result<Notice> {
165 let claims = self.signed_by_apple(payload).await?;
166 check_notice(&claims, &self.url, &self.client_ids)
167 }
168
169 async fn keys(&self, want: Option<&str>) -> Result<Arc<Vec<Jwk>>> {
170 if let Ok(k) = self.keys.lock()
171 && let Some((keys, at)) = k.as_ref()
172 && at.elapsed() < KEYS_LIFE
173 && want.is_none_or(|w| keys.iter().any(|k| k.kid == w))
174 {
175 return Ok(Arc::clone(keys));
176 }
177 let r = self.http.get(format!("{}/auth/keys", self.url)).send().await.context("Apple's keys")?;
178 ensure!(r.status().is_success(), "Apple's keys: {}", r.status());
179 let keys = Arc::new(r.json::<Keys>().await.context("Apple's keys")?.keys);
180 if let Ok(mut k) = self.keys.lock() {
181 *k = Some((Arc::clone(&keys), Instant::now()));
182 }
183 Ok(keys)
184 }
185
186 fn client_secret(&self, client_id: &str, now: i64) -> String {
188 let header = B64.encode(json!({"alg": "ES256", "kid": self.key_id}).to_string());
189 let claims = B64.encode(
190 json!({"iss": self.team_id, "iat": now, "exp": now + 300, "aud": self.url, "sub": client_id})
191 .to_string(),
192 );
193 let input = format!("{header}.{claims}");
194 let sig: Signature = self.key.sign(input.as_bytes());
195 format!("{input}.{}", B64.encode(sig.to_bytes()))
196 }
197
198 pub async fn redeem(&self, code: &str, id: &AppleId, redirect: Option<&str>) -> Result<Option<String>> {
204 let secret = self.client_secret(&id.client_id, chrono::Utc::now().timestamp());
205 let mut form = vec![
206 ("client_id", id.client_id.as_str()),
207 ("client_secret", secret.as_str()),
208 ("code", code),
209 ("grant_type", "authorization_code"),
210 ];
211 if let Some(r) = redirect {
212 form.push(("redirect_uri", r));
213 }
214 let r = self
215 .http
216 .post(format!("{}/auth/token", self.url))
217 .form(&form)
218 .send()
219 .await
220 .context("Apple's token service")?;
221 let status = r.status();
222 let body: Value = r.json().await.unwrap_or(Value::Null);
223 if !status.is_success() {
224 bail!("Apple refused the sign-in code ({status} {})", body["error"].as_str().unwrap_or_default());
225 }
226 if let Some(t) = body["id_token"].as_str() {
229 let (_, _, _, claims) = split(t)?;
230 ensure!(claims["sub"].as_str() == Some(id.sub.as_str()), "the sign-in code is someone else's");
231 }
232 Ok(body["refresh_token"].as_str().map(str::to_owned))
233 }
234
235 pub async fn revoke(&self, client_id: &str, refresh_token: &str) -> Result<()> {
237 let secret = self.client_secret(client_id, chrono::Utc::now().timestamp());
238 let r = self
239 .http
240 .post(format!("{}/auth/revoke", self.url))
241 .form(&[
242 ("client_id", client_id),
243 ("client_secret", secret.as_str()),
244 ("token", refresh_token),
245 ("token_type_hint", "refresh_token"),
246 ])
247 .send()
248 .await
249 .context("Apple's revoke service")?;
250 ensure!(r.status().is_success(), "Apple's revoke service: {}", r.status());
251 Ok(())
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct Notice {
258 pub kind: String,
261 pub sub: String,
262}
263
264fn check_notice(c: &Value, issuer: &str, client_ids: &[String]) -> Result<Notice> {
265 ensure!(c["iss"].as_str() == Some(issuer), "the notification isn't Apple's");
266 let aud = c["aud"].as_str().unwrap_or_default();
267 ensure!(client_ids.iter().any(|id| id == aud), "the notification is for another app");
268 let events = match &c["events"] {
270 Value::String(s) => serde_json::from_str(s).context("the notification's events")?,
271 v => v.clone(),
272 };
273 let kind = events["type"].as_str().context("the notification has no type")?.to_owned();
274 let sub = events["sub"].as_str().filter(|s| !s.is_empty()).context("the notification has no subject")?;
275 Ok(Notice { kind, sub: sub.to_owned() })
276}
277
278fn split(token: &str) -> Result<(String, String, Vec<u8>, Value)> {
280 let mut parts = token.trim().split('.');
281 let (Some(h), Some(c), Some(s), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
282 bail!("the identity token isn't a JWT");
283 };
284 let header: Value = serde_json::from_slice(&B64.decode(h).context("the token's header")?)
285 .context("the token's header")?;
286 let claims: Value = serde_json::from_slice(&B64.decode(c).context("the token's claims")?)
287 .context("the token's claims")?;
288 let sig = B64.decode(s).context("the token's signature")?;
289 let kid = header["kid"].as_str().unwrap_or_default().to_owned();
290 if header["alg"].as_str() != Some("RS256") {
291 bail!("the identity token isn't RS256");
292 }
293 Ok((kid, format!("{h}.{c}"), sig, claims))
294}
295
296pub fn nonce_hash(raw: &str) -> String {
298 hex::encode(Sha256::digest(raw.as_bytes()))
299}
300
301fn truthy(v: &Value) -> bool {
302 v.as_bool().unwrap_or_else(|| v.as_str() == Some("true"))
303}
304
305fn check_claims(c: &Value, issuer: &str, client_ids: &[String], nonce: &str, now: i64) -> Result<AppleId> {
306 ensure!(c["iss"].as_str() == Some(issuer), "the identity token isn't Apple's");
307 let aud = c["aud"].as_str().unwrap_or_default();
308 let client_id =
309 client_ids.iter().find(|id| *id == aud).context("the identity token is for another app")?.clone();
310 let exp = c["exp"].as_i64().context("the identity token has no expiry")?;
311 ensure!(exp + SKEW_SECS > now, "the identity token has expired: sign in again");
312 if let Some(iat) = c["iat"].as_i64() {
313 ensure!(iat - SKEW_SECS <= now, "the identity token is from the future");
314 }
315 ensure!(!nonce.is_empty() && nonce.len() <= 200, "the nonce is missing");
316 ensure!(c["nonce"].as_str() == Some(nonce_hash(nonce).as_str()), "the nonce doesn't match");
317 let sub = c["sub"].as_str().filter(|s| !s.is_empty() && s.len() <= 200).context("no subject")?;
318 let email = c["email"].as_str().filter(|e| e.contains('@')).map(str::to_owned);
319 let verified = c.get("email_verified").is_none_or(truthy);
320 Ok(AppleId {
321 sub: sub.to_owned(),
322 client_id,
323 email: email.filter(|_| verified),
324 private_email: c.get("is_private_email").is_some_and(truthy),
325 })
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 fn claims(nonce: &str) -> Value {
333 json!({
334 "iss": "https://appleid.apple.com", "aud": "org.example.app", "exp": 2_000, "iat": 1_000,
335 "sub": "001234.abcd", "nonce": nonce_hash(nonce), "email": "x@privaterelay.appleid.com",
336 "email_verified": "true", "is_private_email": "true",
337 })
338 }
339
340 #[test]
341 fn checks_the_claims() {
342 let ids = vec!["org.example.app".to_owned()];
343 let iss = "https://appleid.apple.com";
344 let id = check_claims(&claims("n0nce"), iss, &ids, "n0nce", 1_500).unwrap();
345 assert_eq!(id.sub, "001234.abcd");
346 assert_eq!(id.client_id, "org.example.app");
347 assert_eq!(id.email.as_deref(), Some("x@privaterelay.appleid.com"));
348 assert!(id.private_email);
349 assert!(check_claims(&claims("n0nce"), iss, &ids, "other", 1_500).is_err(), "nonce");
350 assert!(check_claims(&claims("n0nce"), iss, &ids, "", 1_500).is_err(), "no nonce");
351 assert!(check_claims(&claims("n0nce"), iss, &ids, "n0nce", 3_000).is_err(), "expired");
352 assert!(check_claims(&claims("n0nce"), iss, &ids, "n0nce", 500).is_err(), "from the future");
353 assert!(check_claims(&claims("n0nce"), iss, &["other".into()], "n0nce", 1_500).is_err(), "audience");
354 assert!(check_claims(&claims("n0nce"), "https://evil.test", &ids, "n0nce", 1_500).is_err(), "issuer");
355 let mut c = claims("n0nce");
356 c["email_verified"] = json!(false);
357 assert_eq!(check_claims(&c, iss, &ids, "n0nce", 1_500).unwrap().email, None, "unverified email");
358 }
359
360 #[test]
361 fn reads_notifications() {
362 let ids = vec!["org.example.app".to_owned()];
363 let iss = "https://appleid.apple.com";
364 let event = json!({"type": "consent-revoked", "sub": "001.x", "event_time": 1});
365 let mut c = json!({"iss": iss, "aud": "org.example.app", "iat": 1, "jti": "j", "events": event});
366 let n = check_notice(&c, iss, &ids).unwrap();
367 assert_eq!((n.kind.as_str(), n.sub.as_str()), ("consent-revoked", "001.x"));
368 c["events"] = json!(r#"{"type":"account-delete","sub":"001.y","event_time":1}"#);
369 assert_eq!(check_notice(&c, iss, &ids).unwrap().kind, "account-delete", "events as a string");
370 c["aud"] = json!("org.other");
371 assert!(check_notice(&c, iss, &ids).is_err());
372 }
373
374 #[test]
375 fn nonce_is_sha256_hex() {
376 assert_eq!(nonce_hash("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
377 }
378
379 #[test]
380 fn splits_only_rs256_jwts() {
381 let h = B64.encode(r#"{"alg":"RS256","kid":"k1"}"#);
382 let c = B64.encode(r#"{"sub":"s"}"#);
383 let (kid, signed, sig, claims) = split(&format!("{h}.{c}.AAEC")).unwrap();
384 assert_eq!((kid.as_str(), sig), ("k1", vec![0, 1, 2]));
385 assert_eq!(signed, format!("{h}.{c}"));
386 assert_eq!(claims["sub"], "s");
387 let none = B64.encode(r#"{"alg":"none"}"#);
388 assert!(split(&format!("{none}.{c}.")).is_err());
389 assert!(split("a.b").is_err());
390 }
391}