Skip to main content

league_link/
auth.rs

1//! Discover LCU credentials from a running League Client.
2//!
3//! Two discovery strategies are supported:
4//!
5//! 1. [`try_find_lcu`] / [`authenticate`] — scan OS processes for
6//!    `LeagueClientUx` (Windows) or `LeagueClient` (macOS) and read the
7//!    `--app-port` / `--remoting-auth-token` command-line arguments.
8//! 2. [`try_find_lcu_via_lockfile`] — parse the `lockfile` written by the
9//!    client to its install directory. Useful when process arguments are
10//!    unavailable (e.g. restricted child processes).
11
12use std::path::Path;
13use std::sync::LazyLock;
14
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17use sysinfo::{ProcessRefreshKind, System};
18
19use crate::error::LcuError;
20
21#[cfg(target_os = "windows")]
22const PROCESS_NAME: &str = "LeagueClientUx";
23#[cfg(not(target_os = "windows"))]
24const PROCESS_NAME: &str = "LeagueClient";
25
26static PORT_RE: LazyLock<Regex> =
27    LazyLock::new(|| Regex::new(r"--app-port=(\d+)").expect("static regex"));
28static PASS_RE: LazyLock<Regex> =
29    LazyLock::new(|| Regex::new(r"--remoting-auth-token=([\w-]+)").expect("static regex"));
30
31/// LCU API credentials extracted from the running League Client.
32///
33/// `Debug` is implemented manually to redact the password — it is never
34/// printed to logs even if an instance is traced.
35#[derive(Clone, Serialize, Deserialize)]
36pub struct Credentials {
37    /// Local port the LCU HTTPS + WSS server is listening on.
38    pub port: u16,
39    /// Password for HTTP Basic Auth. The username is always `riot`.
40    pub password: String,
41    /// Process ID of the League Client.
42    pub pid: u32,
43}
44
45impl std::fmt::Debug for Credentials {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("Credentials")
48            .field("port", &self.port)
49            .field("pid", &self.pid)
50            .field("password", &"***")
51            .finish()
52    }
53}
54
55impl Credentials {
56    /// Build the `Authorization: Basic …` header value.
57    pub fn basic_auth(&self) -> String {
58        use base64::{engine::general_purpose, Engine as _};
59        let raw = format!("riot:{}", self.password);
60        format!("Basic {}", general_purpose::STANDARD.encode(raw))
61    }
62
63    /// HTTPS base URL, e.g. `https://127.0.0.1:52437`.
64    pub fn lcu_base_url(&self) -> String {
65        format!("https://127.0.0.1:{}", self.port)
66    }
67
68    /// WSS URL for the LCU WebSocket, e.g. `wss://127.0.0.1:52437`.
69    pub fn lcu_ws_url(&self) -> String {
70        format!("wss://127.0.0.1:{}", self.port)
71    }
72}
73
74/// Attempt to find a running League Client **once**. Blocking.
75///
76/// Returns `None` if no matching process is found, or if none of the matching
77/// processes have fully initialised command-line arguments yet (protected
78/// child processes, or a client that's still starting up). A partial match on
79/// one process does **not** short-circuit — the scan continues to subsequent
80/// processes.
81///
82/// This call enumerates every OS process and is therefore **blocking**. On a
83/// tokio runtime, prefer [`try_find_lcu_async`] so worker threads are not
84/// stalled.
85pub fn try_find_lcu() -> Option<Credentials> {
86    let mut sys = System::new();
87    sys.refresh_processes_specifics(
88        sysinfo::ProcessesToUpdate::All,
89        true,
90        ProcessRefreshKind::everything(),
91    );
92
93    for (pid, process) in sys.processes() {
94        let name = process.name().to_string_lossy();
95        if !name.contains(PROCESS_NAME) {
96            continue;
97        }
98
99        let cmdline: String = process
100            .cmd()
101            .iter()
102            .map(|s| s.to_string_lossy().into_owned())
103            .collect::<Vec<_>>()
104            .join(" ");
105
106        let Some(port_cap) = PORT_RE.captures(&cmdline) else { continue };
107        let Some(pass_cap) = PASS_RE.captures(&cmdline) else { continue };
108        let Some(port_match) = port_cap.get(1) else { continue };
109        let Some(pass_match) = pass_cap.get(1) else { continue };
110        let Ok(port) = port_match.as_str().parse::<u16>() else { continue };
111
112        return Some(Credentials {
113            port,
114            password: pass_match.as_str().to_string(),
115            pid: pid.as_u32(),
116        });
117    }
118
119    None
120}
121
122/// Async wrapper around [`try_find_lcu`].
123///
124/// The process scan is blocking, so it is dispatched via
125/// [`tokio::task::spawn_blocking`]. Returns `None` if either the scan itself
126/// finds nothing or the blocking task is cancelled.
127pub async fn try_find_lcu_async() -> Option<Credentials> {
128    tokio::task::spawn_blocking(try_find_lcu)
129        .await
130        .ok()
131        .flatten()
132}
133
134/// Parse a `lockfile` written by the League Client.
135///
136/// The lockfile format is colon-delimited: `name:pid:port:password:protocol`
137/// and lives in the client install directory (e.g.
138/// `C:\Riot Games\League of Legends\lockfile`).
139///
140/// Returns [`LcuError::LockfileParse`] if the file does not match this
141/// layout, or [`LcuError::Io`] if the file cannot be read.
142pub fn try_find_lcu_via_lockfile(lockfile_path: impl AsRef<Path>) -> Result<Credentials, LcuError> {
143    let content = std::fs::read_to_string(lockfile_path)?;
144    let parts: Vec<&str> = content.trim().split(':').collect();
145    if parts.len() < 5 {
146        return Err(LcuError::LockfileParse(format!(
147            "expected 5 colon-separated fields, found {}",
148            parts.len()
149        )));
150    }
151    let pid = parts[1]
152        .parse()
153        .map_err(|_| LcuError::LockfileParse(format!("invalid pid: {:?}", parts[1])))?;
154    let port = parts[2]
155        .parse()
156        .map_err(|_| LcuError::LockfileParse(format!("invalid port: {:?}", parts[2])))?;
157    let password = parts[3].to_string();
158    Ok(Credentials { port, password, pid })
159}
160
161/// Poll until a running League Client is found, using [`try_find_lcu_async`].
162///
163/// Sleeps `poll_interval_ms` between attempts. Returns [`LcuError::AuthTimeout`]
164/// after `timeout_secs` seconds if the client is never found.
165pub async fn authenticate(
166    poll_interval_ms: u64,
167    timeout_secs: u64,
168) -> Result<Credentials, LcuError> {
169    let deadline =
170        tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
171    loop {
172        if let Some(creds) = try_find_lcu_async().await {
173            return Ok(creds);
174        }
175        if tokio::time::Instant::now() >= deadline {
176            return Err(LcuError::AuthTimeout);
177        }
178        tokio::time::sleep(tokio::time::Duration::from_millis(poll_interval_ms)).await;
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn basic_auth_is_well_formed() {
188        let creds = Credentials {
189            port: 12345,
190            password: "abc".into(),
191            pid: 42,
192        };
193        // "riot:abc" → base64 "cmlvdDphYmM="
194        assert_eq!(creds.basic_auth(), "Basic cmlvdDphYmM=");
195        assert_eq!(creds.lcu_base_url(), "https://127.0.0.1:12345");
196        assert_eq!(creds.lcu_ws_url(), "wss://127.0.0.1:12345");
197    }
198
199    #[test]
200    fn debug_redacts_password() {
201        let creds = Credentials {
202            port: 1,
203            password: "topsecret".into(),
204            pid: 2,
205        };
206        let rendered = format!("{:?}", creds);
207        assert!(!rendered.contains("topsecret"));
208        assert!(rendered.contains("***"));
209    }
210
211    #[test]
212    fn lockfile_parses_well_formed_input() {
213        let dir = std::env::temp_dir();
214        let path = dir.join("league-link-test-lockfile");
215        std::fs::write(&path, "LeagueClient:1234:52437:secretpw:https").unwrap();
216        let creds = try_find_lcu_via_lockfile(&path).unwrap();
217        assert_eq!(creds.pid, 1234);
218        assert_eq!(creds.port, 52437);
219        assert_eq!(creds.password, "secretpw");
220        let _ = std::fs::remove_file(&path);
221    }
222
223    #[test]
224    fn lockfile_rejects_malformed_input() {
225        let dir = std::env::temp_dir();
226        let path = dir.join("league-link-test-lockfile-bad");
227        std::fs::write(&path, "not:enough:fields").unwrap();
228        assert!(matches!(
229            try_find_lcu_via_lockfile(&path),
230            Err(LcuError::LockfileParse(_))
231        ));
232        let _ = std::fs::remove_file(&path);
233    }
234}