use std::collections::HashMap;
use std::fmt::Write as _;
use axum::Json;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{Html, IntoResponse, Redirect, Response};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use bytes::Bytes;
use chrono::{Duration, Utc};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use crate::app::Ctx;
use crate::auth::{SCOPES, TOKEN_PREFIX, bearer, cookie, hash_key, random_token};
use crate::error::{ApiError, ApiResult};
use crate::http::{form, html_escape};
use crate::model;
const TOKEN_DAYS: i64 = 365;
#[derive(Debug, Clone, PartialEq, Eq)]
struct AuthRequest {
client_id: String,
redirect_uri: String,
challenge: String,
scopes: Vec<String>,
state: Option<String>,
}
fn parse_request(q: &HashMap<String, String>) -> Result<AuthRequest, String> {
let get = |k: &str| q.get(k).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
if get("response_type").as_deref() != Some("code") {
return Err("response_type must be code".into());
}
if get("code_challenge_method").as_deref() != Some("S256") {
return Err("code_challenge_method must be S256".into());
}
let challenge = get("code_challenge").ok_or("missing code_challenge")?;
if !(43..=128).contains(&challenge.len()) {
return Err("code_challenge has the wrong length".into());
}
let client_id = get("client_id").ok_or("missing client_id")?;
if client_id.chars().count() > 80 {
return Err("client_id is too long".into());
}
let redirect_uri = get("redirect_uri").ok_or("missing redirect_uri")?;
let u = url::Url::parse(&redirect_uri).map_err(|_| "redirect_uri isn't a URL".to_owned())?;
if !matches!(u.scheme(), "http" | "https") || u.fragment().is_some() {
return Err("redirect_uri must be http(s) without a fragment".into());
}
let scopes: Vec<String> = get("scope")
.unwrap_or_default()
.split([' ', ','])
.filter(|s| SCOPES.iter().any(|(k, _)| k == s))
.map(str::to_owned)
.collect();
Ok(AuthRequest { client_id, redirect_uri, challenge, scopes, state: get("state") })
}
fn csrf(session: &str, r: &AuthRequest) -> String {
hash_key(&format!("{session}|{}|{}|{}|{}", r.client_id, r.redirect_uri, r.challenge, r.scopes.join(" ")))
}
fn redirect_with(uri: &str, params: &[(&str, &str)]) -> Response {
let mut u = url::Url::parse(uri).expect("checked when parsed");
{
let mut q = u.query_pairs_mut();
for (k, v) in params {
q.append_pair(k, v);
}
}
Redirect::to(u.as_str()).into_response()
}
pub fn page(title: &str, body: &str) -> Response {
use base64::Engine as _;
let icon = base64::engine::general_purpose::STANDARD.encode(crate::http::LOGO_SVG);
Html(format!(
"<!doctype html><html lang=en><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">\
<meta name=theme-color content=\"#121820\"><link rel=icon type=\"image/svg+xml\" href=\"data:image/svg+xml;base64,{icon}\">\
<title>{t} · open-chess</title><style>body{{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem;\
color:#e6ebf0;background:#121820}}a{{color:#6fa3d6}}button{{font:inherit;padding:.5rem 1.2rem;margin-right:.5rem;\
border-radius:8px;border:1px solid #2c3845;background:#19222c;color:inherit;cursor:pointer}}button.go,a.btn{{background:#6fa3d6;\
border-color:#6fa3d6;color:#0c1218;font-weight:700}}a.btn{{display:inline-block;padding:.5rem 1.2rem;border-radius:8px;text-decoration:none}}\
code{{background:#19222c;padding:0 .3rem;border-radius:4px}}li{{margin:.2rem 0}}label{{display:block;margin:.6rem 0}}\
label input:not([type=checkbox]){{display:block;width:100%;box-sizing:border-box;font:inherit;padding:.4rem;margin-top:.2rem;\
border-radius:8px;border:1px solid #2c3845;background:#19222c;color:inherit}}label.check{{margin:.3rem 0}}\
.err{{color:#ef8277}}.ok{{color:#7ee2a8}}.muted{{color:#9aa7b4}}table{{border-collapse:collapse;width:100%}}\
td,th{{text-align:left;padding:.3rem;border-bottom:1px solid #2c3845;vertical-align:top}}code.token{{word-break:break-all}}\
form{{margin:.6rem 0}}.brand{{display:flex;align-items:center;gap:.6rem;color:#9aa7b4;font-weight:700;margin-bottom:1.5rem}}\
.brand svg{{border-radius:9px}}h1{{font-size:1.6rem;margin:0 0 1rem}}</style>\
<div class=brand>{logo}<span>open-chess</span></div><h1>{t}</h1>{body}</html>",
t = html_escape(title),
logo = crate::http::logo(36),
))
.into_response()
}
pub async fn authorize(
State(ctx): State<Ctx>,
h: HeaderMap,
Query(q): Query<HashMap<String, String>>,
) -> ApiResult<Response> {
let req = match parse_request(&q) {
Ok(r) => r,
Err(e) => {
return Ok((
StatusCode::BAD_REQUEST,
page("Can't authorize", &format!("<p>{}</p>", html_escape(&e))),
)
.into_response());
}
};
let Some(player) = ctx.auth.session(&h).await? else {
let base = ctx.cfg.base_path().unwrap_or_default();
let here: String =
url::form_urlencoded::Serializer::new(String::new()).extend_pairs(q.iter()).finish();
let ret = format!("{base}/oauth?{here}");
return Ok(
Redirect::to(&format!("{base}/login?return={}", urlencoding::encode(&ret))).into_response()
);
};
let session = cookie(&h, crate::auth::SESSION_COOKIE).unwrap_or_default();
let c = ctx.db.conn().await?;
let account = model::ensure_account(&ctx.db, &*c, player).await?;
drop(c);
let scopes = crate::http::web::allowed(&ctx, player, &req.scopes).await?;
let granted = AuthRequest { scopes: scopes.clone(), ..req.clone() };
let list: String = if scopes.is_empty() {
"<li>Read your public profile</li>".into()
} else {
let mut out = String::new();
for (k, d) in scopes.iter().filter_map(|s| SCOPES.iter().find(|(k, _)| k == s)) {
let _ = write!(out, "<li>{} <code>{k}</code></li>", html_escape(d));
}
out
};
let hidden = |k: &str, v: &str| format!("<input type=hidden name=\"{k}\" value=\"{}\">", html_escape(v));
let base = ctx.cfg.base_path().unwrap_or_default();
Ok(page(
"Authorize access",
&format!(
"<p><code>{client}</code> wants to act as <b>{user}</b> on this chess server:</p><ul>{list}</ul>\
<p>Redirect: <code>{redirect}</code></p>\
<form method=post action=\"{base}/oauth\">{h1}{h2}{h3}{h4}{h5}{h6}\
<button class=go name=decision value=approve>Authorize</button>\
<button name=decision value=deny>Cancel</button></form>\
<p>You can revoke this later on your <a href=\"{base}/account\">Account page</a>.</p>",
client = html_escape(&req.client_id),
user = html_escape(&account.username),
redirect = html_escape(&req.redirect_uri),
h1 = hidden("client_id", &req.client_id),
h2 = hidden("redirect_uri", &req.redirect_uri),
h3 = hidden("code_challenge", &req.challenge),
h4 = hidden("scope", &scopes.join(" ")),
h5 = hidden("state", req.state.as_deref().unwrap_or("")),
h6 = hidden("csrf", &csrf(&session, &granted)),
),
))
}
pub async fn decide(State(ctx): State<Ctx>, h: HeaderMap, body: Bytes) -> ApiResult<Response> {
let mut f = form(&body);
f.insert("response_type".into(), "code".into());
f.insert("code_challenge_method".into(), "S256".into());
let req = parse_request(&f).map_err(ApiError::bad)?;
let player = ctx.auth.session(&h).await?.ok_or_else(|| ApiError::forbidden("sign in first"))?;
let session = cookie(&h, crate::auth::SESSION_COOKIE).unwrap_or_default();
if f.get("csrf").map(String::as_str) != Some(csrf(&session, &req).as_str()) {
return Err(ApiError::forbidden("this consent form has expired: start again"));
}
let state = req.state.clone().unwrap_or_default();
if f.get("decision").map(String::as_str) != Some("approve") {
return Ok(redirect_with(&req.redirect_uri, &[("error", "access_denied"), ("state", &state)]));
}
let scopes = crate::http::web::allowed(&ctx, player, &req.scopes).await?;
let code = format!("liu_{}", random_token(32));
let c = ctx.db.conn().await?;
c.execute(
&ctx.db.sql(
"INSERT INTO chess.oauth_codes (code_hash, player_id, client_id, redirect_uri, code_challenge, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, now() + interval '2 minutes')",
),
&[&hash_key(&code), &player, &req.client_id, &req.redirect_uri, &req.challenge, &scopes],
)
.await?;
Ok(redirect_with(&req.redirect_uri, &[("code", &code), ("state", &state)]))
}
fn oauth_error(error: &str, description: &str) -> Response {
(StatusCode::BAD_REQUEST, Json(json!({"error": error, "error_description": description}))).into_response()
}
pub async fn token(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
let f = form(&body);
let get = |k: &str| f.get(k).map_or("", String::as_str);
if get("grant_type") != "authorization_code" {
return Ok(oauth_error("unsupported_grant_type", "grant_type must be authorization_code"));
}
let mut c = ctx.db.conn().await?;
let tx = c.transaction().await?;
let row = tx
.query_opt(
&ctx.db.sql(
"UPDATE chess.oauth_codes SET used_at = now()
WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now()
RETURNING player_id, client_id, redirect_uri, code_challenge, scopes",
),
&[&hash_key(get("code"))],
)
.await?;
let Some(row) = row else {
return Ok(oauth_error("invalid_grant", "unknown, used or expired code"));
};
let (player, client, redirect, challenge, scopes): (i64, String, String, String, Vec<String>) =
(row.get(0), row.get(1), row.get(2), row.get(3), row.get(4));
if get("client_id") != client || get("redirect_uri") != redirect {
tx.commit().await?;
return Ok(oauth_error("invalid_grant", "client_id or redirect_uri doesn't match"));
}
if B64.encode(Sha256::digest(get("code_verifier").as_bytes())) != challenge {
tx.commit().await?;
return Ok(oauth_error("invalid_grant", "code_verifier doesn't match the code_challenge"));
}
let token = format!("{TOKEN_PREFIX}{}", random_token(36));
let prefix: String = token.chars().take(TOKEN_PREFIX.len() + 6).collect();
let name: String = format!("OAuth: {client}").chars().take(80).collect();
let expires = Utc::now() + Duration::days(TOKEN_DAYS);
tx.execute(
&ctx.db.sql(
"INSERT INTO chess.api_tokens (player_id, name, token_hash, prefix, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)",
),
&[&player, &name, &hash_key(&token), &prefix, &scopes, &expires],
)
.await?;
tx.commit().await?;
Ok(Json(chess_proto::TokenResponse {
token_type: "Bearer".into(),
access_token: token,
expires_in: TOKEN_DAYS * 86_400,
})
.into_response())
}
pub async fn revoke(State(ctx): State<Ctx>, h: HeaderMap) -> ApiResult<Response> {
let t = bearer(&h).ok_or_else(ApiError::unauthorized)?;
let c = ctx.db.conn().await?;
c.execute(
&ctx.db.sql(
"UPDATE chess.api_tokens SET revoked_at = now(), revoked_by = 'chess: DELETE /api/token'
WHERE token_hash = $1 AND revoked_at IS NULL",
),
&[&hash_key(t)],
)
.await?;
ctx.auth.forget(t);
Ok(StatusCode::NO_CONTENT.into_response())
}
pub async fn test(State(ctx): State<Ctx>, body: Bytes) -> ApiResult<Response> {
let text = String::from_utf8_lossy(&body);
let mut out = Map::new();
for t in text.split(',').map(str::trim).filter(|t| !t.is_empty()).take(1000) {
let v = match ctx.auth.token(t).await? {
Some(id) => {
let c = ctx.db.conn().await?;
let user = model::ensure_account(&ctx.db, &*c, id.player).await?.id();
json!({"userId": user, "scopes": id.scopes.join(","), "expires": id.expires_ms})
}
None => Value::Null,
};
out.insert(t.to_owned(), v);
}
let mut r = Json(Value::Object(out)).into_response();
r.headers_mut().insert(header::CACHE_CONTROL, "no-store".parse().expect("static header"));
Ok(r)
}
#[cfg(test)]
mod tests {
use super::*;
fn q(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
}
fn good() -> Vec<(&'static str, &'static str)> {
vec![
("response_type", "code"),
("client_id", "esports-lab"),
("redirect_uri", "https://training.example/api/me/lichess/callback"),
("code_challenge_method", "S256"),
("code_challenge", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"),
("scope", "challenge:write board:play email:read study:read"),
("state", "xyz"),
]
}
#[test]
fn parses_a_lichess_style_request() {
let r = parse_request(&q(&good())).unwrap();
assert_eq!(r.scopes, ["challenge:write", "board:play", "email:read"], "unknown scopes dropped");
assert_eq!(r.state.as_deref(), Some("xyz"));
}
#[test]
fn rejects_bad_requests() {
for (k, v) in [
("response_type", "token"),
("code_challenge_method", "plain"),
("code_challenge", "short"),
("redirect_uri", "javascript:alert(1)"),
("redirect_uri", "https://x.example/cb#frag"),
] {
let mut p = good();
p.retain(|(kk, _)| *kk != k);
p.push((k, v));
assert!(parse_request(&q(&p)).is_err(), "{k}={v}");
}
}
#[test]
fn csrf_binds_the_session_and_request() {
let r = parse_request(&q(&good())).unwrap();
assert_ne!(csrf("a", &r), csrf("b", &r));
let mut r2 = r.clone();
r2.redirect_uri = "https://evil.example/".into();
assert_ne!(csrf("a", &r), csrf("a", &r2));
}
#[test]
fn pkce_matches_rfc_7636() {
let v = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
assert_eq!(B64.encode(Sha256::digest(v.as_bytes())), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
}