diff --git a/README.md b/README.md index 2b50da7..a251be7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 8b15339..7fe4b21 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -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"] } \ 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/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/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 new file mode 100644 index 0000000..6e47aa6 --- /dev/null +++ b/lib/src/model/oauth/authorize.rs @@ -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, + /// 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 { + /// 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, + 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 + } + + /// 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()); + 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 ff6bc4f..2f24094 100644 --- a/lib/src/model/oauth/mod.rs +++ b/lib/src/model/oauth/mod.rs @@ -2,10 +2,27 @@ 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; +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>; + +/// 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")] @@ -13,7 +30,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/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/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..e9bd8f8 --- /dev/null +++ b/lib/tests/data/response/oauth_access_token.json @@ -0,0 +1,5 @@ +{ + "token_type": "Bearer", + "access_token": "lio_pLwAbN2lFPklzY2m8lTOI1DGApS84u53", + "expires_in": 31536000 +} 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 2263de7..5d53eb2 100644 --- a/lib/tests/offline.rs +++ b/lib/tests/offline.rs @@ -182,6 +182,162 @@ 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") + .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); +} + +#[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()); +} + #[test] pub fn fide_player_ratings() { test_response_model::("fide_player_ratings");