Skip to main content

open_chess_server/
apple.rs

1//! Sign in with Apple, for the iOS app.
2//!
3//! The app asks Apple to sign the player in and sends what it gets back:
4//! an identity token (a JWT Apple signs with RS256), a one-time
5//! authorization code, and the nonce it gave Apple. Here the token is
6//! checked against Apple's published keys (issuer, audience = one of our
7//! client ids, expiry, and the nonce's SHA-256), then the code is traded
8//! with Apple for a refresh token. That trade is what makes a sign-in
9//! single-use (a code works once, for a few minutes) and gives the token
10//! needed to revoke the link when the player unlinks or closes their
11//! account, as Apple requires.
12//!
13//! The client secret for those calls is a short ES256 JWT signed with the
14//! team's Sign in with Apple key (`CHESS_APPLE_KEY`).
15
16use 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
32/// Apple's keys are fetched again after this, or at once for an unknown key id.
33const KEYS_LIFE: Duration = Duration::from_hours(1);
34/// Clock skew allowed on the token's times.
35const SKEW_SECS: i64 = 120;
36
37/// What a checked identity token says.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct AppleId {
40    /// Apple's stable id for this person and our team.
41    pub sub: String,
42    /// Which of our client ids it was issued for.
43    pub client_id: String,
44    pub email: Option<String>,
45    /// The email is Apple's private relay address.
46    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    /// The website's Services ID, if it signs in with Apple too.
69    services_id: Option<String>,
70    /// Apple's sign-in service: `https://appleid.apple.com`, which is also
71    /// the tokens' issuer and the client secret's audience.
72    url: String,
73    http: reqwest::Client,
74    keys: Mutex<Option<(Arc<Vec<Jwk>>, Instant)>>,
75}
76
77impl Apple {
78    /// From the config, when it names a key.
79    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    /// The website's Services ID, when the website signs in with Apple.
120    pub fn services_id(&self) -> Option<&str> {
121        self.services_id.as_deref()
122    }
123
124    /// Where to send a browser to sign in with Apple (the website's flow:
125    /// Apple posts the answer back to `redirect`).
126    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    /// Check an identity token from the app and the nonce it sent Apple
141    /// (raw here; Apple holds its SHA-256, hex).
142    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    /// A JWT's claims, once its signature is checked against Apple's keys.
148    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    /// A server-to-server notification's event (Apple posts
162    /// `{"payload": "<JWT>"}`), once checked: Apple signed it, for one of
163    /// our ids.
164    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    /// The client secret Apple wants with a code or a revocation.
187    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    /// Trade the app's authorization code for a refresh token, checking it
199    /// was issued to the same person. `Ok(None)`: Apple gave no refresh
200    /// token (it doesn't always).
201    /// `redirect`: the website's return URL, which a code from the website's
202    /// flow is traded with.
203    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        // Apple's answer comes straight from Apple, so its id token's
227        // subject is trusted without checking the signature again.
228        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    /// Ask Apple to end the link (unlinking, or the account closed).
236    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/// What Apple tells us about an Apple ID, unasked.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct Notice {
258    /// `consent-revoked`, `account-delete` (or `account-deleted`),
259    /// `email-disabled`, `email-enabled`.
260    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    // `events` is an object, or (as Apple has sent it) that object as a string.
269    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
278/// A JWT's key id, signed part, signature and claims (not yet checked).
279fn 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
296/// SHA-256 of the raw nonce, hex: what the app gives Apple.
297pub 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}