1 //!
2 //! This example showcases the Letterboxd OAuth2 process for requesting access
3 //! to the API features restricted by authentication. Letterboxd requires all
4 //! requests being signed as described in http://api-docs.letterboxd.com/#signing.
5 //! So this serves as an example how to implement a custom client, which signs
6 //! requests and appends the signature to the url query.
7 //!
8 //! Before running it, you'll need to get access to the API.
9 //!
10 //! In order to run the example call:
11 //!
12 //! ```sh
13 //! LETTERBOXD_CLIENT_ID=xxx LETTERBOXD_CLIENT_SECRET=yyy LETTERBOXD_USERNAME=www LETTERBOXD_PASSWORD=zzz cargo run --example letterboxd
14 //! ```
15 
16 use hex::ToHex;
17 use hmac::{Hmac, Mac, NewMac};
18 use oauth2::{
19     basic::BasicClient, AuthType, AuthUrl, ClientId, ClientSecret, HttpRequest, HttpResponse,
20     ResourceOwnerPassword, ResourceOwnerUsername, TokenUrl,
21 };
22 use sha2::Sha256;
23 use url::Url;
24 
25 use std::env;
26 use std::time;
27 
main() -> Result<(), anyhow::Error>28 fn main() -> Result<(), anyhow::Error> {
29     // a.k.a api key in Letterboxd API documentation
30     let letterboxd_client_id = ClientId::new(
31         env::var("LETTERBOXD_CLIENT_ID")
32             .expect("Missing the LETTERBOXD_CLIENT_ID environment variable."),
33     );
34     // a.k.a api secret in Letterboxd API documentation
35     let letterboxd_client_secret = ClientSecret::new(
36         env::var("LETTERBOXD_CLIENT_SECRET")
37             .expect("Missing the LETTERBOXD_CLIENT_SECRET environment variable."),
38     );
39     // Letterboxd uses the Resource Owner flow and does not have an auth url
40     let auth_url = AuthUrl::new("https://api.letterboxd.com/api/v0/auth/404".to_string())?;
41     let token_url = TokenUrl::new("https://api.letterboxd.com/api/v0/auth/token".to_string())?;
42 
43     // Set up the config for the Letterboxd OAuth2 process.
44     let client = BasicClient::new(
45         letterboxd_client_id.clone(),
46         Some(letterboxd_client_secret.clone()),
47         auth_url,
48         Some(token_url),
49     );
50 
51     // Resource Owner flow uses username and password for authentication
52     let letterboxd_username = ResourceOwnerUsername::new(
53         env::var("LETTERBOXD_USERNAME")
54             .expect("Missing the LETTERBOXD_USERNAME environment variable."),
55     );
56     let letterboxd_password = ResourceOwnerPassword::new(
57         env::var("LETTERBOXD_PASSWORD")
58             .expect("Missing the LETTERBOXD_PASSWORD environment variable."),
59     );
60 
61     // All API requests must be signed as described at http://api-docs.letterboxd.com/#signing;
62     // for that, we use a custom http client.
63     let http_client = SigningHttpClient::new(letterboxd_client_id, letterboxd_client_secret);
64 
65     let token_result = client
66         .set_auth_type(AuthType::RequestBody)
67         .exchange_password(&letterboxd_username, &letterboxd_password)
68         .request(|request| http_client.execute(request))?;
69 
70     println!("{:?}", token_result);
71 
72     Ok(())
73 }
74 
75 /// Custom HTTP client which signs requests.
76 ///
77 /// See http://api-docs.letterboxd.com/#signing.
78 #[derive(Debug, Clone)]
79 struct SigningHttpClient {
80     client_id: ClientId,
81     client_secret: ClientSecret,
82 }
83 
84 impl SigningHttpClient {
new(client_id: ClientId, client_secret: ClientSecret) -> Self85     fn new(client_id: ClientId, client_secret: ClientSecret) -> Self {
86         Self {
87             client_id,
88             client_secret,
89         }
90     }
91 
92     /// Signs the request before calling `oauth2::reqwest::http_client`.
execute(&self, mut request: HttpRequest) -> Result<HttpResponse, impl std::error::Error>93     fn execute(&self, mut request: HttpRequest) -> Result<HttpResponse, impl std::error::Error> {
94         let signed_url = self.sign_url(request.url, &request.method, &request.body);
95         request.url = signed_url;
96         oauth2::reqwest::http_client(request)
97     }
98 
99     /// Signs the request based on a random and unique nonce, timestamp, and
100     /// client id and secret.
101     ///
102     /// The client id, nonce, timestamp and signature are added to the url's
103     /// query.
104     ///
105     /// See http://api-docs.letterboxd.com/#signing.
sign_url(&self, mut url: Url, method: &http::method::Method, body: &[u8]) -> Url106     fn sign_url(&self, mut url: Url, method: &http::method::Method, body: &[u8]) -> Url {
107         let nonce = uuid::Uuid::new_v4(); // use UUID as random and unique nonce
108 
109         let timestamp = time::SystemTime::now()
110             .duration_since(time::UNIX_EPOCH)
111             .expect("SystemTime::duration_since failed")
112             .as_secs();
113 
114         url.query_pairs_mut()
115             .append_pair("apikey", &self.client_id)
116             .append_pair("nonce", &format!("{}", nonce))
117             .append_pair("timestamp", &format!("{}", timestamp));
118 
119         // create signature
120         let mut hmac = Hmac::<Sha256>::new_from_slice(&self.client_secret.secret().as_bytes())
121             .expect("HMAC can take key of any size");
122         hmac.update(method.as_str().as_bytes());
123         hmac.update(&[b'\0']);
124         hmac.update(url.as_str().as_bytes());
125         hmac.update(&[b'\0']);
126         hmac.update(body);
127         let signature: String = hmac.finalize().into_bytes().encode_hex();
128 
129         url.query_pairs_mut().append_pair("signature", &signature);
130 
131         url
132     }
133 }
134