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(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}