1use crate::config::{
2 UserAuthState, load_user_auth_state, now_unix, persist_user_auth_state, resolve_api_base,
3};
4use aegis_dto::{
5 HostAlias, HostId, path,
6 protocol::{
7 AegisAliasResponse, AegisCredentialKind, AegisDirectClientCertRequest,
8 AegisDirectClientCertResponse, AegisDirectGateway, AegisDirectGatewayInventory,
9 AegisDirectGatewayPublishRequest, AegisDirectTargetListResponse, AegisDnsSyncRequest,
10 AegisDnsSyncResponse, AegisEgressEnableRequest, AegisEgressIdentityRequest,
11 AegisEgressInventory, AegisEgressResult, AegisEgressStatus, AegisEnrollment,
12 AegisEnrollmentActivateResponse, AegisEnrollmentCreateRequest,
13 AegisEnrollmentCredentialResponse, AegisEnrollmentHeartbeatRequest,
14 AegisEnrollmentListResponse, AegisEnrollmentPrepareRequest, AegisEnrollmentPrepareResponse,
15 AegisHost, AegisHostClientCertRequest, AegisHostListResponse, AegisHostReportRequest,
16 AegisHostReportResponse, AegisNetworkListResponse, AegisNetworkMemberListResponse,
17 AegisNetworkMemberResponse, AegisPutNetworkMemberRequest, AegisSatelliteCreateRequest,
18 AegisSatelliteDetailsResponse, AegisSatelliteListResponse, AegisSatelliteProvisionResponse,
19 AegisTlsSyncRequest, AegisTlsSyncResponse, AgentTokenIssueResponse, AgentTokenRequest,
20 AgentTokenResponse, AgentTokenRevokeRequest, ErrorResponse, SshCaPublicKeyResponse,
21 SshIssueCertResponse,
22 },
23};
24use anyhow::{Context, Result, anyhow, bail};
25use hickory_resolver::{
26 TokioResolver,
27 config::{LookupIpStrategy, NameServerConfig, ResolverConfig, ResolverOpts},
28 net::runtime::TokioRuntimeProvider,
29};
30use oauth2::{
31 AuthType, AuthUrl, AuthorizationCode, Client as OAuthClient, ClientId, CsrfToken,
32 EndpointNotSet, ExtraTokenFields, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl,
33 StandardRevocableToken, StandardTokenIntrospectionResponse, StandardTokenResponse,
34 TokenResponse, TokenUrl,
35 basic::{BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenType},
36};
37use phylax_core::{AccessClaims, dangerous::decode_unverified_claims, oauth};
38use reqwest::{
39 StatusCode,
40 blocking::{Client, Response},
41 dns::{Addrs, Name, Resolve, Resolving},
42 header::{CACHE_CONTROL, CONTENT_LENGTH, HeaderMap, HeaderValue},
43};
44use serde::{Deserialize, Serialize};
45use std::{
46 fmt,
47 net::{IpAddr, SocketAddr},
48 time::Duration,
49};
50use url::Url;
51
52#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
53struct AegisOAuthTokenExtraFields {
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 refresh_expires_in: Option<u64>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 principal: Option<String>,
58}
59
60impl ExtraTokenFields for AegisOAuthTokenExtraFields {}
61
62type AegisTokenResponse = StandardTokenResponse<AegisOAuthTokenExtraFields, BasicTokenType>;
63type AegisOauthClient<HasAuthUrl = EndpointNotSet, HasTokenUrl = EndpointNotSet> = OAuthClient<
64 BasicErrorResponse,
65 AegisTokenResponse,
66 StandardTokenIntrospectionResponse<AegisOAuthTokenExtraFields, BasicTokenType>,
67 StandardRevocableToken,
68 BasicRevocationErrorResponse,
69 HasAuthUrl,
70 EndpointNotSet,
71 EndpointNotSet,
72 EndpointNotSet,
73 HasTokenUrl,
74>;
75const ACCESS_TOKEN_REFRESH_SKEW_SECONDS: i64 = 30;
76const AEGIS_TOOL_CLIENT_ID: &str = "aegis-tool";
77#[cfg(test)]
78const AEGIS_ADMIN_SCOPE: &str = "aegis:admin";
79const API_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
80const API_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
81const OAUTH_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
82const OAUTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
83
84#[derive(Clone, Debug)]
85pub struct AgentAccessState {
86 pub access_token: String,
87 pub host_id: HostId,
88 pub credential_kind: AegisCredentialKind,
89 pub refresh_token: String,
90 pub access_expires_at_unix: i64,
91}
92
93impl AgentAccessState {
94 pub fn access_needs_refresh(&self, now_unix: i64, skew_seconds: i64) -> bool {
95 now_unix.saturating_add(skew_seconds) >= self.access_expires_at_unix
96 }
97}
98
99#[derive(Debug)]
100pub enum ApiClientError {
101 Transport(anyhow::Error),
102 Status { status: StatusCode, message: String },
103}
104
105impl ApiClientError {
106 pub fn is_unauthorized(&self) -> bool {
107 matches!(
108 self,
109 Self::Status {
110 status: StatusCode::UNAUTHORIZED,
111 ..
112 }
113 )
114 }
115}
116
117impl fmt::Display for ApiClientError {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match self {
120 Self::Transport(error) => write!(f, "{error}"),
121 Self::Status { status, message } if message.is_empty() => {
122 write!(f, "api request failed with {status}")
123 }
124 Self::Status { status, message } => {
125 write!(f, "api request failed with {status}: {message}")
126 }
127 }
128 }
129}
130
131impl std::error::Error for ApiClientError {
132 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
133 match self {
134 Self::Transport(error) => Some(error.as_ref()),
135 Self::Status { .. } => None,
136 }
137 }
138}
139
140pub type ApiResult<T> = std::result::Result<T, ApiClientError>;
141
142#[derive(Clone, Debug)]
143pub struct BrowserLoginStart {
144 pub authorization_url: Url,
145 pub state: String,
146 pub pkce_verifier: String,
147}
148
149#[derive(Clone)]
150pub struct ApiClient {
151 base_url: String,
152 http: Client,
153 request_timeout: Duration,
154}
155
156#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
157enum ApiDnsMode {
158 #[default]
159 System,
160 AgentControl,
161}
162
163#[derive(Clone, Copy, Debug)]
164struct ApiClientOptions {
165 dns: ApiDnsMode,
166 connect_timeout: Duration,
167 request_timeout: Duration,
168}
169
170impl Default for ApiClientOptions {
171 fn default() -> Self {
172 Self {
173 dns: ApiDnsMode::System,
174 connect_timeout: API_CONNECT_TIMEOUT,
175 request_timeout: API_REQUEST_TIMEOUT,
176 }
177 }
178}
179
180#[derive(Clone)]
181struct AgentControlDnsResolver {
182 resolver: TokioResolver,
183}
184
185impl AgentControlDnsResolver {
186 fn new() -> Self {
187 let name_servers = [
188 IpAddr::from([1, 1, 1, 1]),
189 IpAddr::from([1, 0, 0, 1]),
190 IpAddr::from([8, 8, 8, 8]),
191 IpAddr::from([8, 8, 4, 4]),
192 "2606:4700:4700::1111".parse().expect("valid address"),
193 "2606:4700:4700::1001".parse().expect("valid address"),
194 "2001:4860:4860::8888".parse().expect("valid address"),
195 "2001:4860:4860::8844".parse().expect("valid address"),
196 ]
197 .into_iter()
198 .map(NameServerConfig::udp_and_tcp)
199 .collect();
200 let config = ResolverConfig::from_parts(None, Vec::new(), name_servers);
201 let mut options = ResolverOpts::default();
202 options.ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
203 let resolver = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default())
204 .with_options(options)
205 .build()
206 .expect("valid DNS resolver configuration");
207 Self { resolver }
208 }
209}
210
211impl Resolve for AgentControlDnsResolver {
212 fn resolve(&self, name: Name) -> Resolving {
213 let resolver = self.resolver.clone();
214 Box::pin(async move {
215 let lookup = resolver.lookup_ip(name.as_str()).await?;
216 let addresses = lookup
217 .iter()
218 .map(|address| SocketAddr::new(address, 0))
219 .collect::<Vec<_>>();
220 let addresses: Addrs = Box::new(addresses.into_iter());
221 Ok(addresses)
222 })
223 }
224}
225
226impl ApiClient {
227 pub fn new(base_url: impl Into<String>) -> Result<Self> {
228 Self::with_options(base_url, ApiClientOptions::default())
229 }
230
231 pub(crate) fn new_agent_control(base_url: impl Into<String>) -> Result<Self> {
232 Self::with_options(
233 base_url,
234 ApiClientOptions {
235 dns: ApiDnsMode::AgentControl,
236 ..ApiClientOptions::default()
237 },
238 )
239 }
240
241 fn with_options(base_url: impl Into<String>, options: ApiClientOptions) -> Result<Self> {
242 let base_url = aegis_dto::namespace::ApiEndpoint::parse(&base_url.into())
243 .map_err(anyhow::Error::msg)?;
244 base_url.require_namespace().map_err(anyhow::Error::msg)?;
245 let base_url = base_url.base_url();
246 let mut default_headers = HeaderMap::new();
247 default_headers.insert(
248 CACHE_CONTROL,
249 HeaderValue::from_static("no-cache, no-store"),
250 );
251 let mut builder = Client::builder()
252 .user_agent(format!("aegis-tool/{}", env!("CARGO_PKG_VERSION")))
253 .connect_timeout(options.connect_timeout)
254 .timeout(options.request_timeout)
255 .default_headers(default_headers);
256 if options.dns == ApiDnsMode::AgentControl {
257 builder = builder.dns_resolver(std::sync::Arc::new(AgentControlDnsResolver::new()));
258 }
259 let http = builder.build()?;
260 Ok(Self {
261 base_url,
262 http,
263 request_timeout: options.request_timeout,
264 })
265 }
266
267 fn operation_request_timeout(&self) -> ApiResult<Duration> {
268 crate::tunnel_operation::request_timeout(self.request_timeout)
269 .map_err(ApiClientError::Transport)
270 }
271
272 pub fn get_namespace_context(
273 &self,
274 token: &str,
275 ) -> ApiResult<aegis_dto::namespace::NamespaceContext> {
276 let response = self
277 .http
278 .get(
279 self.url("/aegis/context")
280 .map_err(ApiClientError::Transport)?,
281 )
282 .bearer_auth(token)
283 .send()
284 .map_err(|error| transport_error(error, "failed to fetch namespace membership"))?;
285 parse_json_response(response)
286 }
287
288 pub fn get_hosts(&self, token: &str) -> ApiResult<AegisHostListResponse> {
289 let response = self
290 .http
291 .get(
292 self.url(path::AEGIS_HOSTS)
293 .map_err(ApiClientError::Transport)?,
294 )
295 .bearer_auth(token)
296 .send()
297 .map_err(|error| transport_error(error, "failed to fetch host inventory"))?;
298 parse_json_response(response)
299 }
300
301 pub fn create_enrollment(
302 &self,
303 token: &str,
304 request: &AegisEnrollmentCreateRequest,
305 ) -> ApiResult<AegisEnrollment> {
306 let response = self
307 .http
308 .post(
309 self.url(path::AEGIS_ENROLLMENTS)
310 .map_err(ApiClientError::Transport)?,
311 )
312 .bearer_auth(token)
313 .json(request)
314 .send()
315 .map_err(|error| transport_error(error, "failed to create Aegis enrollment"))?;
316 parse_json_response(response)
317 }
318
319 pub fn get_enrollments(&self, token: &str) -> ApiResult<AegisEnrollmentListResponse> {
320 let response = self
321 .http
322 .get(
323 self.url(path::AEGIS_ENROLLMENTS)
324 .map_err(ApiClientError::Transport)?,
325 )
326 .bearer_auth(token)
327 .send()
328 .map_err(|error| transport_error(error, "failed to list Aegis enrollments"))?;
329 parse_json_response(response)
330 }
331
332 pub fn get_enrollment(&self, token: &str, host_id: &HostId) -> ApiResult<AegisEnrollment> {
333 let response = self
334 .http
335 .get(
336 self.url(&path::aegis_enrollment(host_id))
337 .map_err(ApiClientError::Transport)?,
338 )
339 .bearer_auth(token)
340 .send()
341 .map_err(|error| {
342 transport_error(error, format!("failed to fetch Aegis enrollment {host_id}"))
343 })?;
344 parse_json_response(response)
345 }
346
347 pub fn issue_enrollment_credential(
348 &self,
349 token: &str,
350 host_id: &HostId,
351 ) -> ApiResult<AegisEnrollmentCredentialResponse> {
352 let response = self
353 .http
354 .post(
355 self.url(&path::aegis_enrollment_credential(host_id))
356 .map_err(ApiClientError::Transport)?,
357 )
358 .bearer_auth(token)
359 .header(CONTENT_LENGTH, "0")
360 .body(Vec::new())
361 .send()
362 .map_err(|error| {
363 transport_error(
364 error,
365 format!("failed to issue credential for Aegis enrollment {host_id}"),
366 )
367 })?;
368 parse_json_response(response)
369 }
370
371 pub fn delete_enrollment(&self, token: &str, host_id: &HostId) -> ApiResult<()> {
372 let response = self
373 .http
374 .delete(
375 self.url(&path::aegis_enrollment(host_id))
376 .map_err(ApiClientError::Transport)?,
377 )
378 .bearer_auth(token)
379 .send()
380 .map_err(|error| {
381 transport_error(
382 error,
383 format!("failed to cancel Aegis enrollment {host_id}"),
384 )
385 })?;
386 parse_empty_response(response)
387 }
388
389 pub fn prepare_enrollment(
390 &self,
391 token: &str,
392 host_id: &HostId,
393 request: &AegisEnrollmentPrepareRequest,
394 ) -> ApiResult<AegisEnrollmentPrepareResponse> {
395 let response = self
396 .http
397 .post(
398 self.url(&path::aegis_enrollment_prepare(host_id))
399 .map_err(ApiClientError::Transport)?,
400 )
401 .bearer_auth(token)
402 .json(request)
403 .send()
404 .map_err(|error| {
405 transport_error(
406 error,
407 format!("failed to prepare Aegis enrollment {host_id}"),
408 )
409 })?;
410 parse_json_response(response)
411 }
412
413 pub fn heartbeat_enrollment(
414 &self,
415 token: &str,
416 host_id: &HostId,
417 request: &AegisEnrollmentHeartbeatRequest,
418 ) -> ApiResult<AegisEnrollment> {
419 let response = self
420 .http
421 .post(
422 self.url(&path::aegis_enrollment_heartbeat(host_id))
423 .map_err(ApiClientError::Transport)?,
424 )
425 .bearer_auth(token)
426 .json(request)
427 .send()
428 .map_err(|error| {
429 transport_error(
430 error,
431 format!("failed to update Aegis enrollment {host_id} progress"),
432 )
433 })?;
434 parse_json_response(response)
435 }
436
437 pub fn activate_enrollment(
438 &self,
439 token: &str,
440 host_id: &HostId,
441 ) -> ApiResult<AegisEnrollmentActivateResponse> {
442 let response = self
443 .http
444 .post(
445 self.url(&path::aegis_enrollment_activate(host_id))
446 .map_err(ApiClientError::Transport)?,
447 )
448 .bearer_auth(token)
449 .header(CONTENT_LENGTH, "0")
450 .body(Vec::new())
451 .send()
452 .map_err(|error| {
453 transport_error(
454 error,
455 format!("failed to activate Aegis enrollment {host_id}"),
456 )
457 })?;
458 parse_json_response(response)
459 }
460
461 pub fn get_alias(&self, token: &str, alias: &HostAlias) -> ApiResult<AegisAliasResponse> {
462 let response = self
463 .http
464 .get(
465 self.url(&path::aegis_alias(alias))
466 .map_err(ApiClientError::Transport)?,
467 )
468 .bearer_auth(token)
469 .send()
470 .map_err(|error| {
471 transport_error(error, format!("failed to resolve host alias {alias}"))
472 })?;
473 parse_json_response(response)
474 }
475
476 pub fn add_host_alias(
477 &self,
478 token: &str,
479 host_id: &HostId,
480 alias: &HostAlias,
481 ) -> ApiResult<AegisHost> {
482 let response = self
483 .http
484 .put(
485 self.url(&path::aegis_host_alias(host_id, alias))
486 .map_err(ApiClientError::Transport)?,
487 )
488 .bearer_auth(token)
489 .header(CONTENT_LENGTH, "0")
490 .body(Vec::new())
491 .send()
492 .map_err(|error| {
493 transport_error(
494 error,
495 format!("failed to add alias {alias} to host {host_id}"),
496 )
497 })?;
498 parse_json_response(response)
499 }
500
501 pub fn promote_host_alias(
502 &self,
503 token: &str,
504 host_id: &HostId,
505 alias: &HostAlias,
506 ) -> ApiResult<AegisHost> {
507 let response = self
508 .http
509 .post(
510 self.url(&path::aegis_host_alias_promote(host_id, alias))
511 .map_err(ApiClientError::Transport)?,
512 )
513 .bearer_auth(token)
514 .header(CONTENT_LENGTH, "0")
515 .body(Vec::new())
516 .send()
517 .map_err(|error| {
518 transport_error(
519 error,
520 format!("failed to promote alias {alias} on host {host_id}"),
521 )
522 })?;
523 parse_json_response(response)
524 }
525
526 pub fn remove_host_alias(
527 &self,
528 token: &str,
529 host_id: &HostId,
530 alias: &HostAlias,
531 ) -> ApiResult<AegisHost> {
532 let response = self
533 .http
534 .delete(
535 self.url(&path::aegis_host_alias(host_id, alias))
536 .map_err(ApiClientError::Transport)?,
537 )
538 .bearer_auth(token)
539 .send()
540 .map_err(|error| {
541 transport_error(
542 error,
543 format!("failed to remove alias {alias} from host {host_id}"),
544 )
545 })?;
546 parse_json_response(response)
547 }
548
549 pub fn get_networks(&self, token: &str) -> ApiResult<AegisNetworkListResponse> {
550 let response = self
551 .http
552 .get(
553 self.url(path::AEGIS_NETWORKS)
554 .map_err(ApiClientError::Transport)?,
555 )
556 .bearer_auth(token)
557 .send()
558 .map_err(|error| transport_error(error, "failed to fetch aegis networks"))?;
559 parse_json_response(response)
560 }
561
562 pub fn get_network_members(
563 &self,
564 token: &str,
565 network: &str,
566 ) -> ApiResult<AegisNetworkMemberListResponse> {
567 let response = self
568 .http
569 .get(
570 self.url(&path::aegis_network_members(network))
571 .map_err(ApiClientError::Transport)?,
572 )
573 .bearer_auth(token)
574 .send()
575 .map_err(|error| {
576 transport_error(
577 error,
578 format!("failed to fetch `{network}` network inventory"),
579 )
580 })?;
581 parse_json_response(response)
582 }
583
584 pub fn report_host(
585 &self,
586 token: &str,
587 host_id: &HostId,
588 request: &AegisHostReportRequest,
589 ) -> ApiResult<AegisHostReportResponse> {
590 let response = self
591 .http
592 .put(
593 self.url(&path::aegis_host_report(host_id))
594 .map_err(ApiClientError::Transport)?,
595 )
596 .bearer_auth(token)
597 .json(request)
598 .send()
599 .map_err(|error| transport_error(error, "failed to publish host report"))?;
600 parse_json_response(response)
601 }
602
603 pub fn sync_dns(
604 &self,
605 token: &str,
606 request: &AegisDnsSyncRequest,
607 ) -> ApiResult<AegisDnsSyncResponse> {
608 let response = self
609 .http
610 .post(
611 self.url(path::AEGIS_DNS_SYNC)
612 .map_err(ApiClientError::Transport)?,
613 )
614 .bearer_auth(token)
615 .json(request)
616 .send()
617 .map_err(|error| transport_error(error, "failed to synchronize Aegis DNS"))?;
618 parse_json_response(response)
619 }
620
621 pub fn sync_tls(
622 &self,
623 token: &str,
624 request: &AegisTlsSyncRequest,
625 ) -> ApiResult<AegisTlsSyncResponse> {
626 let response = self
627 .http
628 .post(
629 self.url(path::AEGIS_TLS_SYNC)
630 .map_err(ApiClientError::Transport)?,
631 )
632 .bearer_auth(token)
633 .json(request)
634 .send()
635 .map_err(|error| {
636 transport_error(error, "failed to synchronize Aegis TLS configuration")
637 })?;
638 parse_json_response(response)
639 }
640
641 pub fn request_network_member_client_cert(
642 &self,
643 token: &str,
644 network: &str,
645 host_id: &HostId,
646 ed25519_public_key: &str,
647 ) -> ApiResult<SshIssueCertResponse> {
648 let response = self
649 .http
650 .post(
651 self.url(&path::aegis_network_member_client_cert(network, host_id))
652 .map_err(ApiClientError::Transport)?,
653 )
654 .bearer_auth(token)
655 .json(&AegisHostClientCertRequest {
656 ed25519_public_key: ed25519_public_key.to_string(),
657 })
658 .send()
659 .map_err(|error| {
660 transport_error(
661 error,
662 format!("failed to request client certificate for {network}/{host_id}"),
663 )
664 })?;
665 parse_json_response(response)
666 }
667
668 pub fn get_direct_gateway_inventory(
669 &self,
670 token: &str,
671 host_id: &HostId,
672 ) -> ApiResult<AegisDirectGatewayInventory> {
673 let response = self
674 .http
675 .get(
676 self.url(&path::aegis_direct_gateway_inventory(host_id))
677 .map_err(ApiClientError::Transport)?,
678 )
679 .bearer_auth(token)
680 .send()
681 .map_err(|error| {
682 transport_error(
683 error,
684 format!("failed to fetch direct-gateway inventory for {host_id}"),
685 )
686 })?;
687 parse_json_response(response)
688 }
689
690 pub fn put_direct_gateway(
691 &self,
692 token: &str,
693 host_id: &HostId,
694 request: &AegisDirectGatewayPublishRequest,
695 ) -> ApiResult<AegisDirectGateway> {
696 let response = self
697 .http
698 .put(
699 self.url(&path::aegis_direct_gateway(host_id))
700 .map_err(ApiClientError::Transport)?,
701 )
702 .bearer_auth(token)
703 .json(request)
704 .send()
705 .map_err(|error| {
706 transport_error(error, format!("failed to publish direct gateway {host_id}"))
707 })?;
708 parse_json_response(response)
709 }
710
711 pub fn delete_direct_gateway(&self, token: &str, host_id: &HostId) -> ApiResult<()> {
712 let response = self
713 .http
714 .delete(
715 self.url(&path::aegis_direct_gateway(host_id))
716 .map_err(ApiClientError::Transport)?,
717 )
718 .bearer_auth(token)
719 .send()
720 .map_err(|error| {
721 transport_error(error, format!("failed to remove direct gateway {host_id}"))
722 })?;
723 parse_empty_response(response)
724 }
725
726 pub fn get_egress_status(
727 &self,
728 token: &str,
729 source_host_id: &HostId,
730 ) -> ApiResult<AegisEgressStatus> {
731 let response = self
732 .http
733 .get(
734 self.url(&path::aegis_egress(source_host_id))
735 .map_err(ApiClientError::Transport)?,
736 )
737 .bearer_auth(token)
738 .timeout(self.operation_request_timeout()?)
739 .send()
740 .map_err(|error| {
741 transport_error(
742 error,
743 format!("failed to fetch egress status for {source_host_id}"),
744 )
745 })?;
746 parse_json_response(response)
747 }
748
749 pub fn put_egress(
750 &self,
751 token: &str,
752 source_host_id: &HostId,
753 request: &AegisEgressEnableRequest,
754 ) -> ApiResult<AegisEgressStatus> {
755 let response = self
756 .http
757 .put(
758 self.url(&path::aegis_egress(source_host_id))
759 .map_err(ApiClientError::Transport)?,
760 )
761 .bearer_auth(token)
762 .json(request)
763 .timeout(self.operation_request_timeout()?)
764 .send()
765 .map_err(|error| {
766 transport_error(
767 error,
768 format!("failed to enable egress on {source_host_id}"),
769 )
770 })?;
771 parse_json_response(response)
772 }
773
774 pub fn delete_egress(&self, token: &str, source_host_id: &HostId) -> ApiResult<()> {
775 let response = self
776 .http
777 .delete(
778 self.url(&path::aegis_egress(source_host_id))
779 .map_err(ApiClientError::Transport)?,
780 )
781 .bearer_auth(token)
782 .timeout(self.operation_request_timeout()?)
783 .send()
784 .map_err(|error| {
785 transport_error(
786 error,
787 format!("failed to disable egress on {source_host_id}"),
788 )
789 })?;
790 parse_empty_response(response)
791 }
792
793 pub fn put_egress_identity(
794 &self,
795 token: &str,
796 host_id: &HostId,
797 request: &AegisEgressIdentityRequest,
798 ) -> ApiResult<()> {
799 let response = self
800 .http
801 .put(
802 self.url(&path::aegis_host_egress(host_id))
803 .map_err(ApiClientError::Transport)?,
804 )
805 .bearer_auth(token)
806 .json(request)
807 .timeout(self.operation_request_timeout()?)
808 .send()
809 .map_err(|error| {
810 transport_error(
811 error,
812 format!("failed to publish egress identity for {host_id}"),
813 )
814 })?;
815 parse_empty_response(response)
816 }
817
818 pub fn get_egress_inventory(&self, token: &str) -> ApiResult<AegisEgressInventory> {
819 let response = self
820 .http
821 .get(
822 self.url(path::AEGIS_EGRESS_INVENTORY)
823 .map_err(ApiClientError::Transport)?,
824 )
825 .bearer_auth(token)
826 .timeout(self.operation_request_timeout()?)
827 .send()
828 .map_err(|error| transport_error(error, "failed to fetch egress inventory"))?;
829 parse_json_response(response)
830 }
831
832 pub fn post_egress_result(
833 &self,
834 token: &str,
835 source_host_id: &HostId,
836 result: &AegisEgressResult,
837 ) -> ApiResult<AegisEgressStatus> {
838 let response = self
839 .http
840 .post(
841 self.url(&path::aegis_egress_result(source_host_id))
842 .map_err(ApiClientError::Transport)?,
843 )
844 .bearer_auth(token)
845 .json(result)
846 .timeout(self.operation_request_timeout()?)
847 .send()
848 .map_err(|error| {
849 transport_error(
850 error,
851 format!("failed to publish egress result for {source_host_id}"),
852 )
853 })?;
854 parse_json_response(response)
855 }
856
857 pub fn put_satellite(
858 &self,
859 token: &str,
860 slug: &str,
861 request: &AegisSatelliteCreateRequest,
862 ) -> ApiResult<AegisSatelliteProvisionResponse> {
863 let response = self
864 .http
865 .put(
866 self.url(&path::aegis_satellite(slug))
867 .map_err(ApiClientError::Transport)?,
868 )
869 .bearer_auth(token)
870 .json(request)
871 .send()
872 .map_err(|error| {
873 transport_error(error, format!("failed to create satellite {slug}"))
874 })?;
875 parse_json_response(response)
876 }
877
878 pub fn get_satellites(&self, token: &str) -> ApiResult<AegisSatelliteListResponse> {
879 let response = self
880 .http
881 .get(
882 self.url(path::AEGIS_SATELLITES)
883 .map_err(ApiClientError::Transport)?,
884 )
885 .bearer_auth(token)
886 .send()
887 .map_err(|error| transport_error(error, "failed to fetch satellites"))?;
888 parse_json_response(response)
889 }
890
891 pub fn get_satellite(
892 &self,
893 token: &str,
894 slug: &str,
895 ) -> ApiResult<AegisSatelliteDetailsResponse> {
896 let response = self
897 .http
898 .get(
899 self.url(&path::aegis_satellite(slug))
900 .map_err(ApiClientError::Transport)?,
901 )
902 .bearer_auth(token)
903 .send()
904 .map_err(|error| transport_error(error, format!("failed to fetch satellite {slug}")))?;
905 parse_json_response(response)
906 }
907
908 pub fn get_satellite_targets(
909 &self,
910 token: &str,
911 slug: &str,
912 ) -> ApiResult<AegisDirectTargetListResponse> {
913 let response = self
914 .http
915 .get(
916 self.url(&path::aegis_satellite_targets(slug))
917 .map_err(ApiClientError::Transport)?,
918 )
919 .bearer_auth(token)
920 .send()
921 .map_err(|error| {
922 transport_error(
923 error,
924 format!("failed to fetch targets for satellite {slug}"),
925 )
926 })?;
927 parse_json_response(response)
928 }
929
930 pub fn request_satellite_client_cert(
931 &self,
932 token: &str,
933 slug: &str,
934 request: &AegisDirectClientCertRequest,
935 ) -> ApiResult<AegisDirectClientCertResponse> {
936 let response = self
937 .http
938 .post(
939 self.url(&path::aegis_satellite_client_cert(slug))
940 .map_err(ApiClientError::Transport)?,
941 )
942 .bearer_auth(token)
943 .json(request)
944 .send()
945 .map_err(|error| {
946 transport_error(
947 error,
948 format!("failed to request a client certificate for satellite {slug}"),
949 )
950 })?;
951 parse_json_response(response)
952 }
953
954 pub fn delete_satellite(&self, token: &str, slug: &str) -> ApiResult<()> {
955 let response = self
956 .http
957 .delete(
958 self.url(&path::aegis_satellite(slug))
959 .map_err(ApiClientError::Transport)?,
960 )
961 .bearer_auth(token)
962 .send()
963 .map_err(|error| {
964 transport_error(error, format!("failed to delete satellite {slug}"))
965 })?;
966 parse_empty_response(response)
967 }
968
969 pub fn get_client_ca_public_key(&self) -> ApiResult<SshCaPublicKeyResponse> {
970 let response = self
971 .http
972 .get(
973 self.url(path::AEGIS_USER_SSH_CA)
974 .map_err(ApiClientError::Transport)?,
975 )
976 .send()
977 .map_err(|error| transport_error(error, "failed to fetch client CA public key"))?;
978 parse_json_response(response)
979 }
980
981 pub(crate) fn issue_tls_certificate(
982 &self,
983 token: &str,
984 label: &str,
985 public_key: &str,
986 ) -> ApiResult<String> {
987 if label.is_empty()
988 || label.len() > 128
989 || !label
990 .bytes()
991 .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
992 {
993 return Err(ApiClientError::Transport(anyhow::anyhow!(
994 "invalid TLS certificate label"
995 )));
996 }
997 let response = self
998 .http
999 .put(
1000 self.url(&path::aegis_tls_cert_public_key(label))
1001 .map_err(ApiClientError::Transport)?,
1002 )
1003 .bearer_auth(token)
1004 .body(public_key.to_owned())
1005 .send()
1006 .map_err(|error| transport_error(error, "failed to publish TLS public key"))?;
1007 parse_empty_response(response)?;
1008 let response = self
1009 .http
1010 .get(
1011 self.url(&path::aegis_tls_cert(label))
1012 .map_err(ApiClientError::Transport)?,
1013 )
1014 .bearer_auth(token)
1015 .send()
1016 .map_err(|error| transport_error(error, "failed to retrieve TLS certificate"))?;
1017 let status = response.status();
1018 let text = response
1019 .text()
1020 .map_err(|error| transport_error(error, "failed to read TLS certificate"))?;
1021 if status.is_success() {
1022 Ok(text)
1023 } else {
1024 Err(status_error(status, text))
1025 }
1026 }
1027
1028 pub fn get_server_ca_public_key(&self) -> ApiResult<SshCaPublicKeyResponse> {
1029 let response = self
1030 .http
1031 .get(
1032 self.url(path::AEGIS_HOST_SSH_CA)
1033 .map_err(ApiClientError::Transport)?,
1034 )
1035 .send()
1036 .map_err(|error| transport_error(error, "failed to fetch server CA public key"))?;
1037 parse_json_response(response)
1038 }
1039
1040 pub fn request_network_member_server_cert(
1041 &self,
1042 token: &str,
1043 network: &str,
1044 host_id: &HostId,
1045 ) -> ApiResult<SshIssueCertResponse> {
1046 let response = self
1047 .http
1048 .post(
1049 self.url(&path::aegis_network_member_server_cert(network, host_id))
1050 .map_err(ApiClientError::Transport)?,
1051 )
1052 .bearer_auth(token)
1053 .header(CONTENT_LENGTH, "0")
1054 .body(Vec::new())
1055 .send()
1056 .map_err(|error| {
1057 transport_error(
1058 error,
1059 format!("failed to request server certificate for {network}/{host_id}"),
1060 )
1061 })?;
1062 parse_json_response(response)
1063 }
1064
1065 pub fn put_network_member(
1066 &self,
1067 token: &str,
1068 network: &str,
1069 host_id: &HostId,
1070 request: &AegisPutNetworkMemberRequest,
1071 ) -> ApiResult<AegisNetworkMemberResponse> {
1072 let response = self
1073 .http
1074 .put(
1075 self.url(&path::aegis_network_member(network, host_id))
1076 .map_err(ApiClientError::Transport)?,
1077 )
1078 .bearer_auth(token)
1079 .json(request)
1080 .send()
1081 .map_err(|error| {
1082 transport_error(
1083 error,
1084 format!("failed to store `{network}` network member {host_id}"),
1085 )
1086 })?;
1087 parse_json_response(response)
1088 }
1089
1090 pub fn delete_host(&self, token: &str, host_id: &HostId) -> ApiResult<()> {
1091 let response = self
1092 .http
1093 .delete(
1094 self.url(&path::aegis_host(host_id))
1095 .map_err(ApiClientError::Transport)?,
1096 )
1097 .bearer_auth(token)
1098 .send()
1099 .map_err(|error| transport_error(error, format!("failed to delete host {host_id}")))?;
1100 parse_empty_response(response)
1101 }
1102
1103 pub fn issue_agent_token(
1104 &self,
1105 token: &str,
1106 host_id: &HostId,
1107 ) -> ApiResult<AgentTokenIssueResponse> {
1108 let response = self
1109 .http
1110 .post(
1111 self.url(&path::aegis_host_agent_token(host_id))
1112 .map_err(ApiClientError::Transport)?,
1113 )
1114 .bearer_auth(token)
1115 .header(CONTENT_LENGTH, "0")
1116 .body(Vec::new())
1117 .send()
1118 .map_err(|error| {
1119 transport_error(error, format!("failed to issue agent token for {host_id}"))
1120 })?;
1121 parse_json_response(response)
1122 }
1123
1124 pub fn revoke_agent_token(
1125 &self,
1126 token: &str,
1127 request: &AgentTokenRevokeRequest,
1128 ) -> ApiResult<()> {
1129 let response = self
1130 .http
1131 .delete(
1132 self.url(path::AEGIS_AGENT_TOKEN)
1133 .map_err(ApiClientError::Transport)?,
1134 )
1135 .bearer_auth(token)
1136 .json(request)
1137 .send()
1138 .map_err(|error| transport_error(error, "failed to revoke agent token"))?;
1139 parse_empty_response(response)
1140 }
1141
1142 pub fn exchange_agent_refresh_token(
1143 &self,
1144 refresh_token: &str,
1145 now: i64,
1146 ) -> ApiResult<AgentAccessState> {
1147 let response = self
1148 .http
1149 .post(
1150 self.url(path::AEGIS_AGENT_TOKEN)
1151 .map_err(ApiClientError::Transport)?,
1152 )
1153 .json(&AgentTokenRequest {
1154 grant_type: oauth::GRANT_TYPE_REFRESH_TOKEN.to_string(),
1155 refresh_token: refresh_token.to_string(),
1156 })
1157 .timeout(self.operation_request_timeout()?)
1158 .send()
1159 .map_err(|error| transport_error(error, "failed to exchange agent refresh token"))?;
1160 let response: AgentTokenResponse = parse_json_response(response)?;
1161 Ok(AgentAccessState {
1162 access_token: response.access_token,
1163 host_id: response.host_id,
1164 credential_kind: response.credential_kind,
1165 refresh_token: response.refresh_token,
1166 access_expires_at_unix: now.saturating_add(response.expires_in as i64),
1167 })
1168 }
1169
1170 fn url(&self, path: &str) -> Result<String> {
1171 let joined = format!("{}{}", self.base_url, path);
1172 Url::parse(&joined)
1173 .with_context(|| format!("invalid api url constructed from {joined}"))?;
1174 Ok(joined)
1175 }
1176}
1177
1178pub struct AuthenticatedApiClient {
1179 api: ApiClient,
1180 access_token: String,
1181 claims: AccessClaims,
1182 stored_auth_state: Option<Box<UserAuthState>>,
1183}
1184
1185pub struct HostAgentApiClient {
1186 api: ApiClient,
1187 access_token: String,
1188 access_expires_at_unix: i64,
1189 refresh_token: String,
1190 host_id: HostId,
1191 credential_kind: AegisCredentialKind,
1192}
1193
1194impl AuthenticatedApiClient {
1195 pub fn load(api_base_override: Option<&str>) -> Result<Self> {
1196 let _auth_lock = crate::locks::user_auth_lock()?;
1197 let installed_agent_api_base = installed_agent_api_base()?;
1198 let api_base = crate::config::namespace_endpoint(&resolve_api_base(
1199 api_base_override,
1200 installed_agent_api_base.as_deref(),
1201 )?)?
1202 .base_url();
1203 let mut auth_state = load_user_auth_state()?.ok_or_else(|| {
1204 anyhow!(
1205 "aegis user auth is missing; run `aegis manage login` with an authorized user principal"
1206 )
1207 })?;
1208 validate_auth_endpoint(&api_base, &auth_state)?;
1209 let now = now_unix();
1210 if auth_state.access_needs_refresh(now, ACCESS_TOKEN_REFRESH_SKEW_SECONDS) {
1211 auth_state = refresh_auth_state(&api_base, &auth_state, now)?;
1212 persist_user_auth_state(&auth_state)?;
1213 }
1214 Self::from_stored_auth_state(api_base, auth_state)
1215 }
1216
1217 pub fn from_access_token(api_base: impl Into<String>, access_token: String) -> Result<Self> {
1218 let api = ApiClient::new(api_base)?;
1219 let claims = decode_unverified_claims::<AccessClaims>(&access_token)
1220 .context("failed to inspect access token")?;
1221 Ok(Self {
1222 api,
1223 access_token,
1224 claims,
1225 stored_auth_state: None,
1226 })
1227 }
1228
1229 fn from_stored_auth_state(
1230 api_base: impl Into<String>,
1231 auth_state: UserAuthState,
1232 ) -> Result<Self> {
1233 validate_user_auth_state(&auth_state)?;
1234 let mut client = Self::from_access_token(api_base, auth_state.access_token.clone())?;
1235 client.stored_auth_state = Some(Box::new(auth_state));
1236 Ok(client)
1237 }
1238
1239 pub fn claims(&mut self) -> Result<AccessClaims> {
1240 Ok(self.claims.clone())
1241 }
1242
1243 pub fn issue_agent_token(&mut self, host_id: &HostId) -> Result<String> {
1244 Ok(self.issue_agent_token_response(host_id)?.refresh_token)
1245 }
1246
1247 pub fn issue_agent_token_response(
1248 &mut self,
1249 host_id: &HostId,
1250 ) -> Result<AgentTokenIssueResponse> {
1251 self.call_authenticated(|api, token| api.issue_agent_token(token, host_id))
1252 }
1253
1254 pub fn revoke_agent_token(&mut self, refresh_token: &str) -> Result<()> {
1255 self.call_authenticated(|api, token| {
1256 api.revoke_agent_token(
1257 token,
1258 &AgentTokenRevokeRequest {
1259 refresh_token: refresh_token.to_string(),
1260 },
1261 )
1262 })
1263 }
1264
1265 pub fn require_user_admin(&mut self, context: &str) -> Result<()> {
1266 let membership = self.call_authenticated(ApiClient::get_namespace_context)?;
1267 if membership.role != aegis_dto::NamespaceRole::Admin {
1268 bail!("namespace administrator required for `{context}`");
1269 }
1270 Ok(())
1271 }
1272
1273 pub fn namespace_context(&mut self) -> Result<aegis_dto::namespace::NamespaceContext> {
1274 self.call_authenticated(ApiClient::get_namespace_context)
1275 }
1276
1277 pub fn api_base(&self) -> &str {
1278 &self.api.base_url
1279 }
1280
1281 pub(crate) fn user_access_token(&mut self, force_refresh: bool) -> Result<String> {
1282 self.refresh_stored_access(force_refresh)?;
1283 Ok(self.access_token.clone())
1284 }
1285
1286 pub fn get_hosts(&mut self) -> Result<AegisHostListResponse> {
1287 self.call_authenticated(|api, token| api.get_hosts(token))
1288 }
1289
1290 pub fn create_enrollment(
1291 &mut self,
1292 request: &AegisEnrollmentCreateRequest,
1293 ) -> Result<AegisEnrollment> {
1294 self.call_authenticated(|api, token| api.create_enrollment(token, request))
1295 }
1296
1297 pub fn get_enrollments(&mut self) -> Result<AegisEnrollmentListResponse> {
1298 self.call_authenticated(ApiClient::get_enrollments)
1299 }
1300
1301 pub fn get_enrollment(&mut self, host_id: &HostId) -> Result<AegisEnrollment> {
1302 self.call_authenticated(|api, token| api.get_enrollment(token, host_id))
1303 }
1304
1305 pub fn issue_enrollment_credential(
1306 &mut self,
1307 host_id: &HostId,
1308 ) -> Result<AegisEnrollmentCredentialResponse> {
1309 self.call_authenticated(|api, token| api.issue_enrollment_credential(token, host_id))
1310 }
1311
1312 pub fn delete_enrollment(&mut self, host_id: &HostId) -> Result<()> {
1313 self.call_authenticated(|api, token| api.delete_enrollment(token, host_id))
1314 }
1315
1316 pub fn get_alias(&mut self, alias: &HostAlias) -> Result<AegisAliasResponse> {
1317 self.call_authenticated(|api, token| api.get_alias(token, alias))
1318 }
1319
1320 pub fn resolve_host_id(&mut self, host: &str) -> Result<HostId> {
1321 if let Ok(host_id) = host.parse::<HostId>() {
1322 return Ok(host_id);
1323 }
1324 let alias = HostAlias::parse(host.to_string())?;
1325 Ok(self.get_alias(&alias)?.host_id)
1326 }
1327
1328 pub fn add_host_alias(&mut self, host_id: &HostId, alias: &HostAlias) -> Result<AegisHost> {
1329 self.call_authenticated(|api, token| api.add_host_alias(token, host_id, alias))
1330 }
1331
1332 pub fn promote_host_alias(&mut self, host_id: &HostId, alias: &HostAlias) -> Result<AegisHost> {
1333 self.call_authenticated(|api, token| api.promote_host_alias(token, host_id, alias))
1334 }
1335
1336 pub fn remove_host_alias(&mut self, host_id: &HostId, alias: &HostAlias) -> Result<AegisHost> {
1337 self.call_authenticated(|api, token| api.remove_host_alias(token, host_id, alias))
1338 }
1339
1340 pub fn get_networks(&mut self) -> Result<AegisNetworkListResponse> {
1341 self.call_authenticated(|api, token| api.get_networks(token))
1342 }
1343
1344 pub fn get_network_members(&mut self, network: &str) -> Result<AegisNetworkMemberListResponse> {
1345 self.call_authenticated(|api, token| api.get_network_members(token, network))
1346 }
1347
1348 pub fn sync_dns(&mut self, request: &AegisDnsSyncRequest) -> Result<AegisDnsSyncResponse> {
1349 self.call_authenticated(|api, token| api.sync_dns(token, request))
1350 }
1351
1352 pub fn sync_tls(&mut self, request: &AegisTlsSyncRequest) -> Result<AegisTlsSyncResponse> {
1353 self.call_authenticated(|api, token| api.sync_tls(token, request))
1354 }
1355
1356 pub fn request_network_member_client_cert(
1357 &mut self,
1358 network: &str,
1359 host_id: &HostId,
1360 ed25519_public_key: &str,
1361 ) -> Result<SshIssueCertResponse> {
1362 self.call_authenticated(|api, token| {
1363 api.request_network_member_client_cert(token, network, host_id, ed25519_public_key)
1364 })
1365 }
1366
1367 pub fn put_satellite(
1368 &mut self,
1369 slug: &str,
1370 request: &AegisSatelliteCreateRequest,
1371 ) -> Result<AegisSatelliteProvisionResponse> {
1372 self.call_authenticated(|api, token| api.put_satellite(token, slug, request))
1373 }
1374
1375 pub fn get_satellites(&mut self) -> Result<AegisSatelliteListResponse> {
1376 self.call_authenticated(ApiClient::get_satellites)
1377 }
1378
1379 pub fn get_satellite(&mut self, slug: &str) -> Result<AegisSatelliteDetailsResponse> {
1380 self.call_authenticated(|api, token| api.get_satellite(token, slug))
1381 }
1382
1383 pub fn delete_satellite(&mut self, slug: &str) -> Result<()> {
1384 self.call_authenticated(|api, token| api.delete_satellite(token, slug))
1385 }
1386
1387 pub fn request_network_member_server_cert(
1388 &mut self,
1389 network: &str,
1390 host_id: &HostId,
1391 ) -> Result<SshIssueCertResponse> {
1392 self.call_authenticated(|api, token| {
1393 api.request_network_member_server_cert(token, network, host_id)
1394 })
1395 }
1396
1397 pub fn delete_host(&mut self, host_id: &HostId) -> Result<()> {
1398 self.call_authenticated(|api, token| api.delete_host(token, host_id))
1399 }
1400
1401 pub fn get_server_ca_public_key(&self) -> Result<SshCaPublicKeyResponse> {
1402 self.api.get_server_ca_public_key().map_err(Into::into)
1403 }
1404
1405 pub fn get_client_ca_public_key(&self) -> Result<SshCaPublicKeyResponse> {
1406 self.api.get_client_ca_public_key().map_err(Into::into)
1407 }
1408
1409 fn call_authenticated<T, F>(&mut self, operation: F) -> Result<T>
1410 where
1411 F: Fn(&ApiClient, &str) -> ApiResult<T>,
1412 {
1413 self.refresh_stored_access(false)?;
1414 match operation(&self.api, &self.access_token) {
1415 Err(error) if error.is_unauthorized() => {
1416 if !self.refresh_stored_access(true)? {
1417 return Err(error.into());
1418 }
1419 operation(&self.api, &self.access_token).map_err(Into::into)
1420 }
1421 result => result.map_err(Into::into),
1422 }
1423 }
1424
1425 fn refresh_stored_access(&mut self, force: bool) -> Result<bool> {
1426 let Some(current) = self.stored_auth_state.as_ref() else {
1427 return Ok(false);
1428 };
1429 let now = now_unix();
1430 if !force && !current.access_needs_refresh(now, ACCESS_TOKEN_REFRESH_SKEW_SECONDS) {
1431 return Ok(false);
1432 }
1433
1434 let current_access_token = current.access_token.clone();
1435 let _auth_lock = crate::locks::user_auth_lock()?;
1436 let disk_state = load_user_auth_state()?.ok_or_else(|| {
1437 anyhow!(
1438 "aegis user auth disappeared while this command was running; run `aegis manage login` again"
1439 )
1440 })?;
1441 let (next_state, persist) =
1442 self.resolve_stored_access(¤t_access_token, disk_state, now)?;
1443 if persist {
1444 persist_user_auth_state(&next_state)?;
1445 }
1446 self.install_stored_access(next_state)?;
1447 Ok(true)
1448 }
1449
1450 fn resolve_stored_access(
1451 &self,
1452 current_access_token: &str,
1453 disk_state: UserAuthState,
1454 now: i64,
1455 ) -> Result<(UserAuthState, bool)> {
1456 if disk_state.access_token != current_access_token
1457 && !disk_state.access_needs_refresh(now, ACCESS_TOKEN_REFRESH_SKEW_SECONDS)
1458 {
1459 return Ok((disk_state, false));
1460 }
1461 let refreshed = refresh_auth_state(&self.api.base_url, &disk_state, now)
1462 .context("failed to renew Aegis OAuth access while this command was running")?;
1463 Ok((refreshed, true))
1464 }
1465
1466 fn install_stored_access(&mut self, next_state: UserAuthState) -> Result<()> {
1467 validate_user_auth_state(&next_state)?;
1468 let claims = decode_unverified_claims::<AccessClaims>(&next_state.access_token)
1469 .context("failed to inspect renewed access token")?;
1470 self.access_token = next_state.access_token.clone();
1471 self.claims = claims;
1472 self.stored_auth_state = Some(Box::new(next_state));
1473 Ok(())
1474 }
1475}
1476
1477impl HostAgentApiClient {
1478 pub fn from_refresh_token(api_base: impl Into<String>, refresh_token: &str) -> Result<Self> {
1479 let api = ApiClient::new(api_base)?;
1480 let access = api.exchange_agent_refresh_token(refresh_token, now_unix())?;
1481 Ok(Self {
1482 api,
1483 access_token: access.access_token,
1484 access_expires_at_unix: access.access_expires_at_unix,
1485 refresh_token: access.refresh_token,
1486 host_id: access.host_id,
1487 credential_kind: access.credential_kind,
1488 })
1489 }
1490
1491 pub fn host_id(&self) -> HostId {
1492 self.host_id
1493 }
1494
1495 pub fn refresh_token(&self) -> &str {
1496 &self.refresh_token
1497 }
1498
1499 pub fn credential_kind(&self) -> AegisCredentialKind {
1500 self.credential_kind
1501 }
1502
1503 pub fn get_enrollment(&mut self) -> Result<AegisEnrollment> {
1504 self.require_enrollment_credential("load enrollment intent")?;
1505 let host_id = self.host_id;
1506 self.call_authenticated(|api, token| api.get_enrollment(token, &host_id))
1507 }
1508
1509 pub fn prepare_enrollment(
1510 &mut self,
1511 request: &AegisEnrollmentPrepareRequest,
1512 ) -> Result<AegisEnrollmentPrepareResponse> {
1513 self.require_enrollment_credential("prepare a host")?;
1514 let host_id = self.host_id;
1515 self.call_authenticated(|api, token| api.prepare_enrollment(token, &host_id, request))
1516 }
1517
1518 pub fn heartbeat_enrollment(
1519 &mut self,
1520 request: &AegisEnrollmentHeartbeatRequest,
1521 ) -> Result<AegisEnrollment> {
1522 self.require_enrollment_credential("update enrollment progress")?;
1523 let host_id = self.host_id;
1524 self.call_authenticated(|api, token| api.heartbeat_enrollment(token, &host_id, request))
1525 }
1526
1527 pub fn activate_enrollment(&mut self) -> Result<AegisEnrollmentActivateResponse> {
1528 self.require_enrollment_credential("activate a host")?;
1529 let host_id = self.host_id;
1530 let response =
1531 self.call_authenticated(|api, token| api.activate_enrollment(token, &host_id))?;
1532 self.credential_kind = AegisCredentialKind::Agent;
1533 Ok(response)
1534 }
1535
1536 fn require_enrollment_credential(&mut self, operation: &str) -> Result<()> {
1537 self.refresh_access_if_needed(false)?;
1538 if self.credential_kind != AegisCredentialKind::Enrollment {
1539 bail!("an enrollment credential is required to {operation}");
1540 }
1541 Ok(())
1542 }
1543
1544 fn call_authenticated<T, F>(&mut self, operation: F) -> Result<T>
1545 where
1546 F: Fn(&ApiClient, &str) -> ApiResult<T>,
1547 {
1548 self.refresh_access_if_needed(false)?;
1549 match operation(&self.api, &self.access_token) {
1550 Err(ApiClientError::Status {
1551 status: StatusCode::UNAUTHORIZED,
1552 ..
1553 }) => {
1554 self.refresh_access_if_needed(true)?;
1555 operation(&self.api, &self.access_token).map_err(Into::into)
1556 }
1557 outcome => outcome.map_err(Into::into),
1558 }
1559 }
1560
1561 fn refresh_access_if_needed(&mut self, force: bool) -> Result<()> {
1562 let now = now_unix();
1563 if !force
1564 && now.saturating_add(ACCESS_TOKEN_REFRESH_SKEW_SECONDS) < self.access_expires_at_unix
1565 {
1566 return Ok(());
1567 }
1568 let access = self
1569 .api
1570 .exchange_agent_refresh_token(&self.refresh_token, now)
1571 .context("failed to renew Aegis machine access while enrollment was running")?;
1572 if access.host_id != self.host_id {
1573 bail!(
1574 "renewed machine credential changed host identity from `{}` to `{}`",
1575 self.host_id,
1576 access.host_id
1577 );
1578 }
1579 self.access_token = access.access_token;
1580 self.access_expires_at_unix = access.access_expires_at_unix;
1581 self.refresh_token = access.refresh_token;
1582 self.credential_kind = access.credential_kind;
1583 Ok(())
1584 }
1585}
1586
1587pub(crate) fn installed_agent_api_base() -> Result<Option<String>> {
1588 Ok(crate::config::AgentContext::load()?.map(|context| context.api_base))
1589}
1590
1591pub(crate) fn uses_local_agent(api_base_override: Option<&str>) -> Result<bool> {
1592 let Some(installed) = installed_agent_api_base()? else {
1593 return Ok(false);
1594 };
1595 let selected = aegis_dto::namespace::ApiEndpoint::parse(&resolve_api_base(
1596 api_base_override,
1597 Some(&installed),
1598 )?)
1599 .map_err(anyhow::Error::msg)?;
1600 selected.require_namespace().map_err(anyhow::Error::msg)?;
1601 let installed =
1602 aegis_dto::namespace::ApiEndpoint::parse(&installed).map_err(anyhow::Error::msg)?;
1603 Ok(selected == installed)
1604}
1605
1606pub fn start_browser_login(api_base: &str, redirect_uri: &Url) -> Result<BrowserLoginStart> {
1607 let auth_url = AuthUrl::new(format!(
1608 "{}{}",
1609 aegis_dto::namespace::ApiEndpoint::parse(api_base)
1610 .map_err(anyhow::Error::msg)?
1611 .service_url(),
1612 oauth::path::OAUTH_AUTHORIZE
1613 ))
1614 .context("invalid oauth authorization endpoint")?;
1615 let token_url = TokenUrl::new(format!(
1616 "{}{}",
1617 aegis_dto::namespace::ApiEndpoint::parse(api_base)
1618 .map_err(anyhow::Error::msg)?
1619 .service_url(),
1620 oauth::path::OAUTH_TOKEN
1621 ))
1622 .context("invalid oauth token endpoint")?;
1623 let oauth = AegisOauthClient::new(ClientId::new(AEGIS_TOOL_CLIENT_ID.to_string()))
1624 .set_auth_uri(auth_url)
1625 .set_token_uri(token_url)
1626 .set_redirect_uri(
1627 RedirectUrl::new(redirect_uri.to_string()).context("invalid oauth redirect_uri")?,
1628 )
1629 .set_auth_type(AuthType::RequestBody);
1630
1631 let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
1632 let authorization_request = oauth
1633 .authorize_url(CsrfToken::new_random)
1634 .set_pkce_challenge(pkce_challenge);
1635 let (authorization_url, csrf_state) = authorization_request.url();
1636
1637 Ok(BrowserLoginStart {
1638 authorization_url,
1639 state: csrf_state.secret().to_string(),
1640 pkce_verifier: pkce_verifier.secret().to_string(),
1641 })
1642}
1643
1644pub fn finish_browser_login(
1645 api_base: &str,
1646 redirect_uri: &Url,
1647 code: &str,
1648 pkce_verifier: &str,
1649 now: i64,
1650) -> Result<UserAuthState> {
1651 let auth_url = AuthUrl::new(format!(
1652 "{}{}",
1653 aegis_dto::namespace::ApiEndpoint::parse(api_base)
1654 .map_err(anyhow::Error::msg)?
1655 .service_url(),
1656 oauth::path::OAUTH_AUTHORIZE
1657 ))
1658 .context("invalid oauth authorization endpoint")?;
1659 let token_url = TokenUrl::new(format!(
1660 "{}{}",
1661 aegis_dto::namespace::ApiEndpoint::parse(api_base)
1662 .map_err(anyhow::Error::msg)?
1663 .service_url(),
1664 oauth::path::OAUTH_TOKEN
1665 ))
1666 .context("invalid oauth token endpoint")?;
1667 let oauth = AegisOauthClient::new(ClientId::new(AEGIS_TOOL_CLIENT_ID.to_string()))
1668 .set_auth_uri(auth_url)
1669 .set_token_uri(token_url)
1670 .set_redirect_uri(
1671 RedirectUrl::new(redirect_uri.to_string()).context("invalid oauth redirect_uri")?,
1672 )
1673 .set_auth_type(AuthType::RequestBody);
1674
1675 let http = oauth_http_client()?;
1676 let response = oauth
1677 .exchange_code(AuthorizationCode::new(code.to_string()))
1678 .set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier.to_string()))
1679 .request(&http)
1680 .context("failed to exchange oauth authorization code")?;
1681 auth_state_from_token_response(response, now)
1682}
1683
1684fn auth_state_from_token_response(response: AegisTokenResponse, now: i64) -> Result<UserAuthState> {
1685 let access_expires_in = response
1686 .expires_in()
1687 .ok_or_else(|| anyhow!("oauth token response missing expires_in"))?;
1688 let refresh_token = response
1689 .refresh_token()
1690 .ok_or_else(|| anyhow!("oauth token response missing refresh_token"))?;
1691 let extra = response.extra_fields();
1692 let refresh_expires_in = extra
1693 .refresh_expires_in
1694 .ok_or_else(|| anyhow!("oauth token response missing refresh_expires_in"))?;
1695 let principal = extra
1696 .principal
1697 .as_deref()
1698 .ok_or_else(|| anyhow!("oauth token response missing principal"))?;
1699 let principal = crate::principal_grants::validate_user_id(principal)
1700 .context("oauth token response principal is not a stable aegis user ID")?;
1701 decode_unverified_claims::<AccessClaims>(response.access_token().secret())
1702 .context("failed to inspect oauth access token")?;
1703
1704 Ok(UserAuthState {
1705 access_token: response.access_token().secret().to_string(),
1706 refresh_token: refresh_token.secret().to_string(),
1707 principal,
1708 access_expires_at_unix: now.saturating_add(access_expires_in.as_secs() as i64),
1709 refresh_expires_at_unix: now.saturating_add(refresh_expires_in as i64),
1710 })
1711}
1712
1713fn validate_user_auth_state(auth_state: &UserAuthState) -> Result<()> {
1714 crate::principal_grants::validate_user_id(&auth_state.principal)
1715 .context("stored user principal is not a stable aegis user ID")?;
1716 Ok(())
1717}
1718
1719pub(crate) fn exchange_refresh_token(
1720 api_base: &str,
1721 refresh_token: &str,
1722 now: i64,
1723) -> Result<UserAuthState> {
1724 let endpoint =
1725 aegis_dto::namespace::ApiEndpoint::parse(api_base).map_err(anyhow::Error::msg)?;
1726 let response = Client::builder()
1727 .connect_timeout(Duration::from_secs(10))
1728 .timeout(Duration::from_secs(30))
1729 .redirect(reqwest::redirect::Policy::none())
1730 .build()?
1731 .post(format!(
1732 "{}{}",
1733 endpoint.service_url(),
1734 aegis_dto::identity::USER_TOKEN_PATH
1735 ))
1736 .json(&serde_json::json!({"grant_type":"refresh_token", "refresh_token":refresh_token}))
1737 .send()
1738 .context("failed to exchange user credential")?
1739 .error_for_status()
1740 .context("user credential expired, revoked, or already imported")?
1741 .json()
1742 .context("invalid user token response")?;
1743 auth_state_from_token_response(response, now)
1744}
1745
1746fn validate_auth_endpoint(api_base: &str, state: &UserAuthState) -> Result<()> {
1747 let endpoint =
1748 aegis_dto::namespace::ApiEndpoint::parse(api_base).map_err(anyhow::Error::msg)?;
1749 let claims = decode_unverified_claims::<AccessClaims>(&state.access_token)?;
1750 anyhow::ensure!(
1751 claims.iss == endpoint.service_url()
1752 && claims.sub.strip_kind("user") == Some(&state.principal),
1753 "Saved user session belongs to another deployment or account; sign in to the selected deployment first"
1754 );
1755 Ok(())
1756}
1757
1758pub(crate) fn refresh_auth_state(
1759 api_base: &str,
1760 current: &UserAuthState,
1761 now: i64,
1762) -> Result<UserAuthState> {
1763 validate_auth_endpoint(api_base, current)?;
1764 if current.refresh_is_expired(now) {
1765 bail!("User session expired; run `aegis manage login` again");
1766 }
1767 exchange_refresh_token(api_base, ¤t.refresh_token, now)
1768}
1769
1770fn oauth_http_client() -> Result<oauth2::reqwest::blocking::Client> {
1771 oauth2::reqwest::blocking::Client::builder()
1772 .redirect(oauth2::reqwest::redirect::Policy::none())
1773 .connect_timeout(OAUTH_CONNECT_TIMEOUT)
1774 .timeout(OAUTH_REQUEST_TIMEOUT)
1775 .build()
1776 .context("failed to build oauth http client")
1777}
1778
1779fn transport_error(error: impl Into<anyhow::Error>, message: impl Into<String>) -> ApiClientError {
1780 ApiClientError::Transport(error.into().context(message.into()))
1781}
1782
1783fn parse_json_response<T>(response: Response) -> ApiResult<T>
1784where
1785 T: serde::de::DeserializeOwned,
1786{
1787 let status = response.status();
1788 if status.is_success() {
1789 return response
1790 .json::<T>()
1791 .map_err(|error| ApiClientError::Transport(error.into()));
1792 }
1793
1794 Err(response_status_error(response))
1795}
1796
1797fn parse_empty_response(response: Response) -> ApiResult<()> {
1798 let status = response.status();
1799 if status.is_success() {
1800 return Ok(());
1801 }
1802
1803 Err(response_status_error(response))
1804}
1805
1806fn response_status_error(response: Response) -> ApiClientError {
1807 status_error(response.status(), response.text().unwrap_or_default())
1808}
1809
1810fn status_error(status: StatusCode, text: String) -> ApiClientError {
1811 let message = serde_json::from_str::<ErrorResponse>(&text)
1812 .map(|payload| payload.error)
1813 .unwrap_or_else(|_| text.trim().to_string());
1814 ApiClientError::Status { status, message }
1815}
1816
1817#[cfg(test)]
1818mod tests {
1819 use super::{
1820 AEGIS_ADMIN_SCOPE, AEGIS_TOOL_CLIENT_ID, AegisOAuthTokenExtraFields, ApiClient,
1821 AuthenticatedApiClient, auth_state_from_token_response, start_browser_login,
1822 };
1823 use crate::config::UserAuthState;
1824 use aegis_dto::{
1825 HostId, path,
1826 protocol::{
1827 AegisAgentHealth, AegisAgentStatus, AegisDirectGatewayReport, AegisHostReportRequest,
1828 AegisPrincipalGrant,
1829 },
1830 };
1831 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
1832 use oauth2::{StandardTokenResponse, basic::BasicTokenType};
1833 use phylax_core::{AccessClaims, ScopeSet, Subject};
1834 use std::{
1835 io::{Read, Write},
1836 net::{TcpListener, TcpStream},
1837 sync::{Arc, Mutex},
1838 thread,
1839 time::Duration,
1840 };
1841 use url::Url;
1842
1843 type TestTokenResponse = StandardTokenResponse<AegisOAuthTokenExtraFields, BasicTokenType>;
1844
1845 #[test]
1846 fn api_client_requires_a_namespace_before_connecting() {
1847 let error = ApiClient::new("https://api.example.test/v2")
1848 .err()
1849 .expect("an unscoped API client must fail");
1850 assert!(error.to_string().contains("select an Aegis namespace"));
1851 assert!(ApiClient::new("https://api.example.test/v2/namespaces/test").is_ok());
1852 }
1853
1854 #[test]
1855 fn tls_issuance_keeps_auth_on_the_namespace_endpoint() {
1856 let (base, recorded, thread) = spawn_mock_server(2, |index, request| {
1857 assert_eq!(request.authorization.as_deref(), Some("Bearer host-token"));
1858 if index == 0 {
1859 assert_eq!(request.method, "PUT");
1860 assert_eq!(
1861 request.path,
1862 "/namespaces/test/aegis/tls/certs/web/public-key.pem"
1863 );
1864 assert_eq!(request.body, "public-key");
1865 MockResponse::empty(204)
1866 } else {
1867 assert_eq!(request.method, "GET");
1868 assert_eq!(
1869 request.path,
1870 "/namespaces/test/aegis/tls/certs/web/cert.pem"
1871 );
1872 MockResponse {
1873 status: 200,
1874 content_type: "application/x-pem-file",
1875 body: "certificate".into(),
1876 }
1877 }
1878 });
1879 let api = ApiClient::new(base).unwrap();
1880 assert!(
1881 api.issue_tls_certificate("host-token", "../other", "public-key")
1882 .is_err()
1883 );
1884 assert_eq!(
1885 api.issue_tls_certificate("host-token", "web", "public-key")
1886 .unwrap(),
1887 "certificate"
1888 );
1889 thread.join().unwrap();
1890 assert_eq!(recorded.lock().unwrap().len(), 2);
1891 }
1892
1893 #[test]
1894 fn administration_uses_current_namespace_membership() {
1895 for (token_admin, role, allowed) in [(true, "member", false), (false, "admin", true)] {
1896 let (base_url, recorded, handle) = spawn_mock_server(1, move |_, request| {
1897 assert_eq!("GET", request.method);
1898 assert_eq!("/namespaces/test/aegis/context", request.path);
1899 MockResponse::json(serde_json::json!({"namespace": "test", "role": role}))
1900 });
1901 let mut client = AuthenticatedApiClient::from_access_token(
1902 base_url,
1903 unsigned_user_access_token(token_admin),
1904 )
1905 .expect("user client should build");
1906 assert_eq!(allowed, client.require_user_admin("test operation").is_ok());
1907 handle.join().expect("mock server should complete");
1908 assert_eq!(1, recorded.lock().unwrap().len());
1909 }
1910 }
1911
1912 fn unsigned_user_access_token(admin: bool) -> String {
1913 unsigned_user_access_token_with("test-jti", 2, admin)
1914 }
1915
1916 fn unsigned_user_access_token_with(jti: &str, exp: i64, admin: bool) -> String {
1917 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"EdDSA","typ":"at+jwt"}"#);
1918 let claims = URL_SAFE_NO_PAD.encode(
1919 serde_json::to_string(&serde_json::json!({
1920 "iss": "https://api.example",
1921 "sub": "user:OpaqueUserID",
1922 "aud": ["api.example"],
1923 "exp": exp,
1924 "iat": 1,
1925 "jti": jti,
1926 "client_id": AEGIS_TOOL_CLIENT_ID,
1927 "scope": if admin {
1928 "aegis:admin aegis:read aegis:user"
1929 } else {
1930 "aegis:read aegis:user"
1931 },
1932 }))
1933 .expect("claims should serialize"),
1934 );
1935 format!("{header}.{claims}.sig")
1936 }
1937
1938 fn user_access_claims(admin: bool) -> AccessClaims {
1939 AccessClaims {
1940 iss: "issuer".to_string(),
1941 sub: Subject::new("user:OpaqueUserID").expect("subject should build"),
1942 aud: vec!["api.example".to_string()],
1943 exp: i64::MAX,
1944 iat: 0,
1945 jti: "test-jti".to_string(),
1946 client_id: AEGIS_TOOL_CLIENT_ID.to_string(),
1947 scope: ScopeSet::new(
1948 ["aegis:read", "aegis:user"]
1949 .into_iter()
1950 .chain(admin.then_some(AEGIS_ADMIN_SCOPE)),
1951 )
1952 .expect("scopes should build"),
1953 sid: Some("test-session".to_string()),
1954 authorized_party: Some(AEGIS_TOOL_CLIENT_ID.to_string()),
1955 }
1956 }
1957
1958 #[derive(Clone, Debug)]
1959 struct RecordedRequest {
1960 method: String,
1961 path: String,
1962 authorization: Option<String>,
1963 cache_control: Option<String>,
1964 body: String,
1965 }
1966
1967 #[derive(Clone, Debug)]
1968 struct MockResponse {
1969 status: u16,
1970 content_type: &'static str,
1971 body: String,
1972 }
1973
1974 impl MockResponse {
1975 fn json(body: serde_json::Value) -> Self {
1976 Self {
1977 status: 200,
1978 content_type: "application/json",
1979 body: body.to_string(),
1980 }
1981 }
1982
1983 fn empty(status: u16) -> Self {
1984 Self {
1985 status,
1986 content_type: "application/octet-stream",
1987 body: String::new(),
1988 }
1989 }
1990 }
1991
1992 fn spawn_mock_server<F>(
1993 expected_requests: usize,
1994 responder: F,
1995 ) -> (
1996 String,
1997 Arc<Mutex<Vec<RecordedRequest>>>,
1998 thread::JoinHandle<()>,
1999 )
2000 where
2001 F: Fn(usize, &RecordedRequest) -> MockResponse + Send + 'static,
2002 {
2003 let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
2004 let base_url = format!(
2005 "http://{}/namespaces/test",
2006 listener.local_addr().expect("local addr")
2007 );
2008 let recorded = Arc::new(Mutex::new(Vec::new()));
2009 let recorded_for_thread = Arc::clone(&recorded);
2010 let handle = thread::spawn(move || {
2011 for index in 0..expected_requests {
2012 let (mut stream, _) = listener.accept().expect("request should arrive");
2013 let request = read_request(&mut stream);
2014 recorded_for_thread
2015 .lock()
2016 .expect("requests lock")
2017 .push(request.clone());
2018 let response = responder(index, &request);
2019 write_response(&mut stream, response);
2020 }
2021 });
2022 (base_url, recorded, handle)
2023 }
2024
2025 fn read_request(stream: &mut TcpStream) -> RecordedRequest {
2026 let mut buffer = Vec::new();
2027 let mut tmp = [0u8; 1024];
2028 let mut header_end = None;
2029 let mut content_length = 0usize;
2030
2031 loop {
2032 let bytes_read = stream.read(&mut tmp).expect("request should read");
2033 if bytes_read == 0 {
2034 break;
2035 }
2036 buffer.extend_from_slice(&tmp[..bytes_read]);
2037 if header_end.is_none()
2038 && let Some(position) = find_subsequence(&buffer, b"\r\n\r\n")
2039 {
2040 header_end = Some(position + 4);
2041 let headers = String::from_utf8_lossy(&buffer[..position + 4]);
2042 content_length = headers
2043 .lines()
2044 .find_map(|line| {
2045 line.split_once(':').and_then(|(name, value)| {
2046 if name.eq_ignore_ascii_case("content-length") {
2047 value.trim().parse::<usize>().ok()
2048 } else {
2049 None
2050 }
2051 })
2052 })
2053 .unwrap_or(0);
2054 }
2055 if let Some(end) = header_end
2056 && buffer.len() >= end + content_length
2057 {
2058 break;
2059 }
2060 }
2061
2062 let header_end = header_end.expect("request should contain headers");
2063 let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
2064 let body = String::from_utf8(buffer[header_end..].to_vec()).expect("UTF-8 request body");
2065 let mut lines = headers.lines();
2066 let request_line = lines.next().expect("request line should exist");
2067 let mut request_parts = request_line.split_whitespace();
2068 let method = request_parts.next().unwrap_or_default().to_string();
2069 let path = request_parts.next().unwrap_or_default().to_string();
2070 let headers = lines
2071 .filter_map(|line| line.split_once(':'))
2072 .map(|(name, value)| (name.trim(), value.trim()))
2073 .collect::<Vec<_>>();
2074 let header = |expected: &str| {
2075 headers
2076 .iter()
2077 .find(|(name, _)| name.eq_ignore_ascii_case(expected))
2078 .map(|(_, value)| (*value).to_string())
2079 };
2080
2081 RecordedRequest {
2082 method,
2083 path,
2084 authorization: header("authorization"),
2085 cache_control: header("cache-control"),
2086 body,
2087 }
2088 }
2089
2090 fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2091 haystack
2092 .windows(needle.len())
2093 .position(|window| window == needle)
2094 }
2095
2096 fn write_response(stream: &mut TcpStream, response: MockResponse) {
2097 let reason = match response.status {
2098 200 => "OK",
2099 204 => "No Content",
2100 401 => "Unauthorized",
2101 other => panic!("unexpected mock status {other}"),
2102 };
2103 write!(
2104 stream,
2105 "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2106 response.status,
2107 reason,
2108 response.content_type,
2109 response.body.len(),
2110 response.body
2111 )
2112 .expect("response should write");
2113 stream.flush().expect("response should flush");
2114 }
2115
2116 #[test]
2117 fn machine_token_exchange_allows_cloud_startup_latency() {
2118 let id: HostId = "00000000-0000-4000-8000-000000000001".parse().unwrap();
2119 let (base, _, server) = spawn_mock_server(1, move |_, request| {
2120 assert_eq!(request.method, "POST");
2121 assert!(request.path.ends_with(path::AEGIS_AGENT_TOKEN));
2122 thread::sleep(Duration::from_secs(6));
2123 MockResponse::json(serde_json::json!({
2124 "access_token": "access",
2125 "refresh_token": "rotated",
2126 "token_type": "Bearer",
2127 "host_id": id,
2128 "credential_kind": "enrollment",
2129 "expires_in": 300,
2130 "refresh_expires_in": 3600,
2131 }))
2132 });
2133 let access = ApiClient::new(base)
2134 .unwrap()
2135 .exchange_agent_refresh_token("initial", 1000)
2136 .unwrap();
2137 server.join().unwrap();
2138 assert_eq!(access.host_id, id);
2139 assert_eq!(access.access_expires_at_unix, 1300);
2140 assert_eq!(access.refresh_token, "rotated");
2141 }
2142
2143 #[test]
2144 fn auth_state_from_token_response_tracks_both_expiries() {
2145 let access_token = unsigned_user_access_token(false);
2146 let response: TestTokenResponse = serde_json::from_value(serde_json::json!({
2147 "access_token": access_token,
2148 "token_type": "bearer",
2149 "expires_in": 300,
2150 "refresh_token": "refresh",
2151 "refresh_expires_in": 3600,
2152 "principal": "OpaqueUserID"
2153 }))
2154 .expect("token response should deserialize");
2155 let state =
2156 auth_state_from_token_response(response, 1_000).expect("auth state should build");
2157
2158 assert_eq!(1_300, state.access_expires_at_unix);
2159 assert_eq!(4_600, state.refresh_expires_at_unix);
2160 assert_eq!("OpaqueUserID", state.principal);
2161 }
2162
2163 #[test]
2164 fn auth_state_from_token_response_requires_refresh_metadata() {
2165 let response: TestTokenResponse = serde_json::from_value(serde_json::json!({
2166 "access_token": "access",
2167 "token_type": "bearer",
2168 "expires_in": 300,
2169 "refresh_token": "refresh",
2170 "principal": "OpaqueUserID"
2171 }))
2172 .expect("token response should deserialize");
2173
2174 let error = auth_state_from_token_response(response, 1_000)
2175 .expect_err("missing refresh metadata should fail");
2176 assert!(
2177 error.to_string().contains("refresh_expires_in"),
2178 "unexpected error: {error}"
2179 );
2180 }
2181
2182 #[test]
2183 fn user_auth_rejects_email_principals() {
2184 let response: TestTokenResponse = serde_json::from_value(serde_json::json!({
2185 "access_token": unsigned_user_access_token(false),
2186 "token_type": "bearer",
2187 "expires_in": 300,
2188 "refresh_token": "refresh",
2189 "refresh_expires_in": 3_600,
2190 "principal": "user@example.com"
2191 }))
2192 .expect("token response should deserialize");
2193 auth_state_from_token_response(response, 1_000)
2194 .expect_err("email token principal must be rejected");
2195
2196 assert!(
2197 AuthenticatedApiClient::from_stored_auth_state(
2198 "https://api.example.test/v2/namespaces/test",
2199 UserAuthState {
2200 access_token: "unused".to_string(),
2201 refresh_token: "refresh".to_string(),
2202 principal: "user@example.com".to_string(),
2203 access_expires_at_unix: 1_300,
2204 refresh_expires_at_unix: 4_600,
2205 },
2206 )
2207 .is_err(),
2208 "stored email principal must be rejected"
2209 );
2210 }
2211
2212 #[test]
2213 fn start_browser_login_builds_standard_authorize_url() {
2214 let redirect_uri = Url::parse("http://127.0.0.1:4567/callback").expect("url");
2215 let login = start_browser_login("https://api.example/v2", &redirect_uri)
2216 .expect("browser login should start");
2217
2218 assert_eq!(
2219 format!(
2220 "https://api.example/v2{}",
2221 phylax_core::oauth::path::OAUTH_AUTHORIZE
2222 ),
2223 format!(
2224 "{}://{}{}",
2225 login.authorization_url.scheme(),
2226 login.authorization_url.host_str().expect("host"),
2227 login.authorization_url.path()
2228 )
2229 );
2230 let query = login
2231 .authorization_url
2232 .query_pairs()
2233 .map(|(key, value)| (key.into_owned(), value.into_owned()))
2234 .collect::<std::collections::HashMap<_, _>>();
2235 assert_eq!(Some("code"), query.get("response_type").map(String::as_str));
2236 assert_eq!(
2237 Some(AEGIS_TOOL_CLIENT_ID),
2238 query.get("client_id").map(String::as_str)
2239 );
2240 assert_eq!(
2241 Some("http://127.0.0.1:4567/callback"),
2242 query.get("redirect_uri").map(String::as_str)
2243 );
2244 assert_eq!(
2245 Some("S256"),
2246 query.get("code_challenge_method").map(String::as_str)
2247 );
2248 assert!(!login.state.is_empty());
2249 assert!(!login.pkce_verifier.is_empty());
2250 }
2251
2252 #[test]
2253 fn authenticated_static_client_sends_bearer_token() {
2254 let (base_url, recorded, handle) =
2255 spawn_mock_server(1, move |index, request| match index {
2256 0 => {
2257 assert_eq!("GET", request.method);
2258 assert_eq!(
2259 format!("/namespaces/test{}", path::AEGIS_HOSTS),
2260 request.path
2261 );
2262 assert_eq!(
2263 Some("Bearer static-access"),
2264 request.authorization.as_deref()
2265 );
2266 assert_eq!(Some("no-cache, no-store"), request.cache_control.as_deref());
2267 MockResponse::json(serde_json::json!({
2268 "hosts": {}
2269 }))
2270 }
2271 _ => unreachable!("unexpected request"),
2272 });
2273 let mut client = AuthenticatedApiClient {
2274 api: ApiClient::new(base_url.clone()).expect("api client should build"),
2275 access_token: "static-access".to_string(),
2276 claims: user_access_claims(false),
2277 stored_auth_state: None,
2278 };
2279
2280 let response = client.get_hosts().expect("hosts request should succeed");
2281 assert!(response.hosts.is_empty());
2282 handle.join().expect("server thread should finish");
2283 assert_eq!(1, recorded.lock().expect("requests lock").len());
2284 }
2285
2286 #[test]
2287 fn host_reports_require_the_canonical_response() {
2288 let host_id: HostId = "00000000-0000-4000-8000-000000000001"
2289 .parse()
2290 .expect("host id should parse");
2291 let expected_path = format!("/namespaces/test{}", path::aegis_host_report(&host_id));
2292 let (base_url, recorded, handle) =
2293 spawn_mock_server(2, move |index, request| match index {
2294 0 => {
2295 assert_eq!("PUT", request.method);
2296 assert_eq!(expected_path, request.path);
2297 MockResponse::empty(204)
2298 }
2299 1 => MockResponse::json(serde_json::json!({
2300 "principal_grants": [{
2301 "login_principal": "ubuntu",
2302 "user_id": "OpaqueUserID"
2303 }]
2304 })),
2305 _ => unreachable!("unexpected request"),
2306 });
2307 let api = ApiClient::new(base_url).expect("api client should build");
2308 let request = AegisHostReportRequest {
2309 messages: Vec::new(),
2310 agent: AegisAgentStatus {
2311 version: "1.2.3".to_string(),
2312 health: AegisAgentHealth {
2313 boot_id: "00000000-0000-0000-0000-000000000001".to_string(),
2314 reconciled_since_boot: true,
2315 applied_aliases: None,
2316 last_reconcile_unix: None,
2317 last_reconcile_warning: None,
2318 last_reconcile_error: None,
2319 },
2320 reported_unix: 1,
2321 },
2322 principal_grants: Vec::new(),
2323 ssh_lockdown_enabled: true,
2324 direct_gateway: AegisDirectGatewayReport {
2325 observed_unix: 1,
2326 peers: Vec::new(),
2327 },
2328 };
2329
2330 assert!(api.report_host("agent-token", &host_id, &request).is_err());
2331 assert_eq!(
2332 vec![AegisPrincipalGrant {
2333 login_principal: "ubuntu".to_string(),
2334 user_id: "OpaqueUserID".to_string(),
2335 }],
2336 api.report_host("agent-token", &host_id, &request)
2337 .expect("canonical response should succeed")
2338 .principal_grants
2339 );
2340 handle.join().expect("server thread should finish");
2341 assert_eq!(2, recorded.lock().expect("requests lock").len());
2342 }
2343
2344 #[test]
2345 fn stored_access_renewal_exchanges_an_expiring_token() {
2346 let renewed_access = unsigned_user_access_token_with("renewed-jti", 1_300, false);
2347 let renewed_access_for_server = renewed_access.clone();
2348 let (base_url, recorded, handle) =
2349 spawn_mock_server(1, move |index, request| match index {
2350 0 => {
2351 assert_eq!("POST", request.method);
2352 assert_eq!(aegis_dto::identity::USER_TOKEN_PATH, request.path);
2353 assert_eq!(None, request.authorization);
2354 MockResponse::json(serde_json::json!({
2355 "access_token": renewed_access_for_server,
2356 "token_type": "bearer",
2357 "expires_in": 300,
2358 "refresh_token": "rotated-refresh",
2359 "refresh_expires_in": 3_600,
2360 "principal": "OpaqueUserID"
2361 }))
2362 }
2363 _ => unreachable!("unexpected request"),
2364 });
2365 let expired_access = unsigned_user_access_token_with("expired-jti", 999, false);
2366 let mut claims =
2367 phylax_core::dangerous::decode_unverified_claims::<AccessClaims>(&expired_access)
2368 .unwrap();
2369 claims.iss = aegis_dto::namespace::ApiEndpoint::parse(&base_url)
2370 .unwrap()
2371 .service_url()
2372 .to_owned();
2373 let expired_access = format!(
2374 "{}.{}.sig",
2375 expired_access.split('.').next().unwrap(),
2376 URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap())
2377 );
2378 let expired_state = UserAuthState {
2379 access_token: expired_access.clone(),
2380 refresh_token: "initial-refresh".to_string(),
2381 principal: "OpaqueUserID".to_string(),
2382 access_expires_at_unix: 999,
2383 refresh_expires_at_unix: 10_000,
2384 };
2385 let mut client =
2386 AuthenticatedApiClient::from_stored_auth_state(base_url, expired_state.clone())
2387 .expect("stored client should build");
2388
2389 let (next_state, persist) = client
2390 .resolve_stored_access(&expired_access, expired_state, 1_000)
2391 .expect("expiring access should renew");
2392 assert!(persist);
2393 assert_eq!(renewed_access, next_state.access_token);
2394 assert_eq!("rotated-refresh", next_state.refresh_token);
2395 assert_eq!(1_300, next_state.access_expires_at_unix);
2396 client
2397 .install_stored_access(next_state)
2398 .expect("renewed access should install");
2399 assert_eq!(renewed_access, client.access_token);
2400
2401 handle.join().expect("server thread should finish");
2402 assert_eq!(1, recorded.lock().expect("requests lock").len());
2403 }
2404}