1use 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
36pub const TOKEN_PREFIX: &str = "ocp_";
38pub const SITE_TOKEN_PREFIX: &str = "esl_";
40pub const PARTNER_PREFIX: &str = "ocs_";
42pub const PARTNER_READ: &str = "read";
44pub 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];
50pub const SESSION_COOKIE: &str = "oc_session";
52pub const CACHE_TTL: Duration = Duration::from_secs(15);
53const NEGATIVE_TTL: Duration = Duration::from_secs(5);
54
55pub 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
69pub 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 pub admin: bool,
86 pub manager: bool,
88 pub pending: bool,
90 pub expires_ms: i64,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Partner {
97 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
109pub 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 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 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 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 pub fn forget_all(&self) {
245 if let Ok(mut c) = self.cache.lock() {
246 c.clear();
247 }
248 }
249
250 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 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 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 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}