1 //! The module contains function about authorization and client-credential
2 // use 3rd party library
3 use chrono::prelude::*;
4 use dotenv::dotenv;
5 use percent_encoding::{utf8_percent_encode, PATH_SEGMENT_ENCODE_SET};
6 use reqwest::Client;
7 
8 // use built-in library
9 use std::collections::{HashMap, HashSet};
10 use std::env;
11 use std::fs::File;
12 use std::fs::OpenOptions;
13 use std::io::prelude::*;
14 use std::iter::FromIterator;
15 use std::path::{Path, PathBuf};
16 
17 // use customized library
18 use super::util::{convert_map_to_string, datetime_to_timestamp, generate_random_string};
19 
20 /// Client credentials object for spotify
21 #[derive(Debug, Clone, Serialize, Deserialize)]
22 pub struct SpotifyClientCredentials {
23     pub client_id: String,
24     pub client_secret: String,
25     pub token_info: Option<TokenInfo>,
26 }
27 /// Authorization for spotify
28 #[derive(Clone, Debug, Serialize, Deserialize)]
29 pub struct SpotifyOAuth {
30     pub client_id: String,
31     pub client_secret: String,
32     pub redirect_uri: String,
33     pub state: String,
34     pub cache_path: PathBuf,
35     pub scope: String,
36     pub proxies: Option<String>,
37 }
38 
39 /// Spotify token-info
40 #[derive(Clone, Debug, Serialize, Deserialize)]
41 pub struct TokenInfo {
42     pub access_token: String,
43     pub token_type: String,
44     pub expires_in: u32,
45     pub expires_at: Option<i64>,
46     pub refresh_token: Option<String>,
47     pub scope: String,
48 }
49 impl TokenInfo {
50     pub fn default() -> TokenInfo {
51         TokenInfo {
52             access_token: String::new(),
53             token_type: String::new(),
54             expires_in: 0u32,
55             expires_at: None,
56             refresh_token: None,
57             scope: String::new(),
58         }
59     }
60     pub fn access_token(mut self, access_token: &str) -> TokenInfo {
61         self.access_token = access_token.to_owned();
62         self
63     }
64     pub fn token_type(mut self, token_type: &str) -> TokenInfo {
65         self.token_type = token_type.to_owned();
66         self
67     }
68     pub fn expires_in(mut self, expires_in: u32) -> TokenInfo {
69         self.expires_in = expires_in;
70         self
71     }
72     pub fn scope(mut self, scope: &str) -> TokenInfo {
73         self.scope = scope.to_owned();
74         self
75     }
76     pub fn expires_at(mut self, expires_at: i64) -> TokenInfo {
77         self.expires_at = Some(expires_at);
78         self
79     }
80     pub fn refresh_token(mut self, refresh_token: &str) -> TokenInfo {
81         self.refresh_token = Some(refresh_token.to_owned());
82         self
83     }
84     pub fn set_expires_at(&mut self, expires_at: i64) {
85         self.expires_at = Some(expires_at);
86     }
87     pub fn set_refresh_token(&mut self, refresh_token: &str) {
88         self.refresh_token = Some(refresh_token.to_owned());
89     }
90 }
91 
92 impl SpotifyClientCredentials {
93     /// build default SpotifyClientCredentials
94     pub fn default() -> SpotifyClientCredentials {
95         dotenv().ok();
96         let client_id = env::var("CLIENT_ID").unwrap_or_default();
97         let client_secret = env::var("CLIENT_SECRET").unwrap_or_default();
98         trace!(
99             "SpotifyClientCredentials.default(): client_id:{:?}, client_secret:{:?}",
100             client_id,
101             client_secret
102         );
103         SpotifyClientCredentials {
104             client_id,
105             client_secret,
106             token_info: None,
107         }
108     }
109     pub fn client_id(mut self, client_id: &str) -> SpotifyClientCredentials {
110         self.client_id = client_id.to_owned();
111         self
112     }
113     pub fn client_secret(mut self, client_secret: &str) -> SpotifyClientCredentials {
114         self.client_secret = client_secret.to_owned();
115         self
116     }
117     pub fn token_info(mut self, token_info: TokenInfo) -> SpotifyClientCredentials {
118         self.token_info = Some(token_info);
119         self
120     }
121     pub fn build(self) -> SpotifyClientCredentials {
122         const ERROR_MESSAGE: &str = "
123     You need to set your Spotify API credentials. You can do this by
124     setting environment variables in `.env` file:
125     CLIENT_ID='your-spotify-client-id'
126     CLIENT_SECRET='your-spotify-client-secret'
127     REDIRECT_URI='your-app-redirect-url'
128     Get your credentials at `https://developer.spotify.com/my-applications`";
129         trace!("SpotifyClientCredentials.default(): client_id:{:?}, client_secret:{:?} empty_flag:{:?}",self.client_id, self.client_secret, !(self.client_id.is_empty()||self.client_secret.is_empty())&&self.token_info.is_none());
130         let empty_flag = (self.client_id.is_empty() || self.client_secret.is_empty())
131             && self.token_info.is_none();
132         if empty_flag {
133             error!("{}", ERROR_MESSAGE);
134         } else {
135             debug!(
136                 "client_id:{:?}, client_secret:{:?}",
137                 self.client_id, self.client_secret
138             );
139         }
140         self
141     }
142     /// get access token from self.token_info, if self.token_info is none or is
143     /// expired. fetch token info by HTTP request
144     pub async fn get_access_token(&self) -> String {
145         let access_token = match self.token_info {
146             Some(ref token_info) => {
147                 if !self.is_token_expired(token_info) {
148                     debug!("token info: {:?}", &token_info);
149                     Some(&token_info.access_token)
150                 } else {
151                     None
152                 }
153             }
154             None => None,
155         };
156         match access_token {
157             Some(access_token) => access_token.to_owned(),
158             None => match self.request_access_token().await {
159                 Some(new_token_info) => {
160                     debug!("token info: {:?}", &new_token_info);
161                     new_token_info.access_token
162                 }
163                 None => String::new(),
164             },
165         }
166     }
167     fn is_token_expired(&self, token_info: &TokenInfo) -> bool {
168         is_token_expired(token_info)
169     }
170     async fn request_access_token(&self) -> Option<TokenInfo> {
171         let mut payload = HashMap::new();
172         payload.insert("grant_type", "client_credentials");
173         if let Some(mut token_info) = self
174             .fetch_access_token(&self.client_id, &self.client_secret, &payload)
175             .await
176         {
177             let expires_in = token_info.expires_in;
178             token_info.set_expires_at(datetime_to_timestamp(expires_in));
179             Some(token_info)
180         } else {
181             None
182         }
183     }
184     async fn fetch_access_token(
185         &self,
186         client_id: &str,
187         client_secret: &str,
188         payload: &HashMap<&str, &str>,
189     ) -> Option<TokenInfo> {
190         fetch_access_token(client_id, client_secret, payload).await
191     }
192 }
193 
194 impl SpotifyOAuth {
195     // spotify token example:
196     // {
197     //    "access_token": "NgCXRK...MzYjw",
198     //    "token_type": "Bearer",
199     //    "scope": "user-read-private user-read-email",
200     //    "expires_in": 3600,
201     //    "refresh_token": "NgAagA...Um_SHo"
202     // }
203 
204     pub fn default() -> SpotifyOAuth {
205         dotenv().ok();
206         let client_id = env::var("CLIENT_ID").unwrap_or_default();
207         let client_secret = env::var("CLIENT_SECRET").unwrap_or_default();
208         let redirect_uri = env::var("REDIRECT_URI").unwrap_or_default();
209         SpotifyOAuth {
210             client_id,
211             client_secret,
212             redirect_uri,
213             state: generate_random_string(16),
214             scope: String::new(),
215             cache_path: PathBuf::from(".spotify_token_cache.json"),
216             proxies: None,
217         }
218     }
219     pub fn client_id(mut self, client_id: &str) -> SpotifyOAuth {
220         self.client_id = client_id.to_owned();
221         self
222     }
223     pub fn client_secret(mut self, client_secret: &str) -> SpotifyOAuth {
224         self.client_secret = client_secret.to_owned();
225         self
226     }
227     pub fn redirect_uri(mut self, redirect_uri: &str) -> SpotifyOAuth {
228         self.redirect_uri = redirect_uri.to_owned();
229         self
230     }
231     pub fn scope(mut self, scope: &str) -> SpotifyOAuth {
232         self.scope = scope.to_owned();
233         self
234     }
235     pub fn state(mut self, state: &str) -> SpotifyOAuth {
236         self.state = state.to_owned();
237         self
238     }
239     pub fn cache_path(mut self, cache_path: PathBuf) -> SpotifyOAuth {
240         self.cache_path = cache_path;
241         self
242     }
243     pub fn proxies(mut self, proxies: &str) -> SpotifyOAuth {
244         self.proxies = Some(proxies.to_owned());
245         self
246     }
247     pub fn build(self) -> SpotifyOAuth {
248         const ERROR_MESSAGE: &str = "
249     You need to set your Spotify API credentials. You can do this by
250     setting environment variables in `.env` file:
251     CLIENT_ID='your-spotify-client-id'
252     CLIENT_SECRET='your-spotify-client-secret'
253     REDIRECT_URI='your-app-redirect-url'
254     Get your credentials at `https://developer.spotify.com/my-applications`";
255         let empty_flag = self.redirect_uri.is_empty()
256             || self.client_id.is_empty()
257             || self.client_secret.is_empty();
258 
259         if empty_flag {
260             error!("{}", ERROR_MESSAGE);
261         } else {
262             trace!(
263                 "client_id:{:?}, client_secret:{:?}, redirect_uri:{:?}",
264                 self.client_id,
265                 self.client_secret,
266                 self.redirect_uri
267             );
268         }
269         self
270     }
271     pub async fn get_cached_token(&mut self) -> Option<TokenInfo> {
272         let display = self.cache_path.display();
273         let mut file = match File::open(&self.cache_path) {
274             Ok(file) => file,
275             Err(why) => {
276                 error!("couldn't open {}: {:?}", display, why.to_string());
277                 return None;
278             }
279         };
280         let mut token_info_string = String::new();
281         match file.read_to_string(&mut token_info_string) {
282             Err(why) => {
283                 error!("couldn't read {}: {}", display, why.to_string());
284                 None
285             }
286             Ok(_) => {
287                 let mut token_info: TokenInfo = serde_json::from_str(&token_info_string)
288                     .unwrap_or_else(|_| {
289                         panic!("convert [{:?}] to json failed", self.cache_path.display())
290                     });
291                 if !SpotifyOAuth::is_scope_subset(&mut self.scope, &mut token_info.scope) {
292                     None
293                 } else if self.is_token_expired(&token_info) {
294                     if let Some(refresh_token) = token_info.refresh_token {
295                         self.refresh_access_token(&refresh_token).await
296                     } else {
297                         None
298                     }
299                 } else {
300                     Some(token_info)
301                 }
302             }
303         }
304     }
305     /// gets the access_token for the app with the given code without caching(without saving token to `cache_path`)
306     pub async fn get_access_token_without_cache(&self, code: &str) -> Option<TokenInfo> {
307         let mut payload: HashMap<&str, &str> = HashMap::new();
308         payload.insert("redirect_uri", &self.redirect_uri);
309         payload.insert("code", code);
310         payload.insert("grant_type", "authorization_code");
311         payload.insert("scope", &self.scope);
312         payload.insert("state", &self.state);
313         self.fetch_access_token(&self.client_id, &self.client_secret, &payload)
314             .await
315     }
316 
317     /// gets the access_token for the app with given the code
318     pub async fn get_access_token(&self, code: &str) -> Option<TokenInfo> {
319         if let Some(token_info) = self.get_access_token_without_cache(code).await {
320             match serde_json::to_string(&token_info) {
321                 Ok(token_info_string) => {
322                     trace!("get_access_token->token_info[{:?}]", &token_info_string);
323                     self.save_token_info(&token_info_string);
324                     Some(token_info)
325                 }
326                 Err(why) => {
327                     panic!(
328                         "couldn't convert token_info to string: {} ",
329                         why.to_string()
330                     );
331                 }
332             }
333         } else {
334             None
335         }
336     }
337     /// fetch access_token
338     async fn fetch_access_token(
339         &self,
340         client_id: &str,
341         client_secret: &str,
342         payload: &HashMap<&str, &str>,
343     ) -> Option<TokenInfo> {
344         trace!("fetch_access_token->payload {:?}", &payload);
345         fetch_access_token(client_id, client_secret, payload).await
346     }
347 
348     /// Parse the response code in the given response url
349     pub fn parse_response_code(&self, url: &mut str) -> Option<String> {
350         url.split("?code=")
351             .nth(1)
352             .and_then(|strs| strs.split('&').next())
353             .map(|s| s.to_owned())
354     }
355     /// Gets the URL to use to authorize this app
356     pub fn get_authorize_url(&self, state: Option<&str>, show_dialog: Option<bool>) -> String {
357         let mut payload: HashMap<&str, &str> = HashMap::new();
358         payload.insert("client_id", &self.client_id);
359         payload.insert("response_type", "code");
360         payload.insert("redirect_uri", &self.redirect_uri);
361         payload.insert("scope", &self.scope);
362         if let Some(state) = state {
363             payload.insert("state", state);
364         } else {
365             payload.insert("state", &self.state);
366         }
367         if let Some(show_dialog) = show_dialog {
368             if show_dialog {
369                 payload.insert("show_dialog", "true");
370             }
371         }
372 
373         let query_str = convert_map_to_string(&payload);
374         let mut authorize_url = String::from("https://accounts.spotify.com/authorize?");
375         authorize_url
376             .push_str(&utf8_percent_encode(&query_str, PATH_SEGMENT_ENCODE_SET).to_string());
377         trace!("{:?}", &authorize_url);
378         authorize_url
379     }
380 
381     /// refreshes token without saving token as cache.
382     pub async fn refresh_access_token_without_cache(
383         &self,
384         refresh_token: &str,
385     ) -> Option<TokenInfo> {
386         let mut payload = HashMap::new();
387         payload.insert("refresh_token", refresh_token);
388         payload.insert("grant_type", "refresh_token");
389         self.fetch_access_token(&self.client_id, &self.client_secret, &payload)
390             .await
391     }
392 
393     /// after refresh access_token, the response may be empty
394     /// when refresh_token again
395     pub async fn refresh_access_token(&self, refresh_token: &str) -> Option<TokenInfo> {
396         if let Some(token_info) = self.refresh_access_token_without_cache(refresh_token).await {
397             match serde_json::to_string(&token_info) {
398                 Ok(token_info_string) => {
399                     self.save_token_info(&token_info_string);
400                     Some(token_info)
401                 }
402                 Err(why) => {
403                     panic!(
404                         "couldn't convert token_info to string: {} ",
405                         why.to_string()
406                     );
407                 }
408             }
409         } else {
410             None
411         }
412     }
413     fn save_token_info(&self, token_info: &str) {
414         save_token_info(token_info, self.cache_path.as_path())
415     }
416     fn is_scope_subset(needle_scope: &mut str, haystack_scope: &mut str) -> bool {
417         let needle_vec: Vec<&str> = needle_scope.split_whitespace().collect();
418         let haystack_vec: Vec<&str> = haystack_scope.split_whitespace().collect();
419         let needle_set: HashSet<&str> = HashSet::from_iter(needle_vec);
420         let haystack_set: HashSet<&str> = HashSet::from_iter(haystack_vec);
421         // needle_set - haystack_set
422         needle_set.is_subset(&haystack_set)
423     }
424     fn is_token_expired(&self, token_info: &TokenInfo) -> bool {
425         is_token_expired(token_info)
426     }
427 }
428 
429 fn is_token_expired(token_info: &TokenInfo) -> bool {
430     let now: DateTime<Utc> = Utc::now();
431     // 10s as buffer time
432     match token_info.expires_at {
433         Some(expires_at) => now.timestamp() > expires_at - 10,
434         None => true,
435     }
436 }
437 fn save_token_info(token_info: &str, path: &Path) {
438     let mut file = OpenOptions::new()
439         .write(true)
440         .create(true)
441         .open(path)
442         .unwrap_or_else(|_| panic!("create file {:?} error", path.display()));
443     file.set_len(0).unwrap_or_else(|_| {
444         panic!(
445             "clear original spoitfy-token-cache file [{:?}] failed",
446             path.display()
447         )
448     });
449     file.write_all(token_info.as_bytes())
450         .expect("error when write file");
451 }
452 
453 async fn fetch_access_token(
454     _client_id: &str,
455     _client_secret: &str,
456     payload: &HashMap<&str, &str>,
457 ) -> Option<TokenInfo> {
458     let client = Client::new();
459     let client_id = _client_id.to_owned();
460     let client_secret = _client_secret.to_owned();
461     let url = "https://accounts.spotify.com/api/token";
462     let response = client
463         .post(url)
464         .basic_auth(client_id, Some(client_secret))
465         .form(&payload)
466         .send()
467         .await
468         .expect("send request failed");
469 
470     if response.status().is_success() {
471         debug!("response content: {:?}", response);
472         let mut token_info: TokenInfo = response
473             .json()
474             .await
475             .expect("Error parsing token_info response");
476         let expires_in = token_info.expires_in;
477         token_info.set_expires_at(datetime_to_timestamp(expires_in));
478         if token_info.refresh_token.is_none() {
479             match payload.get("refresh_token") {
480                 Some(payload_refresh_token) => {
481                     token_info.set_refresh_token(payload_refresh_token);
482                     return Some(token_info);
483                 }
484                 None => {
485                     debug!("could not find refresh_token");
486                 }
487             }
488         }
489         Some(token_info)
490     } else {
491         error!("fetch access token request failed, payload:{:?}", &payload);
492         error!("{:?}", response);
493         None
494     }
495 }
496 
497 #[cfg(test)]
498 mod tests {
499     use super::*;
500     use serde_json;
501     use std::path::PathBuf;
502     #[test]
503     fn test_is_scope_subset() {
504         let mut needle_scope = String::from("1 2 3");
505         let mut haystack_scope = String::from("1 2 3 4");
506         let mut broken_scope = String::from("5 2 4");
507         assert!(SpotifyOAuth::is_scope_subset(
508             &mut needle_scope,
509             &mut haystack_scope
510         ));
511         assert!(!SpotifyOAuth::is_scope_subset(
512             &mut broken_scope,
513             &mut haystack_scope
514         ));
515     }
516     #[test]
517     fn test_save_token_info() {
518         let spotify_oauth = SpotifyOAuth::default()
519             .state(&generate_random_string(16))
520             .scope("playlist-read-private playlist-read-collaborative playlist-modify-public playlist-modify-private streaming ugc-image-upload user-follow-modify user-follow-read user-library-read user-library-modify user-read-private user-read-birthdate user-read-email user-top-read user-read-playback-state user-modify-playback-state user-read-currently-playing user-read-recently-played")
521             .cache_path(PathBuf::from(".spotify_token_cache.json"))
522             .build();
523         let token_info = TokenInfo::default()
524             .access_token("test-access_token")
525             .token_type("code")
526             .expires_in(3600)
527             .expires_at(1515841743)
528             .scope("playlist-read-private playlist-read-collaborative playlist-modify-public playlist-modify-private streaming ugc-image-upload user-follow-modify user-follow-read user-library-read user-library-modify user-read-private user-read-birthdate user-read-email user-top-read user-read-playback-state user-modify-playback-state user-read-currently-playing user-read-recently-played")
529             .refresh_token("fghjklrftyhujkuiovbnm");
530         match serde_json::to_string(&token_info) {
531             Ok(token_info_string) => {
532                 spotify_oauth.save_token_info(&token_info_string);
533                 let display = spotify_oauth.cache_path.display();
534                 let mut file = match File::open(&spotify_oauth.cache_path) {
535                     Err(why) => panic!("couldn't open {}: {}", display, why.to_string()),
536                     Ok(file) => file,
537                 };
538                 let mut token_info_string_from_file = String::new();
539                 match file.read_to_string(&mut token_info_string_from_file) {
540                     Err(why) => panic!("couldn't read {}: {}", display, why.to_string()),
541                     Ok(_) => {
542                         assert_eq!(token_info_string, token_info_string_from_file);
543                     }
544                 }
545             }
546             Err(why) => panic!(
547                 "couldn't convert token_info to string: {} ",
548                 why.to_string()
549             ),
550         }
551     }
552 
553     #[test]
554     fn test_parse_response_code() {
555         let mut url = String::from("http://localhost:8888/callback?code=AQD0yXvFEOvw&state=sN#_=_");
556         let spotify_oauth = SpotifyOAuth::default()
557             .state(&generate_random_string(16))
558             .scope("playlist-read-private playlist-read-collaborative playlist-modify-public playlist-modify-private streaming ugc-image-upload user-follow-modify user-follow-read user-library-read user-library-modify user-read-private user-read-birthdate user-read-email user-top-read user-read-playback-state user-modify-playback-state user-read-currently-playing user-read-recently-played")
559             .cache_path(PathBuf::from(".spotify_token_cache.json"))
560             .build();
561         match spotify_oauth.parse_response_code(&mut url) {
562             Some(code) => assert_eq!(code, "AQD0yXvFEOvw"),
563             None => println!("failed"),
564         }
565     }
566 }
567