use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use base64::Engine;
use chrono::{DateTime, Utc};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use tokio::time::sleep;
use crate::config::AccountConfig;
use crate::paths::AppPaths;
use crate::state_file::write_private_atomically;
const EGS_USER_AGENT: &str =
"UELauncher/11.0.1-14907503+++Portal+Release-Live Windows/10.0.19041.1.256.64bit";
const EGS_CLIENT_ID: &str = "34a02cf8f4414e29b15921876da36f9a";
const EGS_CLIENT_SECRET: &str = "daafbccc737745039dffe53d94fc76cf";
const EGS_OAUTH_HOST: &str = "account-public-service-prod03.ol.epicgames.com";
const EOS_DEPLOYMENT_ID: &str = "da32ae9c12ae40e8a112c52e1f17f3ba";
const EOS_CLIENT_ID: &str = "xyza7891p5D7s9R6Gm6moTHWGloerp7B";
const EOS_SECRET: &str = "Knh18du4NVlFs+3uQ+ZPpDCVto0WYf4yXP8+OcwVt1o";
const REFRESH_MARGIN: Duration = Duration::from_secs(15 * 60);
#[derive(Debug, Clone)]
pub struct AuthManager {
client: EpicClient,
store: TokenStore,
}
impl AuthManager {
pub fn new(paths: &AppPaths, account_id: u32) -> Self {
Self {
client: EpicClient::new(),
store: TokenStore::new(paths.tokens_dir(), account_id, None),
}
}
pub fn for_legacy_profile(paths: &AppPaths, profile_id: u32) -> Self {
Self {
client: EpicClient::new(),
store: TokenStore::new(paths.tokens_dir(), profile_id, Some(profile_id)),
}
}
pub fn for_account(paths: &AppPaths, account: &AccountConfig) -> Self {
Self {
client: EpicClient::new(),
store: TokenStore::new(paths.tokens_dir(), account.id, account.legacy_profile_id()),
}
}
pub fn login_url(&self) -> String {
self.client.login_url()
}
pub fn has_saved_login(&self) -> Result<bool> {
Ok(self.store.read_egs_refresh()?.is_some())
}
pub async fn authenticate_with_code(&self, code: &str) -> Result<EosTokenResponse> {
let egs = self.client.authenticate_with_code(code.trim()).await?;
self.store.save_egs_refresh(&egs.refresh_token)?;
let exchange_code = self
.client
.exchange_code(egs.access_token.expose_secret())
.await?;
let eos = self.client.exchange_eos_token(&exchange_code).await?;
self.store.save_eos_refresh(&eos.refresh_token)?;
Ok(eos)
}
pub async fn begin_device_auth(&self) -> Result<DeviceAuthResponse> {
self.client.begin_device_auth().await
}
pub async fn wait_for_device_auth(
&self,
device: &DeviceAuthResponse,
) -> Result<EosTokenResponse> {
let eos = self.client.wait_for_device_auth(device).await?;
self.store.save_eos_refresh(&eos.refresh_token)?;
Ok(eos)
}
pub async fn restore_or_refresh(&self) -> Result<EosTokenResponse> {
if let Some(refresh_token) = self.store.read_egs_refresh()? {
let egs = self
.client
.authenticate_with_refresh_token(refresh_token.expose_secret())
.await
.context("failed to refresh EGS token from local store")?;
self.store.save_egs_refresh(&egs.refresh_token)?;
let exchange_code = self
.client
.exchange_code(egs.access_token.expose_secret())
.await?;
let eos = self.client.exchange_eos_token(&exchange_code).await?;
self.store.save_eos_refresh(&eos.refresh_token)?;
return Ok(eos);
}
if let Some(refresh_token) = self.store.read_eos_refresh()? {
let eos = self
.client
.refresh_eos_token(refresh_token.expose_secret())
.await
.context("failed to refresh EOS token from local store")?;
self.store.save_eos_refresh(&eos.refresh_token)?;
return Ok(eos);
}
bail!("no saved auth token for account {}", self.store.account_id)
}
pub fn clear(&self) -> Result<()> {
self.store.clear()
}
}
#[derive(Debug, Clone)]
pub struct EpicClient {
http: reqwest::Client,
}
impl EpicClient {
pub fn new() -> Self {
Self {
http: reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("valid reqwest client"),
}
}
pub fn login_url(&self) -> String {
let redirect = format!(
"https://www.epicgames.com/id/api/redirect?clientId={EGS_CLIENT_ID}&responseType=code"
);
format!(
"https://www.epicgames.com/id/login?redirectUrl={}",
url::form_urlencoded::byte_serialize(redirect.as_bytes()).collect::<String>()
)
}
pub async fn authenticate_with_code(&self, code: &str) -> Result<TokenResponse> {
self.request_egs_token(&[
("grant_type", "authorization_code"),
("code", code),
("token_type", "eg1"),
])
.await
}
pub async fn authenticate_with_refresh_token(
&self,
refresh_token: &str,
) -> Result<TokenResponse> {
self.request_egs_token(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("token_type", "eg1"),
])
.await
}
pub async fn exchange_code(&self, access_token: &str) -> Result<String> {
let response = self
.http
.get(format!(
"https://{EGS_OAUTH_HOST}/account/api/oauth/exchange"
))
.header(USER_AGENT, EGS_USER_AGENT)
.header(AUTHORIZATION, format!("bearer {access_token}"))
.send()
.await
.context("failed to request EGS exchange code")?;
let status = response.status();
let body = response
.text()
.await
.context("failed to read exchange response")?;
if !status.is_success() {
bail!("EGS exchange failed with {status}: {body}");
}
#[derive(Deserialize)]
struct ExchangeCode {
code: String,
}
let parsed: ExchangeCode =
serde_json::from_str(&body).context("failed to parse EGS exchange response")?;
Ok(parsed.code)
}
pub async fn exchange_eos_token(&self, exchange_code: &str) -> Result<EosTokenResponse> {
self.request_eos_token(&[
("grant_type", "exchange_code"),
("exchange_code", exchange_code),
])
.await
}
pub async fn refresh_eos_token(&self, refresh_token: &str) -> Result<EosTokenResponse> {
self.request_eos_token(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
])
.await
}
pub async fn begin_device_auth(&self) -> Result<DeviceAuthResponse> {
let response = self
.http
.post("https://api.epicgames.dev/epic/oauth/v2/deviceAuthorization")
.header(USER_AGENT, EGS_USER_AGENT)
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.form(&[("client_id", EOS_CLIENT_ID)])
.send()
.await
.context("failed to begin Epic device authorization")?;
parse_json_response(response, "Epic device authorization").await
}
pub async fn wait_for_device_auth(
&self,
device: &DeviceAuthResponse,
) -> Result<EosTokenResponse> {
if device.interval == 0 || device.expires_in == 0 {
bail!("invalid device authorization polling interval");
}
let attempts = device.expires_in / device.interval;
for _ in 0..attempts {
match self
.request_eos_token(&[
("grant_type", "device_code"),
("device_code", device.device_code.expose_secret()),
])
.await
{
Ok(token) => return Ok(token),
Err(error) => {
tracing::debug!(%error, "device authorization not complete yet");
sleep(Duration::from_secs(device.interval)).await;
}
}
}
bail!("Epic device authorization timed out")
}
async fn request_egs_token(&self, form: &[(&str, &str)]) -> Result<TokenResponse> {
let auth = basic_auth(EGS_CLIENT_ID, EGS_CLIENT_SECRET);
let response = self
.http
.post(format!("https://{EGS_OAUTH_HOST}/account/api/oauth/token"))
.header(USER_AGENT, EGS_USER_AGENT)
.header(AUTHORIZATION, auth)
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.form(form)
.send()
.await
.context("failed to request EGS token")?;
parse_json_response(response, "EGS token request").await
}
async fn request_eos_token(&self, form: &[(&str, &str)]) -> Result<EosTokenResponse> {
let mut owned_form: Vec<(&str, &str)> = Vec::with_capacity(form.len() + 2);
owned_form.extend_from_slice(form);
owned_form.push(("deployment_id", EOS_DEPLOYMENT_ID));
owned_form.push(("scope", "basic_profile"));
let response = self
.http
.post("https://api.epicgames.dev/epic/oauth/v2/token")
.header(USER_AGENT, EGS_USER_AGENT)
.header(AUTHORIZATION, basic_auth(EOS_CLIENT_ID, EOS_SECRET))
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.form(&owned_form)
.send()
.await
.context("failed to request EOS token")?;
parse_json_response(response, "EOS token request").await
}
}
impl Default for EpicClient {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct TokenResponse {
pub access_token: SecretString,
pub refresh_token: SecretString,
pub expires_at: String,
pub account_id: String,
#[serde(default, rename = "displayName")]
pub display_name: String,
}
impl TokenResponse {
pub fn expires_soon(&self) -> bool {
expires_soon(&self.expires_at)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct EosTokenResponse {
pub access_token: SecretString,
pub refresh_token: SecretString,
pub expires_at: String,
pub refresh_expires_at: String,
pub account_id: String,
#[serde(default)]
pub selected_account_id: String,
}
impl EosTokenResponse {
pub fn expires_soon(&self) -> bool {
expires_soon(&self.expires_at)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceAuthResponse {
pub user_code: String,
pub device_code: SecretString,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone)]
struct TokenStore {
dir: PathBuf,
account_id: u32,
legacy_profile_id: Option<u32>,
}
impl TokenStore {
fn new(dir: PathBuf, account_id: u32, legacy_profile_id: Option<u32>) -> Self {
Self {
dir,
account_id,
legacy_profile_id,
}
}
fn read_egs_refresh(&self) -> Result<Option<SecretString>> {
read_secret_with_legacy(&self.egs_path(), self.legacy_egs_path().as_ref())
}
fn read_eos_refresh(&self) -> Result<Option<SecretString>> {
read_secret_with_legacy(&self.eos_path(), self.legacy_eos_path().as_ref())
}
fn save_egs_refresh(&self, refresh_token: &SecretString) -> Result<()> {
write_secret(&self.egs_path(), refresh_token)
}
fn save_eos_refresh(&self, refresh_token: &SecretString) -> Result<()> {
write_secret(&self.eos_path(), refresh_token)
}
fn clear(&self) -> Result<()> {
remove_if_exists(&self.egs_path())?;
remove_if_exists(&self.eos_path())?;
if let Some(path) = self.legacy_egs_path() {
remove_if_exists(&path)?;
}
if let Some(path) = self.legacy_eos_path() {
remove_if_exists(&path)?;
}
Ok(())
}
fn egs_path(&self) -> PathBuf {
self.dir
.join(format!("account-{}-egs.refresh", self.account_id))
}
fn eos_path(&self) -> PathBuf {
self.dir
.join(format!("account-{}-eos.refresh", self.account_id))
}
fn legacy_egs_path(&self) -> Option<PathBuf> {
self.legacy_profile_id
.map(|profile_id| self.dir.join(format!("profile-{profile_id}-egs.refresh")))
}
fn legacy_eos_path(&self) -> Option<PathBuf> {
self.legacy_profile_id
.map(|profile_id| self.dir.join(format!("profile-{profile_id}-eos.refresh")))
}
}
async fn parse_json_response<T: for<'de> Deserialize<'de>>(
response: reqwest::Response,
context: &str,
) -> Result<T> {
let status = response.status();
let body = response
.text()
.await
.with_context(|| format!("failed to read {context} response"))?;
if !status.is_success() {
return Err(anyhow!("{context} failed with {status}: {body}"));
}
serde_json::from_str(&body).with_context(|| format!("failed to parse {context} response"))
}
fn basic_auth(client_id: &str, client_secret: &str) -> String {
let credentials = format!("{client_id}:{client_secret}");
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
format!("Basic {encoded}")
}
fn read_secret(path: &PathBuf) -> Result<Option<SecretString>> {
if !path.exists() {
return Ok(None);
}
let value = fs::read_to_string(path)
.with_context(|| format!("failed to read token {}", path.display()))?
.trim()
.to_string();
if value.is_empty() {
Ok(None)
} else {
Ok(Some(SecretString::from(value)))
}
}
fn read_secret_with_legacy(
path: &PathBuf,
legacy_path: Option<&PathBuf>,
) -> Result<Option<SecretString>> {
if let Some(secret) = read_secret(path)? {
return Ok(Some(secret));
}
if let Some(legacy_path) = legacy_path {
return read_secret(legacy_path);
}
Ok(None)
}
fn write_secret(path: &Path, value: &SecretString) -> Result<()> {
write_private_atomically(path, format!("{}\n", value.expose_secret()))
.with_context(|| format!("failed to write token {}", path.display()))
}
fn remove_if_exists(path: &PathBuf) -> Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("failed to remove {}", path.display())),
}
}
fn expires_soon(expires_at: &str) -> bool {
let Ok(expires_at) = DateTime::parse_from_rfc3339(expires_at) else {
return true;
};
let refresh_margin = chrono::Duration::from_std(REFRESH_MARGIN).unwrap_or_default();
Utc::now() >= expires_at.with_timezone(&Utc) - refresh_margin
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_url_points_at_epic_code_flow() {
let url = EpicClient::new().login_url();
assert!(url.starts_with("https://www.epicgames.com/id/login?redirectUrl="));
assert!(url.contains(EGS_CLIENT_ID));
}
#[test]
fn token_store_uses_account_specific_files() {
let tmp = tempfile::tempdir().unwrap();
let store = TokenStore::new(tmp.path().to_path_buf(), 42, None);
store
.save_eos_refresh(&SecretString::from("refresh-token".to_string()))
.unwrap();
assert_eq!(
store.read_eos_refresh().unwrap().unwrap().expose_secret(),
"refresh-token"
);
assert!(store.read_egs_refresh().unwrap().is_none());
}
#[test]
fn token_store_reads_legacy_profile_files() {
let tmp = tempfile::tempdir().unwrap();
let legacy_path = tmp.path().join("profile-7-eos.refresh");
fs::write(&legacy_path, "legacy-refresh\n").unwrap();
let store = TokenStore::new(tmp.path().to_path_buf(), 42, Some(7));
assert_eq!(
store.read_eos_refresh().unwrap().unwrap().expose_secret(),
"legacy-refresh"
);
store
.save_eos_refresh(&SecretString::from("new-refresh".to_string()))
.unwrap();
assert_eq!(
fs::read_to_string(tmp.path().join("account-42-eos.refresh")).unwrap(),
"new-refresh\n"
);
}
}