From 592b401227208cc5314c008d2b46a7f6dba77e0c Mon Sep 17 00:00:00 2001 From: Arran Ireland Date: Sat, 22 Aug 2026 15:50:06 +0100 Subject: [PATCH 1/5] oauth: fix test-tokens response for invalid tokens The endpoint returns null for any token that isn't valid, so the map values are nullable. Deserializing into a non-optional Token made test_tokens fail on exactly the responses it exists to report on. Also stop skipping expires on serialization; the spec types it as integer|null, always present. Co-Authored-By: Claude Opus 5 --- lib/src/model/oauth/mod.rs | 6 +++--- lib/tests/data/response/oauth_test_tokens.json | 8 ++++++++ lib/tests/offline.rs | 5 +++++ 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 lib/tests/data/response/oauth_test_tokens.json diff --git a/lib/src/model/oauth/mod.rs b/lib/src/model/oauth/mod.rs index ff6bc4f..e0a41e2 100644 --- a/lib/src/model/oauth/mod.rs +++ b/lib/src/model/oauth/mod.rs @@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize}; pub mod revoke; pub mod test; -pub type TestResults = HashMap; +/// Maps each tested token to its details, or `None` if the token is invalid. +pub type TestResults = HashMap>; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -13,7 +14,6 @@ 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, } diff --git a/lib/tests/data/response/oauth_test_tokens.json b/lib/tests/data/response/oauth_test_tokens.json new file mode 100644 index 0000000..70b37d6 --- /dev/null +++ b/lib/tests/data/response/oauth_test_tokens.json @@ -0,0 +1,8 @@ +{ + "lip_jose": { + "scopes": "preference:read,preference:write,email:read,challenge:read,challenge:write,challenge:bulk,study:read,study:write,tournament:write,racer:write,puzzle:read,puzzle:write,team:read,team:write,team:lead,follow:read,follow:write,msg:write,board:play,bot:play,engine:read,engine:write,web:mod", + "userId": "jose", + "expires": null + }, + "lip_badToken": null +} diff --git a/lib/tests/offline.rs b/lib/tests/offline.rs index 8163e5e..a9904bc 100644 --- a/lib/tests/offline.rs +++ b/lib/tests/offline.rs @@ -171,6 +171,11 @@ pub fn broadcasts() { test_response_model::("broadcast_pgn_push"); } +#[test] +pub fn oauth() { + test_response_model::("oauth_test_tokens"); +} + fn test_response_model(file_name: &str) { let path = format!("./tests/data/response/{}.json", file_name); test_model::(path); From 6ee26cfee4df9d6abd71d57e783330f8d4a94880 Mon Sep 17 00:00:00 2001 From: Arran Ireland Date: Sat, 22 Aug 2026 15:52:52 +0100 Subject: [PATCH 2/5] oauth: add authorization and token exchange endpoints Adds the token exchange as a normal form-encoded POST, and the authorization endpoint as a URL builder rather than a request: it renders a consent page for a browser and returns its result via the redirect URI, so there is nothing for the client to call. PKCE code_challenge is supplied by the caller to avoid taking on cryptographic dependencies. Co-Authored-By: Claude Opus 5 --- lib/src/api/oauth.rs | 13 +++ lib/src/model/oauth/authorize.rs | 96 +++++++++++++++++++ lib/src/model/oauth/mod.rs | 11 +++ lib/src/model/oauth/token.rs | 51 ++++++++++ .../data/response/oauth_access_token.json | 5 + lib/tests/offline.rs | 42 ++++++++ 6 files changed, 218 insertions(+) create mode 100644 lib/src/model/oauth/authorize.rs create mode 100644 lib/src/model/oauth/token.rs create mode 100644 lib/tests/data/response/oauth_access_token.json diff --git a/lib/src/api/oauth.rs b/lib/src/api/oauth.rs index 57de6cd..9c5a5e6 100644 --- a/lib/src/api/oauth.rs +++ b/lib/src/api/oauth.rs @@ -10,4 +10,17 @@ impl LichessApi { 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, + ) -> Result { + self.get_single_model(request.into()).await + } } diff --git a/lib/src/model/oauth/authorize.rs b/lib/src/model/oauth/authorize.rs new file mode 100644 index 0000000..c3ccea4 --- /dev/null +++ b/lib/src/model/oauth/authorize.rs @@ -0,0 +1,96 @@ +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`. Use [`AuthorizationUrl::to_url`] +/// to build the URL to send the user to. +/// +/// `response_type` and `code_challenge_method` are fixed by the spec and are +/// set for you. +/// +/// The flow uses PKCE. Generate two random strings unique to each authorization +/// request, a `code_verifier` and a `state`, and store them for the duration of +/// the request. The `code_challenge` below is `BASE64URL(SHA256(code_verifier))`; +/// this library does not compute it, to avoid pulling in cryptographic +/// dependencies. Keep the `code_verifier` out of URLs and off insecure +/// connections. +/// +/// When the user is redirected back, check that the returned `state` matches +/// the one you generated, then exchange the returned `code` for an access token +/// with [`super::token::TokenExchangeForm`]. +#[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, + /// Hint that the user should log in with a specific Lichess username. + pub username: Option, + /// Arbitrary state returned verbatim with the authorization result. + pub state: Option, +} + +impl AuthorizationUrl { + pub fn new( + client_id: impl Into, + redirect_uri: impl Into, + code_challenge: impl Into, + ) -> 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) -> 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) -> Self { + self.username = Some(username.into()); + self + } + + /// Arbitrary state returned verbatim with the authorization result. + pub fn state(mut self, state: impl Into) -> Self { + self.state = Some(state.into()); + self + } + + /// Build the URL to send the user to in order to grant authorization. + pub fn to_url(&self) -> Result { + 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) + } +} diff --git a/lib/src/model/oauth/mod.rs b/lib/src/model/oauth/mod.rs index e0a41e2..6067b3e 100644 --- a/lib/src/model/oauth/mod.rs +++ b/lib/src/model/oauth/mod.rs @@ -2,12 +2,23 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +pub mod authorize; pub mod revoke; pub mod test; +pub mod token; /// Maps each tested token to its details, or `None` if the token is invalid. pub type TestResults = HashMap>; +/// 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 { diff --git a/lib/src/model/oauth/token.rs b/lib/src/model/oauth/token.rs new file mode 100644 index 0000000..bba2716 --- /dev/null +++ b/lib/src/model/oauth/token.rs @@ -0,0 +1,51 @@ +use crate::model::{Body, Request}; +use serde::Serialize; + +/// Form used to exchange an authorization code for an access token. +/// +/// The `code` comes from the redirect back to your `redirect_uri`, and +/// `code_verifier` must be the value the `code_challenge` was derived from. +/// Both `redirect_uri` and `client_id` must match those used to request the +/// authorization code. +#[derive(Clone, Debug, Serialize)] +pub struct TokenExchangeForm { + grant_type: &'static str, + pub code: String, + pub code_verifier: String, + pub redirect_uri: String, + pub client_id: String, +} + +impl TokenExchangeForm { + pub fn new( + code: impl Into, + code_verifier: impl Into, + redirect_uri: impl Into, + client_id: impl Into, + ) -> Self { + Self { + grant_type: "authorization_code", + code: code.into(), + code_verifier: code_verifier.into(), + redirect_uri: redirect_uri.into(), + client_id: client_id.into(), + } + } +} + +#[derive(Default, Clone, Debug, Serialize)] +pub struct PostQuery; + +pub type PostRequest = Request; + +impl PostRequest { + pub fn new(form: TokenExchangeForm) -> Self { + Self::post("/api/token", None, Body::Form(form), None) + } +} + +impl From for PostRequest { + fn from(form: TokenExchangeForm) -> Self { + Self::new(form) + } +} diff --git a/lib/tests/data/response/oauth_access_token.json b/lib/tests/data/response/oauth_access_token.json new file mode 100644 index 0000000..b703f81 --- /dev/null +++ b/lib/tests/data/response/oauth_access_token.json @@ -0,0 +1,5 @@ +{ + "token_type": "Bearer", + "access_token": "lio_pLwAbN7lFPklzY2m8lTOI1DGApS84u57", + "expires_in": 31536000 +} diff --git a/lib/tests/offline.rs b/lib/tests/offline.rs index a9904bc..8732c7d 100644 --- a/lib/tests/offline.rs +++ b/lib/tests/offline.rs @@ -174,6 +174,48 @@ pub fn broadcasts() { #[test] pub fn oauth() { test_response_model::("oauth_test_tokens"); + test_response_model::("oauth_access_token"); +} + +#[test] +pub fn oauth_authorization_url() { + let url = oauth::authorize::AuthorizationUrl::new("example.com", "http://example.com/", "cc") + .scope("preference:read challenge:write") + .state("st") + .to_url() + .expect("Unable to build authorization url."); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host_str(), Some("lichess.org")); + assert_eq!(url.path(), "/oauth"); + + let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect(); + + assert_eq!( + params.get("response_type").map(String::as_str), + Some("code") + ); + assert_eq!( + params.get("client_id").map(String::as_str), + Some("example.com") + ); + assert_eq!( + params.get("redirect_uri").map(String::as_str), + Some("http://example.com/") + ); + assert_eq!( + params.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + assert_eq!(params.get("code_challenge").map(String::as_str), Some("cc")); + assert_eq!( + params.get("scope").map(String::as_str), + Some("preference:read challenge:write") + ); + assert_eq!(params.get("state").map(String::as_str), Some("st")); + + // Unset optional parameters are omitted entirely. + assert_eq!(params.get("username"), None); } fn test_response_model(file_name: &str) { From 596ff3fe6300b2e2237285953e3ada4a08d0f13e Mon Sep 17 00:00:00 2001 From: Arran Ireland Date: Sat, 22 Aug 2026 16:12:51 +0100 Subject: [PATCH 3/5] oauth: add pending authorization flow behind a feature flag Generates the PKCE secrets and CSRF state, then carries them in a PendingAuthorization so the token exchange cannot be reached without verifying the returned state and supplying the verifier. Previously both were left to the caller as doc comments. Adds typed OAuth errors so a cancelled authorization is distinguishable from a bad verifier; the generic handler flattened both into a string and dropped error_description. All of oauth is now behind a default-on feature, so clients using a personal token can drop the crypto dependencies with default-features = false. Co-Authored-By: Claude Opus 5 --- README.md | 14 +- lib/Cargo.toml | 11 ++ lib/src/api/mod.rs | 1 + lib/src/error.rs | 14 ++ lib/src/model/mod.rs | 1 + lib/src/model/oauth/authorize.rs | 74 +++++++-- lib/src/model/oauth/mod.rs | 5 + lib/src/model/oauth/pending.rs | 151 ++++++++++++++++++ lib/src/model/oauth/pkce.rs | 65 ++++++++ .../data/response/oauth_access_token.json | 2 +- lib/tests/offline.rs | 109 +++++++++++++ 11 files changed, 434 insertions(+), 13 deletions(-) create mode 100644 lib/src/model/oauth/pending.rs create mode 100644 lib/src/model/oauth/pkce.rs diff --git a/README.md b/README.md index 2b50da7..51b285b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The following table shows the current level of support for each category of endp | Games | ✅ | | Messaging | ✅ | | Opening Explorer | ✅ | -| OAuth | 🔶 | +| OAuth | ✅ | | Puzzles | ✅ | | Relations | ✅ | | Simuls | ✅ | @@ -52,6 +52,18 @@ The following table shows the current level of support for each category of endp | 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. diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 8b15339..0a2a64b 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -10,22 +10,33 @@ 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"] + [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" [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"] } \ No newline at end of file diff --git a/lib/src/api/mod.rs b/lib/src/api/mod.rs index 64f10ce..74df119 100644 --- a/lib/src/api/mod.rs +++ b/lib/src/api/mod.rs @@ -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; diff --git a/lib/src/error.rs b/lib/src/error.rs index c0d14e1..772221b 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -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, + }, + + #[cfg(feature = "oauth")] + #[error("oauth state mismatch (possible cross site request forgery)")] + OAuthStateMismatch, + #[error("unknown error: {0}")] Unknown(String), } diff --git a/lib/src/model/mod.rs b/lib/src/model/mod.rs index ff61b8b..17f9b10 100644 --- a/lib/src/model/mod.rs +++ b/lib/src/model/mod.rs @@ -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; diff --git a/lib/src/model/oauth/authorize.rs b/lib/src/model/oauth/authorize.rs index c3ccea4..6e47aa6 100644 --- a/lib/src/model/oauth/authorize.rs +++ b/lib/src/model/oauth/authorize.rs @@ -1,3 +1,5 @@ +use super::PendingAuthorization; +use super::pkce::{Pkce, generate_state}; use crate::error::Result; use crate::model::Domain; use serde::Serialize; @@ -7,22 +9,32 @@ use serde_with::skip_serializing_none; /// /// 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`. Use [`AuthorizationUrl::to_url`] -/// to build the URL to send the user to. +/// parameters appended to your `redirect_uri`. /// /// `response_type` and `code_challenge_method` are fixed by the spec and are /// set for you. /// -/// The flow uses PKCE. Generate two random strings unique to each authorization -/// request, a `code_verifier` and a `state`, and store them for the duration of -/// the request. The `code_challenge` below is `BASE64URL(SHA256(code_verifier))`; -/// this library does not compute it, to avoid pulling in cryptographic -/// dependencies. Keep the `code_verifier` out of URLs and off insecure -/// connections. +/// # 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()?; /// -/// When the user is redirected back, check that the returned `state` matches -/// the one you generated, then exchange the returned `code` for an access token -/// with [`super::token::TokenExchangeForm`]. +/// // 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 { @@ -43,6 +55,21 @@ pub struct AuthorizationUrl { } 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, redirect_uri: impl Into) -> 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, redirect_uri: impl Into, @@ -78,6 +105,31 @@ impl AuthorizationUrl { 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 { let base_url = format!("https://{}", Domain::Lichess.as_ref()); diff --git a/lib/src/model/oauth/mod.rs b/lib/src/model/oauth/mod.rs index 6067b3e..2f24094 100644 --- a/lib/src/model/oauth/mod.rs +++ b/lib/src/model/oauth/mod.rs @@ -3,10 +3,15 @@ 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 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>; diff --git a/lib/src/model/oauth/pending.rs b/lib/src/model/oauth/pending.rs new file mode 100644 index 0000000..6364fe4 --- /dev/null +++ b/lib/src/model/oauth/pending.rs @@ -0,0 +1,151 @@ +use super::token::TokenExchangeForm; +use crate::error::{Error, Result}; + +/// The secrets needed to complete an authorization request. +/// +/// Created by [`super::authorize::AuthorizationUrl::start`]. Hold this for the +/// duration of the flow — in session storage for a web backend, in memory for a +/// native or client-side app — then finish with +/// [`PendingAuthorization::complete`]. +/// +/// Keeping the verifier and state together here means the CSRF check and the +/// verifier cannot be forgotten: the only way to reach the token exchange is +/// through a method that performs both. +/// +/// # Example +/// +/// ```no_run +/// use lichess_api::client::LichessApi; +/// use lichess_api::model::oauth::authorize::AuthorizationUrl; +/// +/// # async fn run() -> lichess_api::error::Result<()> { +/// let (url, pending) = AuthorizationUrl::generated("example.com", "http://example.com/") +/// .scope("preference:read") +/// .start()?; +/// +/// // Send the user to `url`. They come back to your `redirect_uri`, which +/// // carries the authorization result in its query string. +/// let redirect_url = url::Url::parse("http://example.com/?code=...&state=...").unwrap(); +/// +/// // No token yet, so the client is unauthenticated here. +/// let api = LichessApi::new(reqwest::Client::new(), None); +/// let token = pending.complete(&api, &redirect_url).await?; +/// +/// // Subsequent requests act on behalf of the user. +/// let api = LichessApi::new(reqwest::Client::new(), Some(token.access_token)); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Debug)] +pub struct PendingAuthorization { + verifier: String, + state: String, + client_id: String, + redirect_uri: String, +} + +impl PendingAuthorization { + pub fn new( + verifier: impl Into, + state: impl Into, + client_id: impl Into, + redirect_uri: impl Into, + ) -> Self { + Self { + verifier: verifier.into(), + state: state.into(), + client_id: client_id.into(), + redirect_uri: redirect_uri.into(), + } + } + + /// The `state` this request expects back from the authorization result. + pub fn state(&self) -> &str { + &self.state + } + + /// Parse an authorization result and produce the token exchange form. + /// + /// `redirect_url` is the full URL the user was redirected back to, + /// including its query string. Returns an error if the authorization was + /// denied, if the `state` does not match, or if the URL is missing the + /// authorization code. + /// + /// Use this when you want to inspect or send the exchange yourself; + /// [`PendingAuthorization::complete`] does this and performs the exchange. + pub fn exchange_form(self, redirect_url: &url::Url) -> Result { + let mut code = None; + let mut state = None; + let mut error = None; + let mut error_description = None; + + for (key, value) in redirect_url.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => state = Some(value.into_owned()), + "error" => error = Some(value.into_owned()), + "error_description" => error_description = Some(value.into_owned()), + _ => {} + } + } + + // Check state before anything else, so a forged redirect is rejected + // regardless of what it carries. + // + // A failed authorization returns the state too, so this is verifiable + // even on the error path. Treat a missing state as a mismatch. + let returned_state = state.unwrap_or_default(); + if !constant_time_eq(returned_state.as_bytes(), self.state.as_bytes()) { + return Err(Error::OAuthStateMismatch); + } + + if let Some(error) = error { + return Err(Error::OAuth { + error, + error_description, + }); + } + + let code = code.ok_or_else(|| { + Error::Response("authorization result has neither a code nor an error".to_string()) + })?; + + Ok(TokenExchangeForm::new( + code, + self.verifier, + self.redirect_uri, + self.client_id, + )) + } + + /// Complete the flow: verify the authorization result and exchange the code + /// for an access token. + /// + /// `redirect_url` is the full URL the user was redirected back to. + /// + /// The client need not be authenticated — this is what produces the token — + /// so `LichessApi::new(client, None)` is fine here. + pub async fn complete( + self, + api: &crate::client::LichessApi, + redirect_url: &url::Url, + ) -> Result { + let form = self.exchange_form(redirect_url)?; + api.obtain_access_token(form).await + } +} + +/// Compare two byte strings without short-circuiting on the first difference. +/// +/// The state is a CSRF token, so its comparison should not leak how much of a +/// guess was correct through timing. +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + + left.iter() + .zip(right) + .fold(0u8, |acc, (l, r)| acc | (l ^ r)) + == 0 +} diff --git a/lib/src/model/oauth/pkce.rs b/lib/src/model/oauth/pkce.rs new file mode 100644 index 0000000..30d48d0 --- /dev/null +++ b/lib/src/model/oauth/pkce.rs @@ -0,0 +1,65 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use rand::RngExt; +use sha2::{Digest, Sha256}; + +/// Number of random bytes used for generated secrets. +/// +/// 32 bytes base64url-encodes to 43 characters, the minimum length RFC 7636 +/// permits for a `code_verifier`. +const SECRET_BYTES: usize = 32; + +/// Generate a cryptographically random, base64url-encoded secret. +fn generate_secret() -> String { + let bytes: [u8; SECRET_BYTES] = rand::rng().random(); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// A PKCE secret pair, as described by RFC 7636. +/// +/// The `verifier` is kept private until the token exchange; the `challenge` is +/// what gets sent in the authorization request. Only the challenge travels over +/// the initial redirect, so an eavesdropper who intercepts the authorization +/// code cannot exchange it without the verifier. +/// +/// Keep the verifier out of URLs and off insecure connections. For fully +/// client-side apps the user themselves can always extract it, which is fine. +#[derive(Clone, Debug)] +pub struct Pkce { + verifier: String, + challenge: String, +} + +impl Pkce { + /// Generate a new random verifier and its derived challenge. + pub fn generate() -> Self { + let verifier = generate_secret(); + let challenge = Self::derive_challenge(&verifier); + + Self { + verifier, + challenge, + } + } + + /// `BASE64URL(SHA256(code_verifier))`, the `S256` challenge method. + pub fn derive_challenge(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) + } + + /// The secret sent only in the token exchange. + pub fn verifier(&self) -> &str { + &self.verifier + } + + /// The derived value sent in the authorization request. + pub fn challenge(&self) -> &str { + &self.challenge + } +} + +/// Generate a random `state` value, used to tie an authorization result back to +/// the request that started it and defend against cross site request forgery. +pub fn generate_state() -> String { + generate_secret() +} diff --git a/lib/tests/data/response/oauth_access_token.json b/lib/tests/data/response/oauth_access_token.json index b703f81..e9bd8f8 100644 --- a/lib/tests/data/response/oauth_access_token.json +++ b/lib/tests/data/response/oauth_access_token.json @@ -1,5 +1,5 @@ { "token_type": "Bearer", - "access_token": "lio_pLwAbN7lFPklzY2m8lTOI1DGApS84u57", + "access_token": "lio_pLwAbN2lFPklzY2m8lTOI1DGApS84u53", "expires_in": 31536000 } diff --git a/lib/tests/offline.rs b/lib/tests/offline.rs index 8732c7d..ebe9db8 100644 --- a/lib/tests/offline.rs +++ b/lib/tests/offline.rs @@ -171,12 +171,14 @@ pub fn broadcasts() { test_response_model::("broadcast_pgn_push"); } +#[cfg(feature = "oauth")] #[test] pub fn oauth() { test_response_model::("oauth_test_tokens"); test_response_model::("oauth_access_token"); } +#[cfg(feature = "oauth")] #[test] pub fn oauth_authorization_url() { let url = oauth::authorize::AuthorizationUrl::new("example.com", "http://example.com/", "cc") @@ -218,6 +220,113 @@ pub fn oauth_authorization_url() { assert_eq!(params.get("username"), None); } +#[cfg(feature = "oauth")] +#[test] +pub fn oauth_pkce() { + use lichess_api::model::oauth::Pkce; + + // Test vector from RFC 7636 appendix B. + assert_eq!( + Pkce::derive_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + + let pkce = Pkce::generate(); + + // 32 random bytes, base64url encoded without padding. + assert_eq!(pkce.verifier().len(), 43); + assert!(!pkce.verifier().contains('=')); + assert_eq!(pkce.challenge(), Pkce::derive_challenge(pkce.verifier())); + assert_ne!(pkce.verifier(), pkce.challenge()); + + // Secrets must not repeat across requests. + assert_ne!(Pkce::generate().verifier(), pkce.verifier()); +} + +#[cfg(feature = "oauth")] +#[test] +pub fn oauth_start_generates_secrets() { + use lichess_api::model::oauth::authorize::AuthorizationUrl; + + let (url, pending) = AuthorizationUrl::generated("example.com", "http://example.com/") + .scope("preference:read") + .start() + .expect("Unable to start authorization."); + + let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect(); + + // The generated state is what the pending authorization will check against, + // and the challenge must never be the verifier itself. + assert_eq!( + params.get("state").map(String::as_str), + Some(pending.state()) + ); + assert_eq!( + params.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + assert!(params.contains_key("code_challenge")); +} + +#[cfg(feature = "oauth")] +#[test] +pub fn oauth_pending_authorization() { + use lichess_api::error::Error; + use lichess_api::model::oauth::PendingAuthorization; + + let pending = + || PendingAuthorization::new("verifier", "st", "example.com", "http://example.com/"); + let redirect = |query: &str| { + url::Url::parse(&format!("http://example.com/?{}", query)).expect("Unable to parse url.") + }; + + // Happy path: the form carries the verifier the challenge was derived from. + let form = pending() + .exchange_form(&redirect("code=abc&state=st")) + .expect("Unable to build exchange form."); + let encoded = serde_urlencoded::to_string(&form).expect("Unable to encode form."); + + assert!(encoded.contains("grant_type=authorization_code")); + assert!(encoded.contains("code=abc")); + assert!(encoded.contains("code_verifier=verifier")); + + // A mismatched state is rejected before anything else is considered. + assert!(matches!( + pending().exchange_form(&redirect("code=abc&state=wrong")), + Err(Error::OAuthStateMismatch) + )); + + // A missing state is a mismatch, not an absent check. + assert!(matches!( + pending().exchange_form(&redirect("code=abc")), + Err(Error::OAuthStateMismatch) + )); + + // Denial is surfaced with its description, not flattened into a string. + let denied = pending().exchange_form(&redirect( + "error=access_denied&error_description=user+cancelled&state=st", + )); + match denied { + Err(Error::OAuth { + error, + error_description, + }) => { + assert_eq!(error, "access_denied"); + assert_eq!(error_description.as_deref(), Some("user cancelled")); + } + other => panic!("expected an oauth error, got {:?}", other.map(|_| ())), + } + + // An error carrying a forged state is still rejected as a mismatch. + assert!(matches!( + pending().exchange_form(&redirect("error=access_denied&state=wrong")), + Err(Error::OAuthStateMismatch) + )); + + // Neither a code nor an error is malformed. + assert!(pending().exchange_form(&redirect("state=st")).is_err()); +} + fn test_response_model(file_name: &str) { let path = format!("./tests/data/response/{}.json", file_name); test_model::(path); From 6cc082099bdbf3343fbcbd841ccd968582d97bd5 Mon Sep 17 00:00:00 2001 From: Arran Ireland Date: Sat, 22 Aug 2026 16:19:39 +0100 Subject: [PATCH 4/5] chore: update readme --- README.md | 42 ++---------------------------------------- 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 51b285b..a251be7 100644 --- a/README.md +++ b/README.md @@ -12,45 +12,7 @@ [apache-url]: LICENSE A Rust API client library for [the current lichess.org API](https://lichess.org/api). - -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 | ✅ | +All endpoints are supported. ## Features @@ -66,6 +28,6 @@ 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. From 849ad9872014aeaf9cdf5dfc18d9d6c6d289feec Mon Sep 17 00:00:00 2001 From: Arran Ireland Date: Sat, 22 Aug 2026 16:34:53 +0100 Subject: [PATCH 5/5] wasm: fix getrandom --- lib/Cargo.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 0a2a64b..7fe4b21 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -14,7 +14,7 @@ readme = "../README.md" 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"] +oauth = ["dep:base64", "dep:rand", "dep:sha2", "dep:getrandom"] [dependencies] # Library dependencies. @@ -35,6 +35,12 @@ 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"] }