spotify_oauth/lib.rs
1//! # Spotify OAuth
2//!
3//! An implementation of the Spotify Authorization Code Flow in Rust.
4//!
5//! # Basic Example
6//!
7//! ```no_run
8//! use std::{io::stdin, str::FromStr, error::Error};
9//! use spotify_oauth::{SpotifyAuth, SpotifyCallback, SpotifyScope};
10//!
11//! #[async_std::main]
12//! async fn main() -> Result<(), Box<dyn Error + Send + Sync + 'static>> {
13//!
14//! // Setup Spotify Auth URL
15//! let auth = SpotifyAuth::new_from_env("code".into(), vec![SpotifyScope::Streaming], false);
16//! let auth_url = auth.authorize_url()?;
17//!
18//! // Open the auth URL in the default browser of the user.
19//! open::that(auth_url)?;
20//!
21//! println!("Input callback URL:");
22//! let mut buffer = String::new();
23//! stdin().read_line(&mut buffer)?;
24//!
25//! // Convert the given callback URL into a token.
26//! let token = SpotifyCallback::from_str(buffer.trim())?
27//! .convert_into_token(auth.client_id, auth.client_secret, auth.redirect_uri).await?;
28//!
29//! println!("Token: {:#?}", token);
30//!
31//! Ok(())
32//! }
33//! ```
34
35use chrono::{DateTime, Utc};
36use dotenv::dotenv;
37use rand::{self, Rng};
38use serde::{Deserialize, Deserializer, Serialize};
39use serde_json::Value;
40use snafu::ResultExt;
41use strum_macros::{Display, EnumString};
42use url::Url;
43
44use std::collections::HashMap;
45use std::env;
46use std::str::FromStr;
47use std::string::ToString;
48
49mod error;
50use crate::error::{SerdeError, *};
51
52const SPOTIFY_AUTH_URL: &str = "https://accounts.spotify.com/authorize";
53const SPOTIFY_TOKEN_URL: &str = "https://accounts.spotify.com/api/token";
54
55/// Convert date and time to a unix timestamp.
56///
57/// # Example
58///
59/// ```no_run
60/// // Uses elapsed seconds and the current timestamp to return a timestamp offset by the seconds.
61/// # use spotify_oauth::datetime_to_timestamp;
62/// let timestamp = datetime_to_timestamp(3600);
63/// ```
64pub fn datetime_to_timestamp(elapsed: u32) -> i64 {
65 let utc: DateTime<Utc> = Utc::now();
66 utc.timestamp() + i64::from(elapsed)
67}
68
69/// Generate a random alphanumeric string with a given length.
70///
71/// # Example
72///
73/// ```no_run
74/// // Uses elapsed seconds and the current timestamp to return a timestamp offset by the seconds.
75/// # use spotify_oauth::generate_random_string;
76/// let timestamp = generate_random_string(20);
77/// ```
78pub fn generate_random_string(length: usize) -> String {
79 rand::thread_rng()
80 .sample_iter(&rand::distributions::Alphanumeric)
81 .take(length)
82 .collect()
83}
84
85/// Spotify Scopes for the API.
86/// This enum implements FromStr and ToString / Display through strum.
87///
88/// All the Spotify API scopes can be found [here](https://developer.spotify.com/documentation/general/guides/scopes/ "Spotify Scopes").
89///
90/// # Example
91///
92/// ```
93/// # use spotify_oauth::SpotifyScope;
94/// # use std::str::FromStr;
95/// // Convert string into scope.
96/// let scope = SpotifyScope::from_str("streaming").unwrap();
97/// # assert_eq!(scope, SpotifyScope::Streaming);
98/// // It can also convert the scope back into a string.
99/// let scope = scope.to_string();
100/// # assert_eq!(scope, "streaming");
101/// ```
102#[derive(EnumString, Serialize, Deserialize, Display, Debug, Clone, PartialEq)]
103pub enum SpotifyScope {
104 #[strum(serialize = "user-read-recently-played")]
105 UserReadRecentlyPlayed,
106 #[strum(serialize = "user-top-read")]
107 UserTopRead,
108
109 #[strum(serialize = "user-library-modify")]
110 UserLibraryModify,
111 #[strum(serialize = "user-library-read")]
112 UserLibraryRead,
113
114 #[strum(serialize = "playlist-read-private")]
115 PlaylistReadPrivate,
116 #[strum(serialize = "playlist-modify-public")]
117 PlaylistModifyPublic,
118 #[strum(serialize = "playlist-modify-private")]
119 PlaylistModifyPrivate,
120 #[strum(serialize = "playlist-read-collaborative")]
121 PlaylistReadCollaborative,
122
123 #[strum(serialize = "user-read-email")]
124 UserReadEmail,
125 #[strum(serialize = "user-read-birthdate")]
126 UserReadBirthDate,
127 #[strum(serialize = "user-read-private")]
128 UserReadPrivate,
129
130 #[strum(serialize = "user-read-playback-state")]
131 UserReadPlaybackState,
132 #[strum(serialize = "user-modify-playback-state")]
133 UserModifyPlaybackState,
134 #[strum(serialize = "user-read-currently-playing")]
135 UserReadCurrentlyPlaying,
136
137 #[strum(serialize = "app-remote-control")]
138 AppRemoteControl,
139 #[strum(serialize = "streaming")]
140 Streaming,
141
142 #[strum(serialize = "user-follow-read")]
143 UserFollowRead,
144 #[strum(serialize = "user-follow-modify")]
145 UserFollowModify,
146}
147
148/// Spotify Authentication
149///
150/// This struct follows the parameters given at [this](https://developer.spotify.com/documentation/general/guides/authorization-guide/ "Spotify Auth Documentation") link.
151///
152/// # Example
153///
154/// ```no_run
155/// # use spotify_oauth::{SpotifyAuth, SpotifyScope};
156/// // Create a new spotify auth object with the scope "Streaming" using the ``new_from_env`` function.
157/// // This object can then be converted into the auth url needed to gain a callback for the token.
158/// let auth = SpotifyAuth::new_from_env("code".into(), vec![SpotifyScope::Streaming], false);
159/// ```
160pub struct SpotifyAuth {
161 /// The Spotify Application Client ID
162 pub client_id: String,
163 /// The Spotify Application Client Secret
164 pub client_secret: String,
165 /// Required by the Spotify API.
166 pub response_type: String,
167 /// The URI to redirect to after the user grants or denies permission.
168 pub redirect_uri: Url,
169 /// A random generated string that can be useful for correlating requests and responses.
170 pub state: String,
171 /// Vec of Spotify Scopes.
172 pub scope: Vec<SpotifyScope>,
173 /// Whether or not to force the user to approve the app again if they’ve already done so.
174 pub show_dialog: bool,
175}
176
177/// Implementation of Default for SpotifyAuth.
178///
179/// If ``CLIENT_ID`` is not found in the ``.env`` in the project directory it will default to ``INVALID_ID``.
180/// If ``REDIRECT_ID`` is not found in the ``.env`` in the project directory it will default to ``http://localhost:8000/callback``.
181///
182/// This implementation automatically generates a state value of length 20 using a random string generator.
183///
184impl Default for SpotifyAuth {
185 fn default() -> Self {
186 // Load local .env file.
187 dotenv().ok();
188
189 Self {
190 client_id: env::var("SPOTIFY_CLIENT_ID").context(EnvError).unwrap(),
191 client_secret: env::var("SPOTIFY_CLIENT_SECRET").context(EnvError).unwrap(),
192 response_type: "code".to_owned(),
193 redirect_uri: Url::parse(&env::var("REDIRECT_URI").context(EnvError).unwrap())
194 .context(UrlError)
195 .unwrap(),
196 state: generate_random_string(20),
197 scope: vec![],
198 show_dialog: false,
199 }
200 }
201}
202
203/// Conversion and helper functions for SpotifyAuth.
204impl SpotifyAuth {
205 /// Generate a new SpotifyAuth structure from values in memory.
206 ///
207 /// This function loads ``SPOTIFY_CLIENT_ID`` and ``SPOTIFY_REDIRECT_ID`` from values given in
208 /// function parameters.
209 ///
210 /// This function also automatically generates a state value of length 20 using a random string generator.
211 ///
212 /// # Example
213 ///
214 /// ```
215 /// # use spotify_oauth::{SpotifyAuth, SpotifyScope};
216 /// // SpotifyAuth with the scope "Streaming".
217 /// let auth = SpotifyAuth::new("00000000000".into(), "secret".into(), "code".into(), "http://localhost:8000/callback".into(), vec![SpotifyScope::Streaming], false);
218 /// # assert_eq!(auth.scope_into_string(), "streaming");
219 /// ```
220 pub fn new(
221 client_id: String,
222 client_secret: String,
223 response_type: String,
224 redirect_uri: String,
225 scope: Vec<SpotifyScope>,
226 show_dialog: bool,
227 ) -> Self {
228 Self {
229 client_id,
230 client_secret,
231 response_type,
232 redirect_uri: Url::parse(&redirect_uri).context(UrlError).unwrap(),
233 state: generate_random_string(20),
234 scope,
235 show_dialog,
236 }
237 }
238
239 /// Generate a new SpotifyAuth structure from values in the environment.
240 ///
241 /// This function loads ``SPOTIFY_CLIENT_ID`` and ``SPOTIFY_REDIRECT_ID`` from the environment.
242 ///
243 /// This function also automatically generates a state value of length 20 using a random string generator.
244 ///
245 /// # Example
246 ///
247 /// ```no_run
248 /// # use spotify_oauth::{SpotifyAuth, SpotifyScope};
249 /// // SpotifyAuth with the scope "Streaming".
250 /// let auth = SpotifyAuth::new_from_env("code".into(), vec![SpotifyScope::Streaming], false);
251 /// # assert_eq!(auth.scope_into_string(), "streaming");
252 /// ```
253 pub fn new_from_env(
254 response_type: String,
255 scope: Vec<SpotifyScope>,
256 show_dialog: bool,
257 ) -> Self {
258 // Load local .env file.
259 dotenv().ok();
260
261 Self {
262 client_id: env::var("SPOTIFY_CLIENT_ID").context(EnvError).unwrap(),
263 client_secret: env::var("SPOTIFY_CLIENT_SECRET").context(EnvError).unwrap(),
264 response_type,
265 redirect_uri: Url::parse(&env::var("SPOTIFY_REDIRECT_URI").context(EnvError).unwrap())
266 .context(UrlError)
267 .unwrap(),
268 state: generate_random_string(20),
269 scope,
270 show_dialog,
271 }
272 }
273
274 /// Concatenate the scope vector into a string needed for the authorization URL.
275 ///
276 /// # Example
277 ///
278 /// ```
279 /// # use spotify_oauth::{SpotifyAuth, SpotifyScope};
280 /// // Default SpotifyAuth with the scope "Streaming".
281 /// let auth = SpotifyAuth::new("00000000000".into(), "secret".into(), "code".into(), "http://localhost:8000/callback".into(), vec![SpotifyScope::Streaming], false);
282 /// # assert_eq!(auth.scope_into_string(), "streaming");
283 /// ```
284 pub fn scope_into_string(&self) -> String {
285 self.scope
286 .iter()
287 .map(|x| x.clone().to_string())
288 .collect::<Vec<String>>()
289 .join(" ")
290 }
291
292 /// Convert the SpotifyAuth struct into the authorization URL.
293 ///
294 /// More information on this URL can be found [here](https://developer.spotify.com/documentation/general/guides/authorization-guide/ "Spotify Auth Documentation").
295 ///
296 /// # Example
297 ///
298 /// ```
299 /// # use spotify_oauth::{SpotifyAuth, SpotifyScope};
300 /// // Default SpotifyAuth with the scope "Streaming" converted into the authorization URL.
301 /// let auth = SpotifyAuth::new("00000000000".into(), "secret".into(), "code".into(), "http://localhost:8000/callback".into(), vec![SpotifyScope::Streaming], false)
302 /// .authorize_url().unwrap();
303 /// ```
304 pub fn authorize_url(&self) -> SpotifyResult<String> {
305 let mut url = Url::parse(SPOTIFY_AUTH_URL).context(UrlError)?;
306
307 url.query_pairs_mut()
308 .append_pair("client_id", &self.client_id)
309 .append_pair("response_type", &self.response_type)
310 .append_pair("redirect_uri", self.redirect_uri.as_str())
311 .append_pair("state", &self.state)
312 .append_pair("scope", &self.scope_into_string())
313 .append_pair("show_dialog", &self.show_dialog.to_string());
314
315 Ok(url.to_string())
316 }
317}
318
319/// The Spotify Callback URL
320///
321/// This struct follows the parameters given at [this](https://developer.spotify.com/documentation/general/guides/authorization-guide/ "Spotify Auth Documentation") link.
322///
323/// The main use of this object is to convert the callback URL into an object that can be used to generate a token.
324/// If needed you can also create this callback object using the ``new`` function in the struct.
325///
326/// # Example
327///
328/// ```
329/// # use spotify_oauth::SpotifyCallback;
330/// # use std::str::FromStr;
331/// // Create a new spotify callback object using the callback url given by the authorization process.
332/// // This object can then be converted into the token needed for the application.
333/// let callback = SpotifyCallback::from_str("https://example.com/callback?code=NApCCgBkWtQ&state=test").unwrap();
334/// # assert_eq!(callback, SpotifyCallback::new(Some("NApCCgBkWtQ".to_string()), None, String::from("test")));
335/// ```
336#[derive(Debug, PartialEq)]
337pub struct SpotifyCallback {
338 /// An authorization code that can be exchanged for an access token.
339 code: Option<String>,
340 /// The reason authorization failed.
341 error: Option<String>,
342 /// The value of the ``state`` parameter supplied in the request.
343 state: String,
344}
345
346/// Implementation of FromStr for Spotify Callback URLs.
347///
348/// # Example
349///
350/// ```
351/// # use spotify_oauth::SpotifyCallback;
352/// # use std::str::FromStr;
353/// // Create a new spotify callback object using the callback url given by the authorization process.
354/// // This object can then be converted into the token needed for the application.
355/// let callback = SpotifyCallback::from_str("https://example.com/callback?code=NApCCgBkWtQ&state=test").unwrap();
356/// # assert_eq!(callback, SpotifyCallback::new(Some("NApCCgBkWtQ".to_string()), None, String::from("test")));
357/// ```
358impl FromStr for SpotifyCallback {
359 type Err = error::SpotifyError;
360
361 fn from_str(s: &str) -> Result<Self, Self::Err> {
362 let url = Url::parse(s).context(UrlError)?;
363 let parsed: Vec<(String, String)> = url
364 .query_pairs()
365 .map(|x| (x.0.into_owned(), x.1.into_owned()))
366 .collect();
367
368 let has_state = parsed.iter().any(|x| x.0 == "state");
369 let has_response = parsed.iter().any(|x| x.0 == "error" || x.0 == "code");
370
371 if !has_state && !has_response {
372 return Err(SpotifyError::CallbackFailure {
373 context: "Does not contain any state or response type query parameters.",
374 });
375 } else if !has_state {
376 return Err(SpotifyError::CallbackFailure {
377 context: "Does not contain any state type query parameters.",
378 });
379 } else if !has_response {
380 return Err(SpotifyError::CallbackFailure {
381 context: "Does not contain any response type query parameters.",
382 });
383 }
384
385 let state = match parsed.iter().find(|x| x.0 == "state") {
386 None => ("state".to_string(), "".to_string()),
387 Some(x) => x.clone(),
388 };
389
390 let response = match parsed.iter().find(|x| x.0 == "error" || x.0 == "code") {
391 None => ("error".to_string(), "access_denied".to_string()),
392 Some(x) => x.clone(),
393 };
394
395 if response.0 == "code" {
396 return Ok(Self {
397 code: Some(response.to_owned().1),
398 error: None,
399 state: state.1,
400 });
401 } else if response.0 == "error" {
402 return Ok(Self {
403 code: None,
404 error: Some(response.to_owned().1),
405 state: state.1,
406 });
407 }
408
409 Err(SpotifyError::CallbackFailure {
410 context: "Does not contain any state or response type query parameters.",
411 })
412 }
413}
414
415/// Conversion and helper functions for SpotifyCallback.
416impl SpotifyCallback {
417 /// Create a new Spotify Callback object with given values.
418 ///
419 /// # Example
420 ///
421 /// ```
422 /// # use spotify_oauth::SpotifyCallback;
423 /// // Create a new spotify callback object using the new function.
424 /// // This object can then be converted into the token needed for the application.
425 /// let callback = SpotifyCallback::new(Some("NApCCgBkWtQ".to_string()), None, String::from("test"));
426 /// ```
427 pub fn new(code: Option<String>, error: Option<String>, state: String) -> Self {
428 Self { code, error, state }
429 }
430
431 /// Converts the Spotify Callback object into a Spotify Token object.
432 ///
433 /// # Example
434 ///
435 /// ```no_run
436 /// # use spotify_oauth::{SpotifyAuth, SpotifyCallback, SpotifyScope};
437 /// # use std::str::FromStr;
438 /// # #[async_std::main]
439 /// # async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
440 /// // Create a new Spotify auth object.
441 /// let auth = SpotifyAuth::new("00000000000".into(), "secret".into(), "code".into(), "http://localhost:8000/callback".into(), vec![SpotifyScope::Streaming], false);
442 ///
443 /// // Create a new spotify callback object using the callback url given by the authorization process and convert it into a token.
444 /// let token = SpotifyCallback::from_str("https://example.com/callback?code=NApCCgBkWtQ&state=test").unwrap()
445 /// .convert_into_token(auth.client_id, auth.client_secret, auth.redirect_uri).await.unwrap();
446 /// # Ok(()) }
447 /// ```
448 pub async fn convert_into_token(
449 self,
450 client_id: String,
451 client_secret: String,
452 redirect_uri: Url,
453 ) -> SpotifyResult<SpotifyToken> {
454 let mut payload: HashMap<String, String> = HashMap::new();
455 payload.insert("grant_type".to_owned(), "authorization_code".to_owned());
456 payload.insert(
457 "code".to_owned(),
458 match self.code {
459 None => {
460 return Err(SpotifyError::TokenFailure {
461 context: "Spotify callback code failed to parse.",
462 })
463 }
464 Some(x) => x,
465 },
466 );
467 payload.insert("redirect_uri".to_owned(), redirect_uri.to_string());
468
469 // Form authorisation header.
470 let auth_value = base64::encode(&format!("{}:{}", client_id, client_secret));
471
472 // POST the request.
473 let mut response = surf::post(SPOTIFY_TOKEN_URL)
474 .set_header("Authorization", format!("Basic {}", auth_value))
475 .body_form(&payload)
476 .unwrap()
477 .await
478 .context(SurfError)?;
479
480 // Read the response body.
481 let buf = response.body_string().await.unwrap();
482
483 if response.status().is_success() {
484 let mut token: SpotifyToken = serde_json::from_str(&buf).context(SerdeError)?;
485 token.expires_at = Some(datetime_to_timestamp(token.expires_in));
486
487 return Ok(token);
488 }
489
490 Err(SpotifyError::TokenFailure {
491 context: "Failed to convert callback into token",
492 })
493 }
494}
495
496/// The Spotify Token object.
497///
498/// This struct follows the parameters given at [this](https://developer.spotify.com/documentation/general/guides/authorization-guide/ "Spotify Auth Documentation") link.
499///
500/// This object can only be formed from a correct Spotify Callback object.
501///
502/// # Example
503///
504/// ```no_run
505/// # use spotify_oauth::{SpotifyAuth, SpotifyScope, SpotifyCallback};
506/// # use std::str::FromStr;
507/// # #[async_std::main]
508/// # async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
509/// // Create a new Spotify auth object.
510/// let auth = SpotifyAuth::new("00000000000".into(), "secret".into(), "code".into(), "http://localhost:8000/callback".into(), vec![SpotifyScope::Streaming], false);
511///
512/// // Create a new Spotify token object using the callback object given by the authorization process.
513/// let token = SpotifyCallback::from_str("https://example.com/callback?code=NApCCgBkWtQ&state=test").unwrap()
514/// .convert_into_token(auth.client_id, auth.client_secret, auth.redirect_uri).await.unwrap();
515/// # Ok(()) }
516/// ```
517#[derive(Serialize, Deserialize, Debug, PartialEq)]
518pub struct SpotifyToken {
519 /// An access token that can be provided in subsequent calls, for example to Spotify Web API services.
520 pub access_token: String,
521 /// How the access token may be used.
522 pub token_type: String,
523 /// A Vec of scopes which have been granted for this ``access_token``.
524 #[serde(deserialize_with = "deserialize_scope_field")]
525 pub scope: Vec<SpotifyScope>,
526 /// The time period (in seconds) for which the access token is valid.
527 pub expires_in: u32,
528 /// The timestamp for which the token will expire at.
529 pub expires_at: Option<i64>,
530 /// A token that can be sent to the Spotify Accounts service in place of an authorization code to request a new ``access_token``.
531 pub refresh_token: String,
532}
533
534/// Custom parsing function for converting a vector of string scopes into SpotifyScope Enums using Serde.
535/// If scope is empty it will return an empty vector.
536fn deserialize_scope_field<'de, D>(de: D) -> Result<Vec<SpotifyScope>, D::Error>
537where
538 D: Deserializer<'de>,
539{
540 let result: Value = Deserialize::deserialize(de)?;
541 match result {
542 Value::String(ref s) => {
543 let split: Vec<&str> = s.split_whitespace().collect();
544 let mut parsed: Vec<SpotifyScope> = Vec::new();
545
546 for x in split {
547 parsed.push(SpotifyScope::from_str(x).unwrap());
548 }
549
550 Ok(parsed)
551 }
552 _ => Ok(vec![]),
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 // Callback Testing
561
562 #[test]
563 fn test_parse_callback_code() {
564 let url = String::from("http://localhost:8888/callback?code=AQD0yXvFEOvw&state=sN");
565
566 assert_eq!(
567 SpotifyCallback::from_str(&url).unwrap(),
568 SpotifyCallback::new(Some("AQD0yXvFEOvw".to_string()), None, "sN".to_string())
569 );
570 }
571
572 #[test]
573 fn test_parse_callback_error() {
574 let url = String::from("http://localhost:8888/callback?error=access_denied&state=sN");
575
576 assert_eq!(
577 SpotifyCallback::from_str(&url).unwrap(),
578 SpotifyCallback::new(None, Some("access_denied".to_string()), "sN".to_string())
579 );
580 }
581
582 #[test]
583 fn test_invalid_response_parse() {
584 let url = String::from("http://localhost:8888/callback?state=sN");
585
586 assert_eq!(
587 SpotifyCallback::from_str(&url).unwrap_err().to_string(),
588 "Callback URL parsing failure: Does not contain any response type query parameters."
589 );
590 }
591
592 #[test]
593 fn test_invalid_parse() {
594 let url = String::from("http://localhost:8888/callback");
595
596 assert_eq!(
597 SpotifyCallback::from_str(&url).unwrap_err().to_string(),
598 "Callback URL parsing failure: Does not contain any state or response type query parameters."
599 );
600 }
601
602 // Token Testing
603
604 #[test]
605 fn test_token_parse() {
606 let token_json = r#"{
607 "access_token": "NgCXRKDjGUSKlfJODUjvnSUhcOMzYjw",
608 "token_type": "Bearer",
609 "scope": "user-read-private user-read-email",
610 "expires_in": 3600,
611 "refresh_token": "NgAagAHfVxDkSvCUm_SHo"
612 }"#;
613
614 let mut token: SpotifyToken = serde_json::from_str(token_json).unwrap();
615 let timestamp = datetime_to_timestamp(token.expires_in);
616 token.expires_at = Some(timestamp);
617
618 assert_eq!(
619 SpotifyToken {
620 access_token: "NgCXRKDjGUSKlfJODUjvnSUhcOMzYjw".to_string(),
621 token_type: "Bearer".to_string(),
622 scope: vec![SpotifyScope::UserReadPrivate, SpotifyScope::UserReadEmail],
623 expires_in: 3600,
624 expires_at: Some(timestamp),
625 refresh_token: "NgAagAHfVxDkSvCUm_SHo".to_string()
626 },
627 token
628 );
629 }
630}