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