Skip to main content

open_chess_server/http/
oauth.rs

1//! OAuth 2.0 with PKCE, as Lichess does it: no client registration (the
2//! `client_id` is any name the app picks), S256 code challenges, and
3//! long-lived access tokens.
4//!
5//! The person approving is whoever is signed in to this server in this
6//! browser (its own accounts, or the host site's: [`crate::http::web`]);
7//! anyone else is sent to sign in first. Tokens issued here are listed and
8//! revocable on the Account page.
9
10use std::collections::HashMap;
11use std::fmt::Write as _;
12
13use axum::Json;
14use axum::extract::{Query, State};
15use axum::http::{HeaderMap, StatusCode, header};
16use axum::response::{Html, IntoResponse, Redirect, Response};
17use base64::Engine as _;
18use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
19use bytes::Bytes;
20use chrono::{Duration, Utc};
21use serde_json::{Map, Value, json};
22use sha2::{Digest, Sha256};
23
24use crate::app::Ctx;
25use crate::auth::{SCOPES, TOKEN_PREFIX, bearer, cookie, hash_key, random_token};
26use crate::error::{ApiError, ApiResult};
27use crate::http::{form, html_escape};
28use crate::model;
29
30const TOKEN_DAYS: i64 = 365;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33struct AuthRequest {
34    client_id: String,
35    redirect_uri: String,
36    challenge: String,
37    scopes: Vec<String>,
38    state: Option<String>,
39}
40
41fn parse_request(q: &HashMap<String, String>) -> Result<AuthRequest, String> {
42    let get = |k: &str| q.get(k).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
43    if get("response_type").as_deref() != Some("code") {
44        return Err("response_type must be code".into());
45    }
46    if get("code_challenge_method").as_deref() != Some("S256") {
47        return Err("code_challenge_method must be S256".into());
48    }
49    let challenge = get("code_challenge").ok_or("missing code_challenge")?;
50    if !(43..=128).contains(&challenge.len()) {
51        return Err("code_challenge has the wrong length".into());
52    }
53    let client_id = get("client_id").ok_or("missing client_id")?;
54    if client_id.chars().count() > 80 {
55        return Err("client_id is too long".into());
56    }
57    let redirect_uri = get("redirect_uri").ok_or("missing redirect_uri")?;
58    let u = url::Url::parse(&redirect_uri).map_err(|_| "redirect_uri isn't a URL".to_owned())?;
59    // http(s), or a native app's private-use scheme: a reversed domain
60    // name, as RFC 8252 (7.1) asks (`org.example.app:/oauth`), which the
61    // iOS app's sign-in sheet catches.
62    let native = u.scheme().contains('.');
63    if !(matches!(u.scheme(), "http" | "https") || native) || u.fragment().is_some() {
64        return Err(
65            "redirect_uri must be http(s), or an app's reversed-domain scheme, without a fragment".into()
66        );
67    }
68    let scopes: Vec<String> = get("scope")
69        .unwrap_or_default()
70        .split([' ', ','])
71        .filter(|s| SCOPES.iter().any(|(k, _)| k == s))
72        .map(str::to_owned)
73        .collect();
74    Ok(AuthRequest { client_id, redirect_uri, challenge, scopes, state: get("state") })
75}
76
77/// Ties the consent form to the session that saw it.
78fn csrf(session: &str, r: &AuthRequest) -> String {
79    hash_key(&format!("{session}|{}|{}|{}|{}", r.client_id, r.redirect_uri, r.challenge, r.scopes.join(" ")))
80}
81
82fn redirect_with(uri: &str, params: &[(&str, &str)]) -> Response {
83    let mut u = url::Url::parse(uri).expect("checked when parsed");
84    {
85        let mut q = u.query_pairs_mut();
86        for (k, v) in params {
87            q.append_pair(k, v);
88        }
89    }
90    Redirect::to(u.as_str()).into_response()
91}
92
93/// The server's own HTML pages (sign-in, account, consent), in the Chess
94/// lab's dark colours with the open-chess logo.
95pub fn page(title: &str, body: &str) -> Response {
96    use base64::Engine as _;
97    let icon = base64::engine::general_purpose::STANDARD.encode(crate::http::LOGO_SVG);
98    Html(format!(
99        "<!doctype html><html lang=en><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">\
100         <meta name=theme-color content=\"#121820\"><link rel=icon type=\"image/svg+xml\" href=\"data:image/svg+xml;base64,{icon}\">\
101         <title>{t} ยท open-chess</title><style>body{{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem;\
102         color:#e6ebf0;background:#121820}}a{{color:#6fa3d6}}button{{font:inherit;padding:.5rem 1.2rem;margin-right:.5rem;\
103         border-radius:8px;border:1px solid #2c3845;background:#19222c;color:inherit;cursor:pointer}}button.go,a.btn{{background:#6fa3d6;\
104         border-color:#6fa3d6;color:#0c1218;font-weight:700}}a.btn{{display:inline-block;padding:.5rem 1.2rem;border-radius:8px;text-decoration:none}}\
105         a.btn.apple{{background:#fff;border-color:#fff;color:#000}}\
106         code{{background:#19222c;padding:0 .3rem;border-radius:4px}}li{{margin:.2rem 0}}label{{display:block;margin:.6rem 0}}\
107         label input:not([type=checkbox]){{display:block;width:100%;box-sizing:border-box;font:inherit;padding:.4rem;margin-top:.2rem;\
108         border-radius:8px;border:1px solid #2c3845;background:#19222c;color:inherit}}label.check{{margin:.3rem 0}}\
109         .err{{color:#ef8277}}.ok{{color:#7ee2a8}}.muted{{color:#9aa7b4}}table{{border-collapse:collapse;width:100%}}\
110         td,th{{text-align:left;padding:.3rem;border-bottom:1px solid #2c3845;vertical-align:top}}code.token{{word-break:break-all}}\
111         form{{margin:.6rem 0}}.brand{{display:flex;align-items:center;gap:.6rem;color:#9aa7b4;font-weight:700;margin-bottom:1.5rem}}\
112         .brand svg{{border-radius:9px}}h1{{font-size:1.6rem;margin:0 0 1rem}}</style>\
113         <div class=brand>{logo}<span>open-chess</span></div><h1>{t}</h1>{body}</html>",
114        t = html_escape(title),
115        logo = crate::http::logo(36),
116    ))
117    .into_response()
118}
119
120/// `GET /oauth`: the consent page.
121pub async fn authorize(
122    State(ctx): State<Ctx>,
123    h: HeaderMap,
124    Query(q): Query<HashMap<String, String>>,
125) -> ApiResult<Response> {
126    let req = match parse_request(&q) {
127        Ok(r) => r,
128        Err(e) => {
129            return Ok((
130                StatusCode::BAD_REQUEST,
131                page("Can't authorize", &format!("<p>{}</p>", html_escape(&e))),
132            )
133                .into_response());
134        }
135    };
136    let Some(player) = ctx.auth.session(&h).await? else {
137        // Sign in (or make an account), then back here.
138        let base = ctx.cfg.base_path().unwrap_or_default();
139        let here: String =
140            url::form_urlencoded::Serializer::new(String::new()).extend_pairs(q.iter()).finish();
141        let ret = format!("{base}/oauth?{here}");
142        return Ok(
143            Redirect::to(&format!("{base}/login?return={}", urlencoding::encode(&ret))).into_response()
144        );
145    };
146    let session = cookie(&h, crate::auth::SESSION_COOKIE).unwrap_or_default();
147    let c = ctx.db.conn().await?;
148    let account = model::ensure_account(&ctx.db, &*c, player).await?;
149    drop(c);
150    let scopes = crate::http::web::allowed(&ctx, player, &req.scopes).await?;
151    let granted = AuthRequest { scopes: scopes.clone(), ..req.clone() };
152    let list: String = if scopes.is_empty() {
153        "<li>Read your public profile</li>".into()
154    } else {
155        let mut out = String::new();
156        for (k, d) in scopes.iter().filter_map(|s| SCOPES.iter().find(|(k, _)| k == s)) {
157            let _ = write!(out, "<li>{} <code>{k}</code></li>", html_escape(d));
158        }
159        out
160    };
161    let hidden = |k: &str, v: &str| format!("<input type=hidden name=\"{k}\" value=\"{}\">", html_escape(v));
162    let base = ctx.cfg.base_path().unwrap_or_default();
163    Ok(page(
164        "Authorize access",
165        &format!(
166            "<p><code>{client}</code> wants to act as <b>{user}</b> on this chess server:</p><ul>{list}</ul>\
167             <p>Redirect: <code>{redirect}</code></p>\
168             <form method=post action=\"{base}/oauth\">{h1}{h2}{h3}{h4}{h5}{h6}\
169             <button class=go name=decision value=approve>Authorize</button>\
170             <button name=decision value=deny>Cancel</button></form>\
171             <p>You can revoke this later on your <a href=\"{base}/account\">Account page</a>.</p>",
172            client = html_escape(&req.client_id),
173            user = html_escape(&account.username),
174            redirect = html_escape(&req.redirect_uri),
175            h1 = hidden("client_id", &req.client_id),
176            h2 = hidden("redirect_uri", &req.redirect_uri),
177            h3 = hidden("code_challenge", &req.challenge),
178            h4 = hidden("scope", &scopes.join(" ")),
179            h5 = hidden("state", req.state.as_deref().unwrap_or("")),
180            h6 = hidden("csrf", &csrf(&session, &granted)),
181        ),
182    ))
183}
184
185/// `POST /oauth`: the consent decision.
186pub async fn decide(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
187    let mut f = form(&body);
188    f.insert("response_type".into(), "code".into());
189    f.insert("code_challenge_method".into(), "S256".into());
190    let req = parse_request(&f).map_err(ApiError::bad)?;
191    let player = ctx.auth.session(&h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
192    let session = cookie(&h, crate::auth::SESSION_COOKIE).unwrap_or_default();
193    if f.get("csrf").map(String::as_str) != Some(csrf(&session, &req).as_str()) {
194        return Err(ApiError::forbidden("this consent form has expired: start again"));
195    }
196    let state = req.state.clone().unwrap_or_default();
197    if f.get("decision").map(String::as_str) != Some("approve") {
198        return Ok(redirect_with(&req.redirect_uri, &[("error", "access_denied"), ("state", &state)]));
199    }
200    let scopes = crate::http::web::allowed(&ctx, player, &req.scopes).await?;
201    let code = format!("liu_{}", random_token(32));
202    let c = ctx.db.conn().await?;
203    c.execute(
204        &ctx.db.sql(
205            "INSERT INTO chess.oauth_codes (code_hash, player_id, client_id, redirect_uri, code_challenge, scopes, expires_at)
206             VALUES ($1, $2, $3, $4, $5, $6, now() + interval '2 minutes')",
207        ),
208        &[&hash_key(&code), &player, &req.client_id, &req.redirect_uri, &req.challenge, &scopes],
209    )
210    .await?;
211    Ok(redirect_with(&req.redirect_uri, &[("code", &code), ("state", &state)]))
212}
213
214fn oauth_error(error: &str, description: &str) -> Response {
215    (StatusCode::BAD_REQUEST, Json(json!({"error": error, "error_description": description}))).into_response()
216}
217
218/// `POST /api/token`: trade a code (and its PKCE verifier) for a token.
219pub async fn token(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
220    let f = form(&body);
221    let get = |k: &str| f.get(k).map_or("", String::as_str);
222    if get("grant_type") != "authorization_code" {
223        return Ok(oauth_error("unsupported_grant_type", "grant_type must be authorization_code"));
224    }
225    let mut c = ctx.db.conn().await?;
226    let tx = c.transaction().await?;
227    let row = tx
228        .query_opt(
229            &ctx.db.sql(
230                "UPDATE chess.oauth_codes SET used_at = now()
231                  WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now()
232                 RETURNING player_id, client_id, redirect_uri, code_challenge, scopes",
233            ),
234            &[&hash_key(get("code"))],
235        )
236        .await?;
237    let Some(row) = row else {
238        return Ok(oauth_error("invalid_grant", "unknown, used or expired code"));
239    };
240    let (player, client, redirect, challenge, scopes): (i64, String, String, String, Vec<String>) =
241        (row.get(0), row.get(1), row.get(2), row.get(3), row.get(4));
242    // A code is spent by any attempt, right or wrong.
243    if get("client_id") != client || get("redirect_uri") != redirect {
244        tx.commit().await?;
245        return Ok(oauth_error("invalid_grant", "client_id or redirect_uri doesn't match"));
246    }
247    if B64.encode(Sha256::digest(get("code_verifier").as_bytes())) != challenge {
248        tx.commit().await?;
249        return Ok(oauth_error("invalid_grant", "code_verifier doesn't match the code_challenge"));
250    }
251    let token = format!("{TOKEN_PREFIX}{}", random_token(36));
252    let prefix: String = token.chars().take(TOKEN_PREFIX.len() + 6).collect();
253    let name: String = format!("OAuth: {client}").chars().take(80).collect();
254    let expires = Utc::now() + Duration::days(TOKEN_DAYS);
255    tx.execute(
256        &ctx.db.sql(
257            "INSERT INTO chess.api_tokens (player_id, name, token_hash, prefix, scopes, expires_at)
258             VALUES ($1, $2, $3, $4, $5, $6)",
259        ),
260        &[&player, &name, &hash_key(&token), &prefix, &scopes, &expires],
261    )
262    .await?;
263    tx.commit().await?;
264    Ok(Json(chess_proto::TokenResponse {
265        token_type: "Bearer".into(),
266        access_token: token,
267        expires_in: TOKEN_DAYS * 86_400,
268    })
269    .into_response())
270}
271
272/// `DELETE /api/token`: revoke the token used for this request.
273pub async fn revoke(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
274    let t = bearer(&h).ok_or_else(ApiError::unauthorized)?;
275    let c = ctx.db.conn().await?;
276    c.execute(
277        &ctx.db.sql(
278            "UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'chess: DELETE /api/token'
279              WHERE token_hash = $1 AND revoked_at IS NULL",
280        ),
281        &[&hash_key(t)],
282    )
283    .await?;
284    ctx.auth.forget(t);
285    Ok(StatusCode::NO_CONTENT.into_response())
286}
287
288/// `POST /api/token/test`: which of these tokens work (comma-separated
289/// body, up to 1000).
290pub async fn test(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
291    let text = String::from_utf8_lossy(&body);
292    let mut out = Map::new();
293    for t in text.split(',').map(str::trim).filter(|t| !t.is_empty()).take(1000) {
294        let v = match ctx.auth.token(t).await? {
295            Some(id) => {
296                let c = ctx.db.conn().await?;
297                let user = model::ensure_account(&ctx.db, &*c, id.player).await?.id();
298                json!({"userId": user, "scopes": id.scopes.join(","), "expires": id.expires_ms})
299            }
300            None => Value::Null,
301        };
302        out.insert(t.to_owned(), v);
303    }
304    let mut r = Json(Value::Object(out)).into_response();
305    r.headers_mut().insert(header::CACHE_CONTROL, "no-store".parse().expect("static header"));
306    Ok(r)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn q(pairs: &[(&str, &str)]) -> HashMap<String, String> {
314        pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
315    }
316
317    fn good() -> Vec<(&'static str, &'static str)> {
318        vec![
319            ("response_type", "code"),
320            ("client_id", "esports-lab"),
321            ("redirect_uri", "https://training.example/api/me/lichess/callback"),
322            ("code_challenge_method", "S256"),
323            ("code_challenge", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"),
324            ("scope", "challenge:write board:play email:read study:read"),
325            ("state", "xyz"),
326        ]
327    }
328
329    #[test]
330    fn parses_a_lichess_style_request() {
331        let r = parse_request(&q(&good())).unwrap();
332        assert_eq!(r.scopes, ["challenge:write", "board:play", "email:read"], "unknown scopes dropped");
333        assert_eq!(r.state.as_deref(), Some("xyz"));
334    }
335
336    #[test]
337    fn rejects_bad_requests() {
338        for (k, v) in [
339            ("response_type", "token"),
340            ("code_challenge_method", "plain"),
341            ("code_challenge", "short"),
342            ("redirect_uri", "javascript:alert(1)"),
343            ("redirect_uri", "https://x.example/cb#frag"),
344            ("redirect_uri", "myapp://cb"),
345            ("redirect_uri", "data:text/html,hi"),
346        ] {
347            let mut p = good();
348            p.retain(|(kk, _)| *kk != k);
349            p.push((k, v));
350            assert!(parse_request(&q(&p)).is_err(), "{k}={v}");
351        }
352    }
353
354    #[test]
355    fn accepts_a_native_apps_scheme() {
356        let mut p = good();
357        p.retain(|(k, _)| *k != "redirect_uri");
358        p.push(("redirect_uri", "org.offenedatenmodellierung.openchess:/oauth"));
359        assert_eq!(
360            parse_request(&q(&p)).unwrap().redirect_uri,
361            "org.offenedatenmodellierung.openchess:/oauth"
362        );
363    }
364
365    #[test]
366    fn csrf_binds_the_session_and_request() {
367        let r = parse_request(&q(&good())).unwrap();
368        assert_ne!(csrf("a", &r), csrf("b", &r));
369        let mut r2 = r.clone();
370        r2.redirect_uri = "https://evil.example/".into();
371        assert_ne!(csrf("a", &r), csrf("a", &r2));
372    }
373
374    #[test]
375    fn pkce_matches_rfc_7636() {
376        let v = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
377        assert_eq!(B64.encode(Sha256::digest(v.as_bytes())), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
378    }
379}