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