diff options
| author | HampusM <hampus@hampusmat.com> | 2022-09-05 17:57:28 +0200 | 
|---|---|---|
| committer | HampusM <hampus@hampusmat.com> | 2022-09-05 17:57:28 +0200 | 
| commit | d7929e7e9fee879a28871c2195620869db291441 (patch) | |
| tree | 4b2c9a6b3bf64144a994d36d0a522404548116f3 | |
| parent | afade48668042eeb0740c7c5a1cc3806baaedc94 (diff) | |
feat: add authentication
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.toml | 10 | ||||
| -rw-r--r-- | examples/access_token/main.rs | 52 | ||||
| -rw-r--r-- | src/auth/mod.rs | 145 | ||||
| -rw-r--r-- | src/auth/service.rs | 40 | ||||
| -rw-r--r-- | src/client.rs | 50 | ||||
| -rw-r--r-- | src/errors/auth.rs | 23 | ||||
| -rw-r--r-- | src/errors/mod.rs | 3 | ||||
| -rw-r--r-- | src/lib.rs | 3 | 
9 files changed, 327 insertions, 0 deletions
| @@ -1,2 +1,3 @@  /target  /Cargo.lock +Settings.toml @@ -3,3 +3,13 @@ name = "deez"  version = "0.1.0"  edition = "2021" +[dependencies] +actix-web = { version = "4.1.0", default-features = false, features = ["macros"] } +tokio = { version = "1.21.0", features = ["macros", "rt-multi-thread"] } +serde = { version = "1.0.144", features = ["derive"] } +reqwest = { version = "0.11.11", features = ["json"] } +thiserror = "1.0.33" + +[dev_dependencies] +config = "0.13.2" +anyhow = "1.0.63" diff --git a/examples/access_token/main.rs b/examples/access_token/main.rs new file mode 100644 index 0000000..d9b4d79 --- /dev/null +++ b/examples/access_token/main.rs @@ -0,0 +1,52 @@ +use std::error::Error; + +use anyhow::Context; +use config::Config; +use deez::auth::{request_access_token, AuthPromptHandler}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Settings +{ +    app_id: u32, +    secret_key: String, +    address: String, +    port: u16, +    uri_scheme: String, +} + +#[tokio::main] +async fn main() -> Result<(), Box<dyn Error + Send + Sync>> +{ +    let settings = Config::builder() +        .add_source(config::File::with_name("examples/access_token/Settings")) +        .build() +        .with_context(|| "Failed to read settings")? +        .try_deserialize::<Settings>() +        .with_context(|| "Invalid settings")?; + +    let auth_code_prompt_info = AuthPromptHandler::run( +        settings.app_id, +        settings.address, +        settings.port, +        settings.uri_scheme, +    ) +    .await?; + +    println!("{}", auth_code_prompt_info.auth_prompt_url); + +    let auth_code = auth_code_prompt_info.handler.await??; + +    println!("Retrieved authentication code '{}'", auth_code); + +    let access_token = +        request_access_token(settings.app_id, settings.secret_key, auth_code).await?; + +    println!( +        "Retrieved access token '{}' which expires in {} seconds", +        access_token.access_token, +        access_token.expires.as_secs() +    ); + +    Ok(()) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..c5beeb3 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,145 @@ +//! Deezer API authentication. +use std::error::Error; +use std::fmt::Display; +use std::time::Duration; + +use actix_web::web::Data; +use actix_web::{App, HttpServer}; +use serde::Deserialize; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio::{select, spawn}; + +use crate::auth::service::retrieve_auth_code; +use crate::errors::auth::{AccessTokenRequestError, AuthPromptHandlerError}; + +mod service; + +const AUTH_URL: &str = "https://connect.deezer.com/oauth/auth.php"; +const ACCESS_TOKEN_URL: &str = "https://connect.deezer.com/oauth/access_token.php"; + +/// A Deezer authentication code. +#[derive(Debug, Deserialize, Clone)] +pub struct AuthCode(String); + +impl Display for AuthCode +{ +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result +    { +        self.0.fmt(f) +    } +} + +/// A Deezer authentication prompt handler. +pub struct AuthPromptHandler +{ +    /// URL to the Deezer authentication prompt. +    pub auth_prompt_url: String, + +    /// Handle for the running authentication prompt handler. +    /// +    /// Finishes when a single authentication has occurred. +    pub handler: JoinHandle<Result<AuthCode, Box<dyn Error + Send + Sync>>>, +} + +impl AuthPromptHandler +{ +    /// Runs a web server that handles a Deezer authentication prompt. +    /// +    /// The argument `address` must be in the same domain as the domain you defined when +    /// you created the application with the ID `app_id`. +    /// +    /// # Errors +    /// Will return Err if the web server created is unable to bind to `address`. +    pub async fn run( +        app_id: u32, +        address: String, +        port: u16, +        uri_scheme: String, +    ) -> Result<Self, AuthPromptHandlerError> +    { +        let (done_tx, mut done_rx) = mpsc::channel::<AuthCode>(1); + +        let done_tx_data = Data::new(done_tx); + +        let server = HttpServer::new(move || { +            App::new() +                .app_data(done_tx_data.clone()) +                .service(retrieve_auth_code) +        }) +        .bind((address.clone(), port)) +        .map_err(|_| AuthPromptHandlerError::BindAddressFailed)?; + +        let server_future = server.run(); + +        let handle = spawn(async move { +            let opt_auth_code = select! { +                result = server_future => { +                    result.map(|_| None) +                }, +                auth_code = async { +                    done_rx.recv().await +                } => Ok(auth_code) +            }?; + +            Ok(opt_auth_code.map_or_else(|| Err("No auth code was received"), Ok)?) +        }); + +        Ok(Self { +            auth_prompt_url: format!( +                "{}?app_id={}&redirect_uri={}://{}:{}&perms=basic_access", +                AUTH_URL, app_id, uri_scheme, address, port +            ), +            handler: handle, +        }) +    } +} + +/// A Deezer access token. +pub struct AccessToken +{ +    /// The access token. +    pub access_token: String, + +    /// The duration until the access token expires. +    pub expires: Duration, +} + +/// Sends a request for a access token. +/// +/// # Errors +/// Will return Err if either sending the request fails or parsing the response fails. +pub async fn request_access_token( +    app_id: u32, +    secret_key: String, +    auth_code: AuthCode, +) -> Result<AccessToken, AccessTokenRequestError> +{ +    let response = reqwest::get(format!( +        "{}?app_id={}&secret={}&code={}&output=json", +        ACCESS_TOKEN_URL, app_id, secret_key, auth_code +    )) +    .await? +    .json::<AccessTokenResponse>() +    .await?; + +    Ok(response.into()) +} + +#[derive(Debug, Deserialize)] +struct AccessTokenResponse +{ +    pub access_token: String, +    pub expires: u64, +} + +impl From<AccessTokenResponse> for AccessToken +{ +    fn from(response: AccessTokenResponse) -> Self +    { +        Self { +            access_token: response.access_token, +            expires: Duration::from_secs(response.expires), +        } +    } +} diff --git a/src/auth/service.rs b/src/auth/service.rs new file mode 100644 index 0000000..b9b44d4 --- /dev/null +++ b/src/auth/service.rs @@ -0,0 +1,40 @@ +use actix_web::web::{Data, Query}; +use actix_web::{get, HttpResponse}; +use serde::Deserialize; +use tokio::sync::mpsc; + +use crate::auth::AuthCode; + +#[derive(Debug, Deserialize)] +struct AuthCodeQuery +{ +    error_reason: Option<String>, +    code: Option<AuthCode>, +} + +#[get("/")] +async fn retrieve_auth_code( +    query: Query<AuthCodeQuery>, +    done_tx: Data<mpsc::Sender<AuthCode>>, +) -> HttpResponse +{ +    if let Some(error_reason) = &query.error_reason { +        return HttpResponse::Unauthorized().body(format!( +            "Error: No authentication code was retrieved. Reason: {}\n\nYou can close this tab", +            error_reason +        )); +    } + +    let auth_code = match &query.code { +        Some(auth_code) => auth_code, +        None => { +            return HttpResponse::BadRequest() +                .body("Error: No authentication code was retrieved"); +        } +    }; + +    done_tx.send(auth_code.clone()).await.unwrap(); + +    HttpResponse::Ok() +        .body("Authentication code was successfully retrieved.\n\nYou can close this tab") +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..5c6858b --- /dev/null +++ b/src/client.rs @@ -0,0 +1,50 @@ +//! Deezer client. +use std::error::Error; + +use serde::Deserialize; + +/// A user. +#[derive(Debug, Deserialize)] +pub struct User +{ +    /// The user ID. +    pub id: u32, + +    /// The user name. +    pub name: String, +} + +/// Deezer client. +#[derive(Default)] +pub struct DeezerClient +{ +    client: reqwest::Client, +    api_url: &'static str, +} + +impl DeezerClient +{ +    /// Creates a new Deezer client. +    #[must_use] +    pub fn new() -> Self +    { +        Self { +            client: reqwest::Client::new(), +            api_url: "https://api.deezer.com", +        } +    } + +    /// Returns the authenticated user. +    pub async fn get_me(&self) -> Result<User, Box<dyn Error + Send + Sync>> +    { +        let user = self +            .client +            .get(format!("{}/user/me", self.api_url)) +            .send() +            .await? +            .json::<User>() +            .await?; + +        Ok(user) +    } +} diff --git a/src/errors/auth.rs b/src/errors/auth.rs new file mode 100644 index 0000000..cd4741d --- /dev/null +++ b/src/errors/auth.rs @@ -0,0 +1,23 @@ +//! Authentication related error types. + +/// Authentication prompt handler error. +#[derive(Debug, thiserror::Error)] +pub enum AuthPromptHandlerError +{ +    /// HTTP server failed to bind to a address. +    #[error("HTTP server failed to bind to address")] +    BindAddressFailed, +} + +/// Access token request error. +#[derive(Debug, thiserror::Error)] +pub enum AccessTokenRequestError +{ +    /// Sending access token request failed. +    #[error("Sending access token request failed")] +    SendFailed(#[from] reqwest::Error), + +    /// Parsing access token respone failed. +    #[error("Parsing access token respone failed")] +    ResponseParseFailed, +} diff --git a/src/errors/mod.rs b/src/errors/mod.rs new file mode 100644 index 0000000..ea85f4a --- /dev/null +++ b/src/errors/mod.rs @@ -0,0 +1,3 @@ +//! Error types. + +pub mod auth; @@ -3,3 +3,6 @@  #![deny(clippy::pedantic)]  #![deny(missing_docs)]  #![allow(clippy::module_name_repetitions)] + +pub mod auth; +pub mod errors; | 
