Game sync and live services for independent game developers (targeting itch.io)
1use anyhow::{anyhow, Result};
2use chrono::{DateTime, Utc};
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use sqlx::{Row, SqlitePool};
6use std::sync::Arc;
7use tokio::sync::RwLock;
8use uuid::Uuid;
9
10use crate::config::AuthConfig;
11
12/// Itch.io OAuth device code response
13#[derive(Debug, Deserialize, Serialize)]
14pub struct DeviceCodeResponse {
15 pub device_code: String,
16 pub user_code: String,
17 pub verification_url: String,
18 pub expires_in: u64,
19 pub interval: u64,
20}
21
22/// Itch.io token response
23#[derive(Debug, Deserialize, Serialize)]
24pub struct TokenResponse {
25 pub access_token: String,
26 pub token_type: String,
27 pub expires_in: Option<u64>,
28 pub refresh_token: Option<String>,
29 pub scope: Option<String>,
30}
31
32/// Itch.io user info response
33#[derive(Debug, Deserialize, Serialize)]
34pub struct ItchUser {
35 pub id: i64,
36 pub username: String,
37 pub display_name: Option<String>,
38 pub url: String,
39}
40
41/// Device code session stored in memory
42pub struct DeviceSession {
43 pub device_code: String,
44 pub user_code: String,
45 pub expires_at: DateTime<Utc>,
46 pub interval: u64,
47 pub poll_count: u32,
48}
49
50/// OAuth state managed in memory
51pub struct OAuthState {
52 pub device_session: Option<DeviceSession>,
53 pub pending_user_id: Option<i64>,
54 pub access_token: Option<String>,
55}
56
57impl OAuthState {
58 pub fn new() -> Self {
59 Self {
60 device_session: None,
61 pending_user_id: None,
62 access_token: None,
63 }
64 }
65}
66
67/// Auth service for itch.io OAuth
68pub struct AuthService {
69 client: Client,
70 config: AuthConfig,
71 pool: SqlitePool,
72 state: Arc<RwLock<OAuthState>>,
73}
74
75impl AuthService {
76 pub fn new(config: AuthConfig, pool: SqlitePool) -> Self {
77 Self {
78 client: Client::new(),
79 config,
80 pool,
81 state: Arc::new(RwLock::new(OAuthState::new())),
82 }
83 }
84
85 /// Start the device code flow - return user code and verification URL
86 pub async fn start_device_flow(&self) -> Result<DeviceCodeResponse> {
87 let params = [
88 ("client_id", self.config.itchio_client_id.as_str()),
89 ("scope", "profile:me"),
90 ];
91
92 let response = self
93 .client
94 .post("https://itch.io/api-integrations/request-token")
95 .form(¶ms)
96 .send()
97 .await?
98 .json::<DeviceCodeResponse>()
99 .await?;
100
101 // Store the device session
102 let session = DeviceSession {
103 device_code: response.device_code.clone(),
104 user_code: response.user_code.clone(),
105 expires_at: Utc::now() + chrono::Duration::seconds(response.expires_in as i64),
106 interval: response.interval,
107 poll_count: 0,
108 };
109
110 let mut state = self.state.write().await;
111 state.device_session = Some(session);
112
113 Ok(response)
114 }
115
116 /// Poll for token completion
117 pub async fn poll_for_token(&self) -> Result<TokenResponse> {
118 let device_code = {
119 let state = self.state.read().await;
120 state
121 .device_session
122 .as_ref()
123 .map(|s| s.device_code.clone())
124 .ok_or_else(|| anyhow!("No device code session active"))?
125 };
126
127 let params = [
128 ("client_id", self.config.itchio_client_id.as_str()),
129 ("client_secret", &self.config.itchio_client_id), // itch.io uses client_id as secret in some flows
130 ("code", &device_code),
131 ("grant_type", "device_token"),
132 ];
133
134 let response = self
135 .client
136 .post("https://itch.io/api-integrations/request-token")
137 .form(¶ms)
138 .send()
139 .await?
140 .json::<TokenResponse>()
141 .await?;
142
143 // Store access token
144 let mut state = self.state.write().await;
145 state.access_token = Some(response.access_token.clone());
146 state.device_session = None;
147
148 Ok(response)
149 }
150
151 /// Get user info from itch.io
152 pub async fn get_itch_user(&self, access_token: &str) -> Result<ItchUser> {
153 let response = self
154 .client
155 .get("https://itch.io/api-integrations/me")
156 .header("Authorization", format!("Bearer {}", access_token))
157 .send()
158 .await?
159 .json::<ItchUser>()
160 .await?;
161
162 Ok(response)
163 }
164
165 /// Exchange device code for final token and register/update user
166 pub async fn complete_device_flow(&self) -> Result<GamerRecord> {
167 let token_response = self.poll_for_token().await?;
168 let itch_user = self.get_itch_user(&token_response.access_token).await?;
169
170 // Upsert gamer in database
171 let gamer_id = self.upsert_gamer(&itch_user).await?;
172
173 Ok(GamerRecord {
174 id: gamer_id,
175 itch_user_id: itch_user.id,
176 username: itch_user.username,
177 access_token: token_response.access_token,
178 })
179 }
180
181 /// Register or update a gamer in the database
182 async fn upsert_gamer(&self, itch_user: &ItchUser) -> Result<String> {
183 let id = Uuid::new_v4().to_string();
184
185 sqlx::query(
186 r#"
187 INSERT INTO gamers (id, itch_user_id, username)
188 VALUES (?, ?, ?)
189 ON CONFLICT (itch_user_id) DO UPDATE SET
190 username = excluded.username,
191 updated_at = CURRENT_TIMESTAMP
192 "#,
193 )
194 .bind(&id)
195 .bind(itch_user.id)
196 .bind(&itch_user.username)
197 .execute(&self.pool)
198 .await?;
199
200 Ok(id)
201 }
202
203 /// Verify an access token and return the gamer record
204 pub async fn verify_token(&self, access_token: &str) -> Result<Option<GamerRecord>> {
205 let itch_user = self.get_itch_user(access_token).await.ok();
206
207 match itch_user {
208 Some(user) => {
209 let gamer = self.get_gamer_by_itch_id(user.id).await?;
210 Ok(Some(GamerRecord {
211 id: gamer.id,
212 itch_user_id: user.id,
213 username: user.username,
214 access_token: access_token.to_string(),
215 }))
216 }
217 None => Ok(None),
218 }
219 }
220
221 /// Get gamer by itch.io user ID
222 async fn get_gamer_by_itch_id(&self, itch_user_id: i64) -> Result<GamerRecord> {
223 let row = sqlx::query("SELECT id, itch_user_id, username FROM gamers WHERE itch_user_id = ?")
224 .bind(itch_user_id)
225 .fetch_optional(&self.pool)
226 .await?
227 .ok_or_else(|| anyhow!("Gamer not found"))?;
228
229 Ok(GamerRecord {
230 id: row.try_get("id")?,
231 itch_user_id: row.try_get("itch_user_id")?,
232 username: row.try_get("username").unwrap_or_default(),
233 access_token: String::new(),
234 })
235 }
236}
237
238/// Record of a gamer in the system
239#[derive(poem_openapi::Object)]
240pub struct GamerRecord {
241 pub id: String,
242 pub itch_user_id: i64,
243 pub username: String,
244 pub access_token: String,
245}