use anyhow::{anyhow, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; use crate::config::AuthConfig; mod state; use crate::auth::state::OAuthStateStore; /// 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, } /// OOB authorization result #[derive(Debug, Serialize)] pub struct OobAuthorizationResult { pub authorization_url: String, pub state: String, pub expires_at: chrono::DateTime, } /// Token verification result #[derive(Debug, Serialize)] pub struct TokenVerificationResult { pub gamer: GamerRecord, pub scope: String, } /// Record of a gamer in the system #[derive(Debug, Clone, Serialize, poem_openapi::Object)] pub struct GamerRecord { pub id: String, pub itchio_user_id: i64, pub username: String, pub access_token: String, } /// Auth service for itch.io OAuth (OOB flow) pub struct AuthService { client: Client, config: AuthConfig, pool: SqlitePool, state_store: OAuthStateStore, } impl AuthService { pub fn new(config: AuthConfig, pool: SqlitePool) -> Self { let state_store = OAuthStateStore::new(pool.clone()); Self { client: Client::new(), config, pool, state_store, } } /// Generate OOB authorization URL for itch.io OAuth pub async fn get_authorization_url(&self) -> Result { if self.config.itchio_client_id.is_empty() { return Err(anyhow!("itch.io OAuth not configured")); } let state = uuid::Uuid::new_v4().to_string(); let expires_at = chrono::Utc::now() + chrono::Duration::minutes(30); self.state_store.create(&state, expires_at).await?; let scope = if self.config.itchio_oauth_scope.is_empty() { "profile:me".to_string() } else { self.config.itchio_oauth_scope.clone() }; let params = [ ("client_id", self.config.itchio_client_id.as_str()), ("scope", &scope), ("redirect_uri", "urn:ietf:wg:oauth:2.0:oob"), ("response_type", "token"), ("state", &state), ]; let query_string = serde_urlencoded::to_string(¶ms) .map_err(|e| anyhow!("Failed to encode params: {}", e))?; let authorization_url = format!("{}?{}", self.config.itchio_authorization_url, query_string); Ok(OobAuthorizationResult { authorization_url, state, expires_at, }) } /// Verify token from itch.io and create/link gamer pub async fn verify_and_link_token( &self, access_token: &str, state: &str, ) -> Result { // Validate and consume the state (ensures single use, not expired) self.state_store.validate_and_consume(state).await?; // Validate token by fetching user info let itch_user = self.get_itch_user(access_token).await?; // Upsert gamer in database let gamer_id = self.upsert_gamer(&itch_user).await?; Ok(TokenVerificationResult { gamer: GamerRecord { id: gamer_id, itchio_user_id: itch_user.id, username: itch_user.username.clone(), access_token: access_token.to_string(), }, scope: "profile:me".to_string(), }) } /// Get user info from itch.io pub async fn get_itch_user(&self, access_token: &str) -> Result { let response = self .client .get("https://api.itch.io/profile") .header("Authorization", format!("Bearer {}", access_token)) .send() .await? .json::() .await?; Ok(response) } /// Register or update a gamer in the database async fn upsert_gamer(&self, itch_user: &ItchUser) -> Result { let id = uuid::Uuid::new_v4().to_string(); sqlx::query( r#" INSERT INTO gamers (id, itchio_user_id, username) VALUES (?, ?, ?) ON CONFLICT (itchio_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 (if valid) pub async fn verify_token(&self, access_token: &str) -> Result> { let itch_user = match self.get_itch_user(access_token).await { Ok(user) => user, Err(_) => return Ok(None), }; let gamer_id = self.upsert_gamer(&itch_user).await?; Ok(Some(GamerRecord { id: gamer_id, itchio_user_id: itch_user.id, username: itch_user.username, access_token: access_token.to_string(), })) } }