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}
34
35impl KickOAuth {
36    /// Creates a new OAuth client by loading credentials from environment variables
37    ///
38    /// Required env vars:
39    /// - KICK_CLIENT_ID
40    /// - KICK_CLIENT_SECRET
41    /// - KICK_REDIRECT_URI
42    pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
43        // Load environment variables
44        let client_id = env::var("KICK_CLIENT_ID")?;
45        let client_secret = env::var("KICK_CLIENT_SECRET")?;
46        let redirect_uri = env::var("KICK_REDIRECT_URI")?;
47
48        // Verify they're not empty
49        if client_id.is_empty() || client_secret.is_empty() || redirect_uri.is_empty() {
50            return Err("One or more OAuth credentials are empty!".into());
51        }
52
53        // Kick's OAuth endpoints
54        let auth_url = AuthUrl::new("https://id.kick.com/oauth/authorize".to_string())?;
55        let token_url = TokenUrl::new("https://id.kick.com/oauth/token".to_string())?;
56
57        // Build the OAuth2 client (oauth2 4.4 API)
58        let client = BasicClient::new(
59            ClientId::new(client_id),
60            Some(ClientSecret::new(client_secret)),
61            auth_url,
62            Some(token_url),
63        )
64        .set_redirect_uri(RedirectUrl::new(redirect_uri)?);
65
66        Ok(Self { client })
67    }
68
69    /// Generates the authorization URL that users should visit
70    ///
71    /// Pass the scopes you need (must match what you configured in your Kick app)
72    ///
73    /// Returns (auth_url, csrf_token, pkce_verifier)
74    /// - auth_url: The URL to send the user to
75    /// - csrf_token: Save this! You'll verify it matches when they return
76    /// - pkce_verifier: REQUIRED! Pass this to exchange_code() later
77    pub fn get_authorization_url(&self, scopes: Vec<&str>) -> (String, CsrfToken, PkceCodeVerifier) {
78        // Generate PKCE challenge (required by Kick)
79        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
80
81        let mut auth_request = self.client
82            .authorize_url(CsrfToken::new_random)
83            .set_pkce_challenge(pkce_challenge);
84
85        // Add each scope
86        for scope in scopes {
87            auth_request = auth_request.add_scope(Scope::new(scope.to_string()));
88        }
89
90        let (auth_url, csrf_token) = auth_request.url();
91
92        (auth_url.to_string(), csrf_token, pkce_verifier)
93    }
94
95    /// Exchanges the authorization code for an access token
96    ///
97    /// After the user authorizes, Kick redirects to your callback with a `code` parameter.
98    /// Pass that code AND the pkce_verifier from get_authorization_url() to this function.
99    ///
100    /// Returns an `OAuthTokenResponse` with access_token, refresh_token, expires_in, etc.
101    pub async fn exchange_code(
102        &self,
103        code: String,
104        pkce_verifier: PkceCodeVerifier,
105    ) -> Result<OAuthTokenResponse, Box<dyn std::error::Error>> {
106        let client_id = env::var("KICK_CLIENT_ID")?;
107        let client_secret = env::var("KICK_CLIENT_SECRET")?;
108        let redirect_uri = env::var("KICK_REDIRECT_URI")?;
109
110        let http_client = reqwest::Client::new();
111        let response = http_client
112            .post("https://id.kick.com/oauth/token")
113            .form(&[
114                ("grant_type", "authorization_code"),
115                ("code", &code),
116                ("client_id", &client_id),
117                ("client_secret", &client_secret),
118                ("redirect_uri", &redirect_uri),
119                ("code_verifier", pkce_verifier.secret()),
120            ])
121            .send()
122            .await?;
123
124        let status = response.status();
125        let body = response.text().await?;
126
127        if status.is_success() {
128            let token_response: OAuthTokenResponse = serde_json::from_str(&body)?;
129            Ok(token_response)
130        } else {
131            Err(format!("Token exchange failed: {}", body).into())
132        }
133    }
134
135    /// Refresh an access token using a refresh token
136    ///
137    /// When your access token expires, use the refresh token from the original
138    /// `exchange_code()` response to get a new one.
139    ///
140    /// # Parameters
141    /// - `refresh_token`: The refresh token from a previous token response
142    pub async fn refresh_token(
143        &self,
144        refresh_token: &str,
145    ) -> Result<OAuthTokenResponse, Box<dyn std::error::Error>> {
146        let client_id = env::var("KICK_CLIENT_ID")?;
147        let client_secret = env::var("KICK_CLIENT_SECRET")?;
148
149        let http_client = reqwest::Client::new();
150        let response = http_client
151            .post("https://id.kick.com/oauth/token")
152            .form(&[
153                ("grant_type", "refresh_token"),
154                ("refresh_token", refresh_token),
155                ("client_id", &client_id),
156                ("client_secret", &client_secret),
157            ])
158            .send()
159            .await?;
160
161        let status = response.status();
162        let body = response.text().await?;
163
164        if status.is_success() {
165            let token_response: OAuthTokenResponse = serde_json::from_str(&body)?;
166            Ok(token_response)
167        } else {
168            Err(format!("Token refresh failed: {}", body).into())
169        }
170    }
171
172    /// Revoke an access or refresh token
173    ///
174    /// Invalidates the given token so it can no longer be used.
175    ///
176    /// # Parameters
177    /// - `token`: The access token or refresh token to revoke
178    pub async fn revoke_token(
179        &self,
180        token: &str,
181    ) -> Result<(), Box<dyn std::error::Error>> {
182        let client_id = env::var("KICK_CLIENT_ID")?;
183        let client_secret = env::var("KICK_CLIENT_SECRET")?;
184
185        let http_client = reqwest::Client::new();
186        let response = http_client
187            .post("https://id.kick.com/oauth/revoke")
188            .form(&[
189                ("token", token),
190                ("client_id", &client_id),
191                ("client_secret", &client_secret),
192            ])
193            .send()
194            .await?;
195
196        let status = response.status();
197        if status.is_success() {
198            Ok(())
199        } else {
200            let body = response.text().await?;
201            Err(format!("Token revocation failed: {}", body).into())
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_oauth_from_env() {
212        // This will fail if env vars aren't set - that's expected
213        // To test: set env vars first, then run `cargo test`
214        dotenvy::dotenv().ok();
215
216        match KickOAuth::from_env() {
217            Ok(oauth) => {
218                let scopes = vec!["user:read", "channel:read"];
219                let (url, _csrf, _verifier) = oauth.get_authorization_url(scopes);
220                println!("Auth URL: {}", url);
221                assert!(url.contains("kick.com"));
222                assert!(url.contains("code_challenge")); // Verify PKCE is included
223            }
224            Err(e) => {
225                println!("Expected failure (env vars not set): {}", e);
226            }
227        }
228    }
229}