Skip to main content

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