1use 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 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
77fn 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
93pub 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
120pub 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 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
185pub 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
218pub 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 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
272pub 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
288pub 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}