Skip to main content

supreme_fs/game_storage/online/
auth.rs

1//! Account/auth API client for the logged-in player mode.
2//!
3//! The device-link flow (design A): the launcher generates a one-time
4//! link token, opens the server's auth page in the browser with it, and
5//! polls until the player links an account there. The wire actions:
6//!
7//! | Method (this module)                 | Action        | Shape |
8//! |--------------------------------------|---------------|-------|
9//! | [`GameStorage::auth_info`]           | `auth_info`   | `{auth_page_url}` (GET) |
10//! | [`GameStorage::auth_poll`]           | `auth_poll`   | `{state: pending\|linked\|expired}` (GET) |
11//! | [`GameStorage::auth_account`]        | `auth_account`| `{account}` (GET, key-authed) |
12//! | [`GameStorage::fetch_auth_avatar`]   | `auth_avatar` | raw image bytes (GET) |
13//! | [`GameStorage::revoke_auth_key`]     | `auth_revoke` | `{status}` (POST) |
14//!
15//! `auth_poll`'s `linked` state carries the account and the device's
16//! `auth_key` exactly once; the key is a per-device secret the rooms
17//! actions attach as the [`RoomIdentity`](super::rooms::RoomIdentity)
18//! body fields and the mod's managed push URLs carry as query params.
19//! Guests keep working unchanged; the server refuses a guest whose
20//! player name matches a registered nickname, that refusal arrives
21//! verbatim through the shared envelope handling.
22//!
23//! The GET endpoints take their inputs as query params (the auth page
24//! and the avatar are linkable/bookmarkable resources); everything else
25//! (envelope, error classification, verbatim messages) matches the
26//! rooms actions exactly, see [`GameStorage::post_room_action`].
27
28use 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
35/// Build one auth GET URL: `?action=<name>` plus the extra params
36/// (pure, so the wire contract is unit-testable). Every value the auth
37/// GETs carry is URL-safe by construction (hex tokens, decimal ids,
38/// hex shas), so no encoding happens here; a future param that isn't
39/// must encode itself before it reaches this builder.
40fn 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/// One account as the auth API returns it. `nickname` is the in-game
49/// display name (the game's own 1-9 letter rule, mutable);
50/// `avatar_sha256` doubles as the avatar cache key and the `v=` param of
51/// [`GameStorage::fetch_auth_avatar`] (`None` = no avatar uploaded).
52#[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/// The auth page location from `auth_info`: the SERVER's own URL (it
63/// knows how it is exposed to browsers), the launcher never assembles it.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct AuthInfo {
66    pub auth_page_url: String,
67}
68
69/// The `auth_poll` tri-state.
70#[derive(Debug, Clone, PartialEq)]
71pub enum AuthPoll {
72    /// The token is known but no account linked yet; keep polling.
73    Pending,
74    /// An account linked: its data plus this device's one-time auth key.
75    Linked {
76        account: AccountDto,
77        auth_key: String,
78    },
79    /// The token expired (or was never registered): the flow must restart
80    /// with a fresh token.
81    Expired,
82}
83
84/// Envelope for `auth_info`.
85#[derive(serde::Deserialize)]
86struct AuthInfoEnvelope {
87    auth_page_url: String,
88}
89
90/// Envelope for `auth_poll`.
91#[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/// Envelope for `auth_account`: `{account: {…}}` (the standard success
101/// envelope's payload field).
102#[derive(serde::Deserialize)]
103struct AccountEnvelope {
104    account: AccountDto,
105}
106
107/// Parse one `auth_poll` response body (already-JSON) into the tri-state
108/// (pure, so the wire contract is unit-testable). A `linked` state
109/// without its account or key is a protocol error, not a state.
110fn 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    /// GET one auth action (`?action=<name>` plus `query`) and decode its
135    /// JSON payload. Same envelope and error handling as the POST
136    /// actions ([`GameStorage::post_room_action`]): an error envelope's
137    /// message surfaces verbatim, non-envelope non-2xx responses go
138    /// through the shared classifier (an old backend's `Unknown action`
139    /// becomes the server-version-mismatch error).
140    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            // Same classification as the POST actions: the error envelope
172            // is the server's refusal copy on ANY status, only the
173            // version-mismatch signatures (an old backend's `Unknown
174            // action`, a missing endpoint) keep their classified error.
175            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    /// Ask the server where its auth page lives (`auth_info`). The page
191    /// URL is the server's own (like the results URL), so the launcher
192    /// never has to guess how the backend is exposed to browsers.
193    ///
194    /// # Errors
195    ///
196    /// See [`GameStorage::get_auth_action`]; a backend predating the
197    /// auth API answers a version-mismatch signature.
198    pub fn auth_info(&self) -> Result<AuthInfo, HybridStorageError> {
199        Ok(self
200            .get_auth_action::<AuthInfoEnvelope>("auth_info", &[])?
201            .into_info())
202    }
203
204    /// Poll the one-time link `token` (`auth_poll`). The token is
205    /// 64 hex chars (the launcher generated it), safe in a URL unencoded.
206    ///
207    /// # Errors
208    ///
209    /// See [`GameStorage::get_auth_action`].
210    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    /// Re-read the linked account through its device key
217    /// (`auth_account`, GET with `user_id` + `auth_key` - the same pair
218    /// the room pushes carry, so the pair is URL-safe by construction
219    /// here too: a decimal id and a 64-hex key). The launcher caches
220    /// the account record from link time while the nickname and the
221    /// avatar are mutable; this is the refresh that follows a rename
222    /// (and an avatar change) without a re-link - the server links
223    /// uploads by the key's identity, the pushed name is display only.
224    ///
225    /// # Errors
226    ///
227    /// See [`GameStorage::get_auth_action`]; a stale key answers the
228    /// same refusal the room pushes give, verbatim.
229    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    /// Fetch one account's avatar bytes (`auth_avatar`). `version_sha` is
242    /// the account's current `avatar_sha256` (the `v=` param, an
243    /// immutable-cache key: the same sha is always the same bytes); the
244    /// server answers raw image bytes, never JSON.
245    ///
246    /// # Errors
247    ///
248    /// Transport failures and non-2xx responses are errors; a backend
249    /// predating the action answers a version-mismatch signature.
250    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    /// Revoke this device's auth key (`auth_revoke`). Called on logout;
289    /// the key dies server-side, a stolen copy of `mods.json` stops
290    /// working. Fire-and-forget by design: a failed revoke (server gone,
291    /// network down) never blocks the local logout, the key still expires
292    /// server-side per the backend's own policy.
293    ///
294    /// # Errors
295    ///
296    /// See [`GameStorage::post_room_action`].
297    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/// Envelope for `auth_revoke`: the standard `{status: "success"}`
315/// envelope (the payload is empty, the status check lives in
316/// [`post_room_action`](GameStorage::post_room_action)).
317#[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        // avatar_sha256 is #[serde(default)]: an account without an
360        // avatar still parses.
361        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        // The plain action GET (auth_info): action only, nothing else.
378        assert_eq!(
379            auth_get_url("https://ssbz.example/index.php", "auth_info", &[]),
380            "https://ssbz.example/index.php?action=auth_info"
381        );
382        // The poll GET: the launcher-generated 64-hex token rides along
383        // verbatim (hex is URL-safe by construction).
384        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        // The avatar GET: decimal id + hex sha version key.
394        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        // The account refresh GET: the same identity pair the room
403        // pushes carry (decimal id + 64-hex device key, URL-safe).
404        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        // The refresh answer: the success envelope's payload field wraps
418        // the same account shape the link flow cached.
419        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        // An answer without the account field is a protocol error.
428        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        // linked without its payload: a protocol error, not a state.
436        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        // An unknown state is a version skew, surfaced as an error.
446        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}