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