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}