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