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