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