mod pkce;
mod scope;
mod token;
pub use pkce::PkceVerifier;
pub use scope::Scope;
pub use token::LichessToken;
use reqwest::Method;
use serde::Deserialize;
use url::Url;
use crate::client::LichessClient;
use crate::config::Host;
use crate::error::{ApiError, LichessError, OAuthError, Result};
use crate::http;
const STATE_LEN: usize = 24;
#[derive(Debug, Clone)]
pub struct AuthorizationRequest<'a> {
pub client_id: &'a str,
pub redirect_uri: &'a str,
pub scopes: &'a [Scope],
pub username_hint: Option<&'a str>,
}
#[derive(Debug)]
pub struct Authorization {
pub url: Url,
pub state: String,
pub verifier: PkceVerifier,
}
#[derive(Debug, Clone)]
pub struct CodeExchange<'a> {
pub code: &'a str,
pub code_verifier: &'a PkceVerifier,
pub redirect_uri: &'a str,
pub client_id: &'a str,
}
#[derive(Debug, Deserialize)]
struct OAuthErrorBody {
error: String,
error_description: Option<String>,
}
#[derive(Debug)]
pub struct OauthApi<'a> {
client: &'a LichessClient,
}
impl<'a> OauthApi<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self { client }
}
pub fn authorization_url(&self, request: &AuthorizationRequest<'_>) -> Result<Authorization> {
let verifier = PkceVerifier::generate();
let state = pkce::random_alphanumeric(STATE_LEN);
let url = self.build_authorize_url(request, &verifier.code_challenge(), &state)?;
Ok(Authorization {
url,
state,
verifier,
})
}
fn build_authorize_url(
&self,
request: &AuthorizationRequest<'_>,
challenge: &str,
state: &str,
) -> Result<Url> {
let scope = join_scopes(request.scopes);
let base = self.client.absolute_url(Host::Default, "/oauth");
let mut params = vec![
("response_type", "code"),
("client_id", request.client_id),
("redirect_uri", request.redirect_uri),
("code_challenge_method", "S256"),
("code_challenge", challenge),
("scope", scope.as_str()),
("state", state),
];
if let Some(hint) = request.username_hint {
params.push(("username", hint));
}
Url::parse_with_params(&base, ¶ms)
.map_err(|err| LichessError::InvalidRequest(format!("invalid base URL: {err}")))
}
pub async fn exchange_code(&self, exchange: &CodeExchange<'_>) -> Result<LichessToken> {
let form = [
("grant_type", "authorization_code"),
("code", exchange.code),
("code_verifier", exchange.code_verifier.as_str()),
("redirect_uri", exchange.redirect_uri),
("client_id", exchange.client_id),
];
let response = self
.client
.request(Method::POST, Host::Default, "/api/token")
.form(&form)
.send()
.await?;
let status = response.status();
let bytes = response.bytes().await?;
if status.is_success() {
serde_json::from_slice(&bytes).map_err(|err| LichessError::decode("LichessToken", err))
} else {
Err(token_failure(status, &bytes))
}
}
pub async fn revoke_token(&self) -> Result<()> {
let request = self
.client
.request(Method::DELETE, Host::Default, "/api/token");
http::ok(request).await
}
pub async fn test_tokens(
&self,
tokens: &[&str],
) -> Result<std::collections::HashMap<String, serde_json::Value>> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/token/test")
.text_body(tokens.join(","));
http::json(request, "token test results").await
}
}
impl LichessClient {
#[must_use]
pub fn oauth(&self) -> OauthApi<'_> {
OauthApi::new(self)
}
}
fn join_scopes(scopes: &[Scope]) -> String {
scopes
.iter()
.map(|scope| scope.as_str())
.collect::<Vec<_>>()
.join(" ")
}
fn token_failure(status: reqwest::StatusCode, body: &[u8]) -> LichessError {
match serde_json::from_slice::<OAuthErrorBody>(body) {
Ok(parsed) => OAuthError::new(&parsed.error, parsed.error_description).into(),
Err(_) => ApiError::new(status, None, None).into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn joins_scopes_with_spaces() {
let joined = join_scopes(&[Scope::BoardPlay, Scope::ChallengeWrite]);
assert_eq!(joined, "board:play challenge:write");
}
#[test]
fn empty_scopes_join_to_empty_string() {
assert_eq!(join_scopes(&[]), "");
}
#[test]
fn token_failure_prefers_oauth_error() {
let body = br#"{"error":"invalid_grant","error_description":"bad verifier"}"#;
let error = token_failure(reqwest::StatusCode::BAD_REQUEST, body);
match error {
LichessError::OAuth(oauth) => {
assert_eq!(oauth.code, crate::error::OAuthErrorCode::InvalidGrant);
}
other => panic!("expected OAuth error, got {other:?}"),
}
}
}