Skip to main content

lichess_api/model/oauth/
pending.rs

1use super::token::TokenExchangeForm;
2use crate::error::{Error, Result};
3
4/// The secrets needed to complete an authorization request.
5///
6/// Created by [`super::authorize::AuthorizationUrl::start`]. Hold this for the
7/// duration of the flow — in session storage for a web backend, in memory for a
8/// native or client-side app — then finish with
9/// [`PendingAuthorization::complete`].
10///
11/// Keeping the verifier and state together here means the CSRF check and the
12/// verifier cannot be forgotten: the only way to reach the token exchange is
13/// through a method that performs both.
14///
15/// # Example
16///
17/// ```no_run
18/// use lichess_api::client::LichessApi;
19/// use lichess_api::model::oauth::authorize::AuthorizationUrl;
20///
21/// # async fn run() -> lichess_api::error::Result<()> {
22/// let (url, pending) = AuthorizationUrl::generated("example.com", "http://example.com/")
23///     .scope("preference:read")
24///     .start()?;
25///
26/// // Send the user to `url`. They come back to your `redirect_uri`, which
27/// // carries the authorization result in its query string.
28/// let redirect_url = url::Url::parse("http://example.com/?code=...&state=...").unwrap();
29///
30/// // No token yet, so the client is unauthenticated here.
31/// let api = LichessApi::new(reqwest::Client::new(), None);
32/// let token = pending.complete(&api, &redirect_url).await?;
33///
34/// // Subsequent requests act on behalf of the user.
35/// let api = LichessApi::new(reqwest::Client::new(), Some(token.access_token));
36/// # Ok(())
37/// # }
38/// ```
39#[derive(Clone, Debug)]
40pub struct PendingAuthorization {
41    verifier: String,
42    state: String,
43    client_id: String,
44    redirect_uri: String,
45}
46
47impl PendingAuthorization {
48    pub fn new(
49        verifier: impl Into<String>,
50        state: impl Into<String>,
51        client_id: impl Into<String>,
52        redirect_uri: impl Into<String>,
53    ) -> Self {
54        Self {
55            verifier: verifier.into(),
56            state: state.into(),
57            client_id: client_id.into(),
58            redirect_uri: redirect_uri.into(),
59        }
60    }
61
62    /// The `state` this request expects back from the authorization result.
63    pub fn state(&self) -> &str {
64        &self.state
65    }
66
67    /// Parse an authorization result and produce the token exchange form.
68    ///
69    /// `redirect_url` is the full URL the user was redirected back to,
70    /// including its query string. Returns an error if the authorization was
71    /// denied, if the `state` does not match, or if the URL is missing the
72    /// authorization code.
73    ///
74    /// Use this when you want to inspect or send the exchange yourself;
75    /// [`PendingAuthorization::complete`] does this and performs the exchange.
76    pub fn exchange_form(self, redirect_url: &url::Url) -> Result<TokenExchangeForm> {
77        let mut code = None;
78        let mut state = None;
79        let mut error = None;
80        let mut error_description = None;
81
82        for (key, value) in redirect_url.query_pairs() {
83            match key.as_ref() {
84                "code" => code = Some(value.into_owned()),
85                "state" => state = Some(value.into_owned()),
86                "error" => error = Some(value.into_owned()),
87                "error_description" => error_description = Some(value.into_owned()),
88                _ => {}
89            }
90        }
91
92        // Check state before anything else, so a forged redirect is rejected
93        // regardless of what it carries.
94        //
95        // A failed authorization returns the state too, so this is verifiable
96        // even on the error path. Treat a missing state as a mismatch.
97        let returned_state = state.unwrap_or_default();
98        if !constant_time_eq(returned_state.as_bytes(), self.state.as_bytes()) {
99            return Err(Error::OAuthStateMismatch);
100        }
101
102        if let Some(error) = error {
103            return Err(Error::OAuth {
104                error,
105                error_description,
106            });
107        }
108
109        let code = code.ok_or_else(|| {
110            Error::Response("authorization result has neither a code nor an error".to_string())
111        })?;
112
113        Ok(TokenExchangeForm::new(
114            code,
115            self.verifier,
116            self.redirect_uri,
117            self.client_id,
118        ))
119    }
120
121    /// Complete the flow: verify the authorization result and exchange the code
122    /// for an access token.
123    ///
124    /// `redirect_url` is the full URL the user was redirected back to.
125    ///
126    /// The client need not be authenticated — this is what produces the token —
127    /// so `LichessApi::new(client, None)` is fine here.
128    pub async fn complete(
129        self,
130        api: &crate::client::LichessApi<reqwest::Client>,
131        redirect_url: &url::Url,
132    ) -> Result<super::AccessToken> {
133        let form = self.exchange_form(redirect_url)?;
134        api.obtain_access_token(form).await
135    }
136}
137
138/// Compare two byte strings without short-circuiting on the first difference.
139///
140/// The state is a CSRF token, so its comparison should not leak how much of a
141/// guess was correct through timing.
142fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
143    if left.len() != right.len() {
144        return false;
145    }
146
147    left.iter()
148        .zip(right)
149        .fold(0u8, |acc, (l, r)| acc | (l ^ r))
150        == 0
151}