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#[derive(Debug, Clone, Deserialize)]
13pub struct OAuthTokenResponse {
14 pub access_token: String,
16
17 pub refresh_token: Option<String>,
19
20 pub expires_in: u64,
22
23 pub scope: String,
25
26 pub token_type: String,
28}
29
30pub struct KickOAuth {
32 client: BasicClient,
33 client_id: String,
34 client_secret: String,
35}
36
37impl KickOAuth {
38 pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
45 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 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 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 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 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 pub fn get_authorization_url(&self, scopes: Vec<&str>) -> (String, CsrfToken, PkceCodeVerifier) {
105 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 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 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 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 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 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 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")); }
284 Err(e) => {
285 println!("Expected failure (env vars not set): {}", e);
286 }
287 }
288 }
289}