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}