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