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