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}