Skip to main content

open_chess_server/http/
apple.rs

1//! Sign in with Apple, from the iOS app and on the website
2//! ([`crate::apple`] checks what Apple gave).
3//!
4//! **The app**: `POST /api/auth/apple` and `DELETE /api/auth/apple`.
5//! - Sign in (no bearer token): the account linked to the Apple ID gets a
6//!   new API token, as the OAuth flow would give. An Apple ID not linked
7//!   yet makes a new account: the first answer is 409 `choose_username`
8//!   with a suggestion, and the app sends the same request again with
9//!   `username`. An Apple ID whose email another account has is refused
10//!   (`email_in_use`): that person signs in as before and links Apple.
11//! - Link (with the player's bearer token): the Apple ID is linked to that
12//!   account. No new token.
13//! - Unlink (`DELETE`, bearer): unless it's the account's only way in;
14//!   Apple is told.
15//!
16//! **The website** (with `CHESS_APPLE_SERVICES_ID`): `GET /auth/apple` sends
17//! the browser to Apple, which posts its answer to `/auth/apple/callback`;
18//! the same rules decide, with a session instead of a token and a page to
19//! choose a username (`/auth/apple/name`). From the account page,
20//! `?link=1` links the signed-in account, and `/account/apple/remove`
21//! unlinks.
22//!
23//! An account closed (`DELETE /api/account`) has its link revoked too.
24
25use std::collections::HashMap;
26use std::fmt::Write as _;
27
28use axum::Json;
29use axum::extract::{Query, State};
30use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
31use axum::response::{IntoResponse, Redirect, Response};
32use bytes::Bytes;
33use serde::Deserialize;
34use serde_json::{Value, json};
35
36use crate::app::{Ctx, Who};
37use crate::apple::{Apple, AppleId};
38use crate::auth::{bearer, cookie, hash_key, random_token};
39use crate::error::{ApiError, ApiResult};
40use crate::http::oauth::page;
41use crate::http::{form, html_escape, web};
42
43const TOKEN_DAYS: i64 = 365;
44/// The cookie that ties Apple's answer to the browser that asked.
45const STATE_COOKIE: &str = "oc_apple";
46
47#[derive(Debug, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct SignIn {
50    identity_token: String,
51    authorization_code: String,
52    nonce: String,
53    #[serde(default)]
54    client_id: Option<String>,
55    #[serde(default)]
56    scopes: Vec<String>,
57    #[serde(default)]
58    username: Option<String>,
59    /// The person's name, which Apple gives the app the first time only.
60    #[serde(default)]
61    name: Option<String>,
62}
63
64fn coded(status: StatusCode, error: &str, message: &str) -> Response {
65    (status, Json(json!({"error": error, "message": message}))).into_response()
66}
67
68fn apple(ctx: &Ctx) -> ApiResult<&Apple> {
69    ctx.apple.as_deref().ok_or_else(|| ApiError::forbidden("Sign in with Apple isn't set up on this server"))
70}
71
72/// A free username from what we know of the person.
73async fn suggest(
74    ctx: &Ctx,
75    c: &impl tokio_postgres::GenericClient,
76    name: &str,
77    email: &str,
78) -> ApiResult<String> {
79    let from = |s: &str| -> String {
80        s.chars().filter(char::is_ascii_alphanumeric).take(14).collect::<String>().to_lowercase()
81    };
82    let mut base = from(name);
83    if base.len() < 3 && !email.contains("privaterelay") {
84        base = from(email.split('@').next().unwrap_or_default());
85    }
86    if base.len() < 3 {
87        base = "player".into();
88    }
89    if web::name_problem(&base, "").is_none() && web::taken(ctx, c, &base, "").await?.is_none() {
90        return Ok(base);
91    }
92    for _ in 0..8 {
93        let n: u32 = rand::random_range(10..10_000);
94        let s = format!("{base}{n}");
95        if web::taken(ctx, c, &s, "").await?.is_none() {
96            return Ok(s);
97        }
98    }
99    Ok(format!("{base}{}", random_token(5).to_lowercase()))
100}
101
102/// Apple's one-time code: spent (traded for the link's refresh token) only
103/// once everything else is in order, and at most once.
104struct Code<'a> {
105    apple: &'a Apple,
106    pending: Option<&'a str>,
107    /// The website's return URL, for a code from the website's flow.
108    redirect: Option<&'a str>,
109    refresh: Option<String>,
110}
111
112impl Code<'_> {
113    async fn spend(&mut self, id: &AppleId) -> Result<Option<String>, ApiError> {
114        if let Some(code) = self.pending.take() {
115            self.refresh = self.apple.redeem(code, id, self.redirect).await.map_err(|e| {
116                tracing::info!("Sign in with Apple refused: {e:#}");
117                ApiError::forbidden(format!("{e:#}"))
118            })?;
119        }
120        Ok(self.refresh.clone())
121    }
122}
123
124/// What a checked Apple sign-in comes to.
125#[derive(Debug)]
126enum Outcome {
127    /// Signed in to this account (`created`: just made).
128    Player { player: i64, created: bool },
129    /// Linked to the signed-in account.
130    Linked { player: i64 },
131    /// A new Apple ID: a username is wanted first.
132    ChooseName { suggestion: String },
133    /// Refused, with a code the app knows and a message for people.
134    Refused(StatusCode, &'static str, &'static str),
135}
136
137/// The rules, the same for the app and the website. `link`: the signed-in
138/// player to link to. `username`: chosen for a new account.
139async fn decide(
140    ctx: &Ctx,
141    id: &AppleId,
142    code: &mut Code<'_>,
143    link: Option<i64>,
144    username: Option<&str>,
145    name_hint: &str,
146) -> ApiResult<Outcome> {
147    let c = ctx.db.conn().await?;
148    let linked: Option<i64> = c
149        .query_opt(&ctx.db.sql("SELECT player_id FROM chess.apple_ids WHERE sub = $1"), &[&id.sub])
150        .await?
151        .map(|r| r.get(0));
152
153    if let Some(player) = link {
154        if linked.is_some_and(|p| p != player) {
155            return Ok(Outcome::Refused(
156                StatusCode::CONFLICT,
157                "apple_linked_elsewhere",
158                "That Apple ID is linked to another account here.",
159            ));
160        }
161        if linked.is_none() {
162            let other = c
163                .query_opt(&ctx.db.sql("SELECT 1 FROM chess.apple_ids WHERE player_id = $1"), &[&player])
164                .await?;
165            if other.is_some() {
166                return Ok(Outcome::Refused(
167                    StatusCode::CONFLICT,
168                    "already_linked",
169                    "This account already has an Apple ID linked: unlink it first.",
170                ));
171            }
172        }
173        let refresh = code.spend(id).await?;
174        c.execute(
175            &ctx.db.sql(
176                "INSERT INTO chess.apple_ids (sub, player_id, client_id, refresh_token, email, private_email)
177                 VALUES ($1, $2, $3, $4, $5, $6)
178                 ON CONFLICT (sub) DO NOTHING",
179            ),
180            &[&id.sub, &player, &id.client_id, &refresh, &id.email, &id.private_email],
181        )
182        .await?;
183        remember(ctx, &*c, id, refresh.as_deref()).await?;
184        tracing::info!(player, "Apple ID linked");
185        return Ok(Outcome::Linked { player });
186    }
187
188    if let Some(player) = linked {
189        let live = c
190            .query_opt(
191                &ctx.db.sql("SELECT 1 FROM chess.players WHERE id = $1 AND disabled_at IS NULL"),
192                &[&player],
193            )
194            .await?;
195        if live.is_none() {
196            return Ok(Outcome::Refused(StatusCode::FORBIDDEN, "closed", "This account is closed."));
197        }
198        let refresh = code.spend(id).await?;
199        remember(ctx, &*c, id, refresh.as_deref()).await?;
200        return Ok(Outcome::Player { player, created: false });
201    }
202
203    if !ctx.cfg.signup {
204        return Ok(Outcome::Refused(
205            StatusCode::FORBIDDEN,
206            "no_signup",
207            "This server doesn't take new accounts, and no account here has that Apple ID.",
208        ));
209    }
210    let email = id.email.clone().unwrap_or_default();
211    if !email.is_empty() && web::taken(ctx, &*c, "", &email).await?.is_some() {
212        return Ok(Outcome::Refused(
213            StatusCode::CONFLICT,
214            "email_in_use",
215            "An account here already has your Apple ID's email. Sign in to it the usual way, then link your Apple ID under Account.",
216        ));
217    }
218    let Some(name) = username.map(str::trim).filter(|s| !s.is_empty()) else {
219        return Ok(Outcome::ChooseName { suggestion: suggest(ctx, &*c, name_hint, &email).await? });
220    };
221    if let Some(why) = web::name_problem(name, "").or(web::taken(ctx, &*c, name, "").await?) {
222        return Ok(Outcome::Refused(StatusCode::BAD_REQUEST, "bad_username", why));
223    }
224    let refresh = code.spend(id).await?;
225    drop(c);
226    let mut c = ctx.db.conn().await?;
227    let tx = c.transaction().await?;
228    if let Some(why) = web::taken(ctx, &tx, name, &email).await? {
229        return Ok(Outcome::Refused(StatusCode::BAD_REQUEST, "bad_username", why));
230    }
231    let player = web::make_account(ctx, &tx, name, &email, None).await?;
232    let n = tx
233        .execute(
234            &ctx.db.sql(
235                "INSERT INTO chess.apple_ids (sub, player_id, client_id, refresh_token, email, private_email)
236                 VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (sub) DO NOTHING",
237            ),
238            &[&id.sub, &player, &id.client_id, &refresh, &id.email, &id.private_email],
239        )
240        .await?;
241    if n == 0 {
242        // Another request made the account first.
243        return Ok(Outcome::Refused(
244            StatusCode::CONFLICT,
245            "just_linked",
246            "That Apple ID was just linked: sign in again.",
247        ));
248    }
249    tx.commit().await?;
250    tracing::info!(player, "account made with Sign in with Apple");
251    Ok(Outcome::Player { player, created: true })
252}
253
254/// Keep the link's refresh token (newest wins) and when it was used.
255async fn remember(
256    ctx: &Ctx,
257    c: &impl tokio_postgres::GenericClient,
258    id: &AppleId,
259    refresh: Option<&str>,
260) -> ApiResult<()> {
261    c.execute(
262        &ctx.db.sql(
263            "UPDATE chess.apple_ids SET last_used_at = now(), client_id = $2,
264                    refresh_token = coalesce($3, refresh_token)
265              WHERE sub = $1",
266        ),
267        &[&id.sub, &id.client_id, &refresh],
268    )
269    .await?;
270    Ok(())
271}
272
273async fn username(ctx: &Ctx, player: i64) -> ApiResult<String> {
274    let c = ctx.db.conn().await?;
275    Ok(crate::model::ensure_account(&ctx.db, &*c, player).await?.username)
276}
277
278// ─── the app ───
279
280/// `POST /api/auth/apple`.
281pub async fn sign_in(State(ctx): State<Ctx>, h: HeaderMap, Json(req): Json<SignIn>) -> ApiResult<Response> {
282    let apple = apple(&ctx)?;
283    let id = apple.verify(&req.identity_token, &req.nonce).await.map_err(|e| {
284        tracing::info!("Sign in with Apple refused: {e:#}");
285        ApiError::forbidden(format!("{e:#}"))
286    })?;
287    let link = match bearer(&h) {
288        Some(t) => Some(ctx.auth.token(t).await?.ok_or_else(ApiError::unauthorized)?.player),
289        None => None,
290    };
291    let mut code = Code { apple, pending: Some(&req.authorization_code), redirect: None, refresh: None };
292    let hint = req.name.as_deref().unwrap_or_default();
293    let (player, created) = match decide(&ctx, &id, &mut code, link, req.username.as_deref(), hint).await? {
294        Outcome::Player { player, created } => (player, created),
295        Outcome::Linked { player } => {
296            let name = username(&ctx, player).await?;
297            return Ok(Json(json!({"linked": true, "created": false, "username": name})).into_response());
298        }
299        Outcome::ChooseName { suggestion } => {
300            return Ok((
301                StatusCode::CONFLICT,
302                Json(json!({"error": "choose_username", "suggestion": suggestion})),
303            )
304                .into_response());
305        }
306        Outcome::Refused(status, error, message) => return Ok(coded(status, error, message)),
307    };
308    web::grant_listed(&ctx, player).await?;
309    let scopes = web::allowed(&ctx, player, &req.scopes).await?;
310    let client = req.client_id.as_deref().map(str::trim).filter(|s| !s.is_empty()).unwrap_or("app");
311    let (token, _) =
312        web::issue(&ctx, player, &format!("Sign in with Apple: {client}"), &scopes, TOKEN_DAYS).await?;
313    Ok(Json(json!({
314        "access_token": token,
315        "token_type": "Bearer",
316        "expires_in": TOKEN_DAYS * 86_400,
317        "username": username(&ctx, player).await?,
318        "created": created,
319        "linked": true,
320    }))
321    .into_response())
322}
323
324/// Unlink a player's Apple ID, unless it's their only way in (then the
325/// message why). Apple is told.
326async fn unlink_player(ctx: &Ctx, player: i64) -> ApiResult<Result<(), &'static str>> {
327    let c = ctx.db.conn().await?;
328    let other_way_in: bool = c
329        .query_one(
330            &ctx.db.sql(
331                "SELECT p.password_hash IS NOT NULL OR p.esports_id IS NOT NULL
332                        OR EXISTS (SELECT 1 FROM chess.passkeys k WHERE k.player_id = p.id)
333                   FROM chess.players p WHERE p.id = $1",
334            ),
335            &[&player],
336        )
337        .await?
338        .get(0);
339    let Some(row) = c
340        .query_opt(
341            &ctx.db.sql("SELECT client_id, refresh_token FROM chess.apple_ids WHERE player_id = $1"),
342            &[&player],
343        )
344        .await?
345    else {
346        return Ok(Ok(()));
347    };
348    if !other_way_in {
349        return Ok(Err("Your Apple ID is how you sign in here. Add a password or a passkey first."));
350    }
351    c.execute(&ctx.db.sql("DELETE FROM chess.apple_ids WHERE player_id = $1"), &[&player]).await?;
352    revoke(ctx, row.get(0), row.get(1)).await;
353    tracing::info!(player, "Apple ID unlinked");
354    Ok(Ok(()))
355}
356
357/// `DELETE /api/auth/apple`: unlink the caller's Apple ID.
358pub async fn unlink(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
359    Ok(match unlink_player(&ctx, who.0.player).await? {
360        Ok(()) => StatusCode::NO_CONTENT.into_response(),
361        Err(why) => coded(StatusCode::CONFLICT, "only_sign_in", why),
362    })
363}
364
365/// Tell Apple a link has ended; a failure is logged, not fatal.
366pub async fn revoke(ctx: &Ctx, client_id: String, refresh: Option<String>) {
367    let (Some(apple), Some(refresh)) = (ctx.apple.as_deref(), refresh) else {
368        return;
369    };
370    if let Err(e) = apple.revoke(&client_id, &refresh).await {
371        tracing::warn!("revoking an Apple ID link: {e:#}");
372    }
373}
374
375// ─── the website ───
376
377/// Whether the website signs in with Apple.
378pub fn on_web(ctx: &Ctx) -> bool {
379    ctx.apple.as_deref().is_some_and(|a| a.services_id().is_some())
380}
381
382fn callback_url(ctx: &Ctx) -> String {
383    format!("{}/auth/apple/callback", ctx.cfg.public_url().trim_end_matches('/'))
384}
385
386/// The state cookie: sent back with Apple's cross-site POST, so
387/// `SameSite=None` (which needs `Secure`; plain http keeps `Lax`).
388fn state_cookie(ctx: &Ctx, value: &str, max_age: i64) -> ApiResult<HeaderValue> {
389    let same_site = if web::secure(ctx).is_empty() { "Lax" } else { "None" };
390    HeaderValue::from_str(&format!(
391        "{STATE_COOKIE}={value}; Path={}; HttpOnly; SameSite={same_site}; Max-Age={max_age}{}",
392        web::cookie_path(ctx),
393        web::secure(ctx)
394    ))
395    .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))
396}
397
398fn sign_in_page(ctx: &Ctx, status: StatusCode, message: &str, ret: &str) -> Response {
399    let base = ctx.cfg.base_path().unwrap_or_default();
400    (
401        status,
402        page(
403            "Sign in with Apple",
404            &format!(
405                "<p class=err>{}</p><p><a href=\"{base}/login?return={}\">Back to sign in</a></p>",
406                html_escape(message),
407                urlencoding::encode(ret)
408            ),
409        ),
410    )
411        .into_response()
412}
413
414fn name_form(ctx: &Ctx, ticket: &str, name: &str, error: Option<&str>) -> Response {
415    let base = ctx.cfg.base_path().unwrap_or_default();
416    let err = error.map(|e| format!("<p class=err>{}</p>", html_escape(e))).unwrap_or_default();
417    (
418        if error.is_some() { StatusCode::BAD_REQUEST } else { StatusCode::OK },
419        page(
420            "Choose a username",
421            &format!(
422                "<p>Your Apple ID is new here: choose the name other players will see.</p>{err}\
423                 <form method=post action=\"{base}/auth/apple/name\">{t}\
424                 <label>Username (letters, digits, _ and -)<input name=name value=\"{n}\" required minlength=2 maxlength=20 autocomplete=username></label>\
425                 <button class=go>Make the account</button></form>",
426                t = web::hidden("ticket", ticket),
427                n = html_escape(name),
428            ),
429        ),
430    )
431        .into_response()
432}
433
434/// Signed in on the website: a session, and on to where they were going.
435async fn signed_in(ctx: &Ctx, player: i64, to: &str, clear_state: bool) -> ApiResult<Response> {
436    let session = web::start_session(ctx, player, false).await?;
437    let mut r = Redirect::to(to).into_response();
438    r.headers_mut().append(header::SET_COOKIE, session);
439    if clear_state {
440        r.headers_mut().append(header::SET_COOKIE, state_cookie(ctx, "", 0)?);
441    }
442    Ok(r)
443}
444
445/// `GET /auth/apple?return=…[&link=1]`: off to Apple.
446pub async fn start(
447    State(ctx): State<Ctx>,
448    h: HeaderMap,
449    Query(q): Query<HashMap<String, String>>,
450) -> ApiResult<Response> {
451    let ret = web::back_to(&ctx, q.get("return").map(String::as_str));
452    let Some(apple) = ctx.apple.as_deref().filter(|_| on_web(&ctx)) else {
453        return Ok(sign_in_page(
454            &ctx,
455            StatusCode::NOT_FOUND,
456            "This server doesn't sign in with Apple on the web.",
457            &ret,
458        ));
459    };
460    let link = if q.get("link").is_some_and(|v| v == "1") {
461        match web::signed_in(&ctx, &h).await? {
462            Some((player, _)) => Some(player),
463            None => {
464                return Ok(Redirect::to(&format!("{}/login", ctx.cfg.base_path().unwrap_or_default()))
465                    .into_response());
466            }
467        }
468    } else {
469        None
470    };
471    let (state, nonce) = (random_token(32), random_token(32));
472    let c = ctx.db.conn().await?;
473    c.execute(&ctx.db.sql("DELETE FROM chess.apple_flows WHERE expires_at < now()"), &[]).await?;
474    c.execute(
475        &ctx.db.sql(
476            "INSERT INTO chess.apple_flows (state_hash, nonce, return_to, player_id, expires_at)
477             VALUES ($1, $2, $3, $4, now() + interval '10 minutes')",
478        ),
479        &[&hash_key(&state), &nonce, &ret, &link],
480    )
481    .await?;
482    let to = apple
483        .authorize_url(&callback_url(&ctx), &state, &nonce)
484        .ok_or_else(|| ApiError::from(anyhow::anyhow!("Apple's sign-in address")))?;
485    let mut r = Redirect::to(&to).into_response();
486    r.headers_mut().append(header::SET_COOKIE, state_cookie(&ctx, &state, 600)?);
487    Ok(r)
488}
489
490/// `POST /auth/apple/callback`: Apple's answer (`code`, `id_token`,
491/// `state`, the first time `user` with the name; or `error`).
492pub async fn callback(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
493    let f = form(&body);
494    let get = |k: &str| f.get(k).map_or("", String::as_str);
495    let apple = apple(&ctx)?;
496    let state = get("state");
497    let flow = {
498        let c = ctx.db.conn().await?;
499        c.query_opt(
500            &ctx.db.sql(
501                "DELETE FROM chess.apple_flows WHERE state_hash = $1 AND expires_at > now()
502                 RETURNING nonce, return_to, player_id",
503            ),
504            &[&hash_key(state)],
505        )
506        .await?
507    };
508    let home = web::back_to(&ctx, None);
509    let Some(flow) = flow else {
510        return Ok(sign_in_page(
511            &ctx,
512            StatusCode::BAD_REQUEST,
513            "That sign-in has expired: try again.",
514            &home,
515        ));
516    };
517    let (nonce, ret, link): (String, String, Option<i64>) = (flow.get(0), flow.get(1), flow.get(2));
518    if state.is_empty() || cookie(&h, STATE_COOKIE).as_deref() != Some(state) {
519        return Ok(sign_in_page(
520            &ctx,
521            StatusCode::FORBIDDEN,
522            "That sign-in didn't start in this browser: try again.",
523            &ret,
524        ));
525    }
526    // Apple's answer when it didn't sign anyone in: said on the page (a
527    // silent return to the sign-in page looked like a button that does
528    // nothing), and logged, as most of these are set-up problems.
529    let error = get("error");
530    if !error.is_empty() {
531        tracing::warn!(error, "Sign in with Apple came back without a sign-in");
532        let message = if error == "user_cancelled_authorize" {
533            "Sign in with Apple was cancelled.".to_owned()
534        } else {
535            format!(
536                "Apple didn't sign you in ({error}). If it happens again, the server's Sign in with Apple settings may need checking (the Services ID and its return URL)."
537            )
538        };
539        return Ok(sign_in_page(&ctx, StatusCode::BAD_REQUEST, &message, &ret));
540    }
541    let id = match apple.verify(get("id_token"), &nonce).await {
542        Ok(id) if Some(id.client_id.as_str()) == apple.services_id() => id,
543        Ok(_) => return Ok(sign_in_page(&ctx, StatusCode::FORBIDDEN, "That sign-in was for the app.", &ret)),
544        Err(e) => {
545            tracing::info!("Sign in with Apple refused: {e:#}");
546            return Ok(sign_in_page(&ctx, StatusCode::FORBIDDEN, &format!("{e:#}"), &ret));
547        }
548    };
549    let user: Value = serde_json::from_str(get("user")).unwrap_or_default();
550    let hint = format!(
551        "{}{}",
552        user["name"]["firstName"].as_str().unwrap_or_default(),
553        user["name"]["lastName"].as_str().unwrap_or_default()
554    );
555    let redirect = callback_url(&ctx);
556    let mut code = Code { apple, pending: Some(get("code")), redirect: Some(&redirect), refresh: None };
557    match decide(&ctx, &id, &mut code, link, None, &hint).await? {
558        Outcome::Player { player, .. } => signed_in(&ctx, player, &ret, true).await,
559        Outcome::Linked { .. } => {
560            let mut r = web::to_account(&ctx);
561            r.headers_mut().append(header::SET_COOKIE, state_cookie(&ctx, "", 0)?);
562            Ok(r)
563        }
564        Outcome::ChooseName { suggestion } => {
565            // Apple's code lasts minutes; choosing a name may take longer.
566            let refresh = code.spend(&id).await?;
567            let ticket = random_token(40);
568            let c = ctx.db.conn().await?;
569            c.execute(&ctx.db.sql("DELETE FROM chess.apple_pending WHERE expires_at < now()"), &[]).await?;
570            c.execute(
571                &ctx.db.sql(
572                    "INSERT INTO chess.apple_pending
573                       (ticket_hash, sub, client_id, refresh_token, email, private_email, return_to, expires_at)
574                     VALUES ($1, $2, $3, $4, $5, $6, $7, now() + interval '30 minutes')",
575                ),
576                &[&hash_key(&ticket), &id.sub, &id.client_id, &refresh, &id.email, &id.private_email, &ret],
577            )
578            .await?;
579            Ok(name_form(&ctx, &ticket, &suggestion, None))
580        }
581        Outcome::Refused(status, _, message) => Ok(sign_in_page(&ctx, status, message, &ret)),
582    }
583}
584
585/// `POST /auth/apple/name`: the username for a new Apple ID (`ticket`, `name`).
586pub async fn choose_name(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
587    let f = form(&body);
588    let ticket = f.get("ticket").map_or("", String::as_str);
589    let name = f.get("name").map_or("", |s| s.trim());
590    let apple = apple(&ctx)?;
591    let row = {
592        let c = ctx.db.conn().await?;
593        c.query_opt(
594            &ctx.db.sql(
595                "SELECT sub, client_id, refresh_token, email, private_email, return_to
596                   FROM chess.apple_pending WHERE ticket_hash = $1 AND expires_at > now()",
597            ),
598            &[&hash_key(ticket)],
599        )
600        .await?
601    };
602    let home = web::back_to(&ctx, None);
603    let Some(row) = row else {
604        return Ok(sign_in_page(
605            &ctx,
606            StatusCode::BAD_REQUEST,
607            "That sign-in has expired: try again.",
608            &home,
609        ));
610    };
611    let id = AppleId { sub: row.get(0), client_id: row.get(1), email: row.get(3), private_email: row.get(4) };
612    let ret: String = row.get(5);
613    let mut code = Code { apple, pending: None, redirect: None, refresh: row.get(2) };
614    match decide(&ctx, &id, &mut code, None, Some(name), "").await? {
615        Outcome::Player { player, .. } => {
616            let c = ctx.db.conn().await?;
617            c.execute(
618                &ctx.db.sql("DELETE FROM chess.apple_pending WHERE ticket_hash = $1"),
619                &[&hash_key(ticket)],
620            )
621            .await?;
622            signed_in(&ctx, player, &ret, false).await
623        }
624        Outcome::Refused(_, "bad_username", why) => Ok(name_form(&ctx, ticket, name, Some(why))),
625        Outcome::ChooseName { suggestion } => {
626            Ok(name_form(&ctx, ticket, &suggestion, Some("Choose a username.")))
627        }
628        Outcome::Linked { .. } => Ok(web::to_account(&ctx)),
629        Outcome::Refused(status, _, message) => Ok(sign_in_page(&ctx, status, message, &ret)),
630    }
631}
632
633/// `POST /account/apple/remove`: unlink from the account page.
634pub async fn account_remove(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
635    let (player, session) =
636        web::signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
637    web::form_csrf_ok(&form(&body), &session)?;
638    web::may_change(&ctx, player, &session).await?;
639    match unlink_player(&ctx, player).await? {
640        Ok(()) => Ok(web::to_account(&ctx)),
641        Err(why) => Err(ApiError::conflict(why)),
642    }
643}
644
645/// The account page's Apple ID section.
646pub async fn account_section(ctx: &Ctx, player: i64, csrf: &str, locked: bool) -> ApiResult<String> {
647    let base = ctx.cfg.base_path().unwrap_or_default();
648    let c = ctx.db.conn().await?;
649    let row = c
650        .query_opt(
651            &ctx.db.sql(
652                "SELECT a.email, a.private_email, to_char(a.created_at, 'YYYY-MM-DD'),
653                        p.password_hash IS NOT NULL OR p.esports_id IS NOT NULL
654                        OR EXISTS (SELECT 1 FROM chess.passkeys k WHERE k.player_id = p.id)
655                   FROM chess.apple_ids a JOIN chess.players p ON p.id = a.player_id WHERE a.player_id = $1",
656            ),
657            &[&player],
658        )
659        .await?;
660    if row.is_none() && !on_web(ctx) {
661        return Ok(String::new());
662    }
663    let mut s = String::from("<h2>Apple ID</h2>");
664    match row {
665        Some(r) => {
666            let email: Option<String> = r.get(0);
667            let private: bool = r.get(1);
668            let shown = match (email, private) {
669                (Some(_), true) => " (with a private email address)".to_owned(),
670                (Some(e), false) => format!(" ({})", html_escape(&e)),
671                (None, _) => String::new(),
672            };
673            let _ = write!(
674                s,
675                "<p>Linked{shown} since {}: you can sign in with Apple.</p>",
676                r.get::<_, String>(2)
677            );
678            let other_way_in: bool = r.get(3);
679            if !other_way_in {
680                s.push_str("<p class=muted>To unlink it, add a password or a passkey first.</p>");
681            } else if !locked {
682                let _ = write!(
683                    s,
684                    "<form method=post action=\"{base}/account/apple/remove\">{}<button>Unlink your Apple ID</button></form>",
685                    web::hidden("csrf", csrf)
686                );
687            }
688        }
689        None => {
690            let _ = write!(
691                s,
692                "<p class=muted>Sign in with your Apple ID too.</p><p><a class=btn href=\"{base}/auth/apple?link=1&amp;return={}\">Link your Apple ID</a></p>",
693                urlencoding::encode(&format!("{base}/account"))
694            );
695        }
696    }
697    Ok(s)
698}
699
700// ─── Apple's notifications ───
701
702/// `POST /auth/apple/notifications`: Apple's server-to-server
703/// notifications (`{"payload": "<JWT>"}`), the URL registered for Sign in
704/// with Apple. Checked as Apple's, then:
705/// - `consent-revoked` (the person stopped using their Apple ID here): the
706///   link goes, and the tokens it signed in; an account with no other way
707///   in is signed out everywhere.
708/// - `account-delete` (their Apple Account is gone for good): the same, and
709///   an account with no other way in is closed, as `DELETE /api/account`
710///   does, since no one can sign in to it again.
711/// - `email-disabled`, `email-enabled`: noted only.
712///
713/// Answers 200 to anything it has handled or doesn't know, so Apple
714/// doesn't retry; 400 to what isn't Apple's.
715pub async fn notification(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
716    let apple = apple(&ctx)?;
717    let v: Value = serde_json::from_slice(&body).map_err(|_| ApiError::bad("not JSON"))?;
718    let payload = v["payload"].as_str().ok_or_else(|| ApiError::bad("no payload"))?;
719    let notice = apple.notification(payload).await.map_err(|e| {
720        tracing::warn!("Apple notification refused: {e:#}");
721        ApiError::bad(format!("{e:#}"))
722    })?;
723    let c = ctx.db.conn().await?;
724    let row = c
725        .query_opt(
726            &ctx.db.sql(
727                "SELECT a.player_id,
728                        p.password_hash IS NOT NULL OR p.esports_id IS NOT NULL
729                        OR EXISTS (SELECT 1 FROM chess.passkeys k WHERE k.player_id = p.id)
730                   FROM chess.apple_ids a JOIN chess.players p ON p.id = a.player_id WHERE a.sub = $1",
731            ),
732            &[&notice.sub],
733        )
734        .await?;
735    let Some(row) = row else {
736        tracing::info!(kind = notice.kind, "Apple notification for an Apple ID not linked here");
737        return Ok(StatusCode::OK.into_response());
738    };
739    let (player, other_way_in): (i64, bool) = (row.get(0), row.get(1));
740    match notice.kind.as_str() {
741        "consent-revoked" | "account-delete" | "account-deleted" => {
742            c.execute(&ctx.db.sql("DELETE FROM chess.apple_ids WHERE sub = $1"), &[&notice.sub]).await?;
743            let tokens = if other_way_in {
744                "UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'Apple: ' || $2
745                  WHERE player_id = $1 AND revoked_at IS NULL AND name LIKE 'Sign in with Apple:%'"
746            } else {
747                "UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'Apple: ' || $2
748                  WHERE player_id = $1 AND revoked_at IS NULL"
749            };
750            c.execute(&ctx.db.sql(tokens), &[&player, &notice.kind]).await?;
751            if !other_way_in {
752                c.execute(
753                    &ctx.db.sql("UPDATE chess.sessions SET revoked_at = now() WHERE player_id = $1 AND revoked_at IS NULL"),
754                    &[&player],
755                )
756                .await?;
757            }
758            drop(c);
759            ctx.auth.forget_player(player);
760            if notice.kind != "consent-revoked" && !other_way_in {
761                crate::http::mobile::close_account(&ctx, player).await?;
762            }
763            tracing::info!(player, kind = notice.kind, other_way_in, "Apple ID link ended by Apple");
764        }
765        other => tracing::info!(player, kind = other, "Apple notification"),
766    }
767    Ok(StatusCode::OK.into_response())
768}