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