Skip to main content

kick_api/oauth/
mod.rs

1use oauth2::{
2    AuthUrl, ClientId, ClientSecret, CsrfToken,
3    PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenUrl,
4    basic::BasicClient,
5};
6use serde::Deserialize;
7use std::env;
8
9/// OAuth token response from Kick
10///
11/// Returned by `exchange_code()` and `refresh_token()`.
12#[derive(Debug, Clone, Deserialize)]
13pub struct OAuthTokenResponse {
14    /// The access token for API requests
15    pub access_token: String,
16
17    /// The refresh token (use with `refresh_token()` to get a new access token)
18    pub refresh_token: Option<String>,
19
20    /// Token lifetime in seconds
21    pub expires_in: u64,
22
23    /// Space-separated list of granted scopes
24    pub scope: String,
25
26    /// Token type (typically "Bearer")
27    pub token_type: String,
28}
29
30/// Holds OAuth credentials and client for Kick.com
31pub struct KickOAuth {
32    client: BasicClient,
33    client_id: String,
34    client_secret: String,
35}
36
37impl KickOAuth {
38    /// Creates a new OAuth client by loading credentials from environment variables
39    ///
40    /// Required env vars:
41    /// - KICK_CLIENT_ID
42    /// - KICK_CLIENT_SECRET
43    /// - KICK_REDIRECT_URI
44    pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
45        // Load environment variables
46        let client_id = env::var("KICK_CLIENT_ID")?;
47        let client_secret = env::var("KICK_CLIENT_SECRET")?;
48        let redirect_uri = env::var("KICK_REDIRECT_URI")?;
49
50        // Verify they're not empty
51        if client_id.is_empty() || client_secret.is_empty() || redirect_uri.is_empty() {
52            return Err("One or more OAuth credentials are empty!".into());
53        }
54
55        // Kick's OAuth endpoints
56        let auth_url = AuthUrl::new("https://id.kick.com/oauth/authorize".to_string())?;
57        let token_url = TokenUrl::new("https://id.kick.com/oauth/token".to_string())?;
58
59        // Build the OAuth2 client (oauth2 4.4 API)
60        let client = BasicClient::new(
61            ClientId::new(client_id.clone()),
62            Some(ClientSecret::new(client_secret.clone())),
63            auth_url,
64            Some(token_url),
65        )
66        .set_redirect_uri(RedirectUrl::new(redirect_uri)?);
67
68        Ok(Self { client, client_id, client_secret })
69    }
70
71    /// Creates an OAuth client for server-to-server use (App Access Tokens only)
72    ///
73    /// Only requires KICK_CLIENT_ID and KICK_CLIENT_SECRET env vars.
74    /// No redirect URI needed since client credentials flow has no user interaction.
75    pub fn from_env_server() -> Result<Self, Box<dyn std::error::Error>> {
76        let client_id = env::var("KICK_CLIENT_ID")?;
77        let client_secret = env::var("KICK_CLIENT_SECRET")?;
78
79        if client_id.is_empty() || client_secret.is_empty() {
80            return Err("KICK_CLIENT_ID or KICK_CLIENT_SECRET is empty!".into());
81        }
82
83        let auth_url = AuthUrl::new("https://id.kick.com/oauth/authorize".to_string())?;
84        let token_url = TokenUrl::new("https://id.kick.com/oauth/token".to_string())?;
85
86        let client = BasicClient::new(
87            ClientId::new(client_id.clone()),
88            Some(ClientSecret::new(client_secret.clone())),
89            auth_url,
90            Some(token_url),
91        );
92
93        Ok(Self { client, client_id, client_secret })
94    }
95
96    /// Generates the authorization URL that users should visit
97    ///
98    /// Pass the scopes you need (must match what you configured in your Kick app)
99    ///
100    /// Returns (auth_url, csrf_token, pkce_verifier)
101    /// - auth_url: The URL to send the user to
102    /// - csrf_token: Save this! You'll verify it matches when they return
103    /// - pkce_verifier: REQUIRED! Pass this to exchange_code() later
104    pub fn get_authorization_url(&self, scopes: Vec<&str>) -> (String, CsrfToken, PkceCodeVerifier) {
105        // Generate PKCE challenge (required by Kick)
106        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
107
108        let mut auth_request = self.client
109            .authorize_url(CsrfToken::new_random)
110            .set_pkce_challenge(pkce_challenge);
111
112        // Add each scope
113        for scope in scopes {
114            auth_request = auth_request.add_scope(Scope::new(scope.to_string()));
115        }
116
117        let (auth_url, csrf_token) = auth_request.url();
118
119        (auth_url.to_string(), csrf_token, pkce_verifier)
120    }
121
122    /// Request an App Access Token using the client credentials grant
123    ///
124    /// This is a server-to-server flow that doesn't require user interaction.
125    /// The returned token can only access publicly available data.
126    ///
127    /// Note: The response will not include a `refresh_token` — just request
128    /// a new app access token when the current one expires.
129    ///
130    /// ```no_run
131    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
132    /// let oauth = kick_api::KickOAuth::from_env_server()?;
133    /// let token = oauth.get_app_access_token().await?;
134    /// let client = kick_api::KickApiClient::with_token(token.access_token);
135    /// # Ok(())
136    /// # }
137    /// ```
138    pub async fn get_app_access_token(
139        &self,
140    ) -> Result<OAuthTokenResponse, Box<dyn std::error::Error>> {
141        let http_client = reqwest::Client::new();
142        let response = http_client
143            .post("https://id.kick.com/oauth/token")
144            .form(&[
145                ("grant_type", "client_credentials"),
146                ("client_id", &self.client_id),
147                ("client_secret", &self.client_secret),
148            ])
149            .send()
150            .await?;
151
152        let status = response.status();
153        let body = response.text().await?;
154
155        if status.is_success() {
156            let token_response: OAuthTokenResponse = serde_json::from_str(&body)?;
157            Ok(token_response)
158        } else {
159            Err(format!("App access token request failed: {}", body).into())
160        }
161    }
162
163    /// Exchanges the authorization code for an access token
164    ///
165    /// After the user authorizes, Kick redirects to your callback with a `code` parameter.
166    /// Pass that code AND the pkce_verifier from get_authorization_url() to this function.
167    ///
168    /// Returns an `OAuthTokenResponse` with access_token, refresh_token, expires_in, etc.
169    pub async fn exchange_code(
170        &self,
171        code: String,
172        pkce_verifier: PkceCodeVerifier,
173    ) -> Result<OAuthTokenResponse, Box<dyn std::error::Error>> {
174        let redirect_uri = env::var("KICK_REDIRECT_URI")?;
175
176        let http_client = reqwest::Client::new();
177        let response = http_client
178            .post("https://id.kick.com/oauth/token")
179            .form(&[
180                ("grant_type", "authorization_code"),
181                ("code", &code),
182                ("client_id", &self.client_id),
183                ("client_secret", &self.client_secret),
184                ("redirect_uri", &redirect_uri),
185                ("code_verifier", pkce_verifier.secret()),
186            ])
187            .send()
188            .await?;
189
190        let status = response.status();
191        let body = response.text().await?;
192
193        if status.is_success() {
194            let token_response: OAuthTokenResponse = serde_json::from_str(&body)?;
195            Ok(token_response)
196        } else {
197            Err(format!("Token exchange failed: {}", body).into())
198        }
199    }
200
201    /// Refresh an access token using a refresh token
202    ///
203    /// When your access token expires, use the refresh token from the original
204    /// `exchange_code()` response to get a new one.
205    ///
206    /// # Parameters
207    /// - `refresh_token`: The refresh token from a previous token response
208    pub async fn refresh_token(
209        &self,
210        refresh_token: &str,
211    ) -> Result<OAuthTokenResponse, Box<dyn std::error::Error>> {
212        let http_client = reqwest::Client::new();
213        let response = http_client
214            .post("https://id.kick.com/oauth/token")
215            .form(&[
216                ("grant_type", "refresh_token"),
217                ("refresh_token", refresh_token),
218                ("client_id", &self.client_id),
219                ("client_secret", &self.client_secret),
220            ])
221            .send()
222            .await?;
223
224        let status = response.status();
225        let body = response.text().await?;
226
227        if status.is_success() {
228            let token_response: OAuthTokenResponse = serde_json::from_str(&body)?;
229            Ok(token_response)
230        } else {
231            Err(format!("Token refresh failed: {}", body).into())
232        }
233    }
234
235    /// Revoke an access or refresh token
236    ///
237    /// Invalidates the given token so it can no longer be used.
238    ///
239    /// # Parameters
240    /// - `token`: The access token or refresh token to revoke
241    pub async fn revoke_token(
242        &self,
243        token: &str,
244    ) -> Result<(), Box<dyn std::error::Error>> {
245        let http_client = reqwest::Client::new();
246        let response = http_client
247            .post("https://id.kick.com/oauth/revoke")
248            .form(&[
249                ("token", token),
250                ("client_id", &self.client_id),
251                ("client_secret", &self.client_secret),
252            ])
253            .send()
254            .await?;
255
256        let status = response.status();
257        if status.is_success() {
258            Ok(())
259        } else {
260            let body = response.text().await?;
261            Err(format!("Token revocation failed: {}", body).into())
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_oauth_from_env() {
272        // This will fail if env vars aren't set - that's expected
273        // To test: set env vars first, then run `cargo test`
274        dotenvy::dotenv().ok();
275
276        match KickOAuth::from_env() {
277            Ok(oauth) => {
278                let scopes = vec!["user:read", "channel:read"];
279                let (url, _csrf, _verifier) = oauth.get_authorization_url(scopes);
280                println!("Auth URL: {}", url);
281                assert!(url.contains("kick.com"));
282                assert!(url.contains("code_challenge")); // Verify PKCE is included
283            }
284            Err(e) => {
285                println!("Expected failure (env vars not set): {}", e);
286            }
287        }
288    }
289}