use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use reqwest::Client; use serde::{Deserialize, Serialize}; use sqlx::{Row, SqlitePool}; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; use crate::config::AuthConfig; /// Itch.io OAuth device code response #[derive(Debug, Deserialize, Serialize)] pub struct DeviceCodeResponse { pub device_code: String, pub user_code: String, pub verification_url: String, pub expires_in: u64, pub interval: u64, } /// Itch.io token response #[derive(Debug, Deserialize, Serialize)] pub struct TokenResponse { pub access_token: String, pub token_type: String, pub expires_in: Option, pub refresh_token: Option, pub scope: Option, } /// Itch.io user info response #[derive(Debug, Deserialize, Serialize)] pub struct ItchUser { pub id: i64, pub username: String, pub display_name: Option, pub url: String, } /// Device code session stored in memory pub struct DeviceSession { pub device_code: String, pub user_code: String, pub expires_at: DateTime, pub interval: u64, pub poll_count: u32, } /// OAuth state managed in memory pub struct OAuthState { pub device_session: Option, pub pending_user_id: Option, pub access_token: Option, } impl OAuthState { pub fn new() -> Self { Self { device_session: None, pending_user_id: None, access_token: None, } } } /// Auth service for itch.io OAuth pub struct AuthService { client: Client, config: AuthConfig, pool: SqlitePool, state: Arc>, } impl AuthService { pub fn new(config: AuthConfig, pool: SqlitePool) -> Self { Self { client: Client::new(), config, pool, state: Arc::new(RwLock::new(OAuthState::new())), } } /// Start the device code flow - return user code and verification URL pub async fn start_device_flow(&self) -> Result { let params = [ ("client_id", self.config.itchio_client_id.as_str()), ("scope", "profile:me"), ]; let response = self .client .post("https://itch.io/api-integrations/request-token") .form(¶ms) .send() .await? .json::() .await?; // Store the device session let session = DeviceSession { device_code: response.device_code.clone(), user_code: response.user_code.clone(), expires_at: Utc::now() + chrono::Duration::seconds(response.expires_in as i64), interval: response.interval, poll_count: 0, }; let mut state = self.state.write().await; state.device_session = Some(session); Ok(response) } /// Poll for token completion pub async fn poll_for_token(&self) -> Result { let device_code = { let state = self.state.read().await; state .device_session .as_ref() .map(|s| s.device_code.clone()) .ok_or_else(|| anyhow!("No device code session active"))? }; let params = [ ("client_id", self.config.itchio_client_id.as_str()), ("client_secret", &self.config.itchio_client_id), // itch.io uses client_id as secret in some flows ("code", &device_code), ("grant_type", "device_token"), ]; let response = self .client .post("https://itch.io/api-integrations/request-token") .form(¶ms) .send() .await? .json::() .await?; // Store access token let mut state = self.state.write().await; state.access_token = Some(response.access_token.clone()); state.device_session = None; Ok(response) } /// Get user info from itch.io pub async fn get_itch_user(&self, access_token: &str) -> Result { let response = self .client .get("https://itch.io/api-integrations/me") .header("Authorization", format!("Bearer {}", access_token)) .send() .await? .json::() .await?; Ok(response) } /// Exchange device code for final token and register/update user pub async fn complete_device_flow(&self) -> Result { let token_response = self.poll_for_token().await?; let itch_user = self.get_itch_user(&token_response.access_token).await?; // Upsert gamer in database let gamer_id = self.upsert_gamer(&itch_user).await?; Ok(GamerRecord { id: gamer_id, itch_user_id: itch_user.id, username: itch_user.username, access_token: token_response.access_token, }) } /// Register or update a gamer in the database async fn upsert_gamer(&self, itch_user: &ItchUser) -> Result { let id = Uuid::new_v4().to_string(); sqlx::query( r#" INSERT INTO gamers (id, itch_user_id, username) VALUES (?, ?, ?) ON CONFLICT (itch_user_id) DO UPDATE SET username = excluded.username, updated_at = CURRENT_TIMESTAMP "#, ) .bind(&id) .bind(itch_user.id) .bind(&itch_user.username) .execute(&self.pool) .await?; Ok(id) } /// Verify an access token and return the gamer record pub async fn verify_token(&self, access_token: &str) -> Result> { let itch_user = self.get_itch_user(access_token).await.ok(); match itch_user { Some(user) => { let gamer = self.get_gamer_by_itch_id(user.id).await?; Ok(Some(GamerRecord { id: gamer.id, itch_user_id: user.id, username: user.username, access_token: access_token.to_string(), })) } None => Ok(None), } } /// Get gamer by itch.io user ID async fn get_gamer_by_itch_id(&self, itch_user_id: i64) -> Result { let row = sqlx::query("SELECT id, itch_user_id, username FROM gamers WHERE itch_user_id = ?") .bind(itch_user_id) .fetch_optional(&self.pool) .await? .ok_or_else(|| anyhow!("Gamer not found"))?; Ok(GamerRecord { id: row.try_get("id")?, itch_user_id: row.try_get("itch_user_id")?, username: row.try_get("username").unwrap_or_default(), access_token: String::new(), }) } } /// Record of a gamer in the system #[derive(poem_openapi::Object)] pub struct GamerRecord { pub id: String, pub itch_user_id: i64, pub username: String, pub access_token: String, }