Skip to main content

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