supreme_fs/game_storage/online/
auth.rs1use super::rooms::room_error_message;
29use super::util::{api_http_error, server_version_mismatch_signature};
30use crate::HybridStorageError;
31use crate::game_storage::GameStorage;
32use serde::de::DeserializeOwned;
33use std::io;
34
35fn auth_get_url(api_url: &str, action: &str, params: &[(&str, String)]) -> String {
41 let mut url = format!("{api_url}?action={action}");
42 for (key, value) in params {
43 url.push_str(&format!("&{key}={value}"));
44 }
45 url
46}
47
48#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
53pub struct AccountDto {
54 pub user_id: i64,
55 pub account_name: String,
56 pub nickname: String,
57 #[serde(default)]
58 pub avatar_sha256: Option<String>,
59 pub created_at: i64,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct AuthInfo {
66 pub auth_page_url: String,
67}
68
69#[derive(Debug, Clone, PartialEq)]
71pub enum AuthPoll {
72 Pending,
74 Linked {
76 account: AccountDto,
77 auth_key: String,
78 },
79 Expired,
82}
83
84#[derive(serde::Deserialize)]
86struct AuthInfoEnvelope {
87 auth_page_url: String,
88}
89
90#[derive(serde::Deserialize)]
92struct AuthPollEnvelope {
93 state: String,
94 #[serde(default)]
95 account: Option<AccountDto>,
96 #[serde(default)]
97 auth_key: Option<String>,
98}
99
100#[derive(serde::Deserialize)]
103struct AccountEnvelope {
104 account: AccountDto,
105}
106
107fn parse_auth_poll(value: &serde_json::Value) -> Result<AuthPoll, HybridStorageError> {
111 let envelope: AuthPollEnvelope = serde_json::from_value(value.clone())
112 .map_err(|e| HybridStorageError::IoError(io::Error::new(io::ErrorKind::InvalidData, e)))?;
113 match envelope.state.as_str() {
114 "pending" => Ok(AuthPoll::Pending),
115 "expired" => Ok(AuthPoll::Expired),
116 "linked" => {
117 let account = envelope.account.ok_or_else(|| {
118 HybridStorageError::UploadFailed(
119 "auth_poll linked without an account payload".to_owned(),
120 )
121 })?;
122 let auth_key = envelope.auth_key.ok_or_else(|| {
123 HybridStorageError::UploadFailed("auth_poll linked without an auth_key".to_owned())
124 })?;
125 Ok(AuthPoll::Linked { account, auth_key })
126 }
127 other => Err(HybridStorageError::UploadFailed(format!(
128 "auth_poll returned an unknown state `{other}`"
129 ))),
130 }
131}
132
133impl GameStorage {
134 fn get_auth_action<T: DeserializeOwned>(
141 &self,
142 action: &str,
143 params: &[(&str, String)],
144 ) -> Result<T, HybridStorageError> {
145 let client = self
146 .client
147 .as_ref()
148 .ok_or_else(|| io::Error::other("Online storage not configured"))?;
149 let api_url = self
150 .api_url
151 .as_ref()
152 .ok_or_else(|| io::Error::other("Online storage not configured"))?;
153
154 let url = auth_get_url(api_url, action, params);
155 let mut request = client.get(&url);
156 if let Some(token) = &self.token {
157 request = request.header("X-SSBZ-Token", token);
158 }
159 let response = request.call()?;
160
161 let value: serde_json::Value = if response.status().is_success() {
162 response.into_body().read_json().map_err(|e| {
163 HybridStorageError::IoError(io::Error::new(
164 io::ErrorKind::InvalidData,
165 format!("auth API returned invalid JSON: {e}"),
166 ))
167 })?
168 } else {
169 let status = response.status().as_u16();
170 let body = response.into_body().read_to_string().unwrap_or_default();
171 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body)
176 && let Some(message) = room_error_message(&parsed)
177 && !server_version_mismatch_signature(status, Some(message.as_str()), true)
178 {
179 return Err(HybridStorageError::UploadFailed(message));
180 }
181 return Err(api_http_error(status, &body));
182 };
183 if let Some(message) = room_error_message(&value) {
184 return Err(HybridStorageError::UploadFailed(message));
185 }
186 serde_json::from_value(value)
187 .map_err(|e| HybridStorageError::IoError(io::Error::new(io::ErrorKind::InvalidData, e)))
188 }
189
190 pub fn auth_info(&self) -> Result<AuthInfo, HybridStorageError> {
199 Ok(self
200 .get_auth_action::<AuthInfoEnvelope>("auth_info", &[])?
201 .into_info())
202 }
203
204 pub fn auth_poll(&self, token: &str) -> Result<AuthPoll, HybridStorageError> {
211 let value =
212 self.get_auth_action::<serde_json::Value>("auth_poll", &[("token", token.to_owned())])?;
213 parse_auth_poll(&value)
214 }
215
216 pub fn auth_account(&self, user_id: i64, auth_key: &str) -> Result<AccountDto, HybridStorageError> {
230 Ok(self
231 .get_auth_action::<AccountEnvelope>(
232 "auth_account",
233 &[
234 ("user_id", user_id.to_string()),
235 ("auth_key", auth_key.to_owned()),
236 ],
237 )?
238 .account)
239 }
240
241 pub fn fetch_auth_avatar(
251 &self,
252 user_id: i64,
253 version_sha: &str,
254 ) -> Result<Vec<u8>, HybridStorageError> {
255 let client = self
256 .client
257 .as_ref()
258 .ok_or_else(|| io::Error::other("Online storage not configured"))?;
259 let api_url = self
260 .api_url
261 .as_ref()
262 .ok_or_else(|| io::Error::other("Online storage not configured"))?;
263
264 let url = auth_get_url(
265 api_url,
266 "auth_avatar",
267 &[
268 ("user_id", user_id.to_string()),
269 ("v", version_sha.to_owned()),
270 ],
271 );
272 let mut request = client.get(&url);
273 if let Some(token) = &self.token {
274 request = request.header("X-SSBZ-Token", token);
275 }
276 let response = request.call()?;
277 if !response.status().is_success() {
278 let status = response.status().as_u16();
279 let body = response.into_body().read_to_string().unwrap_or_default();
280 return Err(api_http_error(status, &body));
281 }
282 response
283 .into_body()
284 .read_to_vec()
285 .map_err(|e| HybridStorageError::IoError(io::Error::other(e)))
286 }
287
288 pub fn revoke_auth_key(&self, auth_key: &str) -> Result<(), HybridStorageError> {
298 self.post_room_action::<AuthRevokedEnvelope>(
299 "auth_revoke",
300 serde_json::json!({ "auth_key": auth_key }),
301 )
302 .map(|_| ())
303 }
304}
305
306impl AuthInfoEnvelope {
307 fn into_info(self) -> AuthInfo {
308 AuthInfo {
309 auth_page_url: self.auth_page_url,
310 }
311 }
312}
313
314#[derive(serde::Deserialize)]
318struct AuthRevokedEnvelope {}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn auth_poll_parses_the_three_states() {
326 let pending: serde_json::Value =
327 serde_json::from_str(r#"{"status":"success","state":"pending"}"#).unwrap();
328 assert_eq!(parse_auth_poll(&pending).unwrap(), AuthPoll::Pending);
329
330 let expired: serde_json::Value =
331 serde_json::from_str(r#"{"status":"success","state":"expired"}"#).unwrap();
332 assert_eq!(parse_auth_poll(&expired).unwrap(), AuthPoll::Expired);
333
334 let linked: serde_json::Value = serde_json::from_str(
335 r#"{"status":"success","state":"linked",
336 "account":{"user_id":7,"account_name":"xinfe","nickname":"Xinfe",
337 "avatar_sha256":null,"created_at":1793138336},
338 "auth_key":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}"#,
339 )
340 .unwrap();
341 assert_eq!(
342 parse_auth_poll(&linked).unwrap(),
343 AuthPoll::Linked {
344 account: AccountDto {
345 user_id: 7,
346 account_name: "xinfe".to_owned(),
347 nickname: "Xinfe".to_owned(),
348 avatar_sha256: None,
349 created_at: 1_793_138_336,
350 },
351 auth_key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
352 .to_owned(),
353 }
354 );
355 }
356
357 #[test]
358 fn auth_poll_linked_with_an_avatar_sha_and_fields_omitted_by_old_servers() {
359 let linked: serde_json::Value = serde_json::from_str(
362 r#"{"status":"success","state":"linked",
363 "account":{"user_id":8,"account_name":"rider","nickname":"GhostRdr",
364 "created_at":1},
365 "auth_key":"key"}"#,
366 )
367 .unwrap();
368 let AuthPoll::Linked { account, auth_key } = parse_auth_poll(&linked).unwrap() else {
369 panic!("expected linked");
370 };
371 assert_eq!(account.avatar_sha256, None);
372 assert_eq!(auth_key, "key");
373 }
374
375 #[test]
376 fn auth_get_urls_carry_the_action_and_url_safe_params() {
377 assert_eq!(
379 auth_get_url("https://ssbz.example/index.php", "auth_info", &[]),
380 "https://ssbz.example/index.php?action=auth_info"
381 );
382 let token = "0123456789abcdef".repeat(4);
385 assert_eq!(
386 auth_get_url(
387 "https://ssbz.example/index.php",
388 "auth_poll",
389 &[("token", token.clone())]
390 ),
391 format!("https://ssbz.example/index.php?action=auth_poll&token={token}")
392 );
393 assert_eq!(
395 auth_get_url(
396 "https://ssbz.example/index.php",
397 "auth_avatar",
398 &[("user_id", 7.to_string()), ("v", "deadbeef".to_owned())]
399 ),
400 "https://ssbz.example/index.php?action=auth_avatar&user_id=7&v=deadbeef"
401 );
402 let key = "0123456789abcdef".repeat(4);
405 assert_eq!(
406 auth_get_url(
407 "https://ssbz.example/index.php",
408 "auth_account",
409 &[("user_id", 7.to_string()), ("auth_key", key.clone())]
410 ),
411 format!("https://ssbz.example/index.php?action=auth_account&user_id=7&auth_key={key}")
412 );
413 }
414
415 #[test]
416 fn auth_account_envelope_parses_the_account_payload() {
417 let value: serde_json::Value = serde_json::from_str(
420 r#"{"status":"success",
421 "account":{"user_id":7,"account_name":"xinfe","nickname":"NewName",
422 "avatar_sha256":"aabbcc","created_at":1793138336}}"#,)
423 .unwrap();
424 let envelope: AccountEnvelope = serde_json::from_value(value).unwrap();
425 assert_eq!(envelope.account.nickname, "NewName");
426 assert_eq!(envelope.account.avatar_sha256.as_deref(), Some("aabbcc"));
427 let bare: serde_json::Value =
429 serde_json::from_str(r#"{"status":"success"}"#).unwrap();
430 assert!(serde_json::from_value::<AccountEnvelope>(bare).is_err());
431 }
432
433 #[test]
434 fn auth_poll_refuses_incomplete_or_unknown_states() {
435 let bare: serde_json::Value =
437 serde_json::from_str(r#"{"status":"success","state":"linked"}"#).unwrap();
438 assert!(parse_auth_poll(&bare).is_err());
439 let no_key: serde_json::Value = serde_json::from_str(
440 r#"{"status":"success","state":"linked",
441 "account":{"user_id":7,"account_name":"a","nickname":"b","created_at":1}}"#,
442 )
443 .unwrap();
444 assert!(parse_auth_poll(&no_key).is_err());
445 let unknown: serde_json::Value =
447 serde_json::from_str(r#"{"status":"success","state":"maybe"}"#).unwrap();
448 assert!(parse_auth_poll(&unknown).is_err());
449 }
450}