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