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}