twitch_irc/
login.rs

1//! Logic for getting credentials to log into chat with.
2
3use async_trait::async_trait;
4use std::convert::Infallible;
5use std::fmt::{Debug, Display};
6
7#[cfg(any(
8    all(
9        feature = "refreshing-token-native-tls",
10        feature = "refreshing-token-rustls-native-roots"
11    ),
12    all(
13        feature = "refreshing-token-native-tls",
14        feature = "refreshing-token-rustls-webpki-roots"
15    ),
16    all(
17        feature = "refreshing-token-rustls-native-roots",
18        feature = "refreshing-token-rustls-webpki-roots"
19    ),
20))]
21compile_error!("`refreshing-token-native-tls`, `refreshing-token-rustls-native-roots` and `refreshing-token-rustls-webpki-roots` feature flags are mutually exclusive, enable at most one of them");
22
23#[cfg(feature = "__refreshing-token")]
24use {
25    chrono::DateTime,
26    chrono::Utc,
27    std::{sync::Arc, time::Duration},
28    thiserror::Error,
29    tokio::sync::Mutex,
30};
31
32#[cfg(feature = "with-serde")]
33use {serde::Deserialize, serde::Serialize};
34
35/// A pair of login name and OAuth token.
36#[derive(Debug, Clone)]
37#[cfg_attr(feature = "with-serde", derive(Serialize, Deserialize))]
38pub struct CredentialsPair {
39    /// Login name of the user that the library should log into chat as.
40    pub login: String,
41    /// OAuth access token, without leading `oauth:` prefix.
42    /// If `None`, then no password will be sent to the server at all (for anonymous
43    /// credentials).
44    pub token: Option<String>,
45}
46
47/// Encapsulates logic for getting the credentials to log into chat, whenever
48/// a new connection is made.
49#[async_trait]
50pub trait LoginCredentials: Debug + Send + Sync + 'static {
51    /// Error type that can occur when trying to fetch the credentials.
52    type Error: Send + Sync + Debug + Display;
53
54    /// Get a fresh set of credentials to be used right-away.
55    async fn get_credentials(&self) -> Result<CredentialsPair, Self::Error>;
56}
57
58/// Simple `LoginCredentials` implementation that always returns the same `CredentialsPair`
59/// and never fails.
60#[derive(Debug, Clone)]
61#[cfg_attr(feature = "with-serde", derive(Serialize, Deserialize))]
62pub struct StaticLoginCredentials {
63    /// The credentials that are always returned.
64    pub credentials: CredentialsPair,
65}
66
67impl StaticLoginCredentials {
68    /// Create new static login credentials from the given Twitch login name and OAuth access token.
69    /// The `token` should be without the `oauth:` prefix.
70    pub fn new(login: String, token: Option<String>) -> StaticLoginCredentials {
71        StaticLoginCredentials {
72            credentials: CredentialsPair { login, token },
73        }
74    }
75
76    /// Creates login credentials for logging into chat as an anonymous user.
77    pub fn anonymous() -> StaticLoginCredentials {
78        StaticLoginCredentials::new("justinfan12345".to_owned(), None)
79    }
80}
81
82#[async_trait]
83impl LoginCredentials for StaticLoginCredentials {
84    type Error = Infallible;
85
86    async fn get_credentials(&self) -> Result<CredentialsPair, Infallible> {
87        Ok(self.credentials.clone())
88    }
89}
90
91/// The necessary details about a Twitch OAuth Access Token. This information is provided
92/// by Twitch's OAuth API after completing the user's authorization.
93#[cfg(feature = "__refreshing-token")]
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct UserAccessToken {
96    /// OAuth access token
97    pub access_token: String,
98    /// OAuth refresh token
99    pub refresh_token: String,
100    /// Timestamp of when this user access token was created
101    pub created_at: DateTime<Utc>,
102    /// Timestamp of when this user access token expires. `None` if this token never expires.
103    pub expires_at: Option<DateTime<Utc>>,
104}
105
106/// Represents the Twitch API response to `POST /oauth2/token` API requests.
107///
108/// Provided as a convenience for your own implementations, as you will typically need
109/// to parse this response during the process of getting the inital token after user authorization
110/// has been granted.
111///
112/// Includes a `impl From<GetAccessTokenResponse> for UserAccessToken` for simple
113/// conversion to a `UserAccessToken`:
114///
115/// ```
116/// # use twitch_irc::login::{GetAccessTokenResponse, UserAccessToken};
117/// let json_response = r#"{"access_token":"xxxxxxxxxxxxxxxxxxxxxxxxxxx","expires_in":14346,"refresh_token":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","scope":["user_read"],"token_type":"bearer"}"#;
118/// let decoded_response: GetAccessTokenResponse = serde_json::from_str(json_response).unwrap();
119/// let user_access_token: UserAccessToken = UserAccessToken::from(decoded_response);
120/// ```
121#[cfg(feature = "__refreshing-token")]
122#[derive(Serialize, Deserialize)]
123pub struct GetAccessTokenResponse {
124    // {
125    //   "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxx",
126    //   "expires_in": 14346, // this is entirely OMITTED for infinitely-lived tokens
127    //   "refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
128    //   "scope": [
129    //     "user_read"
130    //   ], // scope is also entirely omitted if we didn't request any scopes in the request
131    //   "token_type": "bearer"
132    // }
133    /// OAuth access token
134    pub access_token: String,
135    /// OAuth refresh token
136    pub refresh_token: String,
137    /// Specifies the time when this token expires (number of seconds from now). `None` if this token
138    /// never expires.
139    pub expires_in: Option<u64>,
140}
141
142#[cfg(feature = "__refreshing-token")]
143impl From<GetAccessTokenResponse> for UserAccessToken {
144    fn from(response: GetAccessTokenResponse) -> Self {
145        let now = Utc::now();
146        UserAccessToken {
147            access_token: response.access_token,
148            refresh_token: response.refresh_token,
149            created_at: now,
150            expires_at: response
151                .expires_in
152                .map(|d| now + chrono::Duration::from_std(Duration::from_secs(d)).unwrap()),
153        }
154    }
155}
156
157/// Load and store the currently valid version of the user's OAuth Access Token.
158#[cfg(feature = "__refreshing-token")]
159#[async_trait]
160pub trait TokenStorage: Debug + Send + 'static {
161    /// Possible error type when trying to load the token from this storage.
162    type LoadError: Send + Sync + Debug + Display;
163    /// Possible error type when trying to update the token in this storage.
164    type UpdateError: Send + Sync + Debug + Display;
165
166    /// Load the currently stored token from the storage.
167    async fn load_token(&mut self) -> Result<UserAccessToken, Self::LoadError>;
168    /// Called after the token was updated successfully, to save the new token.
169    /// After `update_token()` completes, the `load_token()` method should then return
170    /// that token for future invocations
171    async fn update_token(&mut self, token: &UserAccessToken) -> Result<(), Self::UpdateError>;
172}
173
174/// Login credentials backed by a token storage and using OAuth refresh tokens, allowing use of OAuth tokens that expire.
175/// These can also be cloned before being passed to a `Client` so you can use them in other places,
176/// such as API calls.
177#[cfg(feature = "__refreshing-token")]
178#[derive(Debug, Clone)]
179pub struct RefreshingLoginCredentials<S: TokenStorage> {
180    http_client: reqwest::Client,
181    user_login: Arc<Mutex<Option<String>>>,
182    client_id: String,
183    client_secret: String,
184    token_storage: Arc<Mutex<S>>,
185}
186
187#[cfg(feature = "__refreshing-token")]
188impl<S: TokenStorage> RefreshingLoginCredentials<S> {
189    /// Create new login credentials with a backing token storage. The username belonging to the
190    /// stored token is automatically fetched using the Twitch API when using this constructor.
191    pub fn init(
192        client_id: String,
193        client_secret: String,
194        token_storage: S,
195    ) -> RefreshingLoginCredentials<S> {
196        RefreshingLoginCredentials::init_with_username(
197            None,
198            client_id,
199            client_secret,
200            token_storage,
201        )
202    }
203
204    /// Create new login credentials with a backing token storage and a predefined username.
205    /// If the username is predefined (pass `Some("the_username")` as `user_login`),
206    /// no API call will be made to the Twitch API to determine the username belonging to the token.
207    /// If the passed `user_login` is `None`, this constructor is functionally equivalent to [`RefreshingLoginCredentials::init`].
208    pub fn init_with_username(
209        user_login: Option<String>,
210        client_id: String,
211        client_secret: String,
212        token_storage: S,
213    ) -> RefreshingLoginCredentials<S> {
214        RefreshingLoginCredentials {
215            http_client: reqwest::Client::new(),
216            user_login: Arc::new(Mutex::new(user_login)),
217            client_id,
218            client_secret,
219            token_storage: Arc::new(Mutex::new(token_storage)),
220        }
221    }
222}
223
224/// Error type for the `RefreshingLoginCredentials` implementation.
225#[cfg(feature = "__refreshing-token")]
226#[derive(Error, Debug)]
227pub enum RefreshingLoginError<S: TokenStorage> {
228    /// Failed to retrieve token from storage: `<cause>`
229    #[error("Failed to retrieve token from storage: {0}")]
230    LoadError(S::LoadError),
231    /// Failed to refresh token: `<cause>`
232    #[error("Failed to refresh token: {0}")]
233    RefreshError(reqwest::Error),
234    /// Failed to update token in storage: `<cause>`
235    #[error("Failed to update token in storage: {0}")]
236    UpdateError(S::UpdateError),
237}
238
239#[cfg(feature = "__refreshing-token")]
240const SHOULD_REFRESH_AFTER_FACTOR: f64 = 0.9;
241
242#[cfg(feature = "__refreshing-token")]
243#[async_trait]
244impl<S: TokenStorage> LoginCredentials for RefreshingLoginCredentials<S> {
245    type Error = RefreshingLoginError<S>;
246
247    async fn get_credentials(&self) -> Result<CredentialsPair, RefreshingLoginError<S>> {
248        let mut token_storage = self.token_storage.lock().await;
249
250        let mut current_token = token_storage
251            .load_token()
252            .await
253            .map_err(RefreshingLoginError::LoadError)?;
254
255        let token_expires_after = if let Some(expires_at) = current_token.expires_at {
256            // to_std() converts the time::duration::Duration chrono uses to a std::time::Duration
257            (expires_at - current_token.created_at).to_std().unwrap()
258        } else {
259            // 24 hours
260            Duration::from_secs(24 * 60 * 60)
261        };
262        let token_age = (Utc::now() - current_token.created_at).to_std().unwrap();
263        let max_token_age = token_expires_after.mul_f64(SHOULD_REFRESH_AFTER_FACTOR);
264        let is_token_expired = token_age >= max_token_age;
265
266        if is_token_expired {
267            let response = self
268                .http_client
269                .post("https://id.twitch.tv/oauth2/token")
270                .query(&[
271                    ("grant_type", "refresh_token"),
272                    ("refresh_token", &current_token.refresh_token),
273                    ("client_id", &self.client_id),
274                    ("client_secret", &self.client_secret),
275                ])
276                .send()
277                .await
278                .map_err(RefreshingLoginError::RefreshError)?
279                .json::<GetAccessTokenResponse>()
280                .await
281                .map_err(RefreshingLoginError::RefreshError)?;
282
283            // replace the current token
284            current_token = UserAccessToken::from(response);
285
286            token_storage
287                .update_token(&current_token)
288                .await
289                .map_err(RefreshingLoginError::UpdateError)?;
290        }
291
292        let mut current_login = self.user_login.lock().await;
293
294        let login = match &*current_login {
295            Some(login) => login.clone(),
296            None => {
297                let response = self
298                    .http_client
299                    .get("https://api.twitch.tv/helix/users")
300                    .header("Client-Id", &self.client_id)
301                    .bearer_auth(&current_token.access_token)
302                    .send()
303                    .await
304                    .map_err(RefreshingLoginError::RefreshError)?;
305
306                let users_response = response
307                    .json::<UsersResponse>()
308                    .await
309                    .map_err(RefreshingLoginError::RefreshError)?;
310
311                // If no users are specified in the query, the API reponds with the user of the bearer token.
312                let user = users_response.data.into_iter().next().unwrap();
313
314                // TODO Have the fetched login name expire automatically to be resilient to bot's namechanges
315                // should then also automatically reconnect all connections with the new username, so the change
316                // will be a little more complex than just adding an expiry to this logic here.
317                tracing::info!(
318                    "Fetched login name `{}` for provided auth token",
319                    &user.login
320                );
321
322                *current_login = Some(user.login.clone());
323
324                user.login
325            }
326        };
327
328        Ok(CredentialsPair {
329            login,
330            token: Some(current_token.access_token.clone()),
331        })
332    }
333}
334
335/// Represents the Twitch API response to `/helix/users` API requests.
336/// It is used when fetching the username from the API in `RefreshingLoginCredentials`.
337#[cfg(feature = "__refreshing-token")]
338#[derive(Deserialize)]
339struct UsersResponse {
340    data: Vec<UserObject>,
341}
342
343/// Represents a user object in Twitch API responses. (only the login field is included
344/// here though, because we don't need any of the other fields)
345#[cfg(feature = "__refreshing-token")]
346#[derive(Deserialize)]
347struct UserObject {
348    login: String,
349}