Skip to main content

open_chess_server/http/
web.rs

1//! Signing in to this server: its own accounts (a username and a
2//! password), and, with `ESPORTS_LOGIN`, "Sign in with the host site"
3//! (esports-lab). Both end in this server's session cookie, which the OAuth
4//! consent page and the Account page (API tokens) use.
5//!
6//! **The host site.** It shares a secret with this server
7//! (`ESPORTS_SECRET`) and uses it two ways:
8//!
9//! * Signing in: `GET /auth/esports` sends the browser to the site's
10//!   `/chess/sso?return=…&nonce=…`; the site sends it back to
11//!   `/auth/esports/back?a=…` with a signed statement of who is signed in
12//!   there ([`sign`]: `base64url(json).base64url(HMAC-SHA256)`, the JSON
13//!   `{sub, name, email, role, roles, nonce, exp}`).
14//! * Its server asks for a token for one of its players:
15//!   `POST /api/esports/token` with `Authorization: Bearer <secret>` and
16//!   the same fields as a form (plus `scopes`, `days`, `label`).
17//!
18//! Either way the site's player is linked to a player here by `esports_id`
19//! (made on first use, with a chess account named after them), so the
20//! same person is the same chess player whichever way they come in.
21
22use std::collections::HashMap;
23use std::fmt::Write as _;
24
25use argon2::Argon2;
26use argon2::password_hash::phc::PasswordHash;
27use argon2::password_hash::{PasswordHasher, PasswordVerifier};
28use axum::Json;
29use axum::extract::{Query, State};
30use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
31use axum::response::{IntoResponse, Redirect, Response};
32use base64::Engine as _;
33use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
34use bytes::Bytes;
35use chrono::{Duration, Utc};
36use serde_json::{Value, json};
37use sha2::{Digest, Sha256};
38
39use crate::app::Ctx;
40use crate::auth::{SCOPES, SESSION_COOKIE, TOKEN_PREFIX, bearer, cookie, hash_key, random_token};
41use crate::error::{ApiError, ApiResult};
42use crate::http::oauth::page;
43use crate::http::{form, html_escape};
44use crate::model;
45
46const SESSION_DAYS: i64 = 30;
47const SSO_COOKIE: &str = "oc_sso";
48
49// ─── HMAC and the site's signed statements ───
50
51/// HMAC-SHA256 (RFC 2104).
52pub fn hmac(key: &[u8], msg: &[u8]) -> [u8; 32] {
53    let mut k = [0u8; 64];
54    if key.len() > 64 {
55        k[..32].copy_from_slice(&Sha256::digest(key));
56    } else {
57        k[..key.len()].copy_from_slice(key);
58    }
59    let pad = |b: u8| k.map(|x| x ^ b);
60    let inner = Sha256::new().chain_update(pad(0x36)).chain_update(msg).finalize();
61    Sha256::new().chain_update(pad(0x5c)).chain_update(inner).finalize().into()
62}
63
64fn same(a: &[u8], b: &[u8]) -> bool {
65    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
66}
67
68/// A signed statement: `base64url(json).base64url(hmac)`.
69pub fn sign(secret: &str, v: &Value) -> String {
70    let body = B64.encode(v.to_string());
71    let mac = B64.encode(hmac(secret.as_bytes(), body.as_bytes()));
72    format!("{body}.{mac}")
73}
74
75/// The statement, if its signature is right and it hasn't expired.
76pub fn verify(secret: &str, s: &str, now_secs: i64) -> Option<Value> {
77    let (body, mac) = s.split_once('.')?;
78    let want = hmac(secret.as_bytes(), body.as_bytes());
79    if !same(&B64.decode(mac).ok()?, &want) {
80        return None;
81    }
82    let v: Value = serde_json::from_slice(&B64.decode(body).ok()?).ok()?;
83    (v.get("exp")?.as_i64()? > now_secs).then_some(v)
84}
85
86/// A player of the host site, as it describes them.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct SitePlayer {
89    pub sub: String,
90    pub name: String,
91    pub email: Option<String>,
92    pub role: String,
93    pub roles: Vec<String>,
94}
95
96impl SitePlayer {
97    fn from_fields(get: impl Fn(&str) -> Option<String>) -> Option<SitePlayer> {
98        let sub = get("sub").filter(|s| !s.is_empty() && s.len() <= 64)?;
99        Some(SitePlayer {
100            name: get("name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("player{sub}")),
101            email: get("email").filter(|e| !e.is_empty()),
102            role: get("role").unwrap_or_else(|| "player".into()),
103            roles: get("roles")
104                .map(|r| r.split([',', ' ']).filter(|x| !x.is_empty()).map(str::to_owned).collect())
105                .unwrap_or_default(),
106            sub,
107        })
108    }
109
110    fn from_json(v: &Value) -> Option<SitePlayer> {
111        SitePlayer::from_fields(|k| match v.get(k)? {
112            Value::String(s) => Some(s.clone()),
113            Value::Number(n) => Some(n.to_string()),
114            Value::Array(a) => Some(a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(",")),
115            _ => None,
116        })
117    }
118}
119
120/// The player here linked to the site's, made on first use (and kept up
121/// to date: email and roles). Returns the player's id and chess username.
122pub async fn link(ctx: &Ctx, p: &SitePlayer) -> ApiResult<(i64, String)> {
123    let c = ctx.db.conn().await?;
124    let id: i64 = c
125        .query_one(
126            &ctx.db.sql(
127                "INSERT INTO chess.players (name, email, role, roles, esports_id, key_hash)
128                 VALUES ($1, $2, $3, $4, $5, $6)
129                 ON CONFLICT (esports_id) DO UPDATE
130                    SET email = EXCLUDED.email, role = EXCLUDED.role, roles = EXCLUDED.roles
131                 RETURNING id",
132            ),
133            &[&p.name, &p.email, &p.role, &p.roles, &p.sub, &hash_key(&random_token(28))],
134        )
135        .await?
136        .get(0);
137    let a = model::ensure_account(&ctx.db, &*c, id).await?;
138    Ok((id, a.username))
139}
140
141// ─── sessions ───
142
143fn secure(ctx: &Ctx) -> &'static str {
144    if ctx.cfg.public_url().starts_with("https://") { "; Secure" } else { "" }
145}
146
147fn cookie_path(ctx: &Ctx) -> String {
148    let base = ctx.cfg.base_path().unwrap_or_default();
149    if base.is_empty() { "/".into() } else { base }
150}
151
152/// Start a session: the cookie to set.
153async fn start_session(ctx: &Ctx, player: i64) -> ApiResult<HeaderValue> {
154    let token = random_token(40);
155    let c = ctx.db.conn().await?;
156    c.execute(
157        &ctx.db.sql(
158            "INSERT INTO chess.sessions (token_hash, player_id, expires_at) VALUES ($1, $2, now() + make_interval(days => $3))",
159        ),
160        &[&hash_key(&token), &player, &i32::try_from(SESSION_DAYS).unwrap_or(30)],
161    )
162    .await?;
163    HeaderValue::from_str(&format!(
164        "{SESSION_COOKIE}={token}; Path={}; HttpOnly; SameSite=Lax; Max-Age={}{}",
165        cookie_path(ctx),
166        SESSION_DAYS * 86_400,
167        secure(ctx)
168    ))
169    .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))
170}
171
172/// Where to go after signing in: a path on this server, or the Account page.
173fn back_to(ctx: &Ctx, want: Option<&str>) -> String {
174    let base = ctx.cfg.base_path().unwrap_or_default();
175    match want {
176        Some(p) if p.starts_with('/') && !p.starts_with("//") && !p.contains('\\') => p.to_owned(),
177        _ => format!("{base}/account"),
178    }
179}
180
181fn redirect_with_cookies(to: &str, cookies: Vec<HeaderValue>) -> Response {
182    let mut r = Redirect::to(to).into_response();
183    for c in cookies {
184        r.headers_mut().append(header::SET_COOKIE, c);
185    }
186    r
187}
188
189/// A form tied to the session that saw it.
190fn csrf(session: &str, what: &str) -> String {
191    hash_key(&format!("{session}|{what}"))
192}
193
194// ─── pages ───
195
196fn hidden(k: &str, v: &str) -> String {
197    format!("<input type=hidden name=\"{k}\" value=\"{}\">", html_escape(v))
198}
199
200fn sign_in_form(ctx: &Ctx, ret: &str, error: Option<&str>) -> String {
201    let base = ctx.cfg.base_path().unwrap_or_default();
202    let mut body = String::new();
203    if let Some(e) = error {
204        let _ = write!(body, "<p class=err>{}</p>", html_escape(e));
205    }
206    if ctx.cfg.esports().is_some() {
207        let _ = write!(
208            body,
209            "<p><a class=btn href=\"{base}/auth/esports?return={r}\">Sign in with {site}</a></p><p class=muted>or with an account here:</p>",
210            r = urlencoding::encode(ret),
211            site = html_escape(&ctx.cfg.site_name)
212        );
213    }
214    let _ = write!(
215        body,
216        "<form method=post action=\"{base}/login\">{h}\
217         <label>Username or email<input name=name autocomplete=username required></label>\
218         <label>Password<input name=password type=password autocomplete=current-password required></label>\
219         <button class=go>Sign in</button></form>",
220        h = hidden("return", ret)
221    );
222    if ctx.cfg.signup {
223        let _ = write!(
224            body,
225            "<p class=muted>No account? <a href=\"{base}/signup?return={}\">Make one</a>.</p>",
226            urlencoding::encode(ret)
227        );
228    }
229    body
230}
231
232/// `GET /login`.
233pub async fn login_page(State(ctx): State<Ctx>, Query(q): Query<HashMap<String, String>>) -> Response {
234    let ret = back_to(&ctx, q.get("return").map(String::as_str));
235    page("Sign in", &sign_in_form(&ctx, &ret, q.get("error").map(String::as_str)))
236}
237
238/// `POST /login`: a username (or email) and password.
239pub async fn login(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
240    let f = form(&body);
241    let ret = back_to(&ctx, f.get("return").map(String::as_str));
242    let name = f.get("name").map_or("", |s| s.trim());
243    let password = f.get("password").map_or("", String::as_str);
244    let c = ctx.db.conn().await?;
245    let row = c
246        .query_opt(
247            &ctx.db.sql(
248                "SELECT p.id, p.password_hash FROM chess.players p JOIN chess.accounts a ON a.player_id = p.id
249                  WHERE (lower(a.username) = lower($1) OR lower(p.email) = lower($1))
250                    AND p.disabled_at IS NULL AND p.password_hash IS NOT NULL
251                  LIMIT 1",
252            ),
253            &[&name],
254        )
255        .await?;
256    drop(c);
257    let ok = row.as_ref().is_some_and(|r| {
258        let stored: String = r.get(1);
259        PasswordHash::new(&stored)
260            .is_ok_and(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok())
261    });
262    let Some(row) = row.filter(|_| ok) else {
263        // The same pace either way, and a little slower.
264        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
265        return Ok((
266            StatusCode::UNAUTHORIZED,
267            page(
268                "Sign in",
269                &sign_in_form(&ctx, &ret, Some("That name and password don't match an account here.")),
270            ),
271        )
272            .into_response());
273    };
274    let cookie = start_session(&ctx, row.get(0)).await?;
275    Ok(redirect_with_cookies(&ret, vec![cookie]))
276}
277
278fn signup_form(ctx: &Ctx, ret: &str, error: Option<&str>, name: &str, email: &str) -> String {
279    let base = ctx.cfg.base_path().unwrap_or_default();
280    let mut body = String::new();
281    if let Some(e) = error {
282        let _ = write!(body, "<p class=err>{}</p>", html_escape(e));
283    }
284    let _ = write!(
285        body,
286        "<form method=post action=\"{base}/signup\">{h}\
287         <label>Username (letters, digits, _ and -)<input name=name value=\"{n}\" autocomplete=username required minlength=2 maxlength=20></label>\
288         <label>Email (optional, to sign in with)<input name=email type=email value=\"{e}\" autocomplete=email></label>\
289         <label>Password (10 or more characters)<input name=password type=password autocomplete=new-password required minlength=10></label>\
290         <button class=go>Make the account</button></form>\
291         <p class=muted>Already have one? <a href=\"{base}/login?return={r}\">Sign in</a>.</p>",
292        h = hidden("return", ret),
293        n = html_escape(name),
294        e = html_escape(email),
295        r = urlencoding::encode(ret),
296    );
297    body
298}
299
300/// `GET /signup`.
301pub async fn signup_page(State(ctx): State<Ctx>, Query(q): Query<HashMap<String, String>>) -> Response {
302    if !ctx.cfg.signup {
303        return (
304            StatusCode::FORBIDDEN,
305            page("No sign-ups here", "<p>This server doesn't take new accounts.</p>"),
306        )
307            .into_response();
308    }
309    let ret = back_to(&ctx, q.get("return").map(String::as_str));
310    page("Make an account", &signup_form(&ctx, &ret, None, "", ""))
311}
312
313/// `POST /signup`: a username, a password and (optionally) an email.
314pub async fn signup(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
315    if !ctx.cfg.signup {
316        return Err(ApiError::forbidden("this server doesn't take new accounts"));
317    }
318    let f = form(&body);
319    let ret = back_to(&ctx, f.get("return").map(String::as_str));
320    let name = f.get("name").map_or("", |s| s.trim()).to_owned();
321    let email = f.get("email").map_or("", |s| s.trim()).to_owned();
322    let password = f.get("password").cloned().unwrap_or_default();
323    let refuse = |why: &str| {
324        Ok((
325            StatusCode::BAD_REQUEST,
326            page("Make an account", &signup_form(&ctx, &ret, Some(why), &name, &email)),
327        )
328            .into_response())
329    };
330    let valid = name.len() >= 2
331        && name.len() <= 20
332        && name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
333        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
334    if !valid {
335        return refuse("A username is 2 to 20 letters, digits, _ and -, starting with a letter or digit.");
336    }
337    if name.to_lowercase().starts_with("ai-level-") {
338        return refuse("That name is the computer's.");
339    }
340    if password.chars().count() < 10 {
341        return refuse("The password needs 10 or more characters.");
342    }
343    if !email.is_empty() && !email.contains('@') {
344        return refuse("That email doesn't look right.");
345    }
346    let hash = Argon2::default()
347        .hash_password(password.as_bytes())
348        .map_err(|e| ApiError::from(anyhow::anyhow!("hashing the password: {e}")))?
349        .to_string();
350    let mut c = ctx.db.conn().await?;
351    let tx = c.transaction().await?;
352    if model::account_by_name(&ctx.db, &tx, &name).await?.is_some() {
353        return refuse("That username is taken.");
354    }
355    if !email.is_empty()
356        && tx
357            .query_opt(&ctx.db.sql("SELECT 1 FROM chess.players WHERE lower(email) = lower($1)"), &[&email])
358            .await?
359            .is_some()
360    {
361        return refuse("An account here already has that email.");
362    }
363    let id: i64 = tx
364        .query_one(
365            &ctx.db.sql(
366                "INSERT INTO chess.players (name, email, password_hash) VALUES ($1, $2, $3) RETURNING id",
367            ),
368            &[&name, &(!email.is_empty()).then_some(&email), &hash],
369        )
370        .await?
371        .get(0);
372    tx.execute(
373        &ctx.db.sql("INSERT INTO chess.accounts (player_id, username) VALUES ($1, $2)"),
374        &[&id, &name],
375    )
376    .await?;
377    tx.commit().await?;
378    let cookie = start_session(&ctx, id).await?;
379    Ok(redirect_with_cookies(&ret, vec![cookie]))
380}
381
382/// `POST /logout`.
383pub async fn logout(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
384    if let Some(t) = cookie(&h, SESSION_COOKIE) {
385        let c = ctx.db.conn().await?;
386        c.execute(
387            &ctx.db.sql("UPDATE chess.sessions SET revoked_at = now() WHERE token_hash = $1"),
388            &[&hash_key(&t)],
389        )
390        .await?;
391    }
392    let base = ctx.cfg.base_path().unwrap_or_default();
393    let gone = HeaderValue::from_str(&format!(
394        "{SESSION_COOKIE}=; Path={}; Max-Age=0; HttpOnly; SameSite=Lax",
395        cookie_path(&ctx)
396    ))
397    .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))?;
398    Ok(redirect_with_cookies(&format!("{base}/login"), vec![gone]))
399}
400
401/// The scopes a player may give a token (staff-only ones aside).
402pub async fn allowed(ctx: &Ctx, player: i64, wanted: &[String]) -> ApiResult<Vec<String>> {
403    let c = ctx.db.conn().await?;
404    let r =
405        c.query_one(&ctx.db.sql("SELECT role, roles FROM chess.players WHERE id = $1"), &[&player]).await?;
406    let role: String = r.get(0);
407    let roles: Vec<String> = r.get(1);
408    let staff = role == "admin" || roles.iter().any(|x| x == "manager");
409    Ok(wanted
410        .iter()
411        .filter(|s| SCOPES.iter().any(|(k, _)| k == s))
412        .filter(|s| staff || !matches!(s.as_str(), "chess:fairplay" | "bot:play"))
413        .cloned()
414        .collect())
415}
416
417/// Make a token; returns it (shown once) and when it expires.
418pub async fn issue(
419    ctx: &Ctx,
420    player: i64,
421    label: &str,
422    scopes: &[String],
423    days: i64,
424) -> ApiResult<(String, chrono::DateTime<Utc>)> {
425    issue_for(ctx, player, label, scopes, Duration::days(days.clamp(1, 365))).await
426}
427
428/// Make a token lasting `life`.
429pub async fn issue_for(
430    ctx: &Ctx,
431    player: i64,
432    label: &str,
433    scopes: &[String],
434    life: Duration,
435) -> ApiResult<(String, chrono::DateTime<Utc>)> {
436    let token = format!("{TOKEN_PREFIX}{}", random_token(36));
437    let prefix: String = token.chars().take(TOKEN_PREFIX.len() + 6).collect();
438    let expires = Utc::now() + life;
439    let label: String = label.trim().chars().take(80).collect();
440    let c = ctx.db.conn().await?;
441    c.execute(
442        &ctx.db.sql(
443            "INSERT INTO chess.api_tokens (player_id, name, token_hash, prefix, scopes, expires_at)
444             VALUES ($1, $2, $3, $4, $5, $6)",
445        ),
446        &[
447            &player,
448            &if label.is_empty() { "token".to_owned() } else { label },
449            &hash_key(&token),
450            &prefix,
451            &scopes,
452            &expires,
453        ],
454    )
455    .await?;
456    Ok((token, expires))
457}
458
459async fn account_page(ctx: &Ctx, player: i64, session: &str, fresh: Option<&str>) -> ApiResult<Response> {
460    let base = ctx.cfg.base_path().unwrap_or_default();
461    let c = ctx.db.conn().await?;
462    let a = model::ensure_account(&ctx.db, &*c, player).await?;
463    let linked: Option<String> = c
464        .query_one(&ctx.db.sql("SELECT esports_id FROM chess.players WHERE id = $1"), &[&player])
465        .await?
466        .get(0);
467    let tokens = c
468        .query(
469            &ctx.db.sql(
470                "SELECT id, name, prefix, scopes, to_char(expires_at, 'YYYY-MM-DD'),
471                        coalesce(to_char(last_used_at, 'YYYY-MM-DD'), 'never')
472                   FROM chess.api_tokens
473                  WHERE player_id = $1 AND revoked_at IS NULL AND expires_at > now()
474                  ORDER BY created_at DESC",
475            ),
476            &[&player],
477        )
478        .await?;
479    drop(c);
480    let token_csrf = csrf(session, "tokens");
481    let mut body = format!(
482        "<p>Signed in as <b>{}</b>{}.</p>",
483        html_escape(&a.username),
484        if linked.is_some() {
485            format!(" (linked to your {} account)", html_escape(&ctx.cfg.site_name))
486        } else {
487            String::new()
488        }
489    );
490    if let Some(t) = fresh {
491        let _ = write!(
492            body,
493            "<p class=ok>Your new token, shown once: copy it now.</p><p><code class=token>{}</code></p>",
494            html_escape(t)
495        );
496    }
497    body.push_str("<h2>API tokens</h2>");
498    if tokens.is_empty() {
499        body.push_str("<p class=muted>None yet.</p>");
500    } else {
501        body.push_str("<table><tr><th>Name</th><th>Token</th><th>Scopes</th><th>Expires</th><th>Last used</th><th></th></tr>");
502        for t in &tokens {
503            let id: i64 = t.get(0);
504            let scopes: Vec<String> = t.get(3);
505            let _ = write!(
506                body,
507                "<tr><td>{}</td><td><code>{}…</code></td><td>{}</td><td>{}</td><td>{}</td>\
508                 <td><form method=post action=\"{base}/account/tokens/revoke\">{}{}<button>Revoke</button></form></td></tr>",
509                html_escape(&t.get::<_, String>(1)),
510                html_escape(&t.get::<_, String>(2)),
511                html_escape(&scopes.join(" ")),
512                t.get::<_, String>(4),
513                t.get::<_, String>(5),
514                hidden("csrf", &token_csrf),
515                hidden("id", &id.to_string()),
516            );
517        }
518        body.push_str("</table>");
519    }
520    let mut boxes = String::new();
521    for (k, d) in SCOPES {
522        let checked =
523            if matches!(*k, "board:play" | "challenge:read" | "challenge:write") { " checked" } else { "" };
524        let _ = write!(
525            boxes,
526            "<label class=check><input type=checkbox name=\"scope:{k}\"{checked}> {} <code>{k}</code></label>",
527            html_escape(d)
528        );
529    }
530    let _ = write!(
531        body,
532        "<h2>New token</h2><form method=post action=\"{base}/account/tokens\">{c}\
533         <label>Name<input name=label placeholder=\"e.g. my board\" maxlength=80></label>{boxes}\
534         <label>Days it lasts<input name=days type=number min=1 max=365 value=365></label>\
535         <button class=go>Make the token</button></form>\
536         <form method=post action=\"{base}/logout\"><button>Sign out</button></form>",
537        c = hidden("csrf", &token_csrf)
538    );
539    Ok(page("Your chess account", &body))
540}
541
542async fn signed_in(ctx: &Ctx, h: &HeaderMap) -> ApiResult<Option<(i64, String)>> {
543    let session = cookie(h, SESSION_COOKIE).unwrap_or_default();
544    Ok(ctx.auth.session(h).await?.map(|p| (p, session)))
545}
546
547fn to_login(ctx: &Ctx, ret: &str) -> Response {
548    let base = ctx.cfg.base_path().unwrap_or_default();
549    Redirect::to(&format!("{base}/login?return={}", urlencoding::encode(ret))).into_response()
550}
551
552/// `GET /account`.
553pub async fn account(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
554    let base = ctx.cfg.base_path().unwrap_or_default();
555    let Some((player, session)) = signed_in(&ctx, &h).await? else {
556        return Ok(to_login(&ctx, &format!("{base}/account")));
557    };
558    account_page(&ctx, player, &session, None).await
559}
560
561/// `POST /account/tokens`.
562pub async fn new_token(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
563    let (player, session) = signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
564    let f = form(&body);
565    if f.get("csrf").map(String::as_str) != Some(csrf(&session, "tokens").as_str()) {
566        return Err(ApiError::forbidden("this form has expired: reload the page"));
567    }
568    let wanted: Vec<String> = f.keys().filter_map(|k| k.strip_prefix("scope:")).map(str::to_owned).collect();
569    let scopes = allowed(&ctx, player, &wanted).await?;
570    let days = f.get("days").and_then(|d| d.parse().ok()).unwrap_or(365);
571    let (token, _) = issue(&ctx, player, f.get("label").map_or("", String::as_str), &scopes, days).await?;
572    account_page(&ctx, player, &session, Some(&token)).await
573}
574
575/// `POST /account/tokens/revoke`.
576pub async fn revoke_token(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
577    let (player, session) = signed_in(&ctx, &h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
578    let f = form(&body);
579    if f.get("csrf").map(String::as_str) != Some(csrf(&session, "tokens").as_str()) {
580        return Err(ApiError::forbidden("this form has expired: reload the page"));
581    }
582    let id: i64 = f.get("id").and_then(|i| i.parse().ok()).ok_or_else(|| ApiError::bad("which token?"))?;
583    let c = ctx.db.conn().await?;
584    c.execute(
585        &ctx.db.sql(
586            "UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'chess: account page'
587              WHERE id = $1 AND player_id = $2 AND revoked_at IS NULL",
588        ),
589        &[&id, &player],
590    )
591    .await?;
592    let base = ctx.cfg.base_path().unwrap_or_default();
593    Ok(Redirect::to(&format!("{base}/account")).into_response())
594}
595
596// ─── the host site ───
597
598/// `GET /auth/esports`: off to the site to sign in.
599pub async fn esports_start(
600    State(ctx): State<Ctx>,
601    Query(q): Query<HashMap<String, String>>,
602) -> ApiResult<Response> {
603    if ctx.cfg.esports().is_none() {
604        return Err(ApiError::not_found("signing in with the site"));
605    }
606    let ret = back_to(&ctx, q.get("return").map(String::as_str));
607    let nonce = random_token(24);
608    let back = format!("{}/auth/esports/back", ctx.cfg.public_url());
609    let to = format!(
610        "{}/chess/sso?return={}&nonce={nonce}",
611        ctx.cfg.site_url.trim_end_matches('/'),
612        urlencoding::encode(&back)
613    );
614    let c = HeaderValue::from_str(&format!(
615        "{SSO_COOKIE}={nonce}.{}; Path={}; Max-Age=600; HttpOnly; SameSite=Lax{}",
616        B64.encode(&ret),
617        cookie_path(&ctx),
618        secure(&ctx)
619    ))
620    .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))?;
621    Ok(redirect_with_cookies(&to, vec![c]))
622}
623
624/// `GET /auth/esports/back?a=…`: back from the site, signed in there.
625pub async fn esports_back(
626    State(ctx): State<Ctx>,
627    h: HeaderMap,
628    Query(q): Query<HashMap<String, String>>,
629) -> ApiResult<Response> {
630    let secret = ctx.cfg.esports().ok_or_else(|| ApiError::not_found("signing in with the site"))?.to_owned();
631    let (nonce, ret) = cookie(&h, SSO_COOKIE)
632        .and_then(|c| {
633            let (n, r) = c.split_once('.')?;
634            Some((n.to_owned(), String::from_utf8(B64.decode(r).ok()?).ok()?))
635        })
636        .ok_or_else(|| ApiError::forbidden("this sign-in has expired: start again"))?;
637    let v = q
638        .get("a")
639        .and_then(|a| verify(&secret, a, Utc::now().timestamp()))
640        .ok_or_else(|| ApiError::forbidden("the site's answer isn't valid or has expired: start again"))?;
641    if v.get("nonce").and_then(Value::as_str) != Some(nonce.as_str()) {
642        return Err(ApiError::forbidden("this sign-in was for another page: start again"));
643    }
644    let p = SitePlayer::from_json(&v).ok_or_else(|| ApiError::bad("the site didn't say who you are"))?;
645    let (player, _) = link(&ctx, &p).await?;
646    let session = start_session(&ctx, player).await?;
647    let gone = HeaderValue::from_str(&format!("{SSO_COOKIE}=; Path={}; Max-Age=0", cookie_path(&ctx)))
648        .map_err(|e| ApiError::from(anyhow::anyhow!("{e}")))?;
649    Ok(redirect_with_cookies(&back_to(&ctx, Some(&ret)), vec![session, gone]))
650}
651
652/// `POST /api/esports/token`: the site's server asks for a token for one
653/// of its players (who gets linked, and a chess account, on first use).
654pub async fn esports_token(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
655    let secret = ctx.cfg.esports().ok_or_else(|| ApiError::not_found("the site's token endpoint"))?;
656    if !bearer(&h).is_some_and(|b| same(b.as_bytes(), secret.as_bytes())) {
657        return Err(ApiError::unauthorized());
658    }
659    let f = form(&body);
660    let p = SitePlayer::from_fields(|k| f.get(k).cloned())
661        .ok_or_else(|| ApiError::bad("sub (the site's player id) is required"))?;
662    let (player, username) = link(&ctx, &p).await?;
663    let wanted: Vec<String> = f.get("scopes").map_or_else(
664        || ["board:play", "challenge:read", "challenge:write"].map(str::to_owned).to_vec(),
665        |s| s.split([' ', ',']).filter(|x| !x.is_empty()).map(str::to_owned).collect(),
666    );
667    let scopes = allowed(&ctx, player, &wanted).await?;
668    let label = f.get("label").map_or_else(|| format!("{} (site)", ctx.cfg.site_name), Clone::clone);
669    let (token, expires) = match f.get("hours").and_then(|h| h.parse::<i64>().ok()) {
670        Some(hours) => {
671            issue_for(&ctx, player, &label, &scopes, Duration::hours(hours.clamp(1, 24 * 365))).await?
672        }
673        None => {
674            issue(&ctx, player, &label, &scopes, f.get("days").and_then(|d| d.parse().ok()).unwrap_or(30))
675                .await?
676        }
677    };
678    Ok(Json(json!({
679        "token": token,
680        "username": username,
681        "scopes": scopes,
682        "expires": expires.timestamp_millis(),
683    }))
684    .into_response())
685}
686
687/// `GET /api/esports/players` (bearer the secret): the site's players
688/// linked here, `{"players": [{"sub", "username"}]}`, so the site can tell
689/// whose games the feed shows.
690pub async fn esports_players(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
691    let secret = ctx.cfg.esports().ok_or_else(|| ApiError::not_found("the site's token endpoint"))?;
692    if !bearer(&h).is_some_and(|b| same(b.as_bytes(), secret.as_bytes())) {
693        return Err(ApiError::unauthorized());
694    }
695    let c = ctx.db.conn().await?;
696    let rows = c
697        .query(
698            &ctx.db.sql(
699                "SELECT p.esports_id, a.username FROM chess.players p JOIN chess.accounts a ON a.player_id = p.id
700                  WHERE p.esports_id IS NOT NULL ORDER BY p.id",
701            ),
702            &[],
703        )
704        .await?;
705    let players: Vec<Value> = rows
706        .iter()
707        .map(|r| json!({"sub": r.get::<_, String>(0), "username": r.get::<_, String>(1)}))
708        .collect();
709    Ok(Json(json!({ "players": players })).into_response())
710}
711
712/// `POST /api/esports/revoke` (bearer the secret; form `label`, and
713/// optionally `sub`): revoke the tokens of that name the site asked for
714/// (all its players', or one's). Answers how many.
715pub async fn esports_revoke(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
716    let secret = ctx.cfg.esports().ok_or_else(|| ApiError::not_found("the site's token endpoint"))?;
717    if !bearer(&h).is_some_and(|b| same(b.as_bytes(), secret.as_bytes())) {
718        return Err(ApiError::unauthorized());
719    }
720    let f = form(&body);
721    let label = f.get("label").filter(|l| !l.is_empty()).ok_or_else(|| ApiError::bad("label is required"))?;
722    let sub = f.get("sub").filter(|s| !s.is_empty());
723    let c = ctx.db.conn().await?;
724    let n = c
725        .execute(
726            &ctx.db.sql(
727                "UPDATE chess.api_tokens t SET revoked_at = now(), revoked_by = 'site: ' || $1
728                   FROM chess.players p
729                  WHERE p.id = t.player_id AND p.esports_id IS NOT NULL AND ($2::text IS NULL OR p.esports_id = $2)
730                    AND t.name = $1 AND t.revoked_at IS NULL",
731            ),
732            &[label, &sub],
733        )
734        .await?;
735    Ok(Json(json!({ "revoked": n })).into_response())
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741
742    #[test]
743    fn hmac_matches_rfc_4231() {
744        // Test case 2.
745        assert_eq!(
746            hex::encode(hmac(b"Jefe", b"what do ya want for nothing?")),
747            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
748        );
749        // Test case 6: a key longer than the block.
750        assert_eq!(
751            hex::encode(hmac(&[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")),
752            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
753        );
754    }
755
756    #[test]
757    fn signed_statements() {
758        let secret = "s".repeat(32);
759        let v = json!({"sub": "42", "name": "Mark", "roles": ["manager"], "nonce": "n", "exp": 2000});
760        let s = sign(&secret, &v);
761        assert_eq!(verify(&secret, &s, 1000), Some(v.clone()));
762        assert_eq!(verify(&secret, &s, 3000), None, "expired");
763        assert_eq!(verify(&"t".repeat(32), &s, 1000), None, "another secret");
764        let (body, mac) = s.split_once('.').unwrap();
765        let forged = format!("{}x.{mac}", &body[..body.len() - 1]);
766        assert_eq!(verify(&secret, &forged, 1000), None, "changed");
767        let p = SitePlayer::from_json(&v).unwrap();
768        assert_eq!(
769            (p.sub.as_str(), p.name.as_str(), p.roles.as_slice()),
770            ("42", "Mark", &["manager".to_owned()][..])
771        );
772    }
773}