twitch_oauth2 0.11.0

Oauth2 for Twitch endpoints
Documentation
#![allow(unknown_lints, renamed_and_removed_lints)]
#![deny(missing_docs, broken_intra_doc_links)] // This will be weird until 1.52, see https://github.com/rust-lang/rust/pull/80527
#![cfg_attr(nightly, deny(rustdoc::broken_intra_doc_links))]
#![cfg_attr(nightly, feature(doc_cfg))]
#![cfg_attr(nightly, feature(doc_auto_cfg))]
//! [![github]](https://github.com/twitch-rs/twitch_oauth2) [![crates-io]](https://crates.io/crates/twitch_oauth2) [![docs-rs]](https://docs.rs/twitch_oauth2/0.8.0/twitch_oauth2)
//!
//! [github]: https://img.shields.io/badge/github-twitch--rs/twitch__oauth2-8da0cb?style=for-the-badge&labelColor=555555&logo=github"
//! [crates-io]: https://img.shields.io/crates/v/twitch_oauth2.svg?style=for-the-badge&color=fc8d62&logo=rust"
//! [docs-rs]: https://img.shields.io/badge/docs.rs-twitch__oauth2-66c2a5?style=for-the-badge&labelColor=555555&logoColor=white&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K"
//!
//! <br>
//!
//! <h5>OAuth2 for Twitch endpoints</h5>
//!
//! ```rust,no_run
//! # use twitch_oauth2::{TwitchToken, UserToken, AccessToken, tokens::errors::ValidationError};
//! # #[tokio::main]
//! # async fn run() {
//! # let reqwest_http_client = twitch_oauth2::client::DummyClient; // This is only here to fool doc tests
//!     let token = AccessToken::new("sometokenherewhichisvalidornot".to_string());
//!
//!     match UserToken::from_existing(&reqwest_http_client, token, None, None).await {
//!         Ok(t) => println!("user_token: {}", t.token().secret()),
//!         Err(e) => panic!("got error: {}", e),
//!     }
//! # }
//! # fn main() {run()}
//! ```
#[cfg(feature = "client")]
pub mod client;
pub mod id;
pub mod scopes;
pub mod tokens;
pub mod types;

use http::StatusCode;
use id::TwitchTokenErrorResponse;
#[cfg(feature = "client")]
use tokens::errors::{RefreshTokenError, RevokeTokenError, ValidationError};

#[doc(inline)]
pub use scopes::Scope;
#[doc(inline)]
pub use tokens::{AppAccessToken, TwitchToken, UserToken, ValidatedToken};

pub use url;

pub use types::{AccessToken, ClientId, ClientSecret, CsrfToken, RefreshToken};

#[doc(hidden)]
pub use types::{AccessTokenRef, ClientIdRef, ClientSecretRef, CsrfTokenRef, RefreshTokenRef};

#[cfg(feature = "client")]
use self::client::Client;

/// Generate a url with a default if `mock_api` feature is disabled, or env var is not defined or is invalid utf8
macro_rules! mock_env_url {
    ($var:literal, $default:expr $(,)?) => {
        once_cell::sync::Lazy::new(move || {
            #[cfg(feature = "mock_api")]
            if let Ok(url) = std::env::var($var) {
                return url::Url::parse(&url).expect(concat!(
                    "URL could not be made from `env:",
                    $var,
                    "`."
                ));
            };
            url::Url::parse(&$default).unwrap()
        })
    };
}

/// Defines the root path to twitch auth endpoints
static TWITCH_OAUTH2_URL: once_cell::sync::Lazy<url::Url> =
    mock_env_url!("TWITCH_OAUTH2_URL", "https://id.twitch.tv/oauth2/");

/// Authorization URL (`https://id.twitch.tv/oauth2/authorize`) for `id.twitch.tv`
///
/// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_AUTH_URL` to override the base (`https://id.twitch.tv/oauth2/`) url.
///
/// # Examples
///
/// Set the environment variable `TWITCH_OAUTH2_URL` to `http://localhost:8080/auth/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints.
pub static AUTH_URL: once_cell::sync::Lazy<url::Url> = mock_env_url!("TWITCH_OAUTH2_AUTH_URL", {
    TWITCH_OAUTH2_URL.to_string() + "authorize"
},);
/// Token URL (`https://id.twitch.tv/oauth2/token`) for `id.twitch.tv`
///
/// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_TOKEN_URL` to override the base (`https://id.twitch.tv/oauth2/`) url.
///
/// # Examples
///
/// Set the environment variable `TWITCH_OAUTH2_URL` to `http://localhost:8080/auth/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints.
pub static TOKEN_URL: once_cell::sync::Lazy<url::Url> = mock_env_url!("TWITCH_OAUTH2_TOKEN_URL", {
    TWITCH_OAUTH2_URL.to_string() + "token"
},);
/// Validation URL (`https://id.twitch.tv/oauth2/validate`) for `id.twitch.tv`
///
/// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_VALIDATE_URL` to override the base (`https://id.twitch.tv/oauth2/`) url.
///
/// # Examples
///
/// Set the environment variable `TWITCH_OAUTH2_URL` to `http://localhost:8080/auth/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints.
pub static VALIDATE_URL: once_cell::sync::Lazy<url::Url> =
    mock_env_url!("TWITCH_OAUTH2_VALIDATE_URL", {
        TWITCH_OAUTH2_URL.to_string() + "validate"
    },);
/// Revokation URL (`https://id.twitch.tv/oauth2/revoke`) for `id.twitch.tv`
///
/// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_REVOKE_URL` to override the base (`https://id.twitch.tv/oauth2/`) url.
///
/// # Examples
///
/// Set the environment variable `TWITCH_OAUTH2_URL` to `http://localhost:8080/auth/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints.
pub static REVOKE_URL: once_cell::sync::Lazy<url::Url> =
    mock_env_url!("TWITCH_OAUTH2_REVOKE_URL", {
        TWITCH_OAUTH2_URL.to_string() + "revoke"
    },);

impl AccessTokenRef {
    /// Get the request needed to validate this token.
    ///
    /// Parse the response from this endpoint with [ValidatedToken::from_response](crate::ValidatedToken::from_response)
    pub fn validate_token_request(&self) -> http::Request<Vec<u8>> {
        use http::{header::AUTHORIZATION, HeaderMap, Method};

        let auth_header = format!("OAuth {}", self.secret());
        let mut headers = HeaderMap::new();
        headers.insert(
            AUTHORIZATION,
            auth_header
                .parse()
                .expect("Failed to parse header for validation"),
        );

        crate::construct_request::<&[(String, String)], _, _>(
            &crate::VALIDATE_URL,
            &[],
            headers,
            Method::GET,
            vec![],
        )
    }

    /// Validate this token.
    ///
    /// Should be checked on regularly, according to <https://dev.twitch.tv/docs/authentication#validating-requests>
    #[cfg(feature = "client")]
    pub async fn validate_token<'a, C>(
        &self,
        client: &'a C,
    ) -> Result<ValidatedToken, ValidationError<<C as Client>::Error>>
    where
        C: Client,
    {
        let req = self.validate_token_request();

        let resp = client.req(req).await.map_err(ValidationError::Request)?;
        if resp.status() == StatusCode::UNAUTHORIZED {
            return Err(ValidationError::NotAuthorized);
        }
        ValidatedToken::from_response(&resp).map_err(|v| v.into_other())
    }

    /// Get the request needed to revoke this token.
    pub fn revoke_token_request(&self, client_id: &ClientId) -> http::Request<Vec<u8>> {
        use http::{HeaderMap, Method};
        use std::collections::HashMap;
        let mut params = HashMap::new();
        params.insert("client_id", client_id.as_str());
        params.insert("token", self.secret());

        construct_request(
            &crate::REVOKE_URL,
            &params,
            HeaderMap::new(),
            Method::POST,
            vec![],
        )
    }

    /// Revoke the token.
    ///
    /// See <https://dev.twitch.tv/docs/authentication#revoking-access-tokens>
    #[cfg(feature = "client")]
    pub async fn revoke_token<'a, C>(
        &self,
        http_client: &'a C,
        client_id: &ClientId,
    ) -> Result<(), RevokeTokenError<<C as Client>::Error>>
    where
        C: Client,
    {
        let req = self.revoke_token_request(client_id);

        let resp = http_client
            .req(req)
            .await
            .map_err(RevokeTokenError::RequestError)?;

        let _ = parse_token_response_raw(&resp)?;
        Ok(())
    }
}

impl RefreshTokenRef {
    /// Get the request needed to refresh this token.
    ///
    /// Parse the response from this endpoint with [TwitchTokenResponse::from_response](crate::id::TwitchTokenResponse::from_response)
    pub fn refresh_token_request(
        &self,
        client_id: &ClientId,
        client_secret: &ClientSecret,
    ) -> http::Request<Vec<u8>> {
        use http::{HeaderMap, Method};
        use std::collections::HashMap;

        let mut params = HashMap::new();
        params.insert("client_id", client_id.as_str());
        params.insert("client_secret", client_secret.secret());
        params.insert("grant_type", "refresh_token");
        params.insert("refresh_token", self.secret());

        construct_request(
            &crate::TOKEN_URL,
            &params,
            HeaderMap::new(),
            Method::POST,
            vec![],
        )
    }

    /// Refresh the token, call if it has expired.
    ///
    /// See <https://dev.twitch.tv/docs/authentication#refreshing-access-tokens>
    #[cfg(feature = "client")]
    pub async fn refresh_token<'a, C>(
        &self,
        http_client: &'a C,
        client_id: &ClientId,
        client_secret: &ClientSecret,
    ) -> Result<
        (AccessToken, std::time::Duration, Option<RefreshToken>),
        RefreshTokenError<<C as Client>::Error>,
    >
    where
        C: Client,
    {
        let req = self.refresh_token_request(client_id, client_secret);

        let resp = http_client
            .req(req)
            .await
            .map_err(RefreshTokenError::RequestError)?;
        let res = id::TwitchTokenResponse::from_response(&resp)?;

        let expires_in = res.expires_in().ok_or(RefreshTokenError::NoExpiration)?;
        let refresh_token = res.refresh_token;
        let access_token = res.access_token;
        Ok((access_token, expires_in, refresh_token))
    }
}

/// Construct a request that accepts `application/json` on default
fn construct_request<I, K, V>(
    url: &url::Url,
    params: I,
    headers: http::HeaderMap,
    method: http::Method,
    body: Vec<u8>,
) -> http::Request<Vec<u8>>
where
    I: std::iter::IntoIterator,
    I::Item: std::borrow::Borrow<(K, V)>,
    K: AsRef<str>,
    V: AsRef<str>,
{
    let mut url = url.clone();
    url.query_pairs_mut().extend_pairs(params);
    let url: String = url.into();
    let mut req = http::Request::builder().method(method).uri(url);
    req.headers_mut()
        .map(|h| h.extend(headers.into_iter()))
        .unwrap();
    req.headers_mut()
        .map(|h| {
            if !h.contains_key(http::header::ACCEPT) {
                h.append(http::header::ACCEPT, "application/json".parse().unwrap());
            }
        })
        .unwrap();
    req.body(body).unwrap()
}

/// Parses a response, validating it and returning the response if all ok.
pub(crate) fn parse_token_response_raw<B: AsRef<[u8]>>(
    resp: &http::Response<B>,
) -> Result<&http::Response<B>, RequestParseError> {
    match serde_json::from_slice::<TwitchTokenErrorResponse>(resp.body().as_ref()) {
        Err(_) => match resp.status() {
            StatusCode::OK => Ok(resp),
            _ => Err(RequestParseError::Other(resp.status())),
        },
        Ok(twitch_err) => Err(RequestParseError::TwitchError(twitch_err)),
    }
}

/// Parses a response, validating it and returning json deserialized response
pub(crate) fn parse_response<T: serde::de::DeserializeOwned, B: AsRef<[u8]>>(
    resp: &http::Response<B>,
) -> Result<T, RequestParseError> {
    let body = parse_token_response_raw(resp)?.body().as_ref();
    if let Some(_content) = resp.headers().get(http::header::CONTENT_TYPE) {
        // TODO: Remove this cfg, see issue https://github.com/twitchdev/twitch-cli/issues/81
        #[cfg(not(feature = "mock_api"))]
        if _content != "application/json" {
            return Err(RequestParseError::NotJson {
                found: String::from_utf8_lossy(_content.as_bytes()).into_owned(),
            });
        }
    }
    serde_json::from_slice(body).map_err(Into::into)
}

/// Errors from parsing responses
#[derive(Debug, thiserror::Error, displaydoc::Display)]
pub enum RequestParseError {
    /// deserialization failed
    DeserializeError(#[from] serde_json::Error),
    /// twitch returned an error
    TwitchError(#[from] TwitchTokenErrorResponse),
    /// returned content is not `application/json`, found `{found}`
    NotJson {
        /// Found `Content-Type` header
        found: String,
    },
    /// twitch returned an unexpected status code: {0}
    Other(StatusCode),
}