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(path) = &self.args.credential {
51 import_credential_file(path, self.api_base_override)?;
52 return Ok(0);
53 }
54 if let Some(request) = self.args.remote_auth_relay.as_deref() {
55 return relay_remote_auth(request, self.args.wait_timeout_secs);
56 }
57
58 let installed_agent_api_base = crate::api::installed_agent_api_base()?;
59 let api_base =
60 resolve_api_base(self.api_base_override, installed_agent_api_base.as_deref())?;
61
62 if self.args.remote_auth {
63 return self.run_remote_auth(&api_base);
64 }
65
66 self.run_local_browser_auth(&api_base)
67 }
68
69 fn run_local_browser_auth(&self, api_base: &str) -> Result<i32> {
70 self.complete(api_base, false)
71 }
72
73 fn run_remote_auth(&self, api_base: &str) -> Result<i32> {
74 self.complete(api_base, true)
75 }
76
77 fn complete(&self, api_base: &str, remote: bool) -> Result<i32> {
78 let proof = browser_proof(
79 api_base,
80 remote,
81 Duration::from_secs(self.args.wait_timeout_secs),
82 )?;
83 proof.finish(api_base)?;
84 Ok(0)
85 }
86}
87
88pub fn import_credential_file(path: &std::path::Path, api_override: Option<&str>) -> Result<()> {
89 anyhow::ensure!(
90 std::fs::metadata(path)?.len() <= 64 * 1024,
91 "credential file is too large"
92 );
93 let credential: aegis_dto::identity::UserCredential =
94 serde_json::from_slice(&std::fs::read(path)?).context("invalid Aegis user credential")?;
95 credential.validate()?;
96 anyhow::ensure!(
97 api_override.is_none_or(|endpoint| endpoint == credential.api_base),
98 "API override differs from the credential endpoint"
99 );
100 let _lock = crate::locks::user_auth_lock()?;
101 let task = ui::task(TaskOptions {
102 label: "Importing user credential".into(),
103 deadline: Some(Duration::from_secs(30)),
104 ..Default::default()
105 })?;
106 anyhow::ensure!(
107 credential.expires_unix > now_unix(),
108 "credential expired; request a new credential"
109 );
110 let auth = crate::api::exchange_refresh_token(
111 &credential.api_base,
112 &credential.refresh_token,
113 now_unix(),
114 )?;
115 let claims = phylax_core::dangerous::decode_unverified_claims::<phylax_core::AccessClaims>(
116 &auth.access_token,
117 )?;
118 anyhow::ensure!(
119 auth.principal == credential.user_id
120 && claims.sub.strip_kind("user") == Some(&credential.user_id)
121 && claims.iss
122 == aegis_dto::namespace::ApiEndpoint::parse(&credential.api_base)
123 .map_err(anyhow::Error::msg)?
124 .service_url()
125 && claims.sid.as_deref() == Some(&credential.session_id),
126 "API returned a different user or session"
127 );
128 persist_user_auth_state(&auth).context("credential exchanged but local session could not be saved; revoke its session and issue a replacement")?;
129 crate::config::UserContext {
130 api_base: credential.api_base,
131 }
132 .persist()
133 .context("user session saved; endpoint configuration could not be saved")?;
134 std::fs::remove_file(path)
135 .context("user session and endpoint saved; remove the imported credential file manually")?;
136 task.finish(format!("Signed in as {}", auth.principal));
137 Ok(())
138}
139
140pub struct BrowserProof {
141 pub code: String,
142 pub verifier: String,
143 pub callback: Url,
144}
145
146impl BrowserProof {
147 pub fn finish(self, api_base: &str) -> Result<()> {
148 let exchange = ui::task(TaskOptions {
149 label: "Completing sign-in".into(),
150 deadline: Some(Duration::from_secs(30)),
151 ..Default::default()
152 })?;
153 let auth = finish_browser_login(
154 api_base,
155 &self.callback,
156 &self.code,
157 &self.verifier,
158 now_unix(),
159 )?;
160 persist_user_auth_state(&auth)?;
161 exchange.finish(format!("Logged in as {}", auth.principal));
162 Ok(())
163 }
164}
165
166pub fn browser_proof(api_base: &str, remote: bool, timeout: Duration) -> Result<BrowserProof> {
167 let endpoint =
168 aegis_dto::namespace::ApiEndpoint::parse(api_base).map_err(anyhow::Error::msg)?;
169 let checking = ui::task(TaskOptions {
170 label: "Checking sign-in methods".into(),
171 deadline: Some(Duration::from_secs(30)),
172 ..Default::default()
173 })?;
174 let info: serde_json::Value = reqwest::blocking::Client::builder()
175 .connect_timeout(Duration::from_secs(10))
176 .timeout(Duration::from_secs(30))
177 .redirect(reqwest::redirect::Policy::none())
178 .build()?
179 .get(format!("{}/info", endpoint.service_url()))
180 .send()?
181 .error_for_status()?
182 .json()?;
183 anyhow::ensure!(
184 info["issuer"] == endpoint.service_url(),
185 "endpoint answered for a different Aegis issuer"
186 );
187 anyhow::ensure!(
188 info["authentication"]["oauth"] == true,
189 "Browser OAuth is not enabled. Ask your administrator for a credential, then run `aegis manage login --credential FILE`"
190 );
191 checking.finish_and_clear();
192 ui::require_interactive("Browser sign-in requires an interactive terminal")?;
193 let prepare = ui::task(TaskOptions {
194 label: "Preparing browser sign-in".into(),
195 ..Default::default()
196 })?;
197 let listener = CallbackListener::bind()?;
198 let callback = listener.callback_url().clone();
199 let login = start_browser_login(api_base, &callback)?;
200 prepare.finish_and_clear();
201 let started = Instant::now();
202 let (code, state) = if remote {
203 let request = encode_remote_auth_request(&RemoteAuthRequest {
204 version: REMOTE_AUTH_FORMAT_VERSION,
205 authorization_url: login.authorization_url.to_string(),
206 callback_url: callback.to_string(),
207 state: login.state.clone(),
208 })?;
209 ui::stage(&format!(
210 "On the machine with a browser, run:\n aegis manage login --remote-auth-relay {request}"
211 ));
212 ui::detail(&format!(
213 "Waiting for your relay response until {} UTC. Complete the relay promptly after browser authorization.",
214 time::OffsetDateTime::now_utc()
215 + time::Duration::seconds(timeout.as_secs().try_into()?)
216 ));
217 let response = ui::suspend(prompt_remote_auth_response)?;
218 anyhow::ensure!(
219 started.elapsed() < timeout,
220 "remote sign-in attempt expired; start a new attempt"
221 );
222 let response = decode_remote_auth_response(&response)?;
223 (response.code, response.state)
224 } else {
225 ui::maybe_open_browser(login.authorization_url.as_str());
226 print_authorization_url(&login.authorization_url);
227 let wait = ui::task(TaskOptions {
228 label: "Waiting for browser authorization".into(),
229 deadline: Some(timeout),
230 visibility: TaskVisibility::Immediate,
231 ..Default::default()
232 })?;
233 let response = listener.wait(timeout)?;
234 wait.finish_and_clear();
235 (response.code, response.state)
236 };
237 anyhow::ensure!(
238 state == login.state,
239 "OAuth callback returned the wrong state"
240 );
241 Ok(BrowserProof {
242 code,
243 verifier: login.pkce_verifier,
244 callback,
245 })
246}
247
248fn relay_remote_auth(encoded_request: &str, wait_timeout_secs: u64) -> Result<i32> {
249 ui::require_interactive(
250 "`aegis manage login --remote-auth-relay` requires an interactive terminal",
251 )?;
252 let request = decode_remote_auth_request(encoded_request)?;
253 let (authorization_url, callback_url) = validate_remote_auth_request(&request)?;
254 let listener = CallbackListener::bind_callback(&callback_url)?;
255
256 ui::current().info("Opening browser for remote OAuth login");
257 ui::maybe_open_browser(authorization_url.as_str());
258 print_authorization_url(&authorization_url);
259
260 let callback_timeout = Duration::from_secs(wait_timeout_secs);
261 let wait = ui::task(TaskOptions {
262 label: "Waiting for browser authorization".to_string(),
263 deadline: Some(callback_timeout),
264 visibility: TaskVisibility::Immediate,
265 ..TaskOptions::default()
266 })?;
267 let callback = listener.wait(callback_timeout)?;
268 wait.finish("Browser authorization completed");
269 if callback.state != request.state {
270 bail!("oauth callback returned the wrong state");
271 }
272
273 let response = encode_remote_auth_response(&RemoteAuthResponse {
274 version: REMOTE_AUTH_FORMAT_VERSION,
275 code: callback.code,
276 state: callback.state,
277 })?;
278 ui::stage("Paste this response into the waiting remote prompt:");
279 println!("{response}");
280 ui::success("Remote authentication response ready.");
281 Ok(0)
282}
283
284fn print_authorization_url(url: &Url) {
285 ui::stage(&format!("Authorization URL: {url}"));
286}
287
288fn prompt_remote_auth_response() -> Result<String> {
289 eprint!("Paste the remote authentication response: ");
290 io::stderr()
291 .flush()
292 .context("failed to flush remote authentication prompt")?;
293 let mut response = String::new();
294 io::stdin()
295 .read_line(&mut response)
296 .context("failed to read remote authentication response")?;
297 let response = response.trim();
298 if response.is_empty() {
299 bail!("remote authentication response must not be empty");
300 }
301 Ok(response.to_string())
302}
303
304fn encode_remote_auth_request(request: &RemoteAuthRequest) -> Result<String> {
305 encode_remote_auth_value(request, "request")
306}
307
308fn encode_remote_auth_response(response: &RemoteAuthResponse) -> Result<String> {
309 encode_remote_auth_value(response, "response")
310}
311
312fn encode_remote_auth_value<T: Serialize>(value: &T, kind: &str) -> Result<String> {
313 let json = serde_json::to_vec(value)
314 .with_context(|| format!("failed to encode remote authentication {kind}"))?;
315 Ok(URL_SAFE_NO_PAD.encode(json))
316}
317
318fn decode_remote_auth_request(encoded: &str) -> Result<RemoteAuthRequest> {
319 let request: RemoteAuthRequest = decode_remote_auth_value(encoded, "request")?;
320 if request.version != REMOTE_AUTH_FORMAT_VERSION {
321 bail!(
322 "unsupported remote authentication request version {}",
323 request.version
324 );
325 }
326 Ok(request)
327}
328
329fn decode_remote_auth_response(encoded: &str) -> Result<RemoteAuthResponse> {
330 let response: RemoteAuthResponse = decode_remote_auth_value(encoded, "response")?;
331 if response.version != REMOTE_AUTH_FORMAT_VERSION {
332 bail!(
333 "unsupported remote authentication response version {}",
334 response.version
335 );
336 }
337 if response.code.trim().is_empty() || response.state.trim().is_empty() {
338 bail!("remote authentication response is incomplete");
339 }
340 Ok(response)
341}
342
343fn decode_remote_auth_value<T: for<'de> Deserialize<'de>>(encoded: &str, kind: &str) -> Result<T> {
344 let json = URL_SAFE_NO_PAD
345 .decode(encoded.trim())
346 .with_context(|| format!("failed to decode remote authentication {kind}"))?;
347 serde_json::from_slice(&json)
348 .with_context(|| format!("failed to parse remote authentication {kind}"))
349}
350
351fn validate_remote_auth_request(request: &RemoteAuthRequest) -> Result<(Url, Url)> {
352 let authorization_url = Url::parse(&request.authorization_url)
353 .context("remote authentication request has an invalid authorization URL")?;
354 validate_authorization_url(&authorization_url)?;
355 let callback_url = Url::parse(&request.callback_url)
356 .context("remote authentication request has an invalid callback URL")?;
357 validate_callback_url(&callback_url)?;
358
359 let redirect_uri = query_value(&authorization_url, "redirect_uri")?;
360 if redirect_uri != callback_url.as_str() {
361 bail!("remote authentication request callback URL does not match its authorization URL");
362 }
363 let state = query_value(&authorization_url, "state")?;
364 if state != request.state || state.trim().is_empty() {
365 bail!("remote authentication request has an inconsistent OAuth state");
366 }
367 Ok((authorization_url, callback_url))
368}
369
370fn validate_authorization_url(url: &Url) -> Result<()> {
371 if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() {
372 bail!("remote authentication authorization URL contains forbidden URL components");
373 }
374 let secure = url.scheme() == "https";
375 let loopback_http =
376 url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "::1" | "localhost"));
377 if !secure && !loopback_http {
378 bail!("remote authentication authorization URL must use HTTPS");
379 }
380 Ok(())
381}
382
383fn validate_callback_url(url: &Url) -> Result<()> {
384 if url.scheme() != "http"
385 || url.host_str() != Some("127.0.0.1")
386 || url.port().is_none()
387 || url.path() != "/callback"
388 || url.query().is_some()
389 || url.fragment().is_some()
390 || !url.username().is_empty()
391 || url.password().is_some()
392 {
393 bail!("remote authentication callback must be an unadorned 127.0.0.1 HTTP callback URL");
394 }
395 Ok(())
396}
397
398struct CallbackListener {
399 listener: TcpListener,
400 callback_url: Url,
401}
402
403impl CallbackListener {
404 fn bind() -> Result<Self> {
405 let listener = TcpListener::bind("127.0.0.1:0")
406 .context("failed to bind local login callback listener")?;
407 listener
408 .set_nonblocking(true)
409 .context("failed to configure local login callback listener")?;
410 let callback_url = Url::parse(&format!(
411 "http://127.0.0.1:{}/callback",
412 listener.local_addr()?.port()
413 ))
414 .context("failed to build local callback url")?;
415 Ok(Self {
416 listener,
417 callback_url,
418 })
419 }
420
421 fn bind_callback(callback_url: &Url) -> Result<Self> {
422 validate_callback_url(callback_url)?;
423 let port = callback_url
424 .port()
425 .ok_or_else(|| anyhow!("remote authentication callback URL has no port"))?;
426 let listener = TcpListener::bind(("127.0.0.1", port)).with_context(|| {
427 format!(
428 "failed to bind local remote-auth callback listener on 127.0.0.1:{port}; rerun the remote command to obtain a new request"
429 )
430 })?;
431 listener
432 .set_nonblocking(true)
433 .context("failed to configure local remote-auth callback listener")?;
434 Ok(Self {
435 listener,
436 callback_url: callback_url.clone(),
437 })
438 }
439
440 fn callback_url(&self) -> &Url {
441 &self.callback_url
442 }
443
444 fn wait(&self, timeout: Duration) -> Result<LoginCallback> {
445 let deadline = Instant::now() + timeout;
446 let mut buffer = [0u8; 8192];
447
448 loop {
449 match self.listener.accept() {
450 Ok((mut stream, _)) => {
451 let bytes_read = stream
452 .read(&mut buffer)
453 .context("failed to read oauth callback request")?;
454 let request = String::from_utf8_lossy(&buffer[..bytes_read]);
455 let path = parse_http_request_path(&request)?;
456 let callback_url = Url::parse(&format!("http://127.0.0.1{path}"))
457 .context("failed to parse callback url")?;
458 if let Some(error) = query_optional_value(&callback_url, "error") {
459 let description = query_optional_value(&callback_url, "error_description")
460 .unwrap_or_default();
461 let response = html_response(ERROR_HTML);
462 let _ = stream.write_all(response.as_bytes());
463 let _ = stream.flush();
464 let detail = if description.is_empty() {
465 error
466 } else {
467 format!("{error}: {description}")
468 };
469 bail!("oauth callback reported an error: {detail}");
470 }
471 let code = query_value(&callback_url, "code")?;
472 let state = query_value(&callback_url, "state")?;
473
474 let response = html_response(SUCCESS_HTML);
475 stream
476 .write_all(response.as_bytes())
477 .context("failed to write oauth callback response")?;
478 stream.flush().ok();
479
480 return Ok(LoginCallback { code, state });
481 }
482 Err(error) if error.kind() == ErrorKind::WouldBlock => {
483 if Instant::now() >= deadline {
484 bail!("timed out waiting for browser login callback");
485 }
486 ui::sleep(Duration::from_millis(100))?;
487 }
488 Err(error) => {
489 return Err(error).context("failed waiting for browser login callback");
490 }
491 }
492 }
493 }
494}
495
496struct LoginCallback {
497 code: String,
498 state: String,
499}
500
501pub(super) fn parse_http_request_path(request: &str) -> Result<String> {
502 let first_line = request
503 .lines()
504 .next()
505 .ok_or_else(|| anyhow!("empty http callback request"))?;
506 let mut parts = first_line.split_whitespace();
507 let method = parts.next().unwrap_or_default();
508 let path = parts.next().unwrap_or_default();
509 if method != "GET" || path.is_empty() {
510 bail!("unexpected oauth callback request line: {first_line}");
511 }
512 Ok(path.to_string())
513}
514
515fn query_value(url: &Url, name: &str) -> Result<String> {
516 url.query_pairs()
517 .find(|(key, _)| key == name)
518 .map(|(_, value)| value.into_owned())
519 .ok_or_else(|| anyhow!("oauth callback missing `{name}`"))
520}
521
522fn query_optional_value(url: &Url, name: &str) -> Option<String> {
523 url.query_pairs()
524 .find(|(key, _)| key == name)
525 .map(|(_, value)| value.into_owned())
526}
527
528fn html_response(html: &str) -> String {
529 format!(
530 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
531 html.len(),
532 html
533 )
534}
535
536#[cfg(test)]
537mod tests {
538 use super::{
539 REMOTE_AUTH_FORMAT_VERSION, RemoteAuthRequest, RemoteAuthResponse,
540 decode_remote_auth_request, decode_remote_auth_response, encode_remote_auth_request,
541 encode_remote_auth_response, validate_remote_auth_request,
542 };
543
544 #[test]
545 fn remote_auth_request_round_trip_preserves_browser_handoff() {
546 let request = RemoteAuthRequest {
547 version: REMOTE_AUTH_FORMAT_VERSION,
548 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(),
549 callback_url: "http://127.0.0.1:43123/callback".to_string(),
550 state: "state-1".to_string(),
551 };
552
553 let encoded = encode_remote_auth_request(&request).expect("request should encode");
554 assert!(!encoded.contains('='));
555 let decoded = decode_remote_auth_request(&encoded).expect("request should decode");
556 let (_, callback) =
557 validate_remote_auth_request(&decoded).expect("request should validate");
558 assert_eq!("http://127.0.0.1:43123/callback", callback.as_str());
559 }
560
561 #[test]
562 fn remote_auth_response_round_trip_contains_only_code_and_state() {
563 let response = RemoteAuthResponse {
564 version: REMOTE_AUTH_FORMAT_VERSION,
565 code: "one-time-code".to_string(),
566 state: "state-1".to_string(),
567 };
568
569 let encoded = encode_remote_auth_response(&response).expect("response should encode");
570 let decoded = decode_remote_auth_response(&encoded).expect("response should decode");
571 assert_eq!("one-time-code", decoded.code);
572 assert_eq!("state-1", decoded.state);
573 }
574
575 #[test]
576 fn remote_auth_request_rejects_non_loopback_callback() {
577 let request = RemoteAuthRequest {
578 version: REMOTE_AUTH_FORMAT_VERSION,
579 authorization_url: "https://api.example/v2/oauth/authorize?state=state-1&redirect_uri=https%3A%2F%2Fevil.example%2Fcallback".to_string(),
580 callback_url: "https://evil.example/callback".to_string(),
581 state: "state-1".to_string(),
582 };
583
584 validate_remote_auth_request(&request)
585 .expect_err("non-loopback callbacks must be rejected");
586 }
587}