Skip to main content

rlru/
auth.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use anyhow::{anyhow, bail, Context, Result};
6use base64::Engine;
7use chrono::{DateTime, Utc};
8use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
9use secrecy::{ExposeSecret, SecretString};
10use serde::Deserialize;
11use tokio::time::sleep;
12
13use crate::config::AccountConfig;
14use crate::paths::AppPaths;
15use crate::state_file::write_private_atomically;
16
17const EGS_USER_AGENT: &str =
18    "UELauncher/11.0.1-14907503+++Portal+Release-Live Windows/10.0.19041.1.256.64bit";
19const EGS_CLIENT_ID: &str = "34a02cf8f4414e29b15921876da36f9a";
20const EGS_CLIENT_SECRET: &str = "daafbccc737745039dffe53d94fc76cf";
21const EGS_OAUTH_HOST: &str = "account-public-service-prod03.ol.epicgames.com";
22const EOS_DEPLOYMENT_ID: &str = "da32ae9c12ae40e8a112c52e1f17f3ba";
23const EOS_CLIENT_ID: &str = "xyza7891p5D7s9R6Gm6moTHWGloerp7B";
24const EOS_SECRET: &str = "Knh18du4NVlFs+3uQ+ZPpDCVto0WYf4yXP8+OcwVt1o";
25const REFRESH_MARGIN: Duration = Duration::from_secs(15 * 60);
26
27#[derive(Debug, Clone)]
28pub struct AuthManager {
29    client: EpicClient,
30    store: TokenStore,
31}
32
33impl AuthManager {
34    pub fn new(paths: &AppPaths, account_id: u32) -> Self {
35        Self {
36            client: EpicClient::new(),
37            store: TokenStore::new(paths.tokens_dir(), account_id, None),
38        }
39    }
40
41    pub fn for_legacy_profile(paths: &AppPaths, profile_id: u32) -> Self {
42        Self {
43            client: EpicClient::new(),
44            store: TokenStore::new(paths.tokens_dir(), profile_id, Some(profile_id)),
45        }
46    }
47
48    pub fn for_account(paths: &AppPaths, account: &AccountConfig) -> Self {
49        Self {
50            client: EpicClient::new(),
51            store: TokenStore::new(paths.tokens_dir(), account.id, account.legacy_profile_id()),
52        }
53    }
54
55    pub fn login_url(&self) -> String {
56        self.client.login_url()
57    }
58
59    pub fn has_saved_login(&self) -> Result<bool> {
60        Ok(self.store.read_egs_refresh()?.is_some())
61    }
62
63    pub async fn authenticate_with_code(&self, code: &str) -> Result<EosTokenResponse> {
64        let egs = self.client.authenticate_with_code(code.trim()).await?;
65        self.store.save_egs_refresh(&egs.refresh_token)?;
66
67        let exchange_code = self
68            .client
69            .exchange_code(egs.access_token.expose_secret())
70            .await?;
71        let eos = self.client.exchange_eos_token(&exchange_code).await?;
72        self.store.save_eos_refresh(&eos.refresh_token)?;
73        Ok(eos)
74    }
75
76    pub async fn begin_device_auth(&self) -> Result<DeviceAuthResponse> {
77        self.client.begin_device_auth().await
78    }
79
80    pub async fn wait_for_device_auth(
81        &self,
82        device: &DeviceAuthResponse,
83    ) -> Result<EosTokenResponse> {
84        let eos = self.client.wait_for_device_auth(device).await?;
85        self.store.save_eos_refresh(&eos.refresh_token)?;
86        Ok(eos)
87    }
88
89    pub async fn restore_or_refresh(&self) -> Result<EosTokenResponse> {
90        if let Some(refresh_token) = self.store.read_egs_refresh()? {
91            let egs = self
92                .client
93                .authenticate_with_refresh_token(refresh_token.expose_secret())
94                .await
95                .context("failed to refresh EGS token from local store")?;
96            self.store.save_egs_refresh(&egs.refresh_token)?;
97            let exchange_code = self
98                .client
99                .exchange_code(egs.access_token.expose_secret())
100                .await?;
101            let eos = self.client.exchange_eos_token(&exchange_code).await?;
102            self.store.save_eos_refresh(&eos.refresh_token)?;
103            return Ok(eos);
104        }
105
106        if let Some(refresh_token) = self.store.read_eos_refresh()? {
107            let eos = self
108                .client
109                .refresh_eos_token(refresh_token.expose_secret())
110                .await
111                .context("failed to refresh EOS token from local store")?;
112            self.store.save_eos_refresh(&eos.refresh_token)?;
113            return Ok(eos);
114        }
115
116        bail!("no saved auth token for account {}", self.store.account_id)
117    }
118
119    pub fn clear(&self) -> Result<()> {
120        self.store.clear()
121    }
122}
123
124#[derive(Debug, Clone)]
125pub struct EpicClient {
126    http: reqwest::Client,
127}
128
129impl EpicClient {
130    pub fn new() -> Self {
131        Self {
132            http: reqwest::Client::builder()
133                .timeout(Duration::from_secs(30))
134                .build()
135                .expect("valid reqwest client"),
136        }
137    }
138
139    pub fn login_url(&self) -> String {
140        let redirect = format!(
141            "https://www.epicgames.com/id/api/redirect?clientId={EGS_CLIENT_ID}&responseType=code"
142        );
143        format!(
144            "https://www.epicgames.com/id/login?redirectUrl={}",
145            url::form_urlencoded::byte_serialize(redirect.as_bytes()).collect::<String>()
146        )
147    }
148
149    pub async fn authenticate_with_code(&self, code: &str) -> Result<TokenResponse> {
150        self.request_egs_token(&[
151            ("grant_type", "authorization_code"),
152            ("code", code),
153            ("token_type", "eg1"),
154        ])
155        .await
156    }
157
158    pub async fn authenticate_with_refresh_token(
159        &self,
160        refresh_token: &str,
161    ) -> Result<TokenResponse> {
162        self.request_egs_token(&[
163            ("grant_type", "refresh_token"),
164            ("refresh_token", refresh_token),
165            ("token_type", "eg1"),
166        ])
167        .await
168    }
169
170    pub async fn exchange_code(&self, access_token: &str) -> Result<String> {
171        let response = self
172            .http
173            .get(format!(
174                "https://{EGS_OAUTH_HOST}/account/api/oauth/exchange"
175            ))
176            .header(USER_AGENT, EGS_USER_AGENT)
177            .header(AUTHORIZATION, format!("bearer {access_token}"))
178            .send()
179            .await
180            .context("failed to request EGS exchange code")?;
181
182        let status = response.status();
183        let body = response
184            .text()
185            .await
186            .context("failed to read exchange response")?;
187        if !status.is_success() {
188            bail!("EGS exchange failed with {status}: {body}");
189        }
190
191        #[derive(Deserialize)]
192        struct ExchangeCode {
193            code: String,
194        }
195
196        let parsed: ExchangeCode =
197            serde_json::from_str(&body).context("failed to parse EGS exchange response")?;
198        Ok(parsed.code)
199    }
200
201    pub async fn exchange_eos_token(&self, exchange_code: &str) -> Result<EosTokenResponse> {
202        self.request_eos_token(&[
203            ("grant_type", "exchange_code"),
204            ("exchange_code", exchange_code),
205        ])
206        .await
207    }
208
209    pub async fn refresh_eos_token(&self, refresh_token: &str) -> Result<EosTokenResponse> {
210        self.request_eos_token(&[
211            ("grant_type", "refresh_token"),
212            ("refresh_token", refresh_token),
213        ])
214        .await
215    }
216
217    pub async fn begin_device_auth(&self) -> Result<DeviceAuthResponse> {
218        let response = self
219            .http
220            .post("https://api.epicgames.dev/epic/oauth/v2/deviceAuthorization")
221            .header(USER_AGENT, EGS_USER_AGENT)
222            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
223            .form(&[("client_id", EOS_CLIENT_ID)])
224            .send()
225            .await
226            .context("failed to begin Epic device authorization")?;
227
228        parse_json_response(response, "Epic device authorization").await
229    }
230
231    pub async fn wait_for_device_auth(
232        &self,
233        device: &DeviceAuthResponse,
234    ) -> Result<EosTokenResponse> {
235        if device.interval == 0 || device.expires_in == 0 {
236            bail!("invalid device authorization polling interval");
237        }
238
239        let attempts = device.expires_in / device.interval;
240        for _ in 0..attempts {
241            match self
242                .request_eos_token(&[
243                    ("grant_type", "device_code"),
244                    ("device_code", device.device_code.expose_secret()),
245                ])
246                .await
247            {
248                Ok(token) => return Ok(token),
249                Err(error) => {
250                    tracing::debug!(%error, "device authorization not complete yet");
251                    sleep(Duration::from_secs(device.interval)).await;
252                }
253            }
254        }
255
256        bail!("Epic device authorization timed out")
257    }
258
259    async fn request_egs_token(&self, form: &[(&str, &str)]) -> Result<TokenResponse> {
260        let auth = basic_auth(EGS_CLIENT_ID, EGS_CLIENT_SECRET);
261        let response = self
262            .http
263            .post(format!("https://{EGS_OAUTH_HOST}/account/api/oauth/token"))
264            .header(USER_AGENT, EGS_USER_AGENT)
265            .header(AUTHORIZATION, auth)
266            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
267            .form(form)
268            .send()
269            .await
270            .context("failed to request EGS token")?;
271
272        parse_json_response(response, "EGS token request").await
273    }
274
275    async fn request_eos_token(&self, form: &[(&str, &str)]) -> Result<EosTokenResponse> {
276        let mut owned_form: Vec<(&str, &str)> = Vec::with_capacity(form.len() + 2);
277        owned_form.extend_from_slice(form);
278        owned_form.push(("deployment_id", EOS_DEPLOYMENT_ID));
279        owned_form.push(("scope", "basic_profile"));
280
281        let response = self
282            .http
283            .post("https://api.epicgames.dev/epic/oauth/v2/token")
284            .header(USER_AGENT, EGS_USER_AGENT)
285            .header(AUTHORIZATION, basic_auth(EOS_CLIENT_ID, EOS_SECRET))
286            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
287            .form(&owned_form)
288            .send()
289            .await
290            .context("failed to request EOS token")?;
291
292        parse_json_response(response, "EOS token request").await
293    }
294}
295
296impl Default for EpicClient {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302#[derive(Debug, Clone, Deserialize)]
303pub struct TokenResponse {
304    pub access_token: SecretString,
305    pub refresh_token: SecretString,
306    pub expires_at: String,
307    pub account_id: String,
308    #[serde(default, rename = "displayName")]
309    pub display_name: String,
310}
311
312impl TokenResponse {
313    pub fn expires_soon(&self) -> bool {
314        expires_soon(&self.expires_at)
315    }
316}
317
318#[derive(Debug, Clone, Deserialize)]
319pub struct EosTokenResponse {
320    pub access_token: SecretString,
321    pub refresh_token: SecretString,
322    pub expires_at: String,
323    pub refresh_expires_at: String,
324    pub account_id: String,
325    #[serde(default)]
326    pub selected_account_id: String,
327}
328
329impl EosTokenResponse {
330    pub fn expires_soon(&self) -> bool {
331        expires_soon(&self.expires_at)
332    }
333}
334
335#[derive(Debug, Clone, Deserialize)]
336pub struct DeviceAuthResponse {
337    pub user_code: String,
338    pub device_code: SecretString,
339    pub verification_uri: String,
340    pub expires_in: u64,
341    pub interval: u64,
342}
343
344#[derive(Debug, Clone)]
345struct TokenStore {
346    dir: PathBuf,
347    account_id: u32,
348    legacy_profile_id: Option<u32>,
349}
350
351impl TokenStore {
352    fn new(dir: PathBuf, account_id: u32, legacy_profile_id: Option<u32>) -> Self {
353        Self {
354            dir,
355            account_id,
356            legacy_profile_id,
357        }
358    }
359
360    fn read_egs_refresh(&self) -> Result<Option<SecretString>> {
361        read_secret_with_legacy(&self.egs_path(), self.legacy_egs_path().as_ref())
362    }
363
364    fn read_eos_refresh(&self) -> Result<Option<SecretString>> {
365        read_secret_with_legacy(&self.eos_path(), self.legacy_eos_path().as_ref())
366    }
367
368    fn save_egs_refresh(&self, refresh_token: &SecretString) -> Result<()> {
369        write_secret(&self.egs_path(), refresh_token)
370    }
371
372    fn save_eos_refresh(&self, refresh_token: &SecretString) -> Result<()> {
373        write_secret(&self.eos_path(), refresh_token)
374    }
375
376    fn clear(&self) -> Result<()> {
377        remove_if_exists(&self.egs_path())?;
378        remove_if_exists(&self.eos_path())?;
379        if let Some(path) = self.legacy_egs_path() {
380            remove_if_exists(&path)?;
381        }
382        if let Some(path) = self.legacy_eos_path() {
383            remove_if_exists(&path)?;
384        }
385        Ok(())
386    }
387
388    fn egs_path(&self) -> PathBuf {
389        self.dir
390            .join(format!("account-{}-egs.refresh", self.account_id))
391    }
392
393    fn eos_path(&self) -> PathBuf {
394        self.dir
395            .join(format!("account-{}-eos.refresh", self.account_id))
396    }
397
398    fn legacy_egs_path(&self) -> Option<PathBuf> {
399        self.legacy_profile_id
400            .map(|profile_id| self.dir.join(format!("profile-{profile_id}-egs.refresh")))
401    }
402
403    fn legacy_eos_path(&self) -> Option<PathBuf> {
404        self.legacy_profile_id
405            .map(|profile_id| self.dir.join(format!("profile-{profile_id}-eos.refresh")))
406    }
407}
408
409async fn parse_json_response<T: for<'de> Deserialize<'de>>(
410    response: reqwest::Response,
411    context: &str,
412) -> Result<T> {
413    let status = response.status();
414    let body = response
415        .text()
416        .await
417        .with_context(|| format!("failed to read {context} response"))?;
418    if !status.is_success() {
419        return Err(anyhow!("{context} failed with {status}: {body}"));
420    }
421
422    serde_json::from_str(&body).with_context(|| format!("failed to parse {context} response"))
423}
424
425fn basic_auth(client_id: &str, client_secret: &str) -> String {
426    let credentials = format!("{client_id}:{client_secret}");
427    let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
428    format!("Basic {encoded}")
429}
430
431fn read_secret(path: &PathBuf) -> Result<Option<SecretString>> {
432    if !path.exists() {
433        return Ok(None);
434    }
435    let value = fs::read_to_string(path)
436        .with_context(|| format!("failed to read token {}", path.display()))?
437        .trim()
438        .to_string();
439    if value.is_empty() {
440        Ok(None)
441    } else {
442        Ok(Some(SecretString::from(value)))
443    }
444}
445
446fn read_secret_with_legacy(
447    path: &PathBuf,
448    legacy_path: Option<&PathBuf>,
449) -> Result<Option<SecretString>> {
450    if let Some(secret) = read_secret(path)? {
451        return Ok(Some(secret));
452    }
453
454    if let Some(legacy_path) = legacy_path {
455        return read_secret(legacy_path);
456    }
457
458    Ok(None)
459}
460
461fn write_secret(path: &Path, value: &SecretString) -> Result<()> {
462    write_private_atomically(path, format!("{}\n", value.expose_secret()))
463        .with_context(|| format!("failed to write token {}", path.display()))
464}
465
466fn remove_if_exists(path: &PathBuf) -> Result<()> {
467    match fs::remove_file(path) {
468        Ok(()) => Ok(()),
469        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
470        Err(error) => Err(error).with_context(|| format!("failed to remove {}", path.display())),
471    }
472}
473
474fn expires_soon(expires_at: &str) -> bool {
475    let Ok(expires_at) = DateTime::parse_from_rfc3339(expires_at) else {
476        return true;
477    };
478    let refresh_margin = chrono::Duration::from_std(REFRESH_MARGIN).unwrap_or_default();
479    Utc::now() >= expires_at.with_timezone(&Utc) - refresh_margin
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn login_url_points_at_epic_code_flow() {
488        let url = EpicClient::new().login_url();
489
490        assert!(url.starts_with("https://www.epicgames.com/id/login?redirectUrl="));
491        assert!(url.contains(EGS_CLIENT_ID));
492    }
493
494    #[test]
495    fn token_store_uses_account_specific_files() {
496        let tmp = tempfile::tempdir().unwrap();
497        let store = TokenStore::new(tmp.path().to_path_buf(), 42, None);
498
499        store
500            .save_eos_refresh(&SecretString::from("refresh-token".to_string()))
501            .unwrap();
502
503        assert_eq!(
504            store.read_eos_refresh().unwrap().unwrap().expose_secret(),
505            "refresh-token"
506        );
507        assert!(store.read_egs_refresh().unwrap().is_none());
508    }
509
510    #[test]
511    fn token_store_reads_legacy_profile_files() {
512        let tmp = tempfile::tempdir().unwrap();
513        let legacy_path = tmp.path().join("profile-7-eos.refresh");
514        fs::write(&legacy_path, "legacy-refresh\n").unwrap();
515        let store = TokenStore::new(tmp.path().to_path_buf(), 42, Some(7));
516
517        assert_eq!(
518            store.read_eos_refresh().unwrap().unwrap().expose_secret(),
519            "legacy-refresh"
520        );
521
522        store
523            .save_eos_refresh(&SecretString::from("new-refresh".to_string()))
524            .unwrap();
525
526        assert_eq!(
527            fs::read_to_string(tmp.path().join("account-42-eos.refresh")).unwrap(),
528            "new-refresh\n"
529        );
530    }
531}