twitch_oauth2/
lib.rs

1#![allow(unknown_lints, renamed_and_removed_lints)]
2#![deny(missing_docs, broken_intra_doc_links)] // This will be weird until 1.52, see https://github.com/rust-lang/rust/pull/80527
3#![cfg_attr(nightly, deny(rustdoc::broken_intra_doc_links))]
4#![cfg_attr(nightly, feature(doc_cfg))]
5//! [![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)
6//!
7//! [github]: https://img.shields.io/badge/github-twitch--rs/twitch__oauth2-8da0cb?style=for-the-badge&labelColor=555555&logo=github"
8//! [crates-io]: https://img.shields.io/crates/v/twitch_oauth2.svg?style=for-the-badge&color=fc8d62&logo=rust"
9//! [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"
10//!
11//! <br>
12//!
13//! <h5>OAuth2 for Twitch endpoints</h5>
14//!
15//! ```rust,no_run
16//! use twitch_oauth2::{tokens::errors::ValidationError, AccessToken, TwitchToken, UserToken};
17//! // Make sure you enable the feature "reqwest" for twitch_oauth2 if you want to use reqwest
18//! # async {let client = twitch_oauth2::client::DummyClient; stringify!(
19//! let client = reqwest::Client::builder()
20//!     .redirect(reqwest::redirect::Policy::none())
21//!     .build()?;
22//! # );
23//! let token = AccessToken::new("sometokenherewhichisvalidornot".to_string());
24//! let token = UserToken::from_token(&client, token).await?;
25//! println!("token: {:?}", token.token()); // prints `[redacted access token]`
26//! # Ok::<(), Box<dyn std::error::Error>>(())};
27//! ```
28//!
29//! # About
30//!
31//! ## Scopes
32//!
33//! The library contains all known twitch oauth2 scopes in [`Scope`].
34//!
35//! ## User Access Tokens
36//!
37//! For most basic use cases with user authorization, [`UserToken::from_token`] will be your main way
38//! to create user tokens in this library.
39//!
40//! Things like [`UserTokenBuilder`] can be used to create a token from scratch, via the [OAuth authorization code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow)
41//! You can also use the newer [OAuth device code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow) with [`DeviceUserTokenBuilder`].
42//!
43//! ## App access token
44//!
45//! Similar to [`UserToken`], a token with authorization as the twitch application can be created with
46//! [`AppAccessToken::get_app_access_token`].
47//!
48//! ## HTTP Requests
49//!
50//! To enable client features with a supported http library, enable the http library feature in `twitch_oauth2`, like `twitch_oauth2 = { features = ["reqwest"], version = "0.16.0" }`.
51//! If you're using [twitch_api](https://crates.io/crates/twitch_api), you can use its [`HelixClient`](https://docs.rs/twitch_api/latest/twitch_api/struct.HelixClient.html) instead of the underlying http client.
52//!
53//!
54//! This library can be used without any specific http client library (like if you don't want to use `await`),
55//! using methods like [`AppAccessToken::from_response`] and [`AppAccessToken::get_app_access_token_request`]
56//! or [`UserTokenBuilder::get_user_token_request`] and [`UserToken::from_response`]
57#[cfg(feature = "client")]
58pub mod client;
59pub mod id;
60pub mod scopes;
61pub mod tokens;
62pub mod types;
63
64use http::StatusCode;
65use id::TwitchTokenErrorResponse;
66#[cfg(feature = "client")]
67use tokens::errors::{RefreshTokenError, RevokeTokenError, ValidationError};
68
69#[doc(inline)]
70pub use scopes::{Scope, Validator};
71#[doc(inline)]
72pub use tokens::{
73    AppAccessToken, DeviceUserTokenBuilder, ImplicitUserTokenBuilder, TwitchToken, UserToken,
74    UserTokenBuilder, ValidatedToken,
75};
76
77pub use url;
78
79pub use types::{AccessToken, ClientId, ClientSecret, CsrfToken, RefreshToken};
80
81#[doc(hidden)]
82pub use types::{AccessTokenRef, ClientIdRef, ClientSecretRef, CsrfTokenRef, RefreshTokenRef};
83
84#[cfg(feature = "client")]
85use self::client::Client;
86
87/// Generate a url with a default if `mock_api` feature is disabled, or env var is not defined or is invalid utf8
88macro_rules! mock_env_url {
89    ($var:literal, $default:expr $(,)?) => {
90        once_cell::sync::Lazy::new(move || {
91            #[cfg(feature = "mock_api")]
92            if let Ok(url) = std::env::var($var) {
93                return url::Url::parse(&url).expect(concat!(
94                    "URL could not be made from `env:",
95                    $var,
96                    "`."
97                ));
98            };
99            url::Url::parse(&$default).unwrap()
100        })
101    };
102}
103
104/// Defines the root path to twitch auth endpoints
105static TWITCH_OAUTH2_URL: once_cell::sync::Lazy<url::Url> =
106    mock_env_url!("TWITCH_OAUTH2_URL", "https://id.twitch.tv/oauth2/");
107
108/// Authorization URL (`https://id.twitch.tv/oauth2/authorize`) for `id.twitch.tv`
109///
110/// 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.
111///
112/// # Examples
113///
114/// 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.
115pub static AUTH_URL: once_cell::sync::Lazy<url::Url> = mock_env_url!("TWITCH_OAUTH2_AUTH_URL", {
116    TWITCH_OAUTH2_URL.to_string() + "authorize"
117},);
118/// Token URL (`https://id.twitch.tv/oauth2/token`) for `id.twitch.tv`
119///
120/// 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.
121///
122/// # Examples
123///
124/// 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.
125pub static TOKEN_URL: once_cell::sync::Lazy<url::Url> = mock_env_url!("TWITCH_OAUTH2_TOKEN_URL", {
126    TWITCH_OAUTH2_URL.to_string() + "token"
127},);
128/// Device URL (`https://id.twitch.tv/oauth2/device`) for `id.twitch.tv`
129///
130/// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_DEVICE_URL` to override the base (`https://id.twitch.tv/oauth2/`) url.
131///
132/// # Examples
133///
134/// 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.
135pub static DEVICE_URL: once_cell::sync::Lazy<url::Url> =
136    mock_env_url!("TWITCH_OAUTH2_DEVICE_URL", {
137        TWITCH_OAUTH2_URL.to_string() + "device"
138    },);
139/// Validation URL (`https://id.twitch.tv/oauth2/validate`) for `id.twitch.tv`
140///
141/// 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.
142///
143/// # Examples
144///
145/// 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.
146pub static VALIDATE_URL: once_cell::sync::Lazy<url::Url> =
147    mock_env_url!("TWITCH_OAUTH2_VALIDATE_URL", {
148        TWITCH_OAUTH2_URL.to_string() + "validate"
149    },);
150/// Revokation URL (`https://id.twitch.tv/oauth2/revoke`) for `id.twitch.tv`
151///
152/// 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.
153///
154/// # Examples
155///
156/// 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.
157pub static REVOKE_URL: once_cell::sync::Lazy<url::Url> =
158    mock_env_url!("TWITCH_OAUTH2_REVOKE_URL", {
159        TWITCH_OAUTH2_URL.to_string() + "revoke"
160    },);
161
162impl AccessTokenRef {
163    /// Get the request needed to validate this token.
164    ///
165    /// Parse the response from this endpoint with [ValidatedToken::from_response](crate::ValidatedToken::from_response)
166    pub fn validate_token_request(&self) -> http::Request<Vec<u8>> {
167        use http::{header::AUTHORIZATION, HeaderMap, Method};
168
169        let auth_header = format!("OAuth {}", self.secret());
170        let mut headers = HeaderMap::new();
171        headers.insert(
172            AUTHORIZATION,
173            auth_header
174                .parse()
175                .expect("Failed to parse header for validation"),
176        );
177
178        crate::construct_request::<&[(String, String)], _, _>(
179            &crate::VALIDATE_URL,
180            &[],
181            headers,
182            Method::GET,
183            vec![],
184        )
185    }
186
187    /// Validate this token.
188    ///
189    /// Should be checked on regularly, according to <https://dev.twitch.tv/docs/authentication/validate-tokens/>
190    #[cfg(feature = "client")]
191    pub async fn validate_token<C>(
192        &self,
193        client: &C,
194    ) -> Result<ValidatedToken, ValidationError<<C as Client>::Error>>
195    where
196        C: Client,
197    {
198        let req = self.validate_token_request();
199
200        let resp = client.req(req).await.map_err(ValidationError::Request)?;
201        if resp.status() == StatusCode::UNAUTHORIZED {
202            return Err(ValidationError::NotAuthorized);
203        }
204        ValidatedToken::from_response(&resp).map_err(|v| v.into_other())
205    }
206
207    /// Get the request needed to revoke this token.
208    pub fn revoke_token_request(&self, client_id: &ClientId) -> http::Request<Vec<u8>> {
209        use http::{HeaderMap, Method};
210        use std::collections::HashMap;
211        let mut params = HashMap::new();
212        params.insert("client_id", client_id.as_str());
213        params.insert("token", self.secret());
214
215        construct_request(
216            &crate::REVOKE_URL,
217            &params,
218            HeaderMap::new(),
219            Method::POST,
220            vec![],
221        )
222    }
223
224    /// Revoke the token.
225    ///
226    /// See <https://dev.twitch.tv/docs/authentication/revoke-tokens/>
227    #[cfg(feature = "client")]
228    pub async fn revoke_token<C>(
229        &self,
230        http_client: &C,
231        client_id: &ClientId,
232    ) -> Result<(), RevokeTokenError<<C as Client>::Error>>
233    where
234        C: Client,
235    {
236        let req = self.revoke_token_request(client_id);
237
238        let resp = http_client
239            .req(req)
240            .await
241            .map_err(RevokeTokenError::RequestError)?;
242
243        let _ = parse_token_response_raw(&resp)?;
244        Ok(())
245    }
246}
247
248impl RefreshTokenRef {
249    /// Get the request needed to refresh this token.
250    ///
251    /// Parse the response from this endpoint with [TwitchTokenResponse::from_response](crate::id::TwitchTokenResponse::from_response)
252    pub fn refresh_token_request(
253        &self,
254        client_id: &ClientId,
255        client_secret: Option<&ClientSecret>,
256    ) -> http::Request<Vec<u8>> {
257        use http::{HeaderMap, HeaderValue, Method};
258        use std::collections::HashMap;
259
260        let mut headers = HeaderMap::new();
261        headers.append(
262            "Content-Type",
263            HeaderValue::from_static("application/x-www-form-urlencoded"),
264        );
265
266        let mut params = HashMap::new();
267        params.insert("client_id", client_id.as_str());
268        if let Some(client_secret) = client_secret {
269            params.insert("client_secret", client_secret.secret());
270        }
271        params.insert("grant_type", "refresh_token");
272        params.insert("refresh_token", self.secret());
273        construct_request::<&[(String, String)], _, _>(
274            &crate::TOKEN_URL,
275            &[],
276            headers,
277            Method::POST,
278            url::form_urlencoded::Serializer::new(String::new())
279                .extend_pairs(params)
280                .finish()
281                .into_bytes(),
282        )
283    }
284
285    /// Refresh the token, call if it has expired.
286    ///
287    /// See <https://dev.twitch.tv/docs/authentication/refresh-tokens>
288    #[cfg(feature = "client")]
289    pub async fn refresh_token<C>(
290        &self,
291        http_client: &C,
292        client_id: &ClientId,
293        client_secret: Option<&ClientSecret>,
294    ) -> Result<
295        (AccessToken, std::time::Duration, Option<RefreshToken>),
296        RefreshTokenError<<C as Client>::Error>,
297    >
298    where
299        C: Client,
300    {
301        let req = self.refresh_token_request(client_id, client_secret);
302
303        let resp = http_client
304            .req(req)
305            .await
306            .map_err(RefreshTokenError::RequestError)?;
307        let res = id::TwitchTokenResponse::from_response(&resp)?;
308
309        let expires_in = res.expires_in().ok_or(RefreshTokenError::NoExpiration)?;
310        let refresh_token = res.refresh_token;
311        let access_token = res.access_token;
312        Ok((access_token, expires_in, refresh_token))
313    }
314}
315
316/// Construct a request that accepts `application/json` on default
317fn construct_request<I, K, V>(
318    url: &url::Url,
319    params: I,
320    headers: http::HeaderMap,
321    method: http::Method,
322    body: Vec<u8>,
323) -> http::Request<Vec<u8>>
324where
325    I: std::iter::IntoIterator,
326    I::Item: std::borrow::Borrow<(K, V)>,
327    K: AsRef<str>,
328    V: AsRef<str>,
329{
330    let mut url = url.clone();
331    url.query_pairs_mut().extend_pairs(params);
332    let url: String = url.into();
333    let mut req = http::Request::builder().method(method).uri(url);
334    req.headers_mut().map(|h| h.extend(headers)).unwrap();
335    req.headers_mut()
336        .map(|h| {
337            if !h.contains_key(http::header::ACCEPT) {
338                h.append(http::header::ACCEPT, "application/json".parse().unwrap());
339            }
340        })
341        .unwrap();
342    req.body(body).unwrap()
343}
344
345/// Parses a response, validating it and returning the response if all ok.
346pub(crate) fn parse_token_response_raw<B: AsRef<[u8]>>(
347    resp: &http::Response<B>,
348) -> Result<&http::Response<B>, RequestParseError> {
349    match serde_json::from_slice::<TwitchTokenErrorResponse>(resp.body().as_ref()) {
350        Err(_) => match resp.status() {
351            StatusCode::OK => Ok(resp),
352            _ => Err(RequestParseError::Other(resp.status())),
353        },
354        Ok(twitch_err) => Err(RequestParseError::TwitchError(twitch_err)),
355    }
356}
357
358/// Parses a response, validating it and returning json deserialized response
359pub(crate) fn parse_response<T: serde::de::DeserializeOwned, B: AsRef<[u8]>>(
360    resp: &http::Response<B>,
361) -> Result<T, RequestParseError> {
362    let body = parse_token_response_raw(resp)?.body().as_ref();
363    if let Some(_content) = resp.headers().get(http::header::CONTENT_TYPE) {
364        // TODO: Remove this cfg, see issue https://github.com/twitchdev/twitch-cli/issues/81
365        #[cfg(not(feature = "mock_api"))]
366        if _content != "application/json" {
367            return Err(RequestParseError::NotJson {
368                found: String::from_utf8_lossy(_content.as_bytes()).into_owned(),
369            });
370        }
371    }
372    serde_json::from_slice(body).map_err(Into::into)
373}
374
375/// Errors from parsing responses
376#[derive(Debug, thiserror::Error, displaydoc::Display)]
377#[non_exhaustive]
378pub enum RequestParseError {
379    /// deserialization failed
380    DeserializeError(#[from] serde_json::Error),
381    /// twitch returned an error
382    TwitchError(#[from] TwitchTokenErrorResponse),
383    /// returned content is not `application/json`, found `{found}`
384    NotJson {
385        /// Found `Content-Type` header
386        found: String,
387    },
388    /// twitch returned an unexpected status code: {0}
389    Other(StatusCode),
390}