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