Skip to main content

open_chess_server/
auth.rs

1//! Who is calling: this server's API tokens (bearer) and session cookies.
2//!
3//! Players, tokens and sessions are the server's own (`chess.players`,
4//! `chess.api_tokens`, `chess.sessions`). Tokens are hashed with SHA-256,
5//! with Lichess's scope names (`board:play`, `bot:play`, `challenge:read`,
6//! `challenge:write`, `email:read`) plus `chess:fairplay` for reviewers.
7//! People make and revoke them on the Account page ([`crate::http::web`]),
8//! through the OAuth flow, or (for a host site's players) through the
9//! site's server ([`crate::http::web::esports_token`]). New tokens start
10//! `ocp_`; tokens imported from a host site keep its `esl_`. A short cache
11//! keeps a busy game from hitting the database for every move; a revoked
12//! token stops working within [`CACHE_TTL`].
13//!
14//! **Staff.** A player's roles here (admin; manager) are this server's
15//! alone. Their rights need a passkey check as well, made in the session or
16//! with the token in hand ([`crate::passkeys`]), which lasts
17//! `CHESS_ADMIN_PASSKEY_HOURS`; until then the identity is only `pending`.
18//!
19//! **Partners.** Other services (a host site) call with a partner token
20//! (`ocs_`), which an admin makes; its scopes say what it may do:
21//! [`PARTNER_READ`] (games, players, the feed) and [`PARTNER_SITE`] (the host
22//! site's endpoints: its players' tokens and links). The host site's shared
23//! secret (`ESPORTS_SECRET`) still works as a partner with both, so a site
24//! can move to a partner token at its own pace.
25
26use std::collections::HashMap;
27use std::sync::{Arc, Mutex};
28use std::time::{Duration, Instant};
29
30use anyhow::Result;
31use axum::http::{HeaderMap, header};
32use sha2::{Digest, Sha256};
33
34use crate::db::Db;
35
36/// New tokens' prefix.
37pub const TOKEN_PREFIX: &str = "ocp_";
38/// Tokens imported from a host site (esports-lab) keep its prefix.
39pub const SITE_TOKEN_PREFIX: &str = "esl_";
40/// Partner tokens' prefix.
41pub const PARTNER_PREFIX: &str = "ocs_";
42/// Partner scope: read games, players and the feed.
43pub const PARTNER_READ: &str = "read";
44/// Partner scope: the host site's endpoints (`/api/esports/…`).
45pub const PARTNER_SITE: &str = "site";
46pub const PARTNER_SCOPES: &[(&str, &str)] = &[
47    (PARTNER_READ, "Read games, players and the game feed"),
48    (PARTNER_SITE, "Act as the host site: its players' tokens and account links"),
49];
50/// Signed in to this server (its own sign-in, or with the host site).
51pub const SESSION_COOKIE: &str = "oc_session";
52pub const CACHE_TTL: Duration = Duration::from_secs(15);
53const NEGATIVE_TTL: Duration = Duration::from_secs(5);
54
55/// Scopes this server understands (Lichess's names, and one of ours).
56pub const SCOPES: &[(&str, &str)] = &[
57    ("board:play", "Play games with the Board API"),
58    ("bot:play", "Play games with the Bot API (bot accounts)"),
59    ("challenge:read", "Read incoming challenges"),
60    ("challenge:write", "Create, accept and decline challenges"),
61    ("email:read", "Read your email address"),
62    ("chess:fairplay", "Review fair-play reports (managers and admins)"),
63];
64
65pub fn hash_key(key: &str) -> String {
66    hex::encode(Sha256::digest(key.as_bytes()))
67}
68
69/// Compare secrets in constant time.
70pub fn same(a: &[u8], b: &[u8]) -> bool {
71    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
72}
73
74pub fn random_token(len: usize) -> String {
75    use rand::distr::{Alphanumeric, SampleString};
76    Alphanumeric.sample_string(&mut rand::rng(), len)
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
80pub struct Identity {
81    pub player: i64,
82    pub token_id: i64,
83    pub scopes: Vec<String>,
84    /// An admin here, with a fresh passkey check.
85    pub admin: bool,
86    /// A manager here, with a fresh passkey check.
87    pub manager: bool,
88    /// An admin or manager here whose rights wait on a passkey check.
89    pub pending: bool,
90    /// When the token expires, Unix ms.
91    pub expires_ms: i64,
92}
93
94/// A partner calling (another service), or the host site's shared secret.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Partner {
97    /// None: the host site's shared secret.
98    pub id: Option<i64>,
99    pub name: String,
100    pub scopes: Vec<String>,
101}
102
103impl Partner {
104    pub fn has(&self, scope: &str) -> bool {
105        self.scopes.iter().any(|s| s == scope)
106    }
107}
108
109/// An admin's or manager's roles, as stored.
110pub fn staff_roles(role: &str, roles: &[String]) -> (bool, bool) {
111    (role == "admin", roles.iter().any(|x| x == "manager"))
112}
113
114impl Identity {
115    pub fn has(&self, scope: &str) -> bool {
116        self.scopes.iter().any(|s| s == scope)
117    }
118
119    pub fn has_any(&self, scopes: &[&str]) -> bool {
120        scopes.iter().any(|s| self.has(s))
121    }
122}
123
124type Cache = Arc<Mutex<HashMap<String, (Option<Identity>, Instant)>>>;
125
126#[derive(Clone)]
127pub struct Auth {
128    db: Db,
129    cache: Cache,
130    staff_hours: i32,
131}
132
133pub fn bearer(h: &HeaderMap) -> Option<&str> {
134    h.get(header::AUTHORIZATION)
135        .and_then(|v| v.to_str().ok())
136        .and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")))
137        .map(str::trim)
138        .filter(|t| !t.is_empty())
139}
140
141pub fn cookie(h: &HeaderMap, name: &str) -> Option<String> {
142    h.get_all(header::COOKIE).iter().filter_map(|v| v.to_str().ok()).flat_map(|v| v.split(';')).find_map(
143        |kv| {
144            let (k, v) = kv.trim().split_once('=')?;
145            (k == name).then(|| v.to_owned())
146        },
147    )
148}
149
150impl Auth {
151    pub fn new(db: Db, staff_hours: i32) -> Auth {
152        Auth { db, cache: Arc::default(), staff_hours: staff_hours.max(1) }
153    }
154
155    /// The identity behind a token, if it is valid now.
156    pub async fn token(&self, token: &str) -> Result<Option<Identity>> {
157        if !(token.starts_with(TOKEN_PREFIX) || token.starts_with(SITE_TOKEN_PREFIX)) {
158            return Ok(None);
159        }
160        let key = hash_key(token);
161        if let Ok(c) = self.cache.lock()
162            && let Some((id, at)) = c.get(&key)
163        {
164            let ttl = if id.is_some() { CACHE_TTL } else { NEGATIVE_TTL };
165            if at.elapsed() < ttl {
166                return Ok(id.clone());
167            }
168        }
169        let c = self.db.conn().await?;
170        let r = c
171            .query_opt(
172                &self.db.sql(
173                    "UPDATE chess.api_tokens t
174                        SET last_used_at = CASE WHEN t.last_used_at IS NULL OR t.last_used_at < now() - interval '1 minute'
175                                                THEN now() ELSE t.last_used_at END
176                       FROM chess.players p
177                      WHERE t.token_hash = $1 AND p.id = t.player_id AND t.revoked_at IS NULL
178                        AND t.expires_at > now() AND p.disabled_at IS NULL
179                     RETURNING t.id, t.player_id, t.scopes, p.role, p.roles,
180                               (extract(epoch from t.expires_at) * 1000)::bigint,
181                               coalesce(t.staff_verified_at > now() - make_interval(hours => $2), false)",
182                ),
183                &[&key, &self.staff_hours],
184            )
185            .await?;
186        let id = r.map(|r| {
187            let role: String = r.get(3);
188            let roles: Vec<String> = r.get(4);
189            let (admin, manager) = staff_roles(&role, &roles);
190            let checked: bool = r.get(6);
191            Identity {
192                token_id: r.get(0),
193                player: r.get(1),
194                scopes: r.get(2),
195                admin: admin && checked,
196                manager: manager && checked,
197                pending: (admin || manager) && !checked,
198                expires_ms: r.get(5),
199            }
200        });
201        // A pending staff check isn't cached: once done (perhaps through
202        // another replica), it counts at once.
203        if !id.as_ref().is_some_and(|i| i.pending)
204            && let Ok(mut c) = self.cache.lock()
205        {
206            if c.len() > 10_000 {
207                c.clear();
208            }
209            c.insert(key, (id.clone(), Instant::now()));
210        }
211        Ok(id)
212    }
213
214    /// A partner token's partner (or the host site's shared secret).
215    pub async fn partner(&self, token: &str, site_secret: Option<&str>) -> Result<Option<Partner>> {
216        if let Some(secret) = site_secret
217            && same(token.as_bytes(), secret.as_bytes())
218        {
219            return Ok(Some(Partner {
220                id: None,
221                name: "host site".into(),
222                scopes: vec![PARTNER_READ.into(), PARTNER_SITE.into()],
223            }));
224        }
225        if !token.starts_with(PARTNER_PREFIX) {
226            return Ok(None);
227        }
228        let c = self.db.conn().await?;
229        Ok(c.query_opt(
230            &self.db.sql(
231                "UPDATE chess.partners
232                    SET last_used_at = CASE WHEN last_used_at IS NULL OR last_used_at < now() - interval '1 minute'
233                                            THEN now() ELSE last_used_at END
234                  WHERE token_hash = $1 AND revoked_at IS NULL
235                 RETURNING id, name, scopes",
236            ),
237            &[&hash_key(token)],
238        )
239        .await?
240        .map(|r| Partner { id: Some(r.get(0)), name: r.get(1), scopes: r.get(2) }))
241    }
242
243    /// Forget every cached token (after changing someone's roles).
244    pub fn forget_all(&self) {
245        if let Ok(mut c) = self.cache.lock() {
246            c.clear();
247        }
248    }
249
250    /// Forget a token at once (after revoking it here).
251    pub fn forget(&self, token: &str) {
252        if let Ok(mut c) = self.cache.lock() {
253            c.remove(&hash_key(token));
254        }
255    }
256
257    /// Forget every cached token of a player's (their account closed).
258    pub fn forget_player(&self, player: i64) {
259        if let Ok(mut c) = self.cache.lock() {
260            c.retain(|_, (id, _)| id.as_ref().is_none_or(|i| i.player != player));
261        }
262    }
263
264    /// The player signed in with this session cookie.
265    pub async fn session(&self, h: &HeaderMap) -> Result<Option<i64>> {
266        let Some(token) = cookie(h, SESSION_COOKIE) else {
267            return Ok(None);
268        };
269        let c = self.db.conn().await?;
270        Ok(c.query_opt(
271            &self.db.sql(
272                "SELECT s.player_id FROM chess.sessions s JOIN chess.players p ON p.id = s.player_id
273                  WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() AND p.disabled_at IS NULL",
274            ),
275            &[&hash_key(&token)],
276        )
277        .await?
278        .map(|r| r.get(0)))
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use axum::http::HeaderValue;
286
287    #[test]
288    fn hashes_like_the_site() {
289        // sha256("abc")
290        assert_eq!(hash_key("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
291        let t = random_token(36);
292        assert_eq!(t.len(), 36);
293        assert!(t.chars().all(|c| c.is_ascii_alphanumeric()));
294    }
295
296    #[test]
297    fn reads_bearer_and_cookie() {
298        let mut h = HeaderMap::new();
299        h.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer esl_abc"));
300        h.insert(header::COOKIE, HeaderValue::from_static("a=1; oc_session=xyz; b=2"));
301        assert_eq!(bearer(&h), Some("esl_abc"));
302        assert_eq!(cookie(&h, SESSION_COOKIE).as_deref(), Some("xyz"));
303        assert_eq!(cookie(&h, "none"), None);
304        let mut h = HeaderMap::new();
305        h.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer "));
306        assert_eq!(bearer(&h), None);
307    }
308
309    #[test]
310    fn identity_scopes() {
311        let id =
312            Identity { player: 1, token_id: 2, scopes: vec!["board:play".into()], ..Identity::default() };
313        assert!(id.has("board:play"));
314        assert!(id.has_any(&["bot:play", "board:play"]));
315        assert!(!id.has("bot:play"));
316    }
317}