sf_api/
sso.rs

1use std::{
2    collections::{HashMap, HashSet},
3    sync::Arc,
4};
5
6use chrono::{Local, NaiveDateTime};
7use reqwest::{header::*, Client};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use tokio::sync::Mutex;
11use url::Url;
12
13use crate::{
14    error::SFError,
15    misc::sha1_hash,
16    session::{reqwest_client, ConnectionOptions, PWHash, Session},
17};
18
19#[derive(Debug)]
20#[allow(dead_code)]
21enum SSOAuthData {
22    SF { pw_hash: PWHash },
23    Google,
24    Steam,
25}
26
27#[derive(Debug)]
28pub struct SFAccount {
29    pub(super) username: String,
30    auth: SSOAuthData,
31    pub(super) session: AccountSession,
32    pub(super) client: Client,
33    pub(super) options: ConnectionOptions,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct AccountSession {
38    pub(super) uuid: String,
39    pub(super) bearer_token: String,
40}
41
42// This could just be a Login/Characters thing, but who knows what else will be
43// integrated into this API. Maybe Register?
44#[derive(Debug)]
45enum APIRequest {
46    Get,
47    Post {
48        parameters: Vec<&'static str>,
49        form_data: HashMap<String, String>,
50    },
51}
52
53#[derive(Debug, Default, Serialize, Deserialize)]
54pub struct SSOCharacter {
55    pub(super) id: String,
56    pub(super) name: String,
57    pub(super) server_id: i32,
58}
59impl SFAccount {
60    /// Returns the username of this S&F account
61    #[must_use]
62    pub fn username(&self) -> &str {
63        &self.username
64    }
65
66    /// Initializes a `SFAccount` by logging the user in using the supplied
67    /// clear text credentials
68    ///
69    /// # Errors
70    /// May return basically every possible `SFError` variant, because we are
71    /// both sending a command and parsing the result
72    // TODO: Document what wring pw returns
73    pub async fn login(
74        username: String,
75        password: String,
76    ) -> Result<SFAccount, SFError> {
77        Self::login_with_options(
78            username,
79            password,
80            ConnectionOptions::default(),
81        )
82        .await
83    }
84
85    /// Initializes a `SFAccount` by logging the user in using the supplied
86    /// clear text credentials and the provided options to use for the
87    /// communication with the server
88    ///
89    /// # Errors
90    /// May return basically every possible `SFError` variant, because we are
91    /// both sending a command and parsing the result
92    pub async fn login_with_options(
93        username: String,
94        password: String,
95        options: ConnectionOptions,
96    ) -> Result<SFAccount, SFError> {
97        let pw_hash = PWHash::new(&password);
98        Self::login_hashed_with_options(username, pw_hash, options).await
99    }
100
101    /// Initializes a `SFAccount` by logging the user in using the hashed
102    /// password
103    ///
104    /// # Errors
105    /// May return basically every possible `SFError` variant, because we are
106    /// both sending a command and parsing the result
107    pub async fn login_hashed(
108        username: String,
109        pw_hash: PWHash,
110    ) -> Result<SFAccount, SFError> {
111        Self::login_hashed_with_options(
112            username,
113            pw_hash,
114            ConnectionOptions::default(),
115        )
116        .await
117    }
118
119    /// Initializes a `SFAccount` by logging the user in using the hashed
120    /// password and the provided options to use for communication
121    ///
122    /// # Errors
123    /// May return basically every possible `SFError` variant, because we are
124    /// both sending a command and parsing the result
125    pub async fn login_hashed_with_options(
126        username: String,
127        pw_hash: PWHash,
128        options: ConnectionOptions,
129    ) -> Result<SFAccount, SFError> {
130        let mut tmp_self = Self {
131            username,
132            auth: SSOAuthData::SF { pw_hash },
133            session: AccountSession {
134                uuid: String::new(),
135                bearer_token: String::new(),
136            },
137            client: reqwest_client(&options).ok_or(SFError::ConnectionError)?,
138            options,
139        };
140
141        tmp_self.refresh_login().await?;
142        Ok(tmp_self)
143    }
144
145    /// Refreshes the SSO session by logging in again with the stored
146    /// credentials. This can be used when the server removed our session
147    /// either for being connected too long, or the server was
148    /// restarted/cache cleared and forgot us
149    ///
150    /// # Errors
151    /// `InvalidRequest` if you call this with anything else then a SSO/SF
152    /// account character. Even Google/Steam accounts will not work.
153    pub async fn refresh_login(&mut self) -> Result<(), SFError> {
154        let SSOAuthData::SF { pw_hash } = &self.auth else {
155            // I do not think there is a way to reauth without going through
156            // the SSO process again for these
157            return Err(SFError::InvalidRequest(
158                "Refreshing the SSO-login is only supported for SSO-Accounts",
159            ));
160        };
161
162        let mut form_data = HashMap::new();
163        form_data.insert("username".to_string(), self.username.clone());
164        form_data.insert(
165            "password".to_string(),
166            sha1_hash(&(pw_hash.get().to_string() + "0")),
167        );
168
169        let res = self
170            .send_api_request(
171                "json/login",
172                APIRequest::Post {
173                    parameters: vec![
174                        "client_id=i43nwwnmfc5tced4jtuk4auuygqghud2yopx",
175                        "auth_type=access_token",
176                    ],
177                    form_data,
178                },
179            )
180            .await?;
181
182        #[allow(clippy::indexing_slicing)]
183        let (Some(bearer_token), Some(uuid)) = (
184            val_to_string(&res["token"]["access_token"]),
185            val_to_string(&res["account"]["uuid"]),
186        ) else {
187            return Err(SFError::ParsingError(
188                "missing auth value in api response",
189                format!("{res:?}"),
190            ));
191        };
192
193        self.session = AccountSession { uuid, bearer_token };
194
195        Ok(())
196    }
197
198    /// Queries the SSO for all characters associated with this account. This
199    /// consumes the Account, as the character sessions may need to refresh
200    /// the accounts session, which they are only allowed to do, if they own it
201    /// (in an Arc<Mutex<_>>) and there should be no need to keep the account
202    /// around anyways
203    ///
204    /// # Errors
205    /// May return `ParsingError` if the server changed it's API, or
206    /// `ConnectionError`, if the server could not be reached. The characters
207    /// in the Vec may be `InvalidRequest`, iff the server the server the
208    /// character would be on could not be determined
209    pub async fn characters(
210        self,
211    ) -> Result<Vec<Result<Session, SFError>>, SFError> {
212        // This could be passed in as an argument in case of multiple SSO
213        // accounts to safe on requests, but I dont think people have multiple
214        // and this is way easier
215        let server_lookup =
216            ServerLookup::fetch_with_client(&self.client).await?;
217        let mut res = self
218            .send_api_request("json/client/characters", APIRequest::Get)
219            .await?;
220
221        #[allow(clippy::indexing_slicing)]
222        let characters: Vec<SSOCharacter> =
223            serde_json::from_value(res["characters"].take()).map_err(|_| {
224                SFError::ParsingError("missing json value ", String::new())
225            })?;
226
227        let account = Arc::new(Mutex::new(self));
228
229        let mut chars = vec![];
230        for char in characters {
231            chars.push(
232                Session::from_sso_char(char, account.clone(), &server_lookup)
233                    .await,
234            );
235        }
236
237        Ok(chars)
238    }
239
240    async fn send_api_request(
241        &self,
242        endpoint: &str,
243        method: APIRequest,
244    ) -> Result<Value, SFError> {
245        send_api_request(
246            &self.client,
247            &self.session.bearer_token,
248            endpoint,
249            method,
250        )
251        .await
252    }
253}
254
255/// Send a request to the SSO server. The endpoint will be "json/*". We try
256/// to check if the response is bad in any way, but S&F responses never obey
257/// to HTML status codes, or their own system, so good luck
258#[allow(clippy::items_after_statements)]
259async fn send_api_request(
260    client: &Client,
261    bearer_token: &str,
262    endpoint: &str,
263    method: APIRequest,
264) -> Result<Value, SFError> {
265    let mut url = url::Url::parse("https://sso.playa-games.com")
266        .map_err(|_| SFError::ConnectionError)?;
267    url.set_path(endpoint);
268
269    let mut request = match method {
270        APIRequest::Get => client.get(url.as_str()),
271        APIRequest::Post {
272            parameters,
273            form_data,
274        } => {
275            url.set_query(Some(&parameters.join("&")));
276            client.post(url.as_str()).form(&form_data)
277        }
278    };
279
280    // Set all necessary header values to make our request succeed
281    if !bearer_token.is_empty() {
282        request = request.bearer_auth(bearer_token);
283    }
284    let mut headers = HeaderMap::new();
285    headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
286    headers.insert(
287        REFERER,
288        HeaderValue::from_str(url.authority())
289            .map_err(|_| SFError::ConnectionError)?,
290    );
291
292    let res = request
293        .headers(headers)
294        .send()
295        .await
296        .map_err(|_| SFError::ConnectionError)?;
297    if !res.status().is_success() {
298        return Err(SFError::ConnectionError);
299    }
300    let text = res.text().await.map_err(|_| SFError::ConnectionError)?;
301
302    #[derive(Debug, Serialize, Deserialize)]
303    struct APIResponse {
304        success: bool,
305        status: u8,
306        data: Option<Value>,
307        message: Option<Value>,
308    }
309
310    let resp: APIResponse = serde_json::from_str(&text)
311        .map_err(|_| SFError::ParsingError("API response", text))?;
312
313    if !resp.success {
314        return Err(SFError::ConnectionError);
315    }
316    let data = match resp.data {
317        Some(data) => data,
318        None => match resp.message {
319            Some(message) => message,
320            None => return Err(SFError::ConnectionError),
321        },
322    };
323
324    Ok(data)
325}
326
327#[derive(Debug, Clone)]
328pub struct ServerLookup(HashMap<i32, Url>);
329
330impl ServerLookup {
331    /// Fetches the current mapping of numeric server ids to their URLs.
332    ///
333    /// # Errors
334    /// Returns `ConnectionError`, if the server could not be reached, ro
335    /// `ParsingError`, if the list could not be parsed
336    pub async fn fetch() -> Result<ServerLookup, SFError> {
337        Self::fetch_with_client(&reqwest::Client::new()).await
338    }
339
340    /// Fetches the current mapping of server ids to server URLs.
341    #[allow(clippy::items_after_statements)]
342    async fn fetch_with_client(
343        client: &Client,
344    ) -> Result<ServerLookup, SFError> {
345        let res = client
346            .get("https://sfgame.net/config.json")
347            .send()
348            .await
349            .map_err(|_| SFError::ConnectionError)?
350            .text()
351            .await
352            .map_err(|_| SFError::ConnectionError)?;
353
354        #[derive(Debug, Deserialize, Serialize)]
355        struct ServerResp {
356            servers: Vec<ServerInfo>,
357        }
358
359        #[derive(Debug, Deserialize, Serialize)]
360        struct ServerInfo {
361            #[serde(rename = "i")]
362            id: i32,
363            #[serde(rename = "d")]
364            url: String,
365            #[serde(rename = "c")]
366            country_code: String,
367            #[serde(rename = "md")]
368            merged_into: Option<String>,
369            #[serde(rename = "m")]
370            merge_date_time: Option<String>,
371        }
372
373        let resp: ServerResp = serde_json::from_str(&res).map_err(|_| {
374            SFError::ParsingError("server response", res.to_string())
375        })?;
376
377        let servers: HashMap<i32, Url> = resp
378            .servers
379            .into_iter()
380            .filter_map(|s| {
381                let mut server_url = s.url;
382                if let Some(merged_url) = s.merged_into {
383                    if let Some(mdt) = s.merge_date_time.and_then(|a| {
384                        NaiveDateTime::parse_from_str(&a, "%Y-%m-%d %H:%M:%S")
385                            .ok()
386                    }) {
387                        if Local::now().naive_utc() > mdt {
388                            server_url = merged_url;
389                        }
390                    } else {
391                        server_url = merged_url;
392                    }
393                }
394
395                Some((s.id, format!("https://{server_url}").parse().ok()?))
396            })
397            .collect();
398        if servers.is_empty() {
399            return Err(SFError::ParsingError("empty server list", res));
400        }
401
402        Ok(ServerLookup(servers))
403    }
404
405    /// Gets the mapping of a server id to a URL
406    ///
407    /// # Errors
408    /// Reutns `InvalidRequest` if there was no server with this id
409    pub fn get(&self, server_id: i32) -> Result<Url, SFError> {
410        self.0
411            .get(&server_id)
412            .cloned()
413            .ok_or(SFError::InvalidRequest("There is no server with this id"))
414    }
415
416    /// Returns a set of all the servers, that are currently active, so no
417    /// merged, or not yet available servers
418    #[must_use]
419    pub fn all(&self) -> HashSet<Url> {
420        self.0.iter().map(|a| a.1.clone()).collect()
421    }
422}
423
424#[derive(Debug)]
425pub enum AuthResponse {
426    Success(SFAccount),
427    NoAuth(SSOAuth),
428}
429
430fn val_to_string(val: &Value) -> Option<String> {
431    val.as_str().map(std::string::ToString::to_string)
432}
433
434#[derive(Debug)]
435pub struct SSOAuth {
436    client: Client,
437    options: ConnectionOptions,
438    auth_url: Url,
439    auth_id: String,
440    provider: SSOProvider,
441}
442
443#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
444pub enum SSOProvider {
445    Google,
446    Steam,
447}
448
449impl SSOProvider {
450    fn endpoint_ident(self) -> &'static str {
451        match self {
452            SSOProvider::Google => "googleauth",
453            SSOProvider::Steam => "steamauth",
454        }
455    }
456}
457
458impl SSOAuth {
459    async fn send_api_request(
460        &self,
461        endpoint: &str,
462        method: APIRequest,
463    ) -> Result<Value, SFError> {
464        send_api_request(&self.client, "", endpoint, method).await
465    }
466
467    /// Returns the SSO auth URL, that the user has to login through
468    #[must_use]
469    pub fn auth_url(&self) -> &Url {
470        &self.auth_url
471    }
472
473    /// Tries to login. If the user has successfully authenticated via the
474    /// `auth_url`, this will return the normal `SFAccount`. Otherwise, this
475    /// will return the existing Auth for you to reattempt the login after a
476    /// few seconds
477    ///
478    /// # Errors
479    /// May return `ConnectionError`, or `ParsingError` depending on what part
480    /// of the communication failed
481    #[allow(clippy::indexing_slicing)]
482    pub async fn try_login(self) -> Result<AuthResponse, SFError> {
483        let endpoint = format!(
484            "/json/sso/{}/check/{}",
485            self.provider.endpoint_ident(),
486            self.auth_id
487        );
488        let resp = self.send_api_request(&endpoint, APIRequest::Get).await?;
489
490        if let Some(message) = val_to_string(&resp) {
491            return match message.as_str() {
492                "SSO_POPUP_STATE_PROCESSING" => Ok(AuthResponse::NoAuth(self)),
493                _ => Err(SFError::ConnectionError),
494            };
495        }
496
497        let id_token =
498            val_to_string(&resp["id_token"]).ok_or(SFError::ConnectionError)?;
499
500        let mut form_data = HashMap::new();
501        form_data.insert("token".to_string(), id_token.clone());
502        form_data.insert("language".to_string(), "en".to_string());
503
504        let res = self
505            .send_api_request(
506                &format!("json/login/sso/{}", self.provider.endpoint_ident()),
507                APIRequest::Post {
508                    parameters: vec![
509                        "client_id=i43nwwnmfc5tced4jtuk4auuygqghud2yopx",
510                        "auth_type=access_token",
511                    ],
512                    form_data,
513                },
514            )
515            .await?;
516
517        let access_token = val_to_string(&res["token"]["access_token"])
518            .ok_or(SFError::ConnectionError)?;
519        let uuid = val_to_string(&res["account"]["uuid"])
520            .ok_or(SFError::ConnectionError)?;
521        let username = val_to_string(&res["account"]["username"])
522            .ok_or(SFError::ConnectionError)?;
523
524        Ok(AuthResponse::Success(SFAccount {
525            username,
526            client: self.client,
527            session: AccountSession {
528                uuid,
529                bearer_token: access_token,
530            },
531            options: self.options,
532            auth: match self.provider {
533                SSOProvider::Google => SSOAuthData::Google,
534                SSOProvider::Steam => SSOAuthData::Steam,
535            },
536        }))
537    }
538
539    /// Instantiates a new attempt to login through a SSO provider. A user then
540    /// has to interact with the `auth_url` this returns to validate the
541    /// login. Afterwards you can login and transform this into a normal
542    /// `SFAccount`
543    ///
544    /// # Errors
545    /// May return `ConnectionError`, or `ParsingError` depending on what part
546    /// of the communication failed
547    pub async fn new(provider: SSOProvider) -> Result<Self, SFError> {
548        Self::new_with_options(provider, ConnectionOptions::default()).await
549    }
550
551    /// The same as `new()`, but with optional connection options
552    ///
553    /// # Errors
554    /// May return `ConnectionError`, or `ParsingError` depending on what part
555    /// of the communication failed
556    #[allow(clippy::indexing_slicing)]
557    pub async fn new_with_options(
558        provider: SSOProvider,
559        options: ConnectionOptions,
560    ) -> Result<Self, SFError> {
561        let client =
562            reqwest_client(&options).ok_or(SFError::ConnectionError)?;
563
564        let resp = send_api_request(
565            &client,
566            "",
567            &format!("json/sso/{}", provider.endpoint_ident()),
568            APIRequest::Get,
569        )
570        .await?;
571
572        let auth_url = val_to_string(&resp["redirect"])
573            .and_then(|a| Url::parse(&a).ok())
574            .ok_or(SFError::ConnectionError)?;
575        let auth_id =
576            val_to_string(&resp["id"]).ok_or(SFError::ConnectionError)?;
577        Ok(Self {
578            client,
579            options,
580            auth_url,
581            auth_id,
582            provider,
583        })
584    }
585}