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 ¶ms,
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}