Skip to main content

aegis_tool/app/
login.rs

1use std::io::{self, ErrorKind, Read, Write};
2use std::net::TcpListener;
3use std::time::{Duration, Instant};
4
5use anyhow::{Context, Result, anyhow, bail};
6use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
7use serde::{Deserialize, Serialize};
8use url::Url;
9
10use crate::api::{finish_browser_login, start_browser_login};
11use crate::cli::LoginArgs;
12use crate::config::{now_unix, persist_user_auth_state, resolve_api_base};
13use crate::ui::{self, TaskOptions, TaskVisibility};
14
15const REMOTE_AUTH_FORMAT_VERSION: u8 = 1;
16const SUCCESS_HTML: &str = "<!doctype html><html><head><meta charset=\"utf-8\"><title>aegis login complete</title><style>body{font-family:ui-sans-serif,system-ui,sans-serif;background:#101418;color:#eaf1f6;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}main{max-width:34rem;padding:2rem 2.5rem;border:1px solid #29404f;border-radius:18px;background:#152029;box-shadow:0 20px 60px rgba(0,0,0,.25)}h1{margin:0 0 .75rem;font-size:1.5rem}p{margin:.5rem 0;line-height:1.5;color:#b9cad4}code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.95em}</style></head><body><main><h1><code>aegis</code> login complete</h1><p>You can close this browser window and return to the terminal.</p></main></body></html>";
17const ERROR_HTML: &str = "<!doctype html><html><head><meta charset=\"utf-8\"><title>aegis login failed</title><style>body{font-family:ui-sans-serif,system-ui,sans-serif;background:#151110;color:#f5ebe6;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}main{max-width:34rem;padding:2rem 2.5rem;border:1px solid #5a2a1f;border-radius:18px;background:#221613;box-shadow:0 20px 60px rgba(0,0,0,.3)}h1{margin:0 0 .75rem;font-size:1.5rem}p{margin:.5rem 0;line-height:1.5;color:#e7c1b4}code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.95em}</style></head><body><main><h1><code>aegis</code> login failed</h1><p>The terminal has the detailed error. You can close this browser window.</p></main></body></html>";
18
19#[derive(Debug, Deserialize, Serialize)]
20#[serde(deny_unknown_fields)]
21struct RemoteAuthRequest {
22    version: u8,
23    authorization_url: String,
24    callback_url: String,
25    state: String,
26}
27
28#[derive(Debug, Deserialize, Serialize)]
29#[serde(deny_unknown_fields)]
30struct RemoteAuthResponse {
31    version: u8,
32    code: String,
33    state: String,
34}
35
36pub(super) struct BrowserLogin<'a> {
37    api_base_override: Option<&'a str>,
38    args: &'a LoginArgs,
39}
40
41impl<'a> BrowserLogin<'a> {
42    pub(super) fn from_cli(api_base_override: Option<&'a str>, args: &'a LoginArgs) -> Self {
43        Self {
44            api_base_override,
45            args,
46        }
47    }
48
49    pub(super) fn run(&self) -> Result<i32> {
50        if let Some(request) = self.args.remote_auth_relay.as_deref() {
51            return relay_remote_auth(request, self.args.wait_timeout_secs);
52        }
53
54        let installed_agent_api_base = crate::api::installed_agent_api_base()?;
55        let api_base =
56            resolve_api_base(self.api_base_override, installed_agent_api_base.as_deref())?;
57
58        if self.args.remote_auth {
59            return self.run_remote_auth(&api_base);
60        }
61
62        self.run_local_browser_auth(&api_base)
63    }
64
65    fn run_local_browser_auth(&self, api_base: &str) -> Result<i32> {
66        self.complete(api_base, false)
67    }
68
69    fn run_remote_auth(&self, api_base: &str) -> Result<i32> {
70        self.complete(api_base, true)
71    }
72
73    fn complete(&self, api_base: &str, remote: bool) -> Result<i32> {
74        let proof = browser_proof(
75            api_base,
76            remote,
77            Duration::from_secs(self.args.wait_timeout_secs),
78        )?;
79        proof.finish(api_base)?;
80        Ok(0)
81    }
82}
83
84pub struct BrowserProof {
85    pub code: String,
86    pub verifier: String,
87    pub callback: Url,
88}
89
90impl BrowserProof {
91    pub fn finish(self, api_base: &str) -> Result<()> {
92        let exchange = ui::task(TaskOptions {
93            label: "Completing sign-in".into(),
94            deadline: Some(Duration::from_secs(30)),
95            ..Default::default()
96        })?;
97        let auth = finish_browser_login(
98            api_base,
99            &self.callback,
100            &self.code,
101            &self.verifier,
102            now_unix(),
103        )?;
104        persist_user_auth_state(&auth)?;
105        exchange.finish(format!("Logged in as {}", auth.principal));
106        Ok(())
107    }
108}
109
110pub fn browser_proof(api_base: &str, remote: bool, timeout: Duration) -> Result<BrowserProof> {
111    ui::require_interactive("Browser sign-in requires an interactive terminal")?;
112    let prepare = ui::task(TaskOptions {
113        label: "Preparing browser sign-in".into(),
114        ..Default::default()
115    })?;
116    let listener = CallbackListener::bind()?;
117    let callback = listener.callback_url().clone();
118    let login = start_browser_login(api_base, &callback)?;
119    prepare.finish_and_clear();
120    let started = Instant::now();
121    let (code, state) = if remote {
122        let request = encode_remote_auth_request(&RemoteAuthRequest {
123            version: REMOTE_AUTH_FORMAT_VERSION,
124            authorization_url: login.authorization_url.to_string(),
125            callback_url: callback.to_string(),
126            state: login.state.clone(),
127        })?;
128        ui::stage(&format!(
129            "On the machine with a browser, run:\n  aegis manage login --remote-auth-relay {request}"
130        ));
131        ui::detail(&format!(
132            "Waiting for your relay response until {} UTC. Complete the relay promptly after browser authorization.",
133            time::OffsetDateTime::now_utc()
134                + time::Duration::seconds(timeout.as_secs().try_into()?)
135        ));
136        let response = ui::suspend(prompt_remote_auth_response)?;
137        anyhow::ensure!(
138            started.elapsed() < timeout,
139            "remote sign-in attempt expired; start a new attempt"
140        );
141        let response = decode_remote_auth_response(&response)?;
142        (response.code, response.state)
143    } else {
144        ui::maybe_open_browser(login.authorization_url.as_str());
145        print_authorization_url(&login.authorization_url);
146        let wait = ui::task(TaskOptions {
147            label: "Waiting for browser authorization".into(),
148            deadline: Some(timeout),
149            visibility: TaskVisibility::Immediate,
150            ..Default::default()
151        })?;
152        let response = listener.wait(timeout)?;
153        wait.finish_and_clear();
154        (response.code, response.state)
155    };
156    anyhow::ensure!(
157        state == login.state,
158        "OAuth callback returned the wrong state"
159    );
160    Ok(BrowserProof {
161        code,
162        verifier: login.pkce_verifier,
163        callback,
164    })
165}
166
167fn relay_remote_auth(encoded_request: &str, wait_timeout_secs: u64) -> Result<i32> {
168    ui::require_interactive(
169        "`aegis manage login --remote-auth-relay` requires an interactive terminal",
170    )?;
171    let request = decode_remote_auth_request(encoded_request)?;
172    let (authorization_url, callback_url) = validate_remote_auth_request(&request)?;
173    let listener = CallbackListener::bind_callback(&callback_url)?;
174
175    ui::current().info("Opening browser for remote OAuth login");
176    ui::maybe_open_browser(authorization_url.as_str());
177    print_authorization_url(&authorization_url);
178
179    let callback_timeout = Duration::from_secs(wait_timeout_secs);
180    let wait = ui::task(TaskOptions {
181        label: "Waiting for browser authorization".to_string(),
182        deadline: Some(callback_timeout),
183        visibility: TaskVisibility::Immediate,
184        ..TaskOptions::default()
185    })?;
186    let callback = listener.wait(callback_timeout)?;
187    wait.finish("Browser authorization completed");
188    if callback.state != request.state {
189        bail!("oauth callback returned the wrong state");
190    }
191
192    let response = encode_remote_auth_response(&RemoteAuthResponse {
193        version: REMOTE_AUTH_FORMAT_VERSION,
194        code: callback.code,
195        state: callback.state,
196    })?;
197    ui::stage("Paste this response into the waiting remote prompt:");
198    println!("{response}");
199    ui::success("Remote authentication response ready.");
200    Ok(0)
201}
202
203fn print_authorization_url(url: &Url) {
204    ui::stage(&format!("Authorization URL: {url}"));
205}
206
207fn prompt_remote_auth_response() -> Result<String> {
208    eprint!("Paste the remote authentication response: ");
209    io::stderr()
210        .flush()
211        .context("failed to flush remote authentication prompt")?;
212    let mut response = String::new();
213    io::stdin()
214        .read_line(&mut response)
215        .context("failed to read remote authentication response")?;
216    let response = response.trim();
217    if response.is_empty() {
218        bail!("remote authentication response must not be empty");
219    }
220    Ok(response.to_string())
221}
222
223fn encode_remote_auth_request(request: &RemoteAuthRequest) -> Result<String> {
224    encode_remote_auth_value(request, "request")
225}
226
227fn encode_remote_auth_response(response: &RemoteAuthResponse) -> Result<String> {
228    encode_remote_auth_value(response, "response")
229}
230
231fn encode_remote_auth_value<T: Serialize>(value: &T, kind: &str) -> Result<String> {
232    let json = serde_json::to_vec(value)
233        .with_context(|| format!("failed to encode remote authentication {kind}"))?;
234    Ok(URL_SAFE_NO_PAD.encode(json))
235}
236
237fn decode_remote_auth_request(encoded: &str) -> Result<RemoteAuthRequest> {
238    let request: RemoteAuthRequest = decode_remote_auth_value(encoded, "request")?;
239    if request.version != REMOTE_AUTH_FORMAT_VERSION {
240        bail!(
241            "unsupported remote authentication request version {}",
242            request.version
243        );
244    }
245    Ok(request)
246}
247
248fn decode_remote_auth_response(encoded: &str) -> Result<RemoteAuthResponse> {
249    let response: RemoteAuthResponse = decode_remote_auth_value(encoded, "response")?;
250    if response.version != REMOTE_AUTH_FORMAT_VERSION {
251        bail!(
252            "unsupported remote authentication response version {}",
253            response.version
254        );
255    }
256    if response.code.trim().is_empty() || response.state.trim().is_empty() {
257        bail!("remote authentication response is incomplete");
258    }
259    Ok(response)
260}
261
262fn decode_remote_auth_value<T: for<'de> Deserialize<'de>>(encoded: &str, kind: &str) -> Result<T> {
263    let json = URL_SAFE_NO_PAD
264        .decode(encoded.trim())
265        .with_context(|| format!("failed to decode remote authentication {kind}"))?;
266    serde_json::from_slice(&json)
267        .with_context(|| format!("failed to parse remote authentication {kind}"))
268}
269
270fn validate_remote_auth_request(request: &RemoteAuthRequest) -> Result<(Url, Url)> {
271    let authorization_url = Url::parse(&request.authorization_url)
272        .context("remote authentication request has an invalid authorization URL")?;
273    validate_authorization_url(&authorization_url)?;
274    let callback_url = Url::parse(&request.callback_url)
275        .context("remote authentication request has an invalid callback URL")?;
276    validate_callback_url(&callback_url)?;
277
278    let redirect_uri = query_value(&authorization_url, "redirect_uri")?;
279    if redirect_uri != callback_url.as_str() {
280        bail!("remote authentication request callback URL does not match its authorization URL");
281    }
282    let state = query_value(&authorization_url, "state")?;
283    if state != request.state || state.trim().is_empty() {
284        bail!("remote authentication request has an inconsistent OAuth state");
285    }
286    Ok((authorization_url, callback_url))
287}
288
289fn validate_authorization_url(url: &Url) -> Result<()> {
290    if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() {
291        bail!("remote authentication authorization URL contains forbidden URL components");
292    }
293    let secure = url.scheme() == "https";
294    let loopback_http =
295        url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "::1" | "localhost"));
296    if !secure && !loopback_http {
297        bail!("remote authentication authorization URL must use HTTPS");
298    }
299    Ok(())
300}
301
302fn validate_callback_url(url: &Url) -> Result<()> {
303    if url.scheme() != "http"
304        || url.host_str() != Some("127.0.0.1")
305        || url.port().is_none()
306        || url.path() != "/callback"
307        || url.query().is_some()
308        || url.fragment().is_some()
309        || !url.username().is_empty()
310        || url.password().is_some()
311    {
312        bail!("remote authentication callback must be an unadorned 127.0.0.1 HTTP callback URL");
313    }
314    Ok(())
315}
316
317struct CallbackListener {
318    listener: TcpListener,
319    callback_url: Url,
320}
321
322impl CallbackListener {
323    fn bind() -> Result<Self> {
324        let listener = TcpListener::bind("127.0.0.1:0")
325            .context("failed to bind local login callback listener")?;
326        listener
327            .set_nonblocking(true)
328            .context("failed to configure local login callback listener")?;
329        let callback_url = Url::parse(&format!(
330            "http://127.0.0.1:{}/callback",
331            listener.local_addr()?.port()
332        ))
333        .context("failed to build local callback url")?;
334        Ok(Self {
335            listener,
336            callback_url,
337        })
338    }
339
340    fn bind_callback(callback_url: &Url) -> Result<Self> {
341        validate_callback_url(callback_url)?;
342        let port = callback_url
343            .port()
344            .ok_or_else(|| anyhow!("remote authentication callback URL has no port"))?;
345        let listener = TcpListener::bind(("127.0.0.1", port)).with_context(|| {
346            format!(
347                "failed to bind local remote-auth callback listener on 127.0.0.1:{port}; rerun the remote command to obtain a new request"
348            )
349        })?;
350        listener
351            .set_nonblocking(true)
352            .context("failed to configure local remote-auth callback listener")?;
353        Ok(Self {
354            listener,
355            callback_url: callback_url.clone(),
356        })
357    }
358
359    fn callback_url(&self) -> &Url {
360        &self.callback_url
361    }
362
363    fn wait(&self, timeout: Duration) -> Result<LoginCallback> {
364        let deadline = Instant::now() + timeout;
365        let mut buffer = [0u8; 8192];
366
367        loop {
368            match self.listener.accept() {
369                Ok((mut stream, _)) => {
370                    let bytes_read = stream
371                        .read(&mut buffer)
372                        .context("failed to read oauth callback request")?;
373                    let request = String::from_utf8_lossy(&buffer[..bytes_read]);
374                    let path = parse_http_request_path(&request)?;
375                    let callback_url = Url::parse(&format!("http://127.0.0.1{path}"))
376                        .context("failed to parse callback url")?;
377                    if let Some(error) = query_optional_value(&callback_url, "error") {
378                        let description = query_optional_value(&callback_url, "error_description")
379                            .unwrap_or_default();
380                        let response = html_response(ERROR_HTML);
381                        let _ = stream.write_all(response.as_bytes());
382                        let _ = stream.flush();
383                        let detail = if description.is_empty() {
384                            error
385                        } else {
386                            format!("{error}: {description}")
387                        };
388                        bail!("oauth callback reported an error: {detail}");
389                    }
390                    let code = query_value(&callback_url, "code")?;
391                    let state = query_value(&callback_url, "state")?;
392
393                    let response = html_response(SUCCESS_HTML);
394                    stream
395                        .write_all(response.as_bytes())
396                        .context("failed to write oauth callback response")?;
397                    stream.flush().ok();
398
399                    return Ok(LoginCallback { code, state });
400                }
401                Err(error) if error.kind() == ErrorKind::WouldBlock => {
402                    if Instant::now() >= deadline {
403                        bail!("timed out waiting for browser login callback");
404                    }
405                    ui::sleep(Duration::from_millis(100))?;
406                }
407                Err(error) => {
408                    return Err(error).context("failed waiting for browser login callback");
409                }
410            }
411        }
412    }
413}
414
415struct LoginCallback {
416    code: String,
417    state: String,
418}
419
420pub(super) fn parse_http_request_path(request: &str) -> Result<String> {
421    let first_line = request
422        .lines()
423        .next()
424        .ok_or_else(|| anyhow!("empty http callback request"))?;
425    let mut parts = first_line.split_whitespace();
426    let method = parts.next().unwrap_or_default();
427    let path = parts.next().unwrap_or_default();
428    if method != "GET" || path.is_empty() {
429        bail!("unexpected oauth callback request line: {first_line}");
430    }
431    Ok(path.to_string())
432}
433
434fn query_value(url: &Url, name: &str) -> Result<String> {
435    url.query_pairs()
436        .find(|(key, _)| key == name)
437        .map(|(_, value)| value.into_owned())
438        .ok_or_else(|| anyhow!("oauth callback missing `{name}`"))
439}
440
441fn query_optional_value(url: &Url, name: &str) -> Option<String> {
442    url.query_pairs()
443        .find(|(key, _)| key == name)
444        .map(|(_, value)| value.into_owned())
445}
446
447fn html_response(html: &str) -> String {
448    format!(
449        "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
450        html.len(),
451        html
452    )
453}
454
455#[cfg(test)]
456mod tests {
457    use super::{
458        REMOTE_AUTH_FORMAT_VERSION, RemoteAuthRequest, RemoteAuthResponse,
459        decode_remote_auth_request, decode_remote_auth_response, encode_remote_auth_request,
460        encode_remote_auth_response, validate_remote_auth_request,
461    };
462
463    #[test]
464    fn remote_auth_request_round_trip_preserves_browser_handoff() {
465        let request = RemoteAuthRequest {
466            version: REMOTE_AUTH_FORMAT_VERSION,
467            authorization_url: "https://api.example/v2/oauth/authorize?response_type=code&client_id=aegis-tool&state=state-1&code_challenge=challenge&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A43123%2Fcallback".to_string(),
468            callback_url: "http://127.0.0.1:43123/callback".to_string(),
469            state: "state-1".to_string(),
470        };
471
472        let encoded = encode_remote_auth_request(&request).expect("request should encode");
473        assert!(!encoded.contains('='));
474        let decoded = decode_remote_auth_request(&encoded).expect("request should decode");
475        let (_, callback) =
476            validate_remote_auth_request(&decoded).expect("request should validate");
477        assert_eq!("http://127.0.0.1:43123/callback", callback.as_str());
478    }
479
480    #[test]
481    fn remote_auth_response_round_trip_contains_only_code_and_state() {
482        let response = RemoteAuthResponse {
483            version: REMOTE_AUTH_FORMAT_VERSION,
484            code: "one-time-code".to_string(),
485            state: "state-1".to_string(),
486        };
487
488        let encoded = encode_remote_auth_response(&response).expect("response should encode");
489        let decoded = decode_remote_auth_response(&encoded).expect("response should decode");
490        assert_eq!("one-time-code", decoded.code);
491        assert_eq!("state-1", decoded.state);
492    }
493
494    #[test]
495    fn remote_auth_request_rejects_non_loopback_callback() {
496        let request = RemoteAuthRequest {
497            version: REMOTE_AUTH_FORMAT_VERSION,
498            authorization_url: "https://api.example/v2/oauth/authorize?state=state-1&redirect_uri=https%3A%2F%2Fevil.example%2Fcallback".to_string(),
499            callback_url: "https://evil.example/callback".to_string(),
500            state: "state-1".to_string(),
501        };
502
503        validate_remote_auth_request(&request)
504            .expect_err("non-loopback callbacks must be rejected");
505    }
506}