Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 13 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,48 +12,22 @@
[apache-url]: LICENSE

A Rust API client library for [the current lichess.org API](https://lichess.org/api).
All endpoints are supported.

The goal of this crate is to fully support the latest lichess API - a major version release will be made once all endpoints are supported.

The lichess endpoints will often change without an OpenAPI version or even schema change, so please raise an issue with relevant output if one of the endpoints is failing.

## Endpoints

- ✅ = Fully supported at the time of the most recent release crate.
- 🔶 = Partially supported.
- 🚧 = Work to support this category is currently in progress.
- ❌ = Not currently supported.

The following table shows the current level of support for each category of endpoints.

| Category | Status |
|-----------------------|:-------:|
| Account | 🔶 |
| Analysis | ✅ |
| Arena Tournaments | ❌ |
| Board | ✅ |
| Bot | ✅ |
| Broadcasts | ❌ |
| Bulk Pairings | ❌ |
| Challenges | ✅ |
| External Engine | 🔶 |
| FIDE | ✅ |
| Games | ✅ |
| Messaging | ✅ |
| Opening Explorer | ✅ |
| OAuth | 🔶 |
| Puzzles | ✅ |
| Relations | ✅ |
| Simuls | ✅ |
| Studies | 🔶 |
| Swiss Tournaments | ❌ |
| Tablebase | ✅ |
| Teams | ❌ |
| TV | ✅ |
| Users | ✅ |
## Features

| Feature | Default | Description |
|---------|:-------:|-------------|
| `oauth` | yes | OAuth2 authorization code flow with PKCE, for acting on behalf of other users. Pulls in `rand` and `sha2`. |

Most clients authenticate with a [personal API token](https://lichess.org/account/oauth/token) and don't need the OAuth flow. If you only ever act as yourself, you can drop the dependencies:

```toml
lichess-api = { version = "0.7", default-features = false }
```

## Contributing

Contributions are much appreciated - especially if you can add support for a category of endpoints. Otherwise, if you have any ideas, bug reports, feature requests, or fixes, please make an issue or submit a pull request.
If you have any ideas, bug reports, feature requests, or fixes, please make an issue or submit a pull request.

Thanks.
17 changes: 17 additions & 0 deletions lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,39 @@ homepage = "https://github.com/ion232/lichess-api"
repository = "https://github.com/ion232/lichess-api"
readme = "../README.md"

[features]
default = ["oauth"]
# OAuth2 authorization code flow with PKCE. Pulls in the crypto dependencies
# needed to generate code verifiers, challenges and CSRF state.
oauth = ["dep:base64", "dep:rand", "dep:sha2", "dep:getrandom"]

[dependencies]
# Library dependencies.
base64 = { version = "0.22.1", optional = true }
bytes = "1.12.1"
futures = "0.3.34"
futures-core = "0.3.34"
http = "1.5.0"
mime = "0.3.17"
rand = { version = "0.10.2", optional = true }
reqwest = { version = "0.13.4", features = ["json", "stream"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
serde_with = { version = "3.22.0", features = ["chrono"] }
serde_urlencoded = "0.7.1"
sha2 = { version = "0.11.0", optional = true }
thiserror = "2.0.20"
tracing = "0.1.44"
url = "2.5.8"

# getrandom (pulled in transitively via rand) refuses to build for wasm32
# targets unless the "wasm_js" backend is explicitly enabled. See:
# https://docs.rs/getrandom/latest/getrandom/#webassembly-support
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.4.3", optional = true, features = ["wasm_js"] }

[dev-dependencies]
serde_urlencoded = "0.7.1"
tokio = { version = "1.53.1", features = ["macros", "rt"] }
url = "2.5.8"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
1 change: 1 addition & 0 deletions lib/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod external_engine;
pub mod fide;
pub mod games;
pub mod messaging;
#[cfg(feature = "oauth")]
pub mod oauth;
pub mod openings;
pub mod puzzles;
Expand Down
13 changes: 13 additions & 0 deletions lib/src/api/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,17 @@ impl LichessApi<reqwest::Client> {
pub async fn revoke_token(&self) -> Result<()> {
self.get_empty(revoke::DeleteRequest::new()).await
}

/// Exchange an authorization code for an access token.
///
/// This completes the flow started with
/// [`crate::model::oauth::authorize::AuthorizationUrl`]. It is the one
/// endpoint that takes no bearer token, since it is what produces one, so
/// the client may be built with `LichessApi::new(client, None)`.
pub async fn obtain_access_token(
&self,
request: impl Into<token::PostRequest>,
) -> Result<AccessToken> {
self.get_single_model(request.into()).await
}
}
14 changes: 14 additions & 0 deletions lib/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ pub enum Error {
#[error("json serde error: {0}")]
Json(#[from] serde_json::Error),

#[cfg(feature = "oauth")]
#[error("oauth error: {error}{}", .error_description.as_ref().map(|d| format!(" ({d})")).unwrap_or_default())]
OAuth {
/// The cause of the error, e.g. `access_denied` if the user cancelled
/// authorization, or `invalid_grant`.
error: String,
/// The reason the request was rejected, to aid debugging.
error_description: Option<String>,
},

#[cfg(feature = "oauth")]
#[error("oauth state mismatch (possible cross site request forgery)")]
OAuthStateMismatch,

#[error("unknown error: {0}")]
Unknown(String),
}
Expand Down
1 change: 1 addition & 0 deletions lib/src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod external_engine;
pub mod fide;
pub mod games;
pub mod messaging;
#[cfg(feature = "oauth")]
pub mod oauth;
pub mod openings;
pub mod puzzles;
Expand Down
148 changes: 148 additions & 0 deletions lib/src/model/oauth/authorize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
use super::PendingAuthorization;
use super::pkce::{Pkce, generate_state};
use crate::error::Result;
use crate::model::Domain;
use serde::Serialize;
use serde_with::skip_serializing_none;

/// Parameters for the OAuth2 authorization endpoint.
///
/// This endpoint is not called by this library: it renders an authorization
/// prompt for the user in a browser, and the result is delivered as query
/// parameters appended to your `redirect_uri`.
///
/// `response_type` and `code_challenge_method` are fixed by the spec and are
/// set for you.
///
/// # Example
///
/// ```no_run
/// use lichess_api::model::oauth::authorize::AuthorizationUrl;
///
/// # fn main() -> lichess_api::error::Result<()> {
/// let (url, pending) = AuthorizationUrl::generated("example.com", "http://example.com/")
/// .scope("preference:read")
/// .start()?;
///
/// // Send the user to `url`, and keep `pending` until they are redirected back.
/// # Ok(())
/// # }
/// ```
///
/// [`AuthorizationUrl::start`] generates the PKCE secrets and the `state` for
/// you, and returns a [`PendingAuthorization`] that verifies the result and
/// completes the exchange. Use [`AuthorizationUrl::new`] with
/// [`AuthorizationUrl::to_url`] only if you are managing those secrets
/// yourself, and keep the `code_verifier` out of URLs and off insecure
/// connections.
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize)]
pub struct AuthorizationUrl {
response_type: &'static str,
/// Arbitrary identifier that uniquely identifies your application.
pub client_id: String,
/// The absolute URL the user should be redirected to with the result.
pub redirect_uri: String,
code_challenge_method: &'static str,
/// `BASE64URL(SHA256(code_verifier))`.
pub code_challenge: String,
/// Space separated list of requested OAuth scopes, if any.
pub scope: Option<String>,
/// Hint that the user should log in with a specific Lichess username.
pub username: Option<String>,
/// Arbitrary state returned verbatim with the authorization result.
pub state: Option<String>,
}

impl AuthorizationUrl {
/// Start an authorization request whose PKCE secrets and `state` are
/// generated for you by [`AuthorizationUrl::start`].
///
/// Prefer this over [`AuthorizationUrl::new`] unless you are managing the
/// PKCE secrets yourself.
pub fn generated(client_id: impl Into<String>, redirect_uri: impl Into<String>) -> Self {
Self::new(client_id, redirect_uri, String::new())
}

/// Build an authorization request from a `code_challenge` you computed
/// yourself.
///
/// The challenge is `BASE64URL(SHA256(code_verifier))`; see
/// [`Pkce::derive_challenge`]. Most callers should use
/// [`AuthorizationUrl::generated`] with [`AuthorizationUrl::start`] instead.
pub fn new(
client_id: impl Into<String>,
redirect_uri: impl Into<String>,
code_challenge: impl Into<String>,
) -> Self {
Self {
response_type: "code",
client_id: client_id.into(),
redirect_uri: redirect_uri.into(),
code_challenge_method: "S256",
code_challenge: code_challenge.into(),
scope: None,
username: None,
state: None,
}
}

/// Space separated list of requested OAuth scopes.
pub fn scope(mut self, scope: impl Into<String>) -> Self {
self.scope = Some(scope.into());
self
}

/// Hint that the user should log in with a specific Lichess username.
pub fn username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}

/// Arbitrary state returned verbatim with the authorization result.
pub fn state(mut self, state: impl Into<String>) -> Self {
self.state = Some(state.into());
self
}

/// Begin an authorization request, generating the PKCE secrets and `state`
/// for you.
///
/// Returns the URL to send the user to, and a [`PendingAuthorization`]
/// holding the secrets needed to complete the flow. Store the pending value
/// for the duration of the request (in session storage for a web backend,
/// in memory for a native app) and finish with
/// [`PendingAuthorization::complete`].
///
/// Any `state` set on this builder is replaced by a freshly generated one.
/// Use [`PendingAuthorization::new`] directly if you must supply your own.
pub fn start(mut self) -> Result<(url::Url, PendingAuthorization)> {
let pkce = Pkce::generate();
let state = generate_state();

self.code_challenge = pkce.challenge().to_string();
self.state = Some(state.clone());

let url = self.to_url()?;
let pending =
PendingAuthorization::new(pkce.verifier(), state, self.client_id, self.redirect_uri);

Ok((url, pending))
}

/// Build the URL to send the user to in order to grant authorization.
pub fn to_url(&self) -> Result<url::Url> {
let base_url = format!("https://{}", Domain::Lichess.as_ref());
let mut url = url::Url::parse(&base_url).expect("invalid base url");

{
let mut query_pairs = url.query_pairs_mut();
let query_serializer = serde_urlencoded::Serializer::new(&mut query_pairs);
self.serialize(query_serializer)?;
}

url.set_path("/oauth");

Ok(url)
}
}
22 changes: 19 additions & 3 deletions lib/src/model/oauth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,34 @@ use std::collections::HashMap;

use serde::{Deserialize, Serialize};

pub mod authorize;
pub mod pending;
pub mod pkce;
pub mod revoke;
pub mod test;
pub mod token;

pub type TestResults = HashMap<String, Token>;
pub use pending::PendingAuthorization;
pub use pkce::Pkce;

/// Maps each tested token to its details, or `None` if the token is invalid.
pub type TestResults = HashMap<String, Option<Token>>;

/// An access token obtained by exchanging an authorization code.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AccessToken {
pub token_type: String,
pub access_token: String,
/// Lifetime of the token in seconds.
pub expires_in: u64,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Token {
/// Comma separated
pub scopes: String,
pub user_id: String,
/// Unix timestamp
#[serde(skip_serializing_if = "Option::is_none")]
/// Unix timestamp in milliseconds, or `None` if the token never expires.
pub expires: Option<u64>,
}
Loading
Loading