Skip to main content

microvms_app/session/
proxy.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The endpoint proxy's authentication: two headers, and a token minted inside the
3//! request path.
4//!
5//! # Two headers, not one (TRAP-7)
6//!
7//! `X-aws-proxy-auth` carries a JWE scoped to one MicroVM and a set of ports.
8//! `X-aws-proxy-port` names which of that token's allowed ports *this* request
9//! targets. Omitting the second is a rejection that reads like a bad token, which is
10//! the worst available diagnostic: the header that is wrong is not the header the
11//! error mentions. Both measured 2026-08-05; see `docs/PLATFORM.md`, "Endpoint
12//! authentication".
13//!
14//! The other half of the same finding is the shape of the token. `CreateMicrovmAuthToken`
15//! answers with `authToken` as a **map of header name to value**, not a bare string,
16//! because the API is shaped for schemes needing more than one header. Reading that
17//! map as a string is the trap, and [`ProxyToken`] closes it by construction: it holds
18//! a map, exposes no `as_str`, has no `Display`, and the only way to reach the auth
19//! value is [`ProxyToken::auth_value`], which names the header it reads.
20//!
21//! # Minting inside the request path (TRAP-9)
22//!
23//! The service caps a proxy token at sixty minutes. That is not a choice, and it is
24//! shorter than a long agent run — so a token minted once at construction expires
25//! mid-trial, and the resulting rejection is indistinguishable from a daemon that
26//! died. Minting therefore happens in [`ProxyAuth::headers`], which every request
27//! calls, and a stale cache re-mints there.
28//!
29//! [`DEFAULT_REFRESH_AFTER`] is half the ceiling rather than just under it. Refreshing
30//! at fifty-nine minutes puts the expiry inside the window between building the
31//! headers and the proxy validating them: the token is live when the request is
32//! written and dead when it is read. Half the ceiling means a request in flight across
33//! the rollover is still holding a token with about thirty minutes left.
34//!
35//! A mint failure is [`WireKind::AuthTokenMint`], which is retryable, and that is
36//! load-bearing rather than optimistic: a control-plane throttle at minute thirty must
37//! not kill a trial that is otherwise healthy. The one exception is a credential failure,
38//! which keeps [`ErrorKind::Credentials`]: an identity the control plane refuses is refused
39//! again on the identical request.
40//!
41//! # A WebSocket carries the same two facts as subprotocols, not as headers
42//!
43//! The browser `WebSocket` constructor cannot set a request header, so the platform moves
44//! both facts into the one field a handshake *does* let a client choose:
45//! `Sec-WebSocket-Protocol`. Three values, in this order — [`WS_SUBPROTOCOL`], then the
46//! token under [`WS_AUTH_SUBPROTOCOL_PREFIX`], then the target port under
47//! [`WS_PORT_SUBPROTOCOL_PREFIX`] — and "Lambda removes MicroVM-specific subprotocols from
48//! the request before forwarding it to your application", so an in-VM server never sees
49//! them and must not be written to expect them.
50//!
51//! [`ProxyAuth::subprotocols`] mints through the same cache and the same refresh schedule
52//! as [`ProxyAuth::headers`]. That is the whole point of it living here rather than in a
53//! caller: a second token path would be a second place TRAP-9 has to be got right, and the
54//! one that is not on the hot path is the one that is wrong.
55//!
56//! # Nothing here logs the token, and that is by construction rather than by care
57//!
58//! [`ProxyToken`]'s `Debug` names keys only, and [`ProxyAuth`]'s prints a mint count and a
59//! cached flag. The values that *do* carry the credential — the header pairs and the
60//! subprotocol strings — are returned to a caller and stored on nothing, so there is no
61//! type whose `Debug` could leak one. A caller that logs what it was handed is outside
62//! what this module can prevent, which is why both accessors say so in their own docs.
63
64use std::collections::{BTreeSet, HashMap};
65use std::fmt;
66use std::sync::atomic::{AtomicU64, Ordering};
67use std::sync::{Arc, PoisonError, RwLock};
68use std::time::Duration;
69
70use futures_util::future::BoxFuture;
71
72use crate::error::{Error, ErrorKind, WireKind};
73
74/// The header carrying the minted JWE. One of the two keys read out of the
75/// `authToken` map.
76pub const PROXY_AUTH_HEADER: &str = "X-aws-proxy-auth";
77
78/// The header naming which of the token's allowed ports this request targets.
79///
80/// Sent on every request, never inferred. See the module docs for what its absence
81/// looks like.
82pub const PROXY_PORT_HEADER: &str = "X-aws-proxy-port";
83
84// Keep the published import path while sharing the daemon's default.
85pub use crate::control::DEFAULT_AGENT_PORT;
86
87/// The base subprotocol every MicroVM WebSocket handshake offers first.
88///
89/// Bare, with no suffix: it is the marker that says the other two values in the list are
90/// the platform's rather than the application's.
91///
92/// Re-exported from the `protocol` crate rather than spelled again here, because the daemon
93/// needs the same string for the opposite reason — this client *offers* it and `agentd`
94/// *echoes* it on the direct path, where an offered-but-unechoed subprotocol makes an RFC
95/// 6455 client refuse the handshake. Two definitions would let the offer and the echo drift,
96/// and the failure would name neither side. One `const` aliasing the other makes the
97/// disagreement unrepresentable instead of merely asserted.
98pub const WS_SUBPROTOCOL: &str = protocol::tunnel::WS_MARKER_SUBPROTOCOL;
99
100/// The prefix the JWE rides behind in a WebSocket handshake.
101///
102/// The subprotocol counterpart of [`PROXY_AUTH_HEADER`]. A handshake carries the token
103/// here because `Sec-WebSocket-Protocol` is the only field the browser `WebSocket`
104/// constructor lets a client set.
105pub const WS_AUTH_SUBPROTOCOL_PREFIX: &str = "lambda-microvms.authentication.";
106
107/// The prefix the target port rides behind in a WebSocket handshake.
108///
109/// The subprotocol counterpart of [`PROXY_PORT_HEADER`], and it is present for the same
110/// reason: the token is scoped to a *set* of ports and the request has to name which one.
111/// Omitting it is the same rejection-that-reads-like-a-bad-token described in the module
112/// docs.
113pub const WS_PORT_SUBPROTOCOL_PREFIX: &str = "lambda-microvms.port.";
114
115/// The ceiling the service enforces on a proxy token's life. Not a choice.
116pub const MAX_TOKEN_LIFETIME: Duration = Duration::from_secs(60 * 60);
117
118/// When to re-mint: half [`MAX_TOKEN_LIFETIME`]. See the module docs for why not
119/// fifty-nine minutes.
120pub const DEFAULT_REFRESH_AFTER: Duration = Duration::from_secs(30 * 60);
121
122/// The minted token, as the control plane returns it: a map of header name to value.
123///
124/// Deliberately opaque. There is no `as_str`, no `Display`, and no `Deref`, because
125/// the TRAP-7 mistake is treating this value as the token string — and a type that
126/// cannot be printed as one cannot be used as one. [`ProxyToken::auth_value`] is the
127/// only way out, and it names the header it reads so the call site says which key of
128/// the map it means.
129#[derive(Clone, Default)]
130pub struct ProxyToken {
131    headers: HashMap<String, String>,
132}
133
134impl ProxyToken {
135    /// Wraps the `authToken` map from a `CreateMicrovmAuthToken` response.
136    pub fn new(headers: HashMap<String, String>) -> Self {
137        Self { headers }
138    }
139
140    /// The map as pairs, for a caller that already has them.
141    pub fn from_pairs<I, K, V>(pairs: I) -> Self
142    where
143        I: IntoIterator<Item = (K, V)>,
144        K: Into<String>,
145        V: Into<String>,
146    {
147        Self::new(
148            pairs
149                .into_iter()
150                .map(|(name, value)| (name.into(), value.into()))
151                .collect(),
152        )
153    }
154
155    /// One header's value, matched case-insensitively.
156    ///
157    /// Case-insensitively because HTTP header names are, and the map's keys come from
158    /// a service response rather than from this crate: a client that matched
159    /// `X-aws-proxy-auth` exactly would break on a response that spelled it
160    /// `x-aws-proxy-auth`, and the failure would be a missing header rather than a
161    /// missing key.
162    pub fn get(&self, name: &str) -> Option<&str> {
163        self.headers
164            .iter()
165            .find(|(key, _)| key.eq_ignore_ascii_case(name))
166            .map(|(_, value)| value.as_str())
167    }
168
169    /// Every header the token carries, in unspecified order.
170    ///
171    /// Public because the platform's stated reason for a map is that a scheme may
172    /// need more than one header, so a client that forwarded only the two it knows
173    /// about would silently drop a third the service started sending.
174    pub fn headers(&self) -> impl Iterator<Item = (&str, &str)> {
175        self.headers
176            .iter()
177            .map(|(name, value)| (name.as_str(), value.as_str()))
178    }
179
180    /// The [`PROXY_AUTH_HEADER`] value, or a retryable mint failure if the map has no
181    /// such key.
182    ///
183    /// Retryable rather than a protocol error: an `authToken` map without the auth
184    /// header is a control-plane response this client cannot use, which is the same
185    /// situation as a mint that failed outright, and the same remedy applies.
186    pub fn auth_value(&self) -> Result<&str, Error> {
187        self.get(PROXY_AUTH_HEADER).ok_or_else(|| {
188            Error::wire(
189                WireKind::AuthTokenMint,
190                format!(
191                    "the minted authToken map has no {PROXY_AUTH_HEADER} key (it carries {:?}); \
192                     the response is a header map, not a token string — see docs/PLATFORM.md, \
193                     \"Endpoint authentication\"",
194                    self.headers.keys().collect::<Vec<_>>()
195                ),
196            )
197        })
198    }
199
200    /// Whether the map carries anything at all.
201    pub fn is_empty(&self) -> bool {
202        self.headers.is_empty()
203    }
204}
205
206/// The seam to the control plane's own token type.
207///
208/// The two lanes that landed this module and [`crate::control`] arrived at the same trap
209/// closure from different directions: `control::ProxyToken` keeps the auth value and the
210/// port as two fields and emits a fixed pair from `headers()`, while this one keeps the
211/// service's whole map so a third header the platform adds later rides along without a
212/// code change. Neither is wrong, and unifying them would mean one lane editing the
213/// other's module.
214///
215/// So the conversion lives here, which is the side that consumes it. It is the whole of
216/// what a caller needs to hand `ControlPlane::mint_auth_token`'s result to a
217/// [`TokenMinter`], and it is `From` rather than a named function so the wiring is one
218/// `.into()` at the boundary.
219impl From<crate::control::ProxyToken> for ProxyToken {
220    fn from(token: crate::control::ProxyToken) -> Self {
221        Self::from_pairs(token.headers())
222    }
223}
224
225/// Names the keys and never the values.
226///
227/// A proxy token is a credential, and a `Debug` that printed it would put it in every
228/// log line that formats a [`ProxyAuth`] or an error chain containing one.
229impl fmt::Debug for ProxyToken {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        let mut keys: Vec<&str> = self.headers.keys().map(String::as_str).collect();
232        keys.sort_unstable();
233        f.debug_struct("ProxyToken")
234            .field("headers", &keys)
235            .finish()
236    }
237}
238
239/// Mints a proxy token. One method, so a test needs no AWS.
240///
241/// Narrow on purpose: the control plane's `mint_auth_token` implements this and
242/// nothing else about the control plane is reachable from a session. A `Session`
243/// coupled to the whole control-plane client would be a session that cannot be built
244/// without credentials, and the conformance path talks to a local daemon.
245///
246/// Boxed future rather than `async fn` in the trait: a `Session` holds
247/// `Arc<dyn TokenMinter>`, and an `async fn` in a trait is not dyn-compatible.
248pub trait TokenMinter: Send + Sync {
249    /// Mints a token for the session's own port. The hot path.
250    fn mint(&self) -> BoxFuture<'_, Result<ProxyToken, Error>>;
251
252    /// Mints a token that also authorizes `ports`.
253    ///
254    /// Separate from [`Self::mint`] rather than replacing it, because the two answer
255    /// different questions and the common one must stay free of a list nobody populates:
256    /// every request this client makes on its own behalf targets the agent port, and that is
257    /// what `mint` is for.
258    ///
259    /// **This exists because the scope was the defect.** `Session::connect_headers(port)` and
260    /// `connect_subprotocols(port)` were built over `mint`'s token, which the control plane
261    /// scopes to the agent port alone — so they answered a correct-looking port header behind
262    /// a credential that did not authorize it. Measured 2026-08-15: `GET :8080` through the
263    /// endpoint is **403 `Access to port denied`** under that token and 200 under one minted
264    /// with 8080 in `allowedPorts`, and on the WebSocket path the same rejection is close code
265    /// 1006 with no reason. See [`crate::control::ops::PortSpecification`] for the table.
266    ///
267    /// The default implementation ignores `ports` and delegates, which is honest for the two
268    /// kinds of minter that have no control plane behind them — a test double, or a session
269    /// against a daemon reached directly, where there is no proxy to authorize anything. A
270    /// minter that *can* widen a scope overrides it, and the one in `sandbox.rs` does.
271    fn mint_for_ports(&self, ports: &[u16]) -> BoxFuture<'_, Result<ProxyToken, Error>> {
272        let _ = ports;
273        self.mint()
274    }
275}
276
277/// The clock a refresh boundary is measured on. It's the crate's one [`Clock`], and the
278/// production one is tokio's (`TokioClock`, in `microvms-edges`): a token timed on
279/// `std::time::Instant` would never re-mint inside a simulation.
280pub use crate::clock::Clock;
281
282/// One cached token, when it was minted, and **which ports it authorizes**.
283///
284/// The port set is not bookkeeping: a token is scoped by the control plane at mint time, so
285/// reusing a cached one for a port outside its scope produces a request the proxy refuses
286/// with 403 `Access to port denied` — or, on a WebSocket, close code 1006 with no reason.
287/// That is exactly what `headers_for_port` and `subprotocols` did before this field existed.
288/// See [`crate::control::ops::PortSpecification`] for the measurement.
289struct Cached {
290    token: ProxyToken,
291    minted_at: Duration,
292    /// Every port this token authorizes. A set rather than one port, because a token minted
293    /// for the agent port *and* a workload port serves both and must not be re-minted for
294    /// either.
295    ports: BTreeSet<u16>,
296}
297
298/// Mints, caches, and refreshes the proxy token for one MicroVM and one port.
299///
300/// Shared behind an `Arc` and mutated through `&self`, because every request needs
301/// the headers and a request path that needed `&mut` would serialize the whole
302/// session behind one lock.
303pub struct ProxyAuth {
304    minter: Arc<dyn TokenMinter>,
305    port: u16,
306    refresh_after: Duration,
307    clock: Arc<dyn Clock>,
308    /// The cached token. A `std::sync::RwLock` rather than tokio's, because it is only
309    /// ever held for a read or a write of one field and never across an await — the
310    /// await is under `mint_lock`.
311    cached: RwLock<Option<Cached>>,
312    /// Held across the mint so two concurrent requests arriving on an expired token
313    /// produce one mint rather than two. tokio's, because minting is async.
314    mint_lock: tokio::sync::Mutex<()>,
315    mint_count: AtomicU64,
316}
317
318impl ProxyAuth {
319    /// A [`ProxyAuth`] with the default refresh interval, on `clock`.
320    ///
321    /// Infallible where [`Self::with_refresh_after`] isn't: [`DEFAULT_REFRESH_AFTER`] is a
322    /// constant this module owns, and a test below asserts it satisfies that guard.
323    /// `ProxyAuth::new(minter, port)` is this on tokio's clock, in `microvms_core::prelude`.
324    pub fn with_clock(minter: Arc<dyn TokenMinter>, port: u16, clock: Arc<dyn Clock>) -> Self {
325        Self {
326            minter,
327            port,
328            refresh_after: DEFAULT_REFRESH_AFTER,
329            clock,
330            cached: RwLock::new(None),
331            mint_lock: tokio::sync::Mutex::new(()),
332            mint_count: AtomicU64::new(0),
333        }
334    }
335
336    /// A [`ProxyAuth`] with an explicit refresh interval and clock.
337    ///
338    /// Refuses an interval at or above [`MAX_TOKEN_LIFETIME`] (S2, TRAP-9). An
339    /// interval *at* the ceiling is the specific mistake: it looks like "refresh when
340    /// the token expires", and it means every refresh races the expiry it exists to
341    /// avoid. Refused locally rather than documented, because the symptom is a 401
342    /// from the proxy that reads as a wrong agent token.
343    pub fn with_refresh_after(
344        minter: Arc<dyn TokenMinter>,
345        port: u16,
346        refresh_after: Duration,
347        clock: Arc<dyn Clock>,
348    ) -> Result<Self, Error> {
349        if refresh_after >= MAX_TOKEN_LIFETIME {
350            return Err(Error::invalid_arg(format!(
351                "a proxy-token refresh interval of {}s is not below the service's \
352                 {}s ceiling, so a request can be issued with a token that has already \
353                 expired; see docs/PLATFORM.md, \"Endpoint authentication\"",
354                refresh_after.as_secs(),
355                MAX_TOKEN_LIFETIME.as_secs()
356            )));
357        }
358        Ok(Self {
359            minter,
360            port,
361            refresh_after,
362            clock,
363            cached: RwLock::new(None),
364            mint_lock: tokio::sync::Mutex::new(()),
365            mint_count: AtomicU64::new(0),
366        })
367    }
368
369    /// The port this token is scoped to, and the [`PROXY_PORT_HEADER`] value.
370    pub fn port(&self) -> u16 {
371        self.port
372    }
373
374    /// How many tokens have been minted over this instance's life.
375    ///
376    /// Public because it is the only externally visible evidence that minting happens
377    /// in the request path at all: a token cached forever and a token refreshed on
378    /// schedule produce identical successful requests, and the count is what
379    /// distinguishes them. STATE-8's resume test asserts on it.
380    pub fn mint_count(&self) -> u64 {
381        self.mint_count.load(Ordering::SeqCst)
382    }
383
384    /// Drops the cached token so the next request mints a fresh one (STATE-8).
385    ///
386    /// Called after a resume. The measured behaviour is that a resumed VM keeps its
387    /// endpoint URL and its bootstrap state, but a token minted against the
388    /// pre-suspend instance is not guaranteed to survive — and a stale-token rejection
389    /// there reads exactly like a daemon that died.
390    ///
391    /// Synchronous, so a state machine can call it from a non-async transition.
392    pub fn invalidate(&self) {
393        *self.cached.write().unwrap_or_else(PoisonError::into_inner) = None;
394    }
395
396    /// Whether a token is cached and still inside the refresh window.
397    ///
398    /// Exposed for the resume lane: after [`Self::invalidate`] this is false, which is
399    /// a fact a test can assert without issuing a request.
400    ///
401    /// Asked about **this session's own port**, which is the one every request on the hot path
402    /// targets. A token warmed only for some other port would read false here, and that is
403    /// the right answer to "is a request ready to go" — see [`Self::fresh_token_for`].
404    pub fn is_cached(&self) -> bool {
405        self.fresh_token_for(self.port).is_some()
406    }
407
408    /// Every header this request needs, minting first if the cache is stale.
409    ///
410    /// Both headers, always. The auth value is read out of the token's map by name;
411    /// the rest of the map rides along in case the platform adds a third header; and
412    /// the port header is filled from this instance's port unless the token already
413    /// named one.
414    pub async fn headers(&self) -> Result<Vec<(String, String)>, Error> {
415        Ok(self.headers_from(&self.token().await?))
416    }
417
418    /// Both required headers, for a port this instance was **not** built with.
419    ///
420    /// What a caller opening its own connection to some other port on the same VM needs —
421    /// the harness contract's `getPortEndpoint() -> {url, headers}` is exactly this shape,
422    /// and reimplementing it outside this module would mean a second mint path (see the
423    /// module docs on why there is one).
424    ///
425    /// The requested port **wins over one the token map carries**, which is the opposite of
426    /// [`Self::headers`]. The reason is that a caller naming a port is answering the
427    /// question the header asks, and quietly substituting a different port would send a
428    /// request the caller can neither predict nor debug: the rejection names the token.
429    /// [`Self::headers`] defers to the map instead because nobody named a port there.
430    ///
431    /// The auth value is a bearer credential. Nothing in this crate logs the returned pairs
432    /// and no type here stores them; a caller that logs them is past what this module can
433    /// prevent.
434    pub async fn headers_for_port(&self, port: u16) -> Result<Vec<(String, String)>, Error> {
435        let token = self.token_for(port).await?;
436        let mut headers: Vec<(String, String)> = token
437            .headers()
438            .filter(|(name, _)| !name.eq_ignore_ascii_case(PROXY_PORT_HEADER))
439            .map(|(name, value)| (name.to_string(), value.to_string()))
440            .collect();
441        headers.push((PROXY_PORT_HEADER.to_string(), port.to_string()));
442        Ok(headers)
443    }
444
445    /// The three subprotocols a WebSocket handshake through the endpoint proxy offers.
446    ///
447    /// In wire order: [`WS_SUBPROTOCOL`], the JWE behind
448    /// [`WS_AUTH_SUBPROTOCOL_PREFIX`], and `port` behind [`WS_PORT_SUBPROTOCOL_PREFIX`].
449    /// See the module docs for why a handshake carries these instead of the two headers,
450    /// and note that the platform strips all three before forwarding — so a server inside
451    /// the VM never negotiates them.
452    ///
453    /// Minted through the same cache and refresh schedule as [`Self::headers`]: a
454    /// long-lived connection opened at minute fifty gets a token minted at minute fifty,
455    /// not the one from construction, and nothing here can mint a token this instance's
456    /// schedule does not know about.
457    ///
458    /// The middle string **contains the credential**. Same rule as
459    /// [`Self::headers_for_port`]: this crate stores and logs none of it.
460    pub async fn subprotocols(&self, port: u16) -> Result<[String; 3], Error> {
461        let token = self.token_for(port).await?;
462        // Through `auth_value` rather than by reading the map with a default, so a token
463        // whose map lacks the auth key is a retryable mint failure and never a handshake
464        // offering `lambda-microvms.authentication.` with nothing after it.
465        //
466        // Belt and braces with the identical check in `token()`'s mint path, which is where
467        // the guard test actually fires today. Kept anyway: `token()` only checks a token it
468        // *minted*, so an unusable map that arrived some other way — a cache seeded by a
469        // future constructor, or that check being moved — would reach here, and the failure
470        // it would cause is a handshake rejected for a reason naming neither the token nor
471        // the port.
472        let auth = token.auth_value()?;
473        Ok([
474            WS_SUBPROTOCOL.to_string(),
475            format!("{WS_AUTH_SUBPROTOCOL_PREFIX}{auth}"),
476            format!("{WS_PORT_SUBPROTOCOL_PREFIX}{port}"),
477        ])
478    }
479
480    /// The cached token, minting first if the cache is stale.
481    ///
482    /// The one mint path. [`Self::headers`], [`Self::headers_for_port`], and
483    /// [`Self::subprotocols`] all reach the control plane through here, which is what makes
484    /// "minting happens in the request path" (TRAP-9) a property of the type rather than of
485    /// each accessor remembering to do it.
486    async fn token(&self) -> Result<ProxyToken, Error> {
487        self.token_for(self.port).await
488    }
489
490    /// The cached token if it authorizes `port`, otherwise a fresh one that does.
491    ///
492    /// **A cache miss is now two conditions, not one: stale, or out of scope.** The second is
493    /// the fix. A token the control plane minted for the agent port does not authorize port
494    /// 8080, so handing it back with an `X-aws-proxy-port: 8080` header — which is what this
495    /// did — produces 403 `Access to port denied`, and the WebSocket form of that refusal is
496    /// an unreasoned 1006. Measured 2026-08-15; the table is on
497    /// [`crate::control::ops::PortSpecification`].
498    ///
499    /// A widened mint asks for the agent port **and** `port` together, so warming the cache
500    /// for a workload port does not cost the session its own access. One extra mint per new
501    /// port, cached like any other, and `mint_count` is what makes that visible.
502    async fn token_for(&self, port: u16) -> Result<ProxyToken, Error> {
503        if let Some(token) = self.fresh_token_for(port) {
504            return Ok(token);
505        }
506        // Serialize the mint. Re-check under the lock rather than minting
507        // unconditionally: while this task waited, another may have refreshed, and a
508        // second mint would burn a control-plane call for nothing.
509        let _guard = self.mint_lock.lock().await;
510        if let Some(token) = self.fresh_token_for(port) {
511            return Ok(token);
512        }
513
514        // Every port already covered, plus this one. A superset rather than a replacement,
515        // because dropping a port the session had been using would make a later request on it
516        // re-mint — and two accessors alternating between two ports would then mint on every
517        // call, which is TRAP-9's cost without its benefit.
518        let mut wanted: BTreeSet<u16> = self
519            .cached
520            .read()
521            .unwrap_or_else(PoisonError::into_inner)
522            .as_ref()
523            .map(|entry| entry.ports.clone())
524            .unwrap_or_default();
525        wanted.insert(self.port);
526        wanted.insert(port);
527        let ports: Vec<u16> = wanted.iter().copied().collect();
528
529        let token = self.minter.mint_for_ports(&ports).await.map_err(|err| {
530            // Names every port the mint asked for, not just the session's, because a
531            // rejection on a widened mint is about the widening and a message naming one
532            // port would send the reader to the wrong place.
533            //
534            // A credential failure keeps its class (#280). The control plane refusing the
535            // mint (a 403, or a chain that resolved no identity) fails the identical request
536            // the same way until the caller fixes the identity, so calling it retryable
537            // would send a harness into a retry loop nothing on the platform ends.
538            if err.kind() == ErrorKind::Credentials {
539                let message = format!(
540                    "could not mint a proxy auth token for port(s) {ports:?}: {err}; the \
541                     identity was refused, so the identical request fails the same way until \
542                     the credential is fixed"
543                );
544                return Error::new(ErrorKind::Credentials, message).with_source(err);
545            }
546            // Every other failure is reclassified rather than passed through: from a
547            // caller's point of view it is a mint failure and it is retryable. A minter
548            // that reported, say, a throttle as `ServerError` would still be retryable, but
549            // one that reported it as a protocol error would abort a healthy trial.
550            let message = format!(
551                "could not mint a proxy auth token for port(s) {ports:?}: {err}; minting is \
552                 inside the request path, so the identical request may succeed"
553            );
554            Error::wire(WireKind::AuthTokenMint, message).with_source(err)
555        })?;
556        // Checked before the token is cached, so a map missing the auth header is a
557        // mint failure rather than a cached value that fails every request until the
558        // refresh window rolls over.
559        token.auth_value()?;
560
561        *self.cached.write().unwrap_or_else(PoisonError::into_inner) = Some(Cached {
562            token: token.clone(),
563            minted_at: self.clock.elapsed(),
564            // What was *asked for*, which is the honest record. A minter that silently
565            // narrowed the scope would leave this claiming a port the token does not carry —
566            // and there is nothing in the response to check it against, since the service
567            // returns a header map and not the scope it granted. So the cache records the
568            // request, and the 403 remains the only authority on the grant.
569            ports: wanted,
570        });
571        self.mint_count.fetch_add(1, Ordering::SeqCst);
572        Ok(token)
573    }
574
575    /// The cached token if it is fresh **and** authorizes `port`, otherwise `None`.
576    ///
577    /// Cloned out rather than handed back behind the read guard, because the guard cannot be
578    /// held across the await in [`Self::token_for`]'s slow path (see the field's own comment).
579    /// A [`ProxyToken`] is a small map, and this is once per request at most.
580    fn fresh_token_for(&self, port: u16) -> Option<ProxyToken> {
581        let cached = self.cached.read().unwrap_or_else(PoisonError::into_inner);
582        let entry = cached.as_ref()?;
583        // Scope before age, because an out-of-scope token is wrong at any age and the
584        // failure it produces — a 403 that reads like a bad token — is the harder one to
585        // diagnose.
586        if !entry.ports.contains(&port) {
587            return None;
588        }
589        // `saturating_sub` because a clock is only promised to be monotonic, and a
590        // negative age would otherwise wrap into an enormous one and re-mint on every
591        // request.
592        let age = self.clock.elapsed().saturating_sub(entry.minted_at);
593        if age > self.refresh_after {
594            return None;
595        }
596        Some(entry.token.clone())
597    }
598
599    /// Both required headers, built from one token.
600    fn headers_from(&self, token: &ProxyToken) -> Vec<(String, String)> {
601        let mut headers: Vec<(String, String)> = token
602            .headers()
603            .map(|(name, value)| (name.to_string(), value.to_string()))
604            .collect();
605        // The port header is this client's to send, not the token's to carry — but if
606        // a future control-plane response includes it, that value wins, because the
607        // service knows which ports it scoped the token to and this client only knows
608        // which one it was configured with.
609        if !headers
610            .iter()
611            .any(|(name, _)| name.eq_ignore_ascii_case(PROXY_PORT_HEADER))
612        {
613            headers.push((PROXY_PORT_HEADER.to_string(), self.port.to_string()));
614        }
615        headers
616    }
617}
618
619impl fmt::Debug for ProxyAuth {
620    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
621        f.debug_struct("ProxyAuth")
622            .field("port", &self.port)
623            .field("refresh_after", &self.refresh_after)
624            .field("mint_count", &self.mint_count())
625            .field("cached", &self.is_cached())
626            .finish()
627    }
628}
629
630#[cfg(any(test, feature = "test-support"))]
631#[cfg_attr(test, mutants::skip)] // a test double; see .cargo/mutants.toml
632pub(crate) mod testing {
633    //! The minter the session tests share, so `mod.rs`'s and `exec.rs`'s tests reach the
634    //! same one rather than each growing a near-copy that can drift.
635
636    use super::*;
637
638    #[cfg(test)]
639    pub(crate) use crate::clock::testing::TestClock;
640
641    /// A minter that answers with a token naming its own sequence number, so a test
642    /// can tell a refreshed token from a cached one by the value on the wire.
643    #[derive(Debug, Default)]
644    pub struct CountingMinter {
645        issued: AtomicU64,
646        /// How many of the next mints fail. Drives the retryable-mint scenario.
647        failures: AtomicU64,
648        /// When set, the token map omits [`PROXY_AUTH_HEADER`] — the TRAP-7 shape a
649        /// client that read `authToken` as a string would never notice.
650        omit_auth_header: bool,
651        /// The port list each mint asked for, in order.
652        ///
653        /// The only observable that can tell a *widened* scope from a re-mint of the same one.
654        /// Without it the port-scope defect is invisible to a test: the mint count and the
655        /// token values are identical whether the request named one port or three, and the
656        /// difference shows up only as a 403 from the real proxy. So this records the argument.
657        requested: RwLock<Vec<Vec<u16>>>,
658    }
659
660    impl CountingMinter {
661        pub fn failing(times: u64) -> Self {
662            Self {
663                failures: AtomicU64::new(times),
664                ..Self::default()
665            }
666        }
667
668        pub fn without_auth_header() -> Self {
669            Self {
670                omit_auth_header: true,
671                ..Self::default()
672            }
673        }
674
675        /// The auth value the nth token carries.
676        pub fn value(nth: u64) -> String {
677            format!("jwe-{nth}")
678        }
679
680        /// The port list of every mint, in order. Sorted within each entry.
681        pub fn requested(&self) -> Vec<Vec<u16>> {
682            self.requested
683                .read()
684                .unwrap_or_else(PoisonError::into_inner)
685                .clone()
686        }
687
688        /// One mint's body, shared by both trait methods.
689        fn issue(&self, ports: &[u16]) -> Result<ProxyToken, Error> {
690            if self
691                .failures
692                .try_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
693                    left.checked_sub(1)
694                })
695                .is_ok()
696            {
697                return Err(Error::wire(
698                    WireKind::AuthTokenMint,
699                    "ThrottlingException from CreateMicrovmAuthToken",
700                ));
701            }
702            // Recorded only for a mint that succeeded, so a failing-then-retried mint reads as
703            // one request for one scope rather than two.
704            let mut sorted = ports.to_vec();
705            sorted.sort_unstable();
706            self.requested
707                .write()
708                .unwrap_or_else(PoisonError::into_inner)
709                .push(sorted);
710            let nth = self.issued.fetch_add(1, Ordering::SeqCst);
711            if self.omit_auth_header {
712                return Ok(ProxyToken::from_pairs([("X-aws-something-else", "junk")]));
713            }
714            Ok(ProxyToken::from_pairs([(
715                PROXY_AUTH_HEADER.to_string(),
716                Self::value(nth),
717            )]))
718        }
719    }
720
721    impl TokenMinter for CountingMinter {
722        fn mint(&self) -> BoxFuture<'_, Result<ProxyToken, Error>> {
723            // The agent port, because that is what the real minter's `mint` asks for. A fake
724            // recording an empty list here would make the widened-scope assertions pass
725            // against a client that had stopped naming the session's own port.
726            Box::pin(async move { self.issue(&[DEFAULT_AGENT_PORT]) })
727        }
728
729        /// Overridden rather than inherited, so a test can see the scope that was requested.
730        ///
731        /// The default delegates to `mint` and drops the ports, which is honest for a minter
732        /// with no control plane — but it would also make this fake unable to distinguish the
733        /// defect from the fix, since both would record the same thing.
734        fn mint_for_ports(&self, ports: &[u16]) -> BoxFuture<'_, Result<ProxyToken, Error>> {
735            let ports = ports.to_vec();
736            Box::pin(async move { self.issue(&ports) })
737        }
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::testing::{CountingMinter, TestClock};
744    use super::*;
745    use crate::error::ErrorKind;
746
747    fn auth(clock: Arc<TestClock>, refresh_after: Duration) -> ProxyAuth {
748        auth_with(clock, refresh_after).0
749    }
750
751    /// The same, keeping the minter so a test can read the port lists it was asked for.
752    ///
753    /// Returned as a pair rather than reachable off `ProxyAuth`, because a public accessor for
754    /// "which scopes did you request" would exist only for this test and would be a second
755    /// place the answer lives.
756    fn auth_with(
757        clock: Arc<TestClock>,
758        refresh_after: Duration,
759    ) -> (ProxyAuth, Arc<CountingMinter>) {
760        let minter = Arc::new(CountingMinter::default());
761        let auth = ProxyAuth::with_refresh_after(
762            Arc::clone(&minter) as Arc<dyn TokenMinter>,
763            DEFAULT_AGENT_PORT,
764            refresh_after,
765            clock,
766        )
767        .expect("the interval is below the ceiling");
768        (auth, minter)
769    }
770
771    fn value_of(headers: &[(String, String)], name: &str) -> Option<String> {
772        headers
773            .iter()
774            .find(|(key, _)| key.eq_ignore_ascii_case(name))
775            .map(|(_, value)| value.clone())
776    }
777
778    /// The shipped default has to satisfy the guard the fallible constructor enforces,
779    /// which is what lets [`ProxyAuth::with_clock`] be infallible.
780    ///
781    /// This is also the TRAP-9 falsification at its cheapest: set
782    /// `DEFAULT_REFRESH_AFTER` to `MAX_TOKEN_LIFETIME` and this test is red.
783    #[test]
784    fn the_default_refresh_interval_is_strictly_below_the_service_ceiling() {
785        assert!(
786            DEFAULT_REFRESH_AFTER < MAX_TOKEN_LIFETIME,
787            "refreshing at or after the ceiling puts the expiry inside the window \
788             between building the headers and the proxy reading them"
789        );
790        assert_eq!(MAX_TOKEN_LIFETIME, Duration::from_secs(3600));
791        assert_eq!(DEFAULT_REFRESH_AFTER, MAX_TOKEN_LIFETIME / 2);
792    }
793
794    /// An interval at the ceiling is refused locally, before any request could carry
795    /// an expired token (S2).
796    #[test]
797    fn a_refresh_interval_at_the_ceiling_is_refused() {
798        let err = ProxyAuth::with_refresh_after(
799            Arc::new(CountingMinter::default()),
800            DEFAULT_AGENT_PORT,
801            MAX_TOKEN_LIFETIME,
802            Arc::new(TestClock::default()),
803        )
804        .expect_err("an interval at the ceiling must be refused");
805        assert_eq!(err.kind(), ErrorKind::InvalidArg);
806        assert!(
807            err.to_string().contains("ceiling"),
808            "the message must name the finding: {err}"
809        );
810
811        // One second under is accepted, so the guard is a comparison rather than a
812        // blanket refusal of anything near an hour.
813        assert!(
814            ProxyAuth::with_refresh_after(
815                Arc::new(CountingMinter::default()),
816                DEFAULT_AGENT_PORT,
817                MAX_TOKEN_LIFETIME - Duration::from_secs(1),
818                Arc::new(TestClock::default()),
819            )
820            .is_ok()
821        );
822    }
823
824    /// Both headers on every set, which is the whole of TRAP-7's send side.
825    #[tokio::test]
826    async fn every_header_set_carries_both_the_auth_and_the_port_header() {
827        let auth = auth(Arc::new(TestClock::default()), DEFAULT_REFRESH_AFTER);
828        let headers = auth.headers().await.expect("the fake minter succeeds");
829        assert_eq!(
830            value_of(&headers, PROXY_AUTH_HEADER).as_deref(),
831            Some(CountingMinter::value(0).as_str())
832        );
833        assert_eq!(
834            value_of(&headers, PROXY_PORT_HEADER).as_deref(),
835            Some("9000"),
836            "without the port header the proxy answers as if the token were bad"
837        );
838        assert_eq!(
839            headers.len(),
840            2,
841            "no third header was invented: {headers:?}"
842        );
843    }
844
845    /// The token is a map, and a map that lacks the auth key is a mint failure rather
846    /// than a token.
847    ///
848    /// The TRAP-7 shape: a client that read `authToken` as a string would put the
849    /// stringified map — or nothing — on the wire and see a proxy rejection.
850    #[tokio::test]
851    async fn a_token_map_without_the_auth_header_is_a_retryable_mint_failure() {
852        let auth = ProxyAuth::with_refresh_after(
853            Arc::new(CountingMinter::without_auth_header()),
854            DEFAULT_AGENT_PORT,
855            DEFAULT_REFRESH_AFTER,
856            Arc::new(TestClock::default()),
857        )
858        .expect("interval accepted");
859        let err = auth.headers().await.expect_err("the map has no auth key");
860        assert_eq!(err.wire_kind(), Some(WireKind::AuthTokenMint));
861        assert!(err.retryable());
862        assert!(
863            err.to_string().contains(PROXY_AUTH_HEADER),
864            "the message must name the missing key: {err}"
865        );
866        assert_eq!(
867            auth.mint_count(),
868            0,
869            "an unusable token must not be counted as minted or cached"
870        );
871    }
872
873    /// A second request inside the window reuses the token: minting is per-staleness,
874    /// not per-request.
875    #[tokio::test]
876    async fn a_token_inside_the_refresh_window_is_reused() {
877        let clock = Arc::new(TestClock::default());
878        let auth = auth(Arc::clone(&clock), DEFAULT_REFRESH_AFTER);
879
880        let first = auth.headers().await.expect("mints");
881        clock.advance(DEFAULT_REFRESH_AFTER - Duration::from_secs(1));
882        let second = auth.headers().await.expect("reuses");
883
884        assert_eq!(first, second);
885        assert_eq!(auth.mint_count(), 1);
886    }
887
888    /// Crossing the refresh window re-mints, and the fresh value reaches the wire.
889    ///
890    /// The value assertion is the load-bearing half: a client that refreshed its cache
891    /// but kept emitting the old header would pass a mint-count assertion and fail
892    /// every request.
893    #[tokio::test]
894    async fn crossing_the_refresh_window_mints_a_new_token_and_sends_it() {
895        let clock = Arc::new(TestClock::default());
896        let auth = auth(Arc::clone(&clock), DEFAULT_REFRESH_AFTER);
897
898        let first = auth.headers().await.expect("mints");
899        assert_eq!(
900            value_of(&first, PROXY_AUTH_HEADER).as_deref(),
901            Some(CountingMinter::value(0).as_str())
902        );
903
904        clock.advance(DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
905        let second = auth.headers().await.expect("re-mints");
906
907        assert_eq!(auth.mint_count(), 2);
908        assert_eq!(
909            value_of(&second, PROXY_AUTH_HEADER).as_deref(),
910            Some(CountingMinter::value(1).as_str()),
911            "the refreshed token never reached the headers"
912        );
913    }
914
915    /// Walks two ceilings' worth of requests and reports the oldest token any of them
916    /// carried, plus how many mints it took.
917    ///
918    /// Constructed with a struct literal rather than through
919    /// [`ProxyAuth::with_refresh_after`], deliberately: the constructor refuses an
920    /// interval at the ceiling, which is exactly the interval the test below has to
921    /// measure. Going around the guard is what makes the property falsifiable — a test
922    /// that could only build a *safe* interval could not tell a correct refresh schedule
923    /// from a constructor that happened to reject the one bad input.
924    async fn walk_two_ceilings(refresh_after: Duration) -> (Duration, u64) {
925        let clock = Arc::new(TestClock::default());
926        let auth = ProxyAuth {
927            minter: Arc::new(CountingMinter::default()),
928            port: DEFAULT_AGENT_PORT,
929            refresh_after,
930            clock: Arc::clone(&clock) as Arc<dyn Clock>,
931            cached: RwLock::new(None),
932            mint_lock: tokio::sync::Mutex::new(()),
933            mint_count: AtomicU64::new(0),
934        };
935
936        // One-minute steps, finer than any refresh boundary in play.
937        let mut minted_at = Duration::ZERO;
938        let mut oldest = Duration::ZERO;
939        for minute in 0..120u64 {
940            let now = Duration::from_secs(minute * 60);
941            let before = auth.mint_count();
942            auth.headers().await.expect("the fake minter succeeds");
943            if auth.mint_count() > before {
944                minted_at = now;
945            }
946            oldest = oldest.max(now - minted_at);
947            clock.advance(Duration::from_secs(60));
948        }
949        (oldest, auth.mint_count())
950    }
951
952    /// No request ever carries a token past the service ceiling (TRAP-9).
953    ///
954    /// Stated over the whole walk rather than at one instant: the observable is the age
955    /// of the *oldest* token any request presented, which is the only number that can
956    /// say a schedule is safe rather than that it happened to be safe at the moment
957    /// checked.
958    ///
959    /// **Guard proof.** The second half is the falsification, and it is why this test
960    /// builds a `ProxyAuth` by hand: at a ceiling-width interval the oldest token
961    /// presented is over an hour old, so a client that shipped `refresh_after =
962    /// MAX_TOKEN_LIFETIME` — the plausible mistake, since it reads as "refresh when the
963    /// token expires" — would be caught here. Both halves run, so the test cannot pass
964    /// by measuring nothing.
965    #[tokio::test]
966    async fn a_token_is_never_presented_after_the_service_ceiling() {
967        let (oldest, mints) = walk_two_ceilings(DEFAULT_REFRESH_AFTER).await;
968        assert!(
969            oldest < MAX_TOKEN_LIFETIME,
970            "a request carried a token {}s old, past the {}s ceiling",
971            oldest.as_secs(),
972            MAX_TOKEN_LIFETIME.as_secs()
973        );
974        assert!(
975            MAX_TOKEN_LIFETIME - oldest >= MAX_TOKEN_LIFETIME - DEFAULT_REFRESH_AFTER,
976            "the oldest token presented had only {}s of life left",
977            (MAX_TOKEN_LIFETIME - oldest).as_secs()
978        );
979        // Two hours at a thirty-minute window: mints land at minute 0, then at 31, 62,
980        // and 93, since staleness is `age > refresh_after` rather than `>=`.
981        assert_eq!(mints, 4);
982
983        // The falsification, run rather than described: the exact negation of the
984        // assertion above. A token presented at *exactly* the ceiling has zero life
985        // left, which is why the safe comparison is strict.
986        let (oldest_at_ceiling, _) = walk_two_ceilings(MAX_TOKEN_LIFETIME).await;
987        assert!(
988            oldest_at_ceiling >= MAX_TOKEN_LIFETIME,
989            "refreshing at the ceiling presented a token only {}s old, so this \
990             property cannot tell a safe schedule from an unsafe one",
991            oldest_at_ceiling.as_secs()
992        );
993    }
994
995    /// `invalidate` drops the cache, so the next request mints (STATE-8).
996    #[tokio::test]
997    async fn invalidate_forces_the_next_request_to_mint() {
998        let clock = Arc::new(TestClock::default());
999        let auth = auth(Arc::clone(&clock), DEFAULT_REFRESH_AFTER);
1000
1001        auth.headers().await.expect("mints");
1002        assert_eq!(auth.mint_count(), 1);
1003        assert!(auth.is_cached());
1004
1005        auth.invalidate();
1006        assert!(!auth.is_cached(), "invalidate did not drop the cache");
1007
1008        // No clock movement at all: the re-mint is caused by the invalidation, not by
1009        // the window rolling over.
1010        let refreshed = auth.headers().await.expect("re-mints");
1011        assert_eq!(auth.mint_count(), 2);
1012        assert_eq!(
1013            value_of(&refreshed, PROXY_AUTH_HEADER).as_deref(),
1014            Some(CountingMinter::value(1).as_str())
1015        );
1016    }
1017
1018    /// A mint failure is retryable, and the retry is a plain second call.
1019    #[tokio::test]
1020    async fn a_failed_mint_is_retryable_and_the_next_attempt_succeeds() {
1021        let auth = ProxyAuth::with_refresh_after(
1022            Arc::new(CountingMinter::failing(1)),
1023            DEFAULT_AGENT_PORT,
1024            DEFAULT_REFRESH_AFTER,
1025            Arc::new(TestClock::default()),
1026        )
1027        .expect("interval accepted");
1028
1029        let err = auth.headers().await.expect_err("the first mint fails");
1030        assert_eq!(err.kind(), ErrorKind::Retryable);
1031        assert_eq!(err.wire_kind(), Some(WireKind::AuthTokenMint));
1032        assert_eq!(auth.mint_count(), 0);
1033
1034        let headers = auth.headers().await.expect("the second mint succeeds");
1035        assert_eq!(auth.mint_count(), 1);
1036        assert!(value_of(&headers, PROXY_AUTH_HEADER).is_some());
1037    }
1038
1039    /// **A credential failure during a mint is `ERR_CREDENTIALS`, not `ERR_RETRYABLE` (#280).**
1040    ///
1041    /// The control plane refusing the mint with a 403 is an identity problem: the identical
1042    /// request fails the same way until the caller fixes the credential, so a retryable class
1043    /// sends a harness into a retry loop the platform can't end. The throttle case above stays
1044    /// retryable; only the minter's own credential verdict passes through.
1045    #[tokio::test]
1046    async fn a_credential_failure_during_a_mint_is_err_credentials_not_retryable() {
1047        struct DeniedMinter;
1048        impl TokenMinter for DeniedMinter {
1049            fn mint(&self) -> BoxFuture<'_, Result<ProxyToken, Error>> {
1050                Box::pin(async move {
1051                    Err(Error::new(
1052                        ErrorKind::Credentials,
1053                        "AccessDeniedException from CreateMicrovmAuthToken",
1054                    ))
1055                })
1056            }
1057        }
1058
1059        let auth = ProxyAuth::with_refresh_after(
1060            Arc::new(DeniedMinter),
1061            DEFAULT_AGENT_PORT,
1062            DEFAULT_REFRESH_AFTER,
1063            Arc::new(TestClock::default()),
1064        )
1065        .expect("interval accepted");
1066
1067        let err = auth.headers().await.expect_err("the mint is refused");
1068        assert_eq!(err.kind(), ErrorKind::Credentials, "{err}");
1069        assert_eq!(err.code(), "ERR_CREDENTIALS");
1070        assert!(
1071            !err.retryable(),
1072            "a credential failure was called retryable: {err}"
1073        );
1074        assert_eq!(err.wire_kind(), None, "{err}");
1075        let message = err.to_string();
1076        assert!(
1077            message.contains("AccessDeniedException"),
1078            "the minter's own reason is lost: {message}"
1079        );
1080        assert!(
1081            !message.contains("may succeed"),
1082            "the message still promises a retry: {message}"
1083        );
1084        assert_eq!(auth.mint_count(), 0, "a refused mint was counted");
1085    }
1086
1087    /// Concurrent requests on a cold cache mint once, not once each.
1088    #[tokio::test]
1089    async fn concurrent_requests_on_a_cold_cache_mint_once() {
1090        let auth = Arc::new(auth(Arc::new(TestClock::default()), DEFAULT_REFRESH_AFTER));
1091        let mut sets = Vec::new();
1092        for _ in 0..8 {
1093            sets.push(Arc::clone(&auth).headers_owned());
1094        }
1095        let results = futures_util::future::join_all(sets).await;
1096        for result in &results {
1097            assert!(result.is_ok(), "{result:?}");
1098        }
1099        assert_eq!(
1100            auth.mint_count(),
1101            1,
1102            "eight concurrent requests burned more than one control-plane call"
1103        );
1104    }
1105
1106    impl ProxyAuth {
1107        /// `headers()` with an owned receiver, so a test can hold several futures at
1108        /// once without borrowing one `ProxyAuth` eight times.
1109        #[cfg(test)]
1110        async fn headers_owned(self: Arc<Self>) -> Result<Vec<(String, String)>, Error> {
1111            self.headers().await
1112        }
1113    }
1114
1115    // ── the WebSocket handshake credentials (`subprotocols`) ──────────────────
1116    //
1117    // Four tests. The first pins the wire format, because the platform matches these strings
1118    // exactly and a client that got a separator wrong would fail a handshake with no
1119    // diagnostic that names the subprotocol. The rest are the same three properties the
1120    // header path already has — one mint, refresh at the window, and an unusable token map is
1121    // a retryable failure — asserted through *this* accessor, because "headers() mints
1122    // correctly" is not a statement about a second door onto the same cache.
1123
1124    /// **The three subprotocols are exactly the platform's strings, in order.**
1125    ///
1126    /// The port is substituted rather than fixed, and the base value carries no suffix. Every
1127    /// separator is asserted with a literal on purpose: the platform matches these by exact
1128    /// string, so a `:` where a `.` belongs, or the port and auth values transposed, is a
1129    /// handshake failure whose error message names neither.
1130    ///
1131    /// **Guard proof.** Change the separator in `WS_PORT_SUBPROTOCOL_PREFIX` from `.` to `-`
1132    /// and the third assertion is red; swap the order of the last two array elements and the
1133    /// order assertion is red. Both verified.
1134    #[tokio::test]
1135    async fn the_subprotocols_are_the_platforms_three_exact_strings_in_order() {
1136        let auth = auth(Arc::new(TestClock::default()), DEFAULT_REFRESH_AFTER);
1137        let offered = auth
1138            .subprotocols(8080)
1139            .await
1140            .expect("the fake minter mints");
1141
1142        assert_eq!(
1143            offered,
1144            [
1145                "lambda-microvms".to_string(),
1146                format!(
1147                    "lambda-microvms.authentication.{}",
1148                    CountingMinter::value(0)
1149                ),
1150                "lambda-microvms.port.8080".to_string(),
1151            ],
1152            "the handshake offered something the proxy does not match"
1153        );
1154        // The port is read from the argument and not from the instance, which is what lets one
1155        // session mint for a workload port it was not built against.
1156        assert_eq!(auth.port(), DEFAULT_AGENT_PORT);
1157        assert!(
1158            offered[2].ends_with("8080"),
1159            "the port was taken from the instance rather than the argument: {}",
1160            offered[2]
1161        );
1162    }
1163
1164    /// **A handshake reuses the cached token for a port that token covers, and re-mints for
1165    /// one it does not.**
1166    ///
1167    /// Two properties, and the second was a live defect this test used to assert the wrong way
1168    /// round. It read `auth.subprotocols(3000)` and required `mint_count() == 1` — reuse across
1169    /// *any* port — which is precisely the bug: a token the control plane scoped to port 9000
1170    /// does not authorize 3000, so that reuse produces a handshake refused with close code
1171    /// 1006 (or, on the header path, 403 `Access to port denied`). Measured 2026-08-15 against
1172    /// real AWS; the table is on [`crate::control::ops::PortSpecification`].
1173    ///
1174    /// So the shared-cache property is now stated per port, which is the strongest form that
1175    /// is also true: repeated calls for a covered port touch the control plane once, and the
1176    /// first call for a new port mints once more and then caches. `subprotocols` and `headers`
1177    /// still share one cache, which is the thing this test was written to protect.
1178    ///
1179    /// **Guard proof.** Mint directly in `subprotocols` (`self.minter.mint().await`) instead of
1180    /// going through `token_for()`: the count reads 4 and the last assertion fails. Have
1181    /// `token_for` ignore the port and call `fresh_token_for(self.port)` — the pre-fix
1182    /// behaviour — and the 3000 mint never happens, so the count reads 1 where 2 is asserted.
1183    /// Both applied and observed.
1184    #[tokio::test]
1185    async fn a_handshake_reuses_the_cached_token_and_re_mints_for_an_uncovered_port() {
1186        let clock = Arc::new(TestClock::default());
1187        let (auth, minter) = auth_with(Arc::clone(&clock), DEFAULT_REFRESH_AFTER);
1188
1189        let first = auth.subprotocols(9000).await.expect("mints");
1190        clock.advance(DEFAULT_REFRESH_AFTER - Duration::from_secs(1));
1191        let second = auth.subprotocols(9000).await.expect("reuses");
1192        let headers = auth.headers().await.expect("reuses");
1193
1194        assert_eq!(first, second, "the same cached token produced two values");
1195        assert_eq!(
1196            auth.mint_count(),
1197            1,
1198            "the handshake path burned its own control-plane call for a port the cached token \
1199             already covers, so TRAP-9 is now two schedules to get right instead of one"
1200        );
1201        // The same token reached both surfaces, which is what "one cache" means observably.
1202        assert_eq!(
1203            value_of(&headers, PROXY_AUTH_HEADER).as_deref(),
1204            Some(CountingMinter::value(0).as_str())
1205        );
1206
1207        // A port the token does not cover. One more mint, asking for both ports.
1208        let third = auth.subprotocols(3000).await.expect("mints a wider token");
1209        assert_eq!(
1210            auth.mint_count(),
1211            2,
1212            "a port outside the cached token's scope reused it, which is a handshake the proxy \
1213             refuses as 1006 with no reason"
1214        );
1215        assert_ne!(
1216            third[1], first[1],
1217            "the wider token carries the same auth value as the 9000-only one, so nothing was \
1218             actually re-scoped"
1219        );
1220        assert_eq!(third[2], "lambda-microvms.port.3000");
1221        assert_eq!(
1222            minter.requested(),
1223            vec![vec![9000], vec![3000, 9000]],
1224            "the widened mint dropped the session's own port, so the next request on 9000 \
1225             re-mints and two alternating ports mint forever"
1226        );
1227
1228        // And the widened token now serves both, so neither port re-mints.
1229        auth.subprotocols(3000).await.expect("reuses");
1230        auth.headers().await.expect("reuses");
1231        assert_eq!(
1232            auth.mint_count(),
1233            2,
1234            "the widened token did not serve both ports, so the scope was replaced rather than \
1235             extended"
1236        );
1237    }
1238
1239    /// **A handshake past the refresh window carries the fresh token.**
1240    ///
1241    /// The lazy-refresh half of TRAP-9, on the handshake path: a connection opened after the
1242    /// window has rolled over must carry a token minted now, because a WebSocket lives longer
1243    /// than a request and a stale one is rejected as a bad token.
1244    ///
1245    /// **Guard proof.** Have `subprotocols` read `self.cached` directly instead of going
1246    /// through `token()` and the second value still reads `jwe-0`. Verified.
1247    #[tokio::test]
1248    async fn a_handshake_after_the_refresh_window_carries_the_freshly_minted_token() {
1249        let clock = Arc::new(TestClock::default());
1250        let auth = auth(Arc::clone(&clock), DEFAULT_REFRESH_AFTER);
1251
1252        let first = auth.subprotocols(9000).await.expect("mints");
1253        assert!(first[1].ends_with(&CountingMinter::value(0)));
1254
1255        clock.advance(DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
1256        let second = auth.subprotocols(9000).await.expect("re-mints");
1257
1258        assert_eq!(auth.mint_count(), 2);
1259        assert_eq!(
1260            second[1],
1261            format!("{WS_AUTH_SUBPROTOCOL_PREFIX}{}", CountingMinter::value(1)),
1262            "the handshake offered the expired token, which the proxy rejects as if it were \
1263             the wrong token"
1264        );
1265    }
1266
1267    /// **A token map with no auth key is a retryable mint failure here too, and no handshake
1268    /// offers an empty credential.**
1269    ///
1270    /// The TRAP-7 shape on the handshake path. The failure that matters is not the error type
1271    /// but its alternative: `format!("{PREFIX}{}", "")` is a *syntactically valid*
1272    /// subprotocol, so a client that skipped the check would offer
1273    /// `lambda-microvms.authentication.` and get a rejection that says nothing about an empty
1274    /// token.
1275    ///
1276    /// **Guard proof.** Removing *both* `auth_value` checks — the one in `token()`'s mint path
1277    /// and the one in `subprotocols` — makes this red: the call succeeds and the middle value
1278    /// is the bare prefix. Verified. Removing only the `subprotocols` one leaves this green,
1279    /// which is the honest measurement: `token()`'s pre-cache check is the guard that fires
1280    /// today, and `subprotocols` carries the same check for the reason its own comment gives.
1281    #[tokio::test]
1282    async fn a_token_map_without_the_auth_key_fails_the_handshake_rather_than_offering_nothing() {
1283        let auth = ProxyAuth::with_refresh_after(
1284            Arc::new(CountingMinter::without_auth_header()),
1285            DEFAULT_AGENT_PORT,
1286            DEFAULT_REFRESH_AFTER,
1287            Arc::new(TestClock::default()),
1288        )
1289        .expect("interval accepted");
1290
1291        let err = auth
1292            .subprotocols(9000)
1293            .await
1294            .expect_err("the map has no auth key");
1295        assert_eq!(err.wire_kind(), Some(WireKind::AuthTokenMint));
1296        assert!(err.retryable());
1297        assert!(
1298            err.to_string().contains(PROXY_AUTH_HEADER),
1299            "the message must name the missing key: {err}"
1300        );
1301    }
1302
1303    /// **A subprotocol list is a credential, so nothing this module stores can print one.**
1304    ///
1305    /// Not a claim about the strings — they are returned, and a caller can log anything it is
1306    /// handed. The claim is about the *types*: after a handshake has been built, the
1307    /// `ProxyAuth` that built it and the `ProxyToken` it cached both render without the value,
1308    /// so a log line that formats a session or an error chain carries no token.
1309    ///
1310    /// **Guard proof.** Add the auth value to either `Debug` impl and this is red. Verified by
1311    /// adding `.field("token", &offered[1])` to `ProxyAuth`'s.
1312    #[tokio::test]
1313    async fn nothing_that_survives_a_handshake_can_print_the_credential() {
1314        let auth = ProxyAuth::with_refresh_after(
1315            Arc::new(CountingMinter::default()),
1316            DEFAULT_AGENT_PORT,
1317            DEFAULT_REFRESH_AFTER,
1318            Arc::new(TestClock::default()),
1319        )
1320        .expect("interval accepted");
1321        let offered = auth.subprotocols(9000).await.expect("mints");
1322        let credential = CountingMinter::value(0);
1323        assert!(
1324            offered[1].contains(&credential),
1325            "the test is measuring nothing unless the value really is in the list"
1326        );
1327
1328        let rendered = format!("{auth:?}");
1329        assert!(
1330            !rendered.contains(&credential),
1331            "the auth token reached ProxyAuth's Debug: {rendered}"
1332        );
1333        assert!(
1334            rendered.contains("mint_count"),
1335            "the Debug still has to be useful: {rendered}"
1336        );
1337    }
1338
1339    // ── the per-port header pair (`headers_for_port`) ─────────────────────────
1340
1341    /// **`headers_for_port` answers both headers for a port this instance was not built with,
1342    /// behind a token that actually authorizes that port.**
1343    ///
1344    /// The `getPortEndpoint() -> {url, headers}` shape, and the test that used to encode the
1345    /// defect. It asserted `mint_count() == 1` with the message "the second port burned a
1346    /// second mint" — treating reuse as the property and a fresh mint as the failure. That is
1347    /// backwards: the control plane scopes a token at mint time, so the cached 9000 token does
1348    /// not authorize 8080, and reusing it produces a header pair the proxy refuses with **403
1349    /// `Access to port denied`**. Measured 2026-08-15 against real AWS on one VM with a
1350    /// listener on 8080, varying only `allowedPorts`; the table is on
1351    /// [`crate::control::ops::PortSpecification`].
1352    ///
1353    /// So the claim is now three-sided: the requested port reaches the header, the mint that
1354    /// produced the token *asked for* that port, and the session's own port is still in scope
1355    /// afterwards.
1356    ///
1357    /// **Guard proof.** Return `self.headers()` from `headers_for_port` and the port assertion
1358    /// reads 9000. Have `headers_for_port` call `token()` instead of `token_for(port)` — the
1359    /// pre-fix code — and the mint count reads 1 where 2 is asserted, and `requested()` shows
1360    /// no mint ever named 8080. Both applied and observed.
1361    #[tokio::test]
1362    async fn headers_for_a_port_are_backed_by_a_token_minted_for_that_port() {
1363        let (auth, minter) = auth_with(Arc::new(TestClock::default()), DEFAULT_REFRESH_AFTER);
1364
1365        auth.headers().await.expect("mints for the session's port");
1366        let for_workload = auth.headers_for_port(8080).await.expect("mints wider");
1367
1368        assert_eq!(
1369            value_of(&for_workload, PROXY_PORT_HEADER).as_deref(),
1370            Some("8080"),
1371            "the request would target the session's port rather than the caller's"
1372        );
1373        assert_eq!(
1374            for_workload.len(),
1375            2,
1376            "no third header was invented and none was duplicated: {for_workload:?}"
1377        );
1378        assert_eq!(
1379            minter.requested(),
1380            vec![vec![9000], vec![8080, 9000]],
1381            "the port header said 8080 behind a token nobody scoped to 8080, which the proxy \
1382             refuses with 403 'Access to port denied'"
1383        );
1384        assert_eq!(
1385            value_of(&for_workload, PROXY_AUTH_HEADER).as_deref(),
1386            Some(CountingMinter::value(1).as_str()),
1387            "the headers carried the narrow first token rather than the widened second one"
1388        );
1389        // The session's own port stayed in scope, so the hot path does not now re-mint.
1390        auth.headers().await.expect("reuses the widened token");
1391        assert_eq!(
1392            auth.mint_count(),
1393            2,
1394            "widening the scope for 8080 evicted 9000, so alternating ports mint forever"
1395        );
1396    }
1397
1398    /// **A requested port wins over one the token map carries, and there is exactly one port
1399    /// header on the wire.**
1400    ///
1401    /// The deliberate divergence from [`ProxyAuth::headers`], which defers to the map. Here a
1402    /// caller named a port, and answering with a different one would send a request the caller
1403    /// cannot predict. The duplicate is the sharper half: HTTP would carry both values and the
1404    /// proxy would read whichever it saw first, so the behaviour would depend on header
1405    /// ordering.
1406    ///
1407    /// **Guard proof.** Drop the `.filter(..)` in `headers_for_port` and the length assertion
1408    /// reads 3 with two port headers present. Verified.
1409    #[tokio::test]
1410    async fn a_requested_port_replaces_one_the_token_map_carried() {
1411        struct PortCarryingMinter;
1412        impl TokenMinter for PortCarryingMinter {
1413            fn mint(&self) -> BoxFuture<'_, Result<ProxyToken, Error>> {
1414                Box::pin(async move {
1415                    Ok(ProxyToken::from_pairs([
1416                        (PROXY_AUTH_HEADER, "jwe-from-control"),
1417                        (PROXY_PORT_HEADER, "9000"),
1418                    ]))
1419                })
1420            }
1421        }
1422
1423        let auth = ProxyAuth::with_refresh_after(
1424            Arc::new(PortCarryingMinter),
1425            DEFAULT_AGENT_PORT,
1426            DEFAULT_REFRESH_AFTER,
1427            Arc::new(TestClock::default()),
1428        )
1429        .expect("interval accepted");
1430
1431        // The header path defers to the map, which is its own documented rule.
1432        let session_headers = auth.headers().await.expect("mints");
1433        assert_eq!(
1434            value_of(&session_headers, PROXY_PORT_HEADER).as_deref(),
1435            Some("9000")
1436        );
1437
1438        let for_workload = auth.headers_for_port(8080).await.expect("reuses");
1439        assert_eq!(
1440            for_workload
1441                .iter()
1442                .filter(|(name, _)| name.eq_ignore_ascii_case(PROXY_PORT_HEADER))
1443                .count(),
1444            1,
1445            "two port headers went out, so which port is targeted depends on ordering: \
1446             {for_workload:?}"
1447        );
1448        assert_eq!(
1449            value_of(&for_workload, PROXY_PORT_HEADER).as_deref(),
1450            Some("8080"),
1451            "the caller's port lost to the token map's"
1452        );
1453    }
1454
1455    /// A token's `Debug` names its keys and never its values, so a logged session does
1456    /// not log a credential.
1457    #[test]
1458    fn a_proxy_token_debug_does_not_print_the_credential() {
1459        let token = ProxyToken::from_pairs([(PROXY_AUTH_HEADER, "eyJhbGciOi-secret")]);
1460        let rendered = format!("{token:?}");
1461        assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
1462        assert!(
1463            !rendered.contains("secret"),
1464            "the token value reached a Debug string: {rendered}"
1465        );
1466    }
1467
1468    /// Header lookup is case-insensitive, because the keys come from a service
1469    /// response rather than from this crate.
1470    #[test]
1471    fn the_auth_header_is_found_whatever_case_the_service_used() {
1472        let token = ProxyToken::from_pairs([("x-AWS-Proxy-AUTH", "jwe")]);
1473        assert_eq!(token.auth_value().expect("found"), "jwe");
1474        assert!(!token.is_empty());
1475    }
1476
1477    /// The control plane's token converts into this one with both headers intact.
1478    ///
1479    /// The two lanes independently defined `PROXY_AUTH_HEADER` and `PROXY_PORT_HEADER`,
1480    /// and this is what makes the duplication safe rather than a latent divergence: the
1481    /// conversion goes through the *other* module's spelling, so if either lane ever
1482    /// changed a header name the port assertion here would fail. Cheaper than merging the
1483    /// constants, which would mean one lane editing the other's file.
1484    #[test]
1485    fn a_control_plane_token_converts_with_both_headers_intact() {
1486        let mut map = std::collections::BTreeMap::new();
1487        map.insert(
1488            crate::control::microvm::PROXY_AUTH_HEADER.to_string(),
1489            "jwe-from-control".to_string(),
1490        );
1491        let minted =
1492            crate::control::ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
1493
1494        let converted: ProxyToken = minted.into();
1495        assert_eq!(
1496            converted.auth_value().expect("the auth value survived"),
1497            "jwe-from-control"
1498        );
1499        assert_eq!(
1500            converted.get(PROXY_PORT_HEADER),
1501            Some("9000"),
1502            "the port header did not survive the conversion, so every request built \
1503             from a control-plane token would be rejected as if the token were bad"
1504        );
1505    }
1506}