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