1use 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;
44const 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 #[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
72async 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
102struct Code<'a> {
105 apple: &'a Apple,
106 pending: Option<&'a str>,
107 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#[derive(Debug)]
126enum Outcome {
127 Player { player: i64, created: bool },
129 Linked { player: i64 },
131 ChooseName { suggestion: String },
133 Refused(StatusCode, &'static str, &'static str),
135}
136
137async 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 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
254async 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
278pub 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
324async 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
357pub 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
365pub 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
375pub 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
386fn 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
434async 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
445pub 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
490pub 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 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
578pub 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
626pub 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
638pub 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&return={}\">Link your Apple ID</a></p>",
686 urlencoding::encode(&format!("{base}/account"))
687 );
688 }
689 }
690 Ok(s)
691}
692
693pub 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 &[¬ice.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"), &[¬ice.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, ¬ice.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}