Skip to main content

aegis_tool/
api.rs

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(&current_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, &current.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}