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    if !get("error").is_empty() {
527        return Ok(Redirect::to(&format!(
528            "{}/login?return={}",
529            ctx.cfg.base_path().unwrap_or_default(),
530            urlencoding::encode(&ret)
531        ))
532        .into_response());
533    }
534    let id = match apple.verify(get("id_token"), &nonce).await {
535        Ok(id) if Some(id.client_id.as_str()) == apple.services_id() => id,
536        Ok(_) => return Ok(sign_in_page(&ctx, StatusCode::FORBIDDEN, "That sign-in was for the app.", &ret)),
537        Err(e) => {
538            tracing::info!("Sign in with Apple refused: {e:#}");
539            return Ok(sign_in_page(&ctx, StatusCode::FORBIDDEN, &format!("{e:#}"), &ret));
540        }
541    };
542    let user: Value = serde_json::from_str(get("user")).unwrap_or_default();
543    let hint = format!(
544        "{}{}",
545        user["name"]["firstName"].as_str().unwrap_or_default(),
546        user["name"]["lastName"].as_str().unwrap_or_default()
547    );
548    let redirect = callback_url(&ctx);
549    let mut code = Code { apple, pending: Some(get("code")), redirect: Some(&redirect), refresh: None };
550    match decide(&ctx, &id, &mut code, link, None, &hint).await? {
551        Outcome::Player { player, .. } => signed_in(&ctx, player, &ret, true).await,
552        Outcome::Linked { .. } => {
553            let mut r = web::to_account(&ctx);
554            r.headers_mut().append(header::SET_COOKIE, state_cookie(&ctx, "", 0)?);
555            Ok(r)
556        }
557        Outcome::ChooseName { suggestion } => {
558            // Apple's code lasts minutes; choosing a name may take longer.
559            let refresh = code.spend(&id).await?;
560            let ticket = random_token(40);
561            let c = ctx.db.conn().await?;
562            c.execute(&ctx.db.sql("DELETE FROM chess.apple_pending WHERE expires_at < now()"), &[]).await?;
563            c.execute(
564                &ctx.db.sql(
565                    "INSERT INTO chess.apple_pending
566                       (ticket_hash, sub, client_id, refresh_token, email, private_email, return_to, expires_at)
567                     VALUES ($1, $2, $3, $4, $5, $6, $7, now() + interval '30 minutes')",
568                ),
569                &[&hash_key(&ticket), &id.sub, &id.client_id, &refresh, &id.email, &id.private_email, &ret],
570            )
571            .await?;
572            Ok(name_form(&ctx, &ticket, &suggestion, None))
573        }
574        Outcome::Refused(status, _, message) => Ok(sign_in_page(&ctx, status, message, &ret)),
575    }
576}
577
578/// `POST /auth/apple/name`: the username for a new Apple ID (`ticket`, `name`).
579pub async fn choose_name(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
580    let f = form(&body);
581    let ticket = f.get("ticket").map_or("", String::as_str);
582    let name = f.get("name").map_or("", |s| s.trim());
583    let apple = apple(&ctx)?;
584    let row = {
585        let c = ctx.db.conn().await?;
586        c.query_opt(
587            &ctx.db.sql(
588                "SELECT sub, client_id, refresh_token, email, private_email, return_to
589                   FROM chess.apple_pending WHERE ticket_hash = $1 AND expires_at > now()",
590            ),
591            &[&hash_key(ticket)],
592        )
593        .await?
594    };
595    let home = web::back_to(&ctx, None);
596    let Some(row) = row else {
597        return Ok(sign_in_page(
598            &ctx,
599            StatusCode::BAD_REQUEST,
600            "That sign-in has expired: try again.",
601            &home,
602        ));
603    };
604    let id = AppleId { sub: row.get(0), client_id: row.get(1), email: row.get(3), private_email: row.get(4) };
605    let ret: String = row.get(5);
606    let mut code = Code { apple, pending: None, redirect: None, refresh: row.get(2) };
607    match decide(&ctx, &id, &mut code, None, Some(name), "").await? {
608        Outcome::Player { player, .. } => {
609            let c = ctx.db.conn().await?;
610            c.execute(
611                &ctx.db.sql("DELETE FROM chess.apple_pending WHERE ticket_hash = $1"),
612                &[&hash_key(ticket)],
613            )
614            .await?;
615            signed_in(&ctx, player, &ret, false).await
616        }
617        Outcome::Refused(_, "bad_username", why) => Ok(name_form(&ctx, ticket, name, Some(why))),
618        Outcome::ChooseName { suggestion } => {
619            Ok(name_form(&ctx, ticket, &suggestion, Some("Choose a username.")))
620        }
621        Outcome::Linked { .. } => Ok(web::to_account(&ctx)),
622        Outcome::Refused(status, _, message) => Ok(sign_in_page(&ctx, status, message, &ret)),
623    }
624}
625
626/// `POST /account/apple/remove`: unlink from the account page.
627pub async fn account_remove(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
628    let (player, session) =
629        web::signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
630    web::form_csrf_ok(&form(&body), &session)?;
631    web::may_change(&ctx, player, &session).await?;
632    match unlink_player(&ctx, player).await? {
633        Ok(()) => Ok(web::to_account(&ctx)),
634        Err(why) => Err(ApiError::conflict(why)),
635    }
636}
637
638/// The account page's Apple ID section.
639pub async fn account_section(ctx: &Ctx, player: i64, csrf: &str, locked: bool) -> ApiResult<String> {
640    let base = ctx.cfg.base_path().unwrap_or_default();
641    let c = ctx.db.conn().await?;
642    let row = c
643        .query_opt(
644            &ctx.db.sql(
645                "SELECT a.email, a.private_email, to_char(a.created_at, 'YYYY-MM-DD'),
646                        p.password_hash IS NOT NULL OR p.esports_id IS NOT NULL
647                        OR EXISTS (SELECT 1 FROM chess.passkeys k WHERE k.player_id = p.id)
648                   FROM chess.apple_ids a JOIN chess.players p ON p.id = a.player_id WHERE a.player_id = $1",
649            ),
650            &[&player],
651        )
652        .await?;
653    if row.is_none() && !on_web(ctx) {
654        return Ok(String::new());
655    }
656    let mut s = String::from("<h2>Apple ID</h2>");
657    match row {
658        Some(r) => {
659            let email: Option<String> = r.get(0);
660            let private: bool = r.get(1);
661            let shown = match (email, private) {
662                (Some(_), true) => " (with a private email address)".to_owned(),
663                (Some(e), false) => format!(" ({})", html_escape(&e)),
664                (None, _) => String::new(),
665            };
666            let _ = write!(
667                s,
668                "<p>Linked{shown} since {}: you can sign in with Apple.</p>",
669                r.get::<_, String>(2)
670            );
671            let other_way_in: bool = r.get(3);
672            if !other_way_in {
673                s.push_str("<p class=muted>To unlink it, add a password or a passkey first.</p>");
674            } else if !locked {
675                let _ = write!(
676                    s,
677                    "<form method=post action=\"{base}/account/apple/remove\">{}<button>Unlink your Apple ID</button></form>",
678                    web::hidden("csrf", csrf)
679                );
680            }
681        }
682        None => {
683            let _ = write!(
684                s,
685                "<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>",
686                urlencoding::encode(&format!("{base}/account"))
687            );
688        }
689    }
690    Ok(s)
691}
692
693// ─── Apple's notifications ───
694
695/// `POST /auth/apple/notifications`: Apple's server-to-server
696/// notifications (`{"payload": "<JWT>"}`), the URL registered for Sign in
697/// with Apple. Checked as Apple's, then:
698/// - `consent-revoked` (the person stopped using their Apple ID here): the
699///   link goes, and the tokens it signed in; an account with no other way
700///   in is signed out everywhere.
701/// - `account-delete` (their Apple Account is gone for good): the same, and
702///   an account with no other way in is closed, as `DELETE /api/account`
703///   does, since no one can sign in to it again.
704/// - `email-disabled`, `email-enabled`: noted only.
705///
706/// Answers 200 to anything it has handled or doesn't know, so Apple
707/// doesn't retry; 400 to what isn't Apple's.
708pub async fn notification(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
709    let apple = apple(&ctx)?;
710    let v: Value = serde_json::from_slice(&body).map_err(|_| ApiError::bad("not JSON"))?;
711    let payload = v["payload"].as_str().ok_or_else(|| ApiError::bad("no payload"))?;
712    let notice = apple.notification(payload).await.map_err(|e| {
713        tracing::warn!("Apple notification refused: {e:#}");
714        ApiError::bad(format!("{e:#}"))
715    })?;
716    let c = ctx.db.conn().await?;
717    let row = c
718        .query_opt(
719            &ctx.db.sql(
720                "SELECT a.player_id,
721                        p.password_hash IS NOT NULL OR p.esports_id IS NOT NULL
722                        OR EXISTS (SELECT 1 FROM chess.passkeys k WHERE k.player_id = p.id)
723                   FROM chess.apple_ids a JOIN chess.players p ON p.id = a.player_id WHERE a.sub = $1",
724            ),
725            &[&notice.sub],
726        )
727        .await?;
728    let Some(row) = row else {
729        tracing::info!(kind = notice.kind, "Apple notification for an Apple ID not linked here");
730        return Ok(StatusCode::OK.into_response());
731    };
732    let (player, other_way_in): (i64, bool) = (row.get(0), row.get(1));
733    match notice.kind.as_str() {
734        "consent-revoked" | "account-delete" | "account-deleted" => {
735            c.execute(&ctx.db.sql("DELETE FROM chess.apple_ids WHERE sub = $1"), &[&notice.sub]).await?;
736            let tokens = if other_way_in {
737                "UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'Apple: ' || $2
738                  WHERE player_id = $1 AND revoked_at IS NULL AND name LIKE 'Sign in with Apple:%'"
739            } else {
740                "UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'Apple: ' || $2
741                  WHERE player_id = $1 AND revoked_at IS NULL"
742            };
743            c.execute(&ctx.db.sql(tokens), &[&player, &notice.kind]).await?;
744            if !other_way_in {
745                c.execute(
746                    &ctx.db.sql("UPDATE chess.sessions SET revoked_at = now() WHERE player_id = $1 AND revoked_at IS NULL"),
747                    &[&player],
748                )
749                .await?;
750            }
751            drop(c);
752            ctx.auth.forget_player(player);
753            if notice.kind != "consent-revoked" && !other_way_in {
754                crate::http::mobile::close_account(&ctx, player).await?;
755            }
756            tracing::info!(player, kind = notice.kind, other_way_in, "Apple ID link ended by Apple");
757        }
758        other => tracing::info!(player, kind = other, "Apple notification"),
759    }
760    Ok(StatusCode::OK.into_response())
761}