fix(session): keep the load balancer's affinity cookies so requests and the realtime socket reach one server node (#48)

This commit was merged in pull request #48.
This commit is contained in:
james.maes committed 2026-11-11 03:01:36 +00:00
1 parent 7b99a29d77
commit 65a99d3049
11 files changed
+948 -21

No files matched your search

+13
View File
@@ -2,6 +2,19 @@
## Unreleased
Servers that run as several nodes (2.0; the 1.2.x fix carried over). A load balancer in front of such a server
names the node it chose in a cookie; the client attached only the session cookie, so its realtime socket could open
on one node while its claims and downloads went to another, and a server whose claim gate needs the socket on the
same node (QQQ Printing 1.1.x) answered 409. The client now keeps the Application Load Balancer's cookies (names
beginning `AWSALB`) from any answer of the server, the WebSocket upgrade answer included, and sends them on every
later request and on the upgrade, so everything reaches the node that holds the socket. When that node stops, the
load balancer rewrites the cookie on the next answer, an error answer or a refused upgrade included, and the client
follows it. The cookies are held in memory only, never saved with the session and never shown in a log, limited to
eight bounded values scoped to the server's host and root path, and sent nowhere but the server: never to the
sign-in provider. Cookies under other names no longer decide whether a session answer is valid, because a load
balancer adds its own to every answer. Nothing changes against a single-node server. No change to pairing, history
or the printing protocol.
The development-only certificate exception now covers two origins at most (2.0, narrows behavior).
- "Allow untrusted server certificate" used to make a session accept an untrusted certificate from every host it
+39
View File
@@ -23,6 +23,45 @@ apply. No credentials are placed in URLs or messages. WSS and HTTPS share the
configured origin; redirects cannot select another document host. Plain WS/HTTP
is restricted to explicit loopback development.
### A server that runs as several nodes
A QQQ server may run as several nodes behind a load balancer with cookie
stickiness. Sessions are rows in its database, so any node accepts the session;
what differs per node is which one holds this device's socket. The client keeps
the load balancer's own cookies (Application Load Balancer names, beginning
`AWSALB`) from every answer of the configured origin, including the answer to
the WebSocket upgrade, accepted or refused, and sends them on every later
request to that origin and on the next upgrade. Discovery runs immediately
before each upgrade, so the socket opens on the node the HTTP requests already
reach, and claims and downloads then go to the node that holds the socket. The
cookies are held in memory only, for the life of one session client, are never
saved with the session, are kept across sign-out, are never sent to the sign-in
provider, and are bounded (eight values of at most 4096 characters, names of at
most 64, scoped to the origin's host and root path). The log counts them and
never shows a name or a value.
When that node stops:
| What the client sees | What it does | Needs a person |
|---|---|---|
| The socket closes or stops answering heartbeats | Marks realtime not ready, cancels the claim wave, and reconnects after the configured backoff (1 to 30 seconds by default). The load balancer routes the reconnect to a healthy node and rewrites its cookie; the client follows it. Job events and receipts travel on the socket only, so one that was not acknowledged stays in the journal and is sent again, under the same identifier, on the new socket. After its welcome and applied state the server announces outstanding work again and claims resume; a claimed job replays, a job already recorded locally is not submitted twice | No |
| 502, 503 or 504 (or any 5xx, or 409) on a claim | The request fails; the session is kept. The claim wave ends with one `Print lane stopped` line; the job stays on the server and is claimed by the next wave | No |
| The same on a document download, after the claim | The session is kept. Windows was never asked, so the job is recorded FAILED locally with nothing submitted, and the lane goes on | No (the FAILED job needs an operator retry) |
| A dropped connection with no answer | The same as a 5xx: a transport failure, session kept | No |
| 5xx on the WebSocket upgrade | A failed connection attempt; retried after backoff, with the load balancer's cookie from that refusal | No |
| 401 on any request, or 401/403 on the WebSocket upgrade | The server withdrew authorization. A 401 to a request clears the session; a refused upgrade makes the service block printing. Either way reconnecting stops until someone signs in | Yes |
A server at QQQ Printing 1.1.x refuses a claim (409) unless the socket is on
the node that received it. The cookie keeps the two together until the load
balancer chooses another node for this client's requests while its socket is
still open on the first. That happens while a node is being drained, and
equally after a failed health check or whenever else the load balancer
chooses again. From then on every claim is refused, and it stays so until the
socket closes for any reason and the reconnect follows the new cookie: the
client does not yet close the socket on this account. A server with the
several-node support in QQQ Printing after 1.1.2 accepts the claim on any
node.
The service owns separate connection, receive, send, synchronization, command
and job workers. Settings, desired state, commands, inventory, job events and
receipts use small bounded WebSocket messages. A job notification wakes a finite
+1 -1
View File
@@ -12,7 +12,7 @@ The native QQQ session adapter requires discovery before credentials and never t
Printing has one path. A document reaches Windows only as a staged file whose length and SHA-256 were verified while it streamed: the worker hands the file's path to the print helper, which checks the file is a regular, absolute path and verifies its length and hash again before the spooler. The helper is the service's own executable started with `--printer-worker`, and it accepts three operations: discover printers, print a staged file by path, and cancel one spool job. A request is one line of at most 1 MiB characters (it holds a job's description and a path, a few kilobytes); the helper counts while it reads and refuses a longer one before it is held in memory. The service bounds the helper's answer at the same size, after reading it. The operation that carried a document inside the request, and the in-memory submission behind it, were deleted in 2.0, so no document travels in a message between the two processes.
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. Passwords stay in the companion; provider refresh/access tokens remain server-side. The companion holds a session only while a sign-in attempt runs: it discards its copy as soon as the service confirms that it took the session over, and also when a hand-over that was sent could not be confirmed, so the service is the only process that keeps one. CLI operations use the existing authenticated service pipe. The enrollment code reaches the CLI only through an environment variable or standard input, never as an argument, and the CLI prints it nowhere. Typed at a Windows console it is read without echo; redirected input is read as a line, and that includes a terminal that hands native programs a pipe instead of a console (the default Git Bash window, for example), where the terminal itself may echo what is typed and the variable should be used instead. An unattended caller with a console nobody types at must set the variable or redirect input, or the command waits. A command line the CLI refuses is never echoed, because it can carry a misplaced enrollment code or a sign-in callback address. The private browser callback is fixed and MSI-owned, forwarding bounded data only into an active same-user/session companion inbox after executable verification. It carries a short-lived authorization code, never a session cookie or PKCE verifier. Core validates exact advertised redirect, random state, PKCE and single-use expiry; when the server advertises `nonceValidation: REQUIRED` it also sends only that attempt's own authorization nonce as `expectedNonce` (never a callback-supplied or regenerated value) and refuses any other non-empty nonce policy; no callback creates an authentication attempt. Provider registration, installed callback delivery and long-duration renewal remain deployment qualifications.
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. The only other cookies the session client ever sends are a load balancer's affinity cookies (Application Load Balancer names beginning `AWSALB`), which select a server node and carry no identity: they are taken only from answers of the configured origin, the answer to the realtime upgrade included, accepted only when scoped to that host and the root path with a plain value of at most 4096 characters and a name of at most 64 (eight at most, a `Secure` one only over HTTPS), sent only to that origin, never to the identity provider, held in memory and never written to disk or to a log, which only counts them. They go with every request to that origin, a request that carries no credential included, so the compatibility check's two public requests may carry them once an earlier answer set them; they are still not a credential. A hostile server could set them, but it already controls every answer the client trusts; the bounds keep it from growing the request without limit. Cookies under any other name are neither stored nor sent, and no longer decide whether a session answer is valid. Passwords stay in the companion; provider refresh/access tokens remain server-side. The companion holds a session only while a sign-in attempt runs: it discards its copy as soon as the service confirms that it took the session over, and also when a hand-over that was sent could not be confirmed, so the service is the only process that keeps one. CLI operations use the existing authenticated service pipe. The enrollment code reaches the CLI only through an environment variable or standard input, never as an argument, and the CLI prints it nowhere. Typed at a Windows console it is read without echo; redirected input is read as a line, and that includes a terminal that hands native programs a pipe instead of a console (the default Git Bash window, for example), where the terminal itself may echo what is typed and the variable should be used instead. An unattended caller with a console nobody types at must set the variable or redirect input, or the command waits. A command line the CLI refuses is never echoed, because it can carry a misplaced enrollment code or a sign-in callback address. The private browser callback is fixed and MSI-owned, forwarding bounded data only into an active same-user/session companion inbox after executable verification. It carries a short-lived authorization code, never a session cookie or PKCE verifier. Core validates exact advertised redirect, random state, PKCE and single-use expiry; when the server advertises `nonceValidation: REQUIRED` it also sends only that attempt's own authorization nonce as `expectedNonce` (never a callback-supplied or regenerated value) and refuses any other non-empty nonce policy; no callback creates an authentication attempt. Provider registration, installed callback delivery and long-duration renewal remain deployment qualifications.
Sign-out also ends the identity provider's browser session. The companion sends only the fixed registered callback with `sign-out`; the service refuses any other value, asks QQQ to log out with `nativeLogout.postLogoutRedirectUri`, and returns the provider end-session request to the companion over the administrator-only pipe. That request carries the provider's ID token hint (sensitive, bounded to 32 KiB, no whitespace or control characters), so it is never logged, the record prints redacted, and only the provider host is logged. Core refuses the data unless the issuer equals the one QQQ advertises, the end-session endpoint is HTTPS on the issuer's origin and identical to the provider's own discovery, and the callback is the one requested. The browser return is accepted only on the exact callback with the state this sign-out generated. Browser sign-in also sends `max_age=0` beside `prompt=login`.
@@ -25,6 +25,15 @@ internal interface IQqqSocketConnector : IDisposable
/// <summary>
/// Hands over the headers of the server's answer to the upgrade, whether it accepted or refused, and lets go of them;
/// null when there was no HTTP answer, or when they were already taken. The session client reads the load balancer's
/// cookies from them. The connector passes on what the platform collected and selects nothing.
/// </summary>
IReadOnlyDictionary<string, IEnumerable<string>>? TakeUpgradeHeaders();
/// <summary>Performs the upgrade described by the request; it throws when the connection or the upgrade fails.</summary>
Task ConnectAsync(QqqSocketRequest request, CancellationToken cancellationToken);
@@ -49,6 +49,16 @@ internal sealed class QqqPlatformSocketConnector : IQqqSocketConnector
/// <summary>Returns the upgrade response's headers as the socket collected them and releases the socket's copy, which it would otherwise hold for as long as it lives.</summary>
public IReadOnlyDictionary<string, IEnumerable<string>>? TakeUpgradeHeaders()
{
IReadOnlyDictionary<string, IEnumerable<string>>? headers = _socket.HttpResponseHeaders;
_socket.HttpResponseHeaders = null;
return headers;
}
/// <summary>Copies the request onto the socket options and connects through the invoker.</summary>
public Task ConnectAsync(QqqSocketRequest request, CancellationToken cancellationToken)
{
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>
/// Load balancer affinity. A QQQ server may run as several nodes behind a load balancer that names the node it chose in a
/// cookie and expects that cookie back, so one client's requests keep reaching one node. This client attaches its cookies by
/// hand and used to drop everything but the session, so its requests and its realtime socket could land on different nodes.
/// It now keeps the load balancer's own cookies, and nothing else: they are held in memory only, for the life of this client
/// object, sent only to the session origin, and replaced or removed whenever the load balancer rewrites them (which is how it
/// moves a client off a node that stopped). They carry routing, never identity, so they are not saved with the session and
/// signing out neither needs nor clears them.
/// </summary>
public sealed partial class QqqSessionClient
{
private const int MaximumAffinityCookies = 8;
private const int MaximumAffinityValueLength = 4096;
private readonly Lock _affinityLock = new();
private readonly SortedDictionary<string, string> _affinity = new(StringComparer.Ordinal);
/// <summary>How many load balancer cookies are held; for the log and tests, never their values.</summary>
internal int AffinityCookieCount
{
get
{
lock(_affinityLock)
{ return _affinity.Count; }
}
}
/// <summary>
/// Keeps or drops load balancer cookies named in a response's Set-Cookie headers. Only the Application Load Balancer
/// family (names beginning AWSALB) is recognized. A cookie is ignored unless it is scoped to this host and the root path,
/// has a plain bounded value and, when marked Secure, arrived over HTTPS; one the server expired is removed. A malformed
/// header is skipped, because a routing hint must never fail a request.
/// </summary>
private void AdoptAffinity(IEnumerable<string> setCookieHeaders)
{
DateTimeOffset now = _timeProvider.GetUtcNow();
bool secureOrigin = string.Equals(Origin.Scheme, "https", StringComparison.Ordinal);
foreach(string header in setCookieHeaders)
{
string[] parts = header.Split(';');
int separator = parts[0].IndexOf('=', StringComparison.Ordinal);
if(header.Length > 8192 || separator <= 0)
{ continue; }
string name = parts[0][..separator].Trim();
if(!IsAffinityCookieName(name))
{ continue; }
string value = parts[0][(separator + 1)..].Trim();
bool expired = value.Length == 0;
bool usable = value.Length <= MaximumAffinityValueLength && !value.Any(character => character is <= ' ' or '"' or ',' or '\\' or >= (char)0x7f);
foreach(string attribute in parts.Skip(1))
{
string[] pair = attribute.Split('=', 2);
string key = pair[0].Trim();
string detail = pair.Length == 2 ? pair[1].Trim() : "";
if(key.Equals("Max-Age", StringComparison.OrdinalIgnoreCase))
{ expired |= long.TryParse(detail, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long seconds) && seconds <= 0; }
else if(key.Equals("Expires", StringComparison.OrdinalIgnoreCase))
{ expired |= DateTimeOffset.TryParse(detail, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset at) && at <= now; }
else if(key.Equals("Domain", StringComparison.OrdinalIgnoreCase))
{ usable &= string.Equals(detail.TrimStart('.'), Origin.Host, StringComparison.OrdinalIgnoreCase); }
else if(key.Equals("Path", StringComparison.OrdinalIgnoreCase))
{ usable &= string.Equals(detail, "/", StringComparison.Ordinal); }
else if(key.Equals("Secure", StringComparison.OrdinalIgnoreCase))
{ usable &= secureOrigin; }
}
lock(_affinityLock)
{
if(expired)
{ _affinity.Remove(name); }
else if(usable && (_affinity.ContainsKey(name) || _affinity.Count < MaximumAffinityCookies))
{ _affinity[name] = value; }
}
}
}
/// <summary>The Cookie header for a request to the session origin: the session cookies the caller passed, when any, followed by the load balancer cookies; null when there is neither.</summary>
[return: NotNullIfNotNull(nameof(sessionCookie))]
private string? WithAffinity(string? sessionCookie)
{
string affinity;
lock(_affinityLock)
{
if(_affinity.Count == 0)
{ return sessionCookie; }
affinity = string.Join("; ", _affinity.Select(pair => pair.Key + "=" + pair.Value));
}
return string.IsNullOrEmpty(sessionCookie) ? affinity : sessionCookie + "; " + affinity;
}
/// <summary>
/// Reads the load balancer cookies from the answer to a WebSocket upgrade, which the connector hands over and lets go
/// of. It runs whether the upgrade was accepted or refused: a node that is stopping answers 502 or 503, and that answer
/// carries the load balancer's new choice of node for the retry. The header name is matched without regard to letter
/// case, here and not in the connector, so the platform adapter decides nothing. An attempt that got no HTTP answer
/// changes nothing.
/// </summary>
private void AdoptSocketAffinity(IQqqSocketConnector connector)
{
IReadOnlyDictionary<string, IEnumerable<string>>? headers = connector.TakeUpgradeHeaders();
if(headers is null)
{ return; }
foreach(KeyValuePair<string, IEnumerable<string>> header in headers)
{
if(header.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase))
{ AdoptAffinity(header.Value); }
}
}
/// <summary>True for the Application Load Balancer's cookie names: AWSALB, AWSALBCORS, AWSALBTG, AWSALBTGCORS and the AWSALBAPP series.</summary>
private static bool IsAffinityCookieName(string name) => name.Length <= 64 && name.StartsWith("AWSALB", StringComparison.Ordinal)
&& name.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_');
}
@@ -22,7 +22,12 @@ public sealed partial class QqqSessionClient
/// <summary>Refreshes standard discovery once per socket establishment, then connects only to that origin with the current session and approved device proof.</summary>
/// <summary>
/// Refreshes standard discovery once per socket establishment, then connects only to that origin with the current
/// session and approved device proof. The answer to the upgrade is read for the load balancer's cookies whatever the
/// outcome, an accepted upgrade, a refusal with 401 or 403 and any other failure alike, because a refusal from a
/// node that is stopping carries the load balancer's choice of node for the retry.
/// </summary>
public async Task<WebSocket> ConnectPrintingAsync(TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout, CancellationToken cancellationToken = default)
{
IQqqSocketConnector connector = _transport.CreateSocketConnector(TransportPolicy, ValidateSocketCertificate);
@@ -31,7 +36,9 @@ public sealed partial class QqqSessionClient
(QqqSocketRequest request, long generation) = await PrepareSocketAsync(heartbeatInterval, heartbeatTimeout, cancellationToken).ConfigureAwait(false);
_diagnostics.Info($"Opening the realtime socket to {QqqSessionDiagnostics.Describe(request.Uri)} with subprotocol {PrintingSubProtocol} (session cookie and device proof attached; heartbeat every {heartbeatInterval.TotalSeconds:0} s, timeout {heartbeatTimeout.TotalSeconds:0} s).");
long started = _timeProvider.GetTimestamp();
await connector.ConnectAsync(request, cancellationToken).ConfigureAwait(false);
try
{ await connector.ConnectAsync(request, cancellationToken).ConfigureAwait(false); }
finally { AdoptSocketAffinity(connector); }
_diagnostics.Info($"Realtime socket upgraded in {Elapsed(started)}; server selected subprotocol {ShownOr(connector.SubProtocol, "(none)")}.");
await ConfirmSocketAsync(connector.SubProtocol, generation, cancellationToken).ConfigureAwait(false);
return connector.Release();
@@ -54,8 +61,11 @@ public sealed partial class QqqSessionClient
/// <summary>
/// Under the client gate: requires a session, refreshes discovery, requires an approved device binding, and builds the
/// upgrade request (address, subprotocol, session cookie, origin, client channel, device proof and keep-alive). It
/// upgrade request (address, subprotocol, cookies, origin, client channel, device proof and keep-alive). It
/// returns the request with the session generation it was built under, so a session change during the connect is noticed.
/// The upgrade carries the load balancer's cookies beside the session, so the socket opens on the node this client's
/// HTTP requests already reach, and that node then serves its claims and downloads too. The discovery made just
/// before may itself have rewritten those cookies, and the request is built after it, so it carries the newest.
/// </summary>
private async Task<(QqqSocketRequest Request, long Generation)> PrepareSocketAsync(TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout, CancellationToken cancellationToken)
{
@@ -73,7 +83,7 @@ public sealed partial class QqqSessionClient
var uri = new UriBuilder(new Uri(Origin, PrintingPath + "device/realtime")) { Scheme = string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) ? "wss" : "ws" };
KeyValuePair<string, string>[] headers =
[
new("Cookie", CookieHeader()),
new("Cookie", WithAffinity(CookieHeader())),
new("Origin", Origin.GetLeftPart(UriPartial.Authority)),
new("X-QQQ-Client", ClientChannel),
new("X-QQQ-Device-Id", _device.DeviceId),
@@ -45,8 +45,9 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
/// <summary>
/// Creates a client bound to one server origin. The origin must be an absolute https URL with no credentials, path,
/// query or fragment (plain http only when explicitly allowed and loopback). The handler never follows redirects or
/// manages cookies, because redirects are refused and the session cookie is attached by hand to each request, and the
/// HTTP client has no overall timeout because every request sets its own bound. A certificate Windows does not trust
/// manages cookies, because redirects are refused and the cookies this client sends (the session, and a load
/// balancer's affinity cookies) are attached by hand to each request, and the HTTP client has no overall timeout
/// because every request sets its own bound. A certificate Windows does not trust
/// is accepted only from the origins the certificate exception names; without one, from nowhere.
/// </summary>
public QqqSessionClient(Uri origin, HttpMessageHandler? handler = null, TimeProvider? timeProvider = null, bool allowLoopbackHttp = false, ApplicationTrafficMeter? traffic = null,
@@ -552,7 +553,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
}
string route = QqqSessionDiagnostics.Describe(uri);
long started = _timeProvider.GetTimestamp();
_diagnostics.Debug(() => $"HTTP {method} {route}: session cookie {(cookie is not null ? "yes" : "no")}, device proof {(device is not null ? "yes" : "no")}, basic credential {(basic is not null ? "yes" : "no")}, body {(content is null ? "none" : content.Headers.ContentType?.MediaType ?? "present")}, limit {(boundedByCaller ? "caller" : "20 s")}, response bound {maximumBytes} bytes.");
_diagnostics.Debug(() => $"HTTP {method} {route}: session cookie {(cookie is not null ? "yes" : "no")}, load balancer cookies {(providerDiscovery ? 0 : AffinityCookieCount)}, device proof {(device is not null ? "yes" : "no")}, basic credential {(basic is not null ? "yes" : "no")}, body {(content is null ? "none" : content.Headers.ContentType?.MediaType ?? "present")}, limit {(boundedByCaller ? "caller" : "20 s")}, response bound {maximumBytes} bytes.");
using var timeout = new Deadline(_timeProvider, boundedByCaller ? Timeout.InfiniteTimeSpan : RequestLimit, cancellationToken);
using HttpRequestMessage request = BuildRequest(uri, method, content, cookie, device, basic, providerDiscovery);
HttpResponseMessage response;
@@ -568,6 +569,14 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
_diagnostics.Debug(() => $"HTTP {method} {route} -> {(int)response.StatusCode} {Shown(response.ReasonPhrase)} in {Elapsed(started)}; content type {ShownOr(response.Content.Headers.ContentType?.MediaType, "(none)")}, length {response.Content.Headers.ContentLength?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unknown"}, Set-Cookie {(response.Headers.Contains("Set-Cookie") ? "present" : "absent")}.");
if(!providerDiscovery && _traffic is not null)
{ response.Content = new TrafficHttpContent(response.Content, _traffic.RecordDownload); }
/////////////////////////////////////////////////////////////////////////
// every answer from the session origin may rewrite the load //
// balancer's cookies, an error from a node that is stopping included; //
// an answer from the sign-in provider is never read for them //
/////////////////////////////////////////////////////////////////////////
if(!providerDiscovery && response.Headers.TryGetValues("Set-Cookie", out IEnumerable<string>? setCookies))
{ AdoptAffinity(setCookies); }
if((int)response.StatusCode is >= 300 and < 400)
{
_diagnostics.Info($"HTTP {method} {route} answered {(int)response.StatusCode} (redirect); redirects are refused, so the request failed. Check the server address: it must be the exact HTTPS origin QQQ serves from.");
@@ -591,7 +600,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
/// <summary>Builds the outgoing request: no-store caching, this agent's channel header, and only the cookie, origin, Basic credential and device proof the caller passed.</summary>
/// <summary>
/// Builds the outgoing request: no-store caching, this agent's channel header, and only the session cookie, origin, Basic
/// credential and device proof the caller passed. A request to the session origin also carries the load balancer's
/// cookies, with or without a session, so it reaches the node the load balancer last chose; the provider never sees them.
/// The Origin header still goes only with a session cookie on an unsafe method, as before.
/// </summary>
private HttpRequestMessage BuildRequest(Uri uri, HttpMethod method, HttpContent? content, string? cookie, QqqDeviceCredential? device, string? basic, bool providerDiscovery)
{
var request = new HttpRequestMessage(method, uri)
@@ -601,13 +615,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
request.Headers.CacheControl = new CacheControlHeaderValue { NoStore = true };
if(!providerDiscovery)
{ request.Headers.Add("X-QQQ-Client", ClientChannel); }
if(cookie is not null)
string? cookies = providerDiscovery ? cookie : WithAffinity(cookie);
if(cookies is not null)
{ request.Headers.Add("Cookie", cookies); }
if(cookie is not null && method != HttpMethod.Get && method != HttpMethod.Head && method != HttpMethod.Options)
{
request.Headers.Add("Cookie", cookie);
if(method != HttpMethod.Get && method != HttpMethod.Head && method != HttpMethod.Options)
{
request.Headers.Add("Origin", Origin.GetLeftPart(UriPartial.Authority));
}
request.Headers.Add("Origin", Origin.GetLeftPart(UriPartial.Authority));
}
if(basic is not null)
{ request.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); }
@@ -701,7 +714,9 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
/// <summary>
/// Extracts the session token from Set-Cookie headers, strictly. Headers are size-bounded, the cookie must be for this
/// host with path "/", and repeated session cookies must agree. An expired, empty or deleted session cookie means the
/// server ended the session, so the local session is cleared and sign-in is required.
/// server ended the session, so the local session is cleared and sign-in is required. Cookies under any other name are
/// not the session and are not examined: a load balancer in front of the server adds its own to every answer, and one
/// this parser dislikes must not turn a good answer into an invalid one.
/// </summary>
private string? ReadSessionCookie(HttpResponseMessage response)
{
@@ -712,12 +727,14 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
{
if(header.Length > 8192)
{ throw InvalidResponse(); }
string name = header.Split('=', 2)[0].Trim();
if(name is not "sessionUUID" and not "sessionId")
{ continue; }
var parsed = new CookieContainer();
try
{ parsed.SetCookies(Origin, header); }
catch(CookieException) { throw InvalidResponse(); }
string name = header.Split('=', 2)[0].Trim();
if((name is "sessionUUID" or "sessionId") && parsed.GetAllCookies().Count == 0)
if(parsed.GetAllCookies().Count == 0)
{
_diagnostics.Info($"The server deleted the {name} cookie; the session has ended and sign-in is required.");
ClearSession();
@@ -762,7 +779,11 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
/// <summary>Forgets the token, cookies, device and bound identity and advances the generation, so every request already in flight is refused when it returns.</summary>
/// <summary>
/// Forgets the token, session cookies, device and bound identity and advances the generation, so every request already in
/// flight is refused when it returns. The load balancer's cookies are kept: they say which node to reach, not who is
/// asking, and the sign-in that follows should reach the same node.
/// </summary>
private void ClearSession()
{
_sessionToken = null;
@@ -7,8 +7,9 @@ namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>
/// Everything about one realtime socket upgrade, decided by the session client before any platform object is touched:
/// the address, the subprotocol to offer, the request headers (session cookie, origin, client channel and device proof)
/// and the keep-alive timing. The headers carry credentials, so the label is fixed text.
/// the address, the subprotocol to offer, the request headers (session cookie with the load balancer's cookies after it,
/// origin, client channel and device proof) and the keep-alive timing. The headers carry credentials, so the label is
/// fixed text.
/// </summary>
internal sealed record QqqSocketRequest(Uri Uri, string SubProtocol, IReadOnlyList<KeyValuePair<string, string>> Headers, TimeSpan KeepAliveInterval, TimeSpan KeepAliveTimeout)
{
@@ -110,6 +110,27 @@ internal sealed class FakeSocketConnector : IQqqSocketConnector
/// <summary>The headers the fake server answers the upgrade with, accepted or refused; null, the default, means there was no HTTP answer.</summary>
internal Dictionary<string, IEnumerable<string>>? UpgradeHeaders { get; set; }
/// <summary>How many times the client took the upgrade answer's headers.</summary>
internal int HeadersTaken { get; private set; }
/// <summary>Hands the answer's headers over once and keeps none, as the platform connector does.</summary>
public IReadOnlyDictionary<string, IEnumerable<string>>? TakeUpgradeHeaders()
{
HeadersTaken++;
Dictionary<string, IEnumerable<string>>? headers = UpgradeHeaders;
UpgradeHeaders = null;
return headers;
}
/// <summary>Keeps the request and runs the test's hook.</summary>
public async Task ConnectAsync(QqqSocketRequest request, CancellationToken cancellationToken)
{
@@ -0,0 +1,672 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Core.Tests;
/// <summary>
/// A QQQ server that runs as several nodes sits behind a load balancer which names the node it chose in a cookie. These tests
/// fix what the session client does with that cookie: it keeps the load balancer's cookies and nothing else, sends them on
/// every later request and on the realtime upgrade so claims reach the node that holds the socket, and follows the load
/// balancer when it rewrites them. They also fix what a refused request means when a node stops:
/// only a 401 ends the session, and a 5xx or a dropped connection leaves it signed in for the next attempt.
/// The HTTP side runs on the scripted server and the upgrade on the fake transport; two tests at the end send a real
/// upgrade over loopback, because reading the answer's headers from the platform socket is the one step no fake shows.
/// </summary>
public sealed class LoadBalancerAffinityTests
{
private static readonly TimeSpan Heartbeat = TimeSpan.FromSeconds(20);
private static readonly TimeSpan HeartbeatLimit = TimeSpan.FromSeconds(60);
private static readonly Uri Loopback = new("http://127.0.0.1:18581");
private const string Session = "sessionUUID=session-secret; Path=/; Secure; HttpOnly";
private const string Claim = "device/jobs/claim";
private const string NoJob = """{"job":null}""";
private const string NodeOne = "AWSALB=node-one+aB3/xYz==; AWSALBCORS=node-one+aB3/xYz==";
private const string NodeTwo = "AWSALB=node-two+aB3/xYz==; AWSALBCORS=node-two+aB3/xYz==";
/// <summary>The two cookies an Application Load Balancer with duration stickiness adds to every answer, in its own format.</summary>
private static string[] Balancer(string node) =>
[
$"AWSALB={node}+aB3/xYz==; Expires=Tue, 17 Nov 2099 18:00:00 GMT; Path=/",
$"AWSALBCORS={node}+aB3/xYz==; Expires=Tue, 17 Nov 2099 18:00:00 GMT; Path=/; SameSite=None; Secure",
];
/// <summary>
/// The load balancer's cookies from the first public answer travel on every later request, with or without a session, and
/// never displace or disturb the session cookie. When a later answer rewrites one (the load balancer moved this client to
/// another node), the next request carries the new value. The sign-out request carries them too, so it reaches that node.
/// They are kept across the sign-out, so the next request, here a server check that holds no session, still carries them.
/// </summary>
[Fact]
public async Task CookiesFromTheLoadBalancerTravelOnEveryLaterRequestBesideTheSession()
{
using var server = new ScriptedServer();
Answer(server, ScriptedServer.Discovery, Balancer("node-one")).Add(ScriptedServer.Table);
Answer(server, "{}", [Session, .. Balancer("node-one")]).Add(ScriptedServer.Identity);
Answer(server, NoJob, Balancer("node-two")).Add(NoJob).Add("{}").Add(ScriptedServer.Discovery).Add(ScriptedServer.Table);
using var client = new QqqSessionClient(ScriptedServer.Origin, server);
await client.SignInWithPasswordAsync("user", "password");
Assert.Null(server.Requests[0].Cookie);
Assert.Equal(NodeOne, server.Requests[1].Cookie);
Assert.Equal(NodeOne, server.Requests[2].Cookie);
Assert.Null(server.Requests[1].Origin);
Assert.Null(server.Requests[2].Origin);
Assert.True(client.IsAuthenticated);
Assert.Equal("session-secret", client.ExportCredential().SessionToken);
await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "proof"));
using(HttpResponseMessage first = await ClaimAsync(client))
{ Assert.Equal(HttpStatusCode.OK, first.StatusCode); }
Assert.Equal("sessionUUID=session-secret; sessionId=session-secret; " + NodeOne, server.Requests[4].Cookie);
Assert.Equal(ScriptedServer.Origin.GetLeftPart(UriPartial.Authority), server.Requests[4].Origin);
using(HttpResponseMessage second = await ClaimAsync(client))
{ Assert.Equal(HttpStatusCode.OK, second.StatusCode); }
Assert.Equal("sessionUUID=session-secret; sessionId=session-secret; " + NodeTwo, server.Requests[5].Cookie);
Assert.Equal(2, client.AffinityCookieCount);
await client.SignOutAsync();
Assert.Equal("/qqq/v1/logout", server.Requests[6].Uri.AbsolutePath);
Assert.Equal("sessionUUID=session-secret; sessionId=session-secret; " + NodeTwo, server.Requests[6].Cookie);
Assert.False(client.IsAuthenticated);
Assert.Equal(2, client.AffinityCookieCount);
Assert.True((await client.CheckCompatibilityAsync()).IsCompatible);
Assert.Equal(NodeTwo, server.Requests[7].Cookie);
Assert.Equal(NodeTwo, server.Requests[8].Cookie);
Assert.Null(server.Requests[7].Authorization);
Assert.Null(server.Requests[7].DeviceToken);
}
/// <summary>
/// Only the load balancer's own cookies are kept, and only when they are scoped to this host and the root path with a
/// plain value. Any other cookie the server or a proxy sets, and any malformed one, is not stored and is never sent back.
/// Attribute names are read without regard to letter case, a leading dot on the domain is allowed, and an attribute this
/// client cannot read (an expiry that is not a date, a lifetime that is not a number) is ignored, not treated as expiry.
/// A plain value is printable ASCII without a space, quote, comma or backslash. A carriage return or a line feed, the
/// two characters that end a header line, a tab or any other control character, DEL, and a character outside ASCII
/// each refuse the cookie, because the value is written into a Cookie header as it is and none of them may reach one.
/// The fake server hands a value with a carriage return or a line feed to the client unchanged, so those rows test
/// the client's own rule and not the platform's handling of such a header; the third is what the rule is for, a
/// second header forged behind a line break.
/// </summary>
[Theory]
[InlineData("AWSALB=value; Path=/", true)]
[InlineData("AWSALB=value", true)]
[InlineData("AWSALBTG=value; Expires=Tue, 17 Nov 2099 18:00:00 GMT; Path=/", true)]
[InlineData("AWSALBAPP-0=value; Path=/", true)]
[InlineData("AWSALBCORS=value; Path=/; SameSite=None; Secure", true)]
[InlineData("AWSALB=value; Domain=printing.example.invalid; Path=/", true)]
[InlineData("AWSALB=value; Max-Age=3600; Path=/", true)]
[InlineData("AWSALB=value; domain=.PRINTING.example.invalid; path=/; secure", true)]
[InlineData(" AWSALB = value ; Path = / ", true)]
[InlineData("AWSALB=value; Max-Age=soon; Path=/", true)]
[InlineData("AWSALB=value; Expires=not a date; Path=/", true)]
[InlineData("tracking=value; Path=/", false)]
[InlineData("JSESSIONID=value; Path=/", false)]
[InlineData("awsalb=value; Path=/", false)]
[InlineData("AWSALB.other=value; Path=/", false)]
[InlineData("AWSALB=value; Domain=other.example.invalid; Path=/", false)]
[InlineData("AWSALB=value; Domain=example.invalid; Path=/", false)]
[InlineData("AWSALB=value; Path=/elsewhere", false)]
[InlineData("AWSALB=value; Path=", false)]
[InlineData("AWSALB=two words; Path=/", false)]
[InlineData("AWSALB=\"quoted\"; Path=/", false)]
[InlineData("AWSALB=one,two; Path=/", false)]
[InlineData("AWSALB=back\\slash; Path=/", false)]
[InlineData("AWSALB=one\rtwo; Path=/", false)]
[InlineData("AWSALB=one\ntwo; Path=/", false)]
[InlineData("AWSALB=one\r\nSet-Cookie:sessionUUID=forged; Path=/", false)]
[InlineData("AWSALB=one\ttwo; Path=/", false)]
[InlineData("AWSALB=one\u0001two; Path=/", false)]
[InlineData("AWSALB=one\u001Ftwo; Path=/", false)]
[InlineData("AWSALB=one\u007Ftwo; Path=/", false)]
[InlineData("AWSALB=n\u00F6de; Path=/", false)]
[InlineData("AWSALB=node\u2028two; Path=/", false)]
[InlineData("AWSALB=!#$%&'()*+-./:<=>?@[]^_`{|}~; Path=/", true)]
[InlineData("AWSALB=; Path=/", false)]
[InlineData("AWSALB=value; Max-Age=0; Path=/", false)]
[InlineData("AWSALB=value; Max-Age=-1; Path=/", false)]
[InlineData("AWSALB=value; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/", false)]
[InlineData("AWSALB", false)]
[InlineData("=value", false)]
[InlineData("", false)]
public async Task OnlyTheLoadBalancersOwnWellFormedCookiesAreKept(string setCookie, bool kept)
{
using var server = new ScriptedServer();
Answer(server, ScriptedServer.Discovery, [setCookie]);
using var client = new QqqSessionClient(ScriptedServer.Origin, server);
await client.DiscoverAsync();
Assert.Equal(kept ? 1 : 0, client.AffinityCookieCount);
}
/// <summary>
/// A value longer than a load balancer ever sends, an oversized header, an over-long name and a ninth cookie are all
/// refused, so a hostile server cannot make this client carry an unbounded Cookie header. The largest value and name
/// that are allowed are kept. Once eight are held a new name is refused, while a rewrite of one already held is
/// still followed, so a full set never stops the load balancer from moving the client.
/// </summary>
[Fact]
public async Task TheHeldCookiesAreBounded()
{
string longestName = "AWSALBAPP-" + new string('n', 54);
using var server = new ScriptedServer();
Answer(server, "{}", ["AWSALB=" + new string('a', 4097) + "; Path=/", "AWSALBTG=" + new string('a', 9000), longestName + "x=value; Path=/"], HttpStatusCode.ServiceUnavailable);
Answer(server, "{}", ["AWSALB=" + new string('a', 4096) + "; Path=/", longestName + "=value; Path=/"], HttpStatusCode.ServiceUnavailable);
Answer(server, "{}", [.. Enumerable.Range(0, 12).Select(index => $"AWSALBAPP-{index}=value; Path=/")], HttpStatusCode.ServiceUnavailable);
Answer(server, "{}", ["AWSALBTG=refused; Path=/", "AWSALB=moved; Path=/"], HttpStatusCode.ServiceUnavailable).Add("{}", HttpStatusCode.ServiceUnavailable);
using var client = new QqqSessionClient(ScriptedServer.Origin, server);
Assert.False((await client.CheckCompatibilityAsync()).IsCompatible);
Assert.Equal(0, client.AffinityCookieCount);
Assert.False((await client.CheckCompatibilityAsync()).IsCompatible);
Assert.Equal(2, client.AffinityCookieCount);
Assert.False((await client.CheckCompatibilityAsync()).IsCompatible);
Assert.Equal(8, client.AffinityCookieCount);
Assert.Equal(64, longestName.Length);
Assert.Null(server.Requests[1].Cookie);
Assert.Equal("AWSALB=" + new string('a', 4096) + "; " + longestName + "=value", server.Requests[2].Cookie);
Assert.False((await client.CheckCompatibilityAsync()).IsCompatible);
Assert.Equal(8, client.AffinityCookieCount);
Assert.False((await client.CheckCompatibilityAsync()).IsCompatible);
Assert.Equal(8, server.Requests[3].Cookie!.Split("; ").Length);
Assert.StartsWith("AWSALB=moved; AWSALBAPP-0=value; ", server.Requests[4].Cookie, StringComparison.Ordinal);
Assert.DoesNotContain("AWSALBTG", server.Requests[4].Cookie, StringComparison.Ordinal);
Assert.Equal(8, server.Requests[4].Cookie!.Split("; ").Length);
}
/// <summary>
/// A header has a bound of its own, 8,192 characters, whatever the length of the value in it: one character more and
/// the cookie is not kept although its value is short, and a header of exactly that length is read. (The over-long
/// header in the test above also carries an over-long value, so it cannot tell the two bounds apart.)
/// </summary>
[Theory]
[InlineData(8192, 1)]
[InlineData(8193, 0)]
public async Task AHeaderIsBoundedWhateverTheLengthOfItsValue(int length, int held)
{
const string Start = "AWSALB=value; Path=/; Comment=";
string header = Start + new string('c', length - Start.Length);
using var server = new ScriptedServer();
Answer(server, ScriptedServer.Discovery, [header]);
using var client = new QqqSessionClient(ScriptedServer.Origin, server);
await client.DiscoverAsync();
Assert.Equal(length, header.Length);
Assert.Equal(held, client.AffinityCookieCount);
}
/// <summary>The load balancer replaces a cookie by sending it again and removes one by expiring it; an answer without the cookie leaves it alone.</summary>
[Fact]
public async Task ARewrittenCookieReplacesTheOldOneAndAnExpiredOneIsDropped()
{
using var server = new ScriptedServer();
Answer(server, ScriptedServer.Discovery, Balancer("node-one"));
Answer(server, ScriptedServer.Table, ["AWSALB=node-two; Path=/"]);
Answer(server, "{}", [Session, "AWSALBCORS=gone; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=None; Secure"]).Add(ScriptedServer.Identity);
Answer(server, ScriptedServer.Identity, ["AWSALB=; Max-Age=0; Path=/"]).Add(ScriptedServer.Identity);
using var client = new QqqSessionClient(ScriptedServer.Origin, server);
await client.SignInWithPasswordAsync("user", "password");
Assert.Equal("AWSALB=node-two; AWSALBCORS=node-one+aB3/xYz==", server.Requests[2].Cookie);
await client.GetSessionIdentityAsync();
Assert.Equal("sessionUUID=session-secret; sessionId=session-secret; AWSALB=node-two", server.Requests[3].Cookie);
await client.GetSessionIdentityAsync();
await client.GetSessionIdentityAsync();
Assert.Equal("sessionUUID=session-secret; sessionId=session-secret; AWSALB=node-two", server.Requests[4].Cookie);
Assert.Equal("sessionUUID=session-secret; sessionId=session-secret", server.Requests[5].Cookie);
Assert.Equal(0, client.AffinityCookieCount);
Assert.True(client.IsAuthenticated);
}
/// <summary>
/// An expiry is judged on the client's clock: a cookie the load balancer sends with an expiry that has already passed on
/// that clock removes the one held, and the same header an instant before that time keeps it.
/// </summary>
[Theory]
[InlineData(-1, 1)]
[InlineData(0, 0)]
[InlineData(1, 0)]
public async Task AnExpiryIsJudgedOnTheClientsClock(int secondsPastExpiry, int held)
{
var time = new ManualTimeProvider();
time.Advance(TimeSpan.FromHours(1) + TimeSpan.FromSeconds(secondsPastExpiry));
using var server = new ScriptedServer();
Answer(server, ScriptedServer.Discovery, ["AWSALB=node-one; Path=/"]);
Answer(server, ScriptedServer.Table, ["AWSALB=node-one; Expires=Thu, 01 Jan 2026 01:00:00 GMT; Path=/"]);
using var client = new QqqSessionClient(ScriptedServer.Origin, server, time);
await client.GetAuthenticationMetadataAsync();
Assert.Equal(held, client.AffinityCookieCount);
}
/// <summary>
/// Cookies under other names never decide whether a session answer is valid. A load balancer or proxy in front of the
/// server may add one this client would not accept as a session cookie (here, scoped to another domain); sign-in still
/// succeeds, the foreign cookie is not kept, and the session cookie is still checked as strictly as before. A second
/// cookie that arrives folded into the session's own header, after a comma, is not the session either and is passed over.
/// </summary>
[Fact]
public async Task CookiesUnderOtherNamesNeverInvalidateASessionAnswer()
{
using var server = new ScriptedServer();
server.Add(ScriptedServer.Discovery).Add(ScriptedServer.Table);
Answer(server, "{}", ["edge=abc; Domain=other.example.invalid; Path=/", Session, .. Balancer("node-one")]).Add(ScriptedServer.Identity);
using var client = new QqqSessionClient(ScriptedServer.Origin, server);
await client.SignInWithPasswordAsync("user", "password");
Assert.True(client.IsAuthenticated);
await client.GetSessionIdentityAsync();
Assert.Equal("sessionUUID=session-secret; sessionId=session-secret; " + NodeOne, server.Requests[3].Cookie);
using var strict = new ScriptedServer();
strict.Add(ScriptedServer.Discovery).Add(ScriptedServer.Table);
Answer(strict, "{}", ["sessionUUID=session-secret; Domain=other.example.invalid; Path=/", .. Balancer("node-one")]);
using var refused = new QqqSessionClient(ScriptedServer.Origin, strict);
await Assert.ThrowsAsync<QqqSessionException>(() => refused.SignInWithPasswordAsync("user", "password"));
Assert.False(refused.IsAuthenticated);
using var folding = new ScriptedServer();
folding.Add(ScriptedServer.Discovery).Add(ScriptedServer.Table);
Answer(folding, "{}", ["sessionUUID=session-secret; Path=/, edge=abc; Path=/"]);
using var folded = new QqqSessionClient(ScriptedServer.Origin, folding);
await folded.SignInWithPasswordAsync("user", "password");
Assert.Equal("session-secret", folded.ExportCredential().SessionToken);
}
/// <summary>
/// A node that is stopping answers 5xx, or the load balancer answers 502, 503 or 504 for it. That is not a verdict on the
/// session: the answer is handed back, the session stays signed in, and the load balancer's new choice of node in that
/// answer is adopted for the retry. Only a 401 ends the session.
/// </summary>
[Theory]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.GatewayTimeout)]
[InlineData(HttpStatusCode.InternalServerError)]
[InlineData(HttpStatusCode.Conflict)]
public async Task AServerErrorKeepsTheSessionAndFollowsTheLoadBalancer(HttpStatusCode status)
{
using ScriptedServer server = SignedIn("node-one");
Answer(server, """{"error":"unavailable"}""", Balancer("node-two"), status).Add(NoJob).Add("{}", HttpStatusCode.Unauthorized);
using var client = new QqqSessionClient(ScriptedServer.Origin, server);
await client.SignInWithPasswordAsync("user", "password");
await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "proof"));
using(HttpResponseMessage failed = await ClaimAsync(client))
{ Assert.Equal(status, failed.StatusCode); }
Assert.True(client.IsAuthenticated);
using(HttpResponseMessage retried = await ClaimAsync(client))
{ Assert.Equal(HttpStatusCode.OK, retried.StatusCode); }
Assert.Equal("sessionUUID=session-secret; sessionId=session-secret; " + NodeTwo, server.Requests[^1].Cookie);
QqqSessionException ended = await Assert.ThrowsAsync<QqqSessionException>(() => ClaimAsync(client));
Assert.Equal(QqqSessionException.FailureKind.AuthenticationRequired, ended.Failure);
Assert.False(client.IsAuthenticated);
Assert.Equal(2, client.AffinityCookieCount);
}
/// <summary>A connection that drops before any answer (the node died mid-request) surfaces as a transport failure and leaves the session and the held cookies as they were, so the next attempt goes out signed in.</summary>
[Fact]
public async Task ADroppedConnectionKeepsTheSession()
{
using ScriptedServer server = SignedIn("node-one");
server.AddFailure(() => new HttpRequestException("The connection was reset.", new IOException("reset"))).Add(NoJob);
using var client = new QqqSessionClient(ScriptedServer.Origin, server);
await client.SignInWithPasswordAsync("user", "password");
await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "proof"));
await Assert.ThrowsAsync<HttpRequestException>(() => ClaimAsync(client));
Assert.True(client.IsAuthenticated);
using HttpResponseMessage retried = await ClaimAsync(client);
Assert.Equal(HttpStatusCode.OK, retried.StatusCode);
Assert.Equal("sessionUUID=session-secret; sessionId=session-secret; " + NodeOne, server.Requests[^1].Cookie);
}
/// <summary>
/// The realtime upgrade carries the load balancer's cookie beside the session, so the socket opens on the node the HTTP
/// requests already reach. A cookie on the upgrade answer itself is adopted, so requests made after the socket is open go
/// to the node that holds it. Over loopback HTTP a cookie marked Secure is not kept, as a browser would not keep it. The
/// answer's headers are taken from the connector once, and the header name is matched whatever its letter case.
/// </summary>
[Theory]
[InlineData("Set-Cookie")]
[InlineData("set-cookie")]
public async Task TheRealtimeUpgradeCarriesAndAdoptsTheLoadBalancerCookie(string headerName)
{
(QqqSessionClient client, FakeTransport transport, ScriptedServer server) = await BoundOverLoopbackAsync(["AWSALB=node-one; Path=/", "AWSALBCORS=node-one; Path=/; SameSite=None; Secure"]);
using(client)
{
Assert.Equal(1, client.AffinityCookieCount);
transport.Prepare = connector => connector.UpgradeHeaders = new(StringComparer.Ordinal) { ["Server"] = ["synthetic"], [headerName] = ["AWSALB=node-two; Path=/"] };
server.Add(ScriptedServer.Discovery).Add(ScriptedServer.Identity);
using WebSocket socket = await client.ConnectPrintingAsync(Heartbeat, HeartbeatLimit);
FakeSocketConnector connector = Assert.Single(transport.Connectors);
Assert.Equal("sessionUUID=session; sessionId=session; AWSALB=node-one", UpgradeCookie(connector));
Assert.Equal(1, connector.HeadersTaken);
Assert.True(connector.Released);
await client.GetSessionIdentityAsync();
Assert.Equal("sessionUUID=session; sessionId=session; AWSALB=node-two", server.Requests[^1].Cookie);
}
}
/// <summary>
/// Discovery runs just before each upgrade, and the load balancer may move the client on that very answer. The upgrade
/// request is built after it, so the socket goes to the node the load balancer chose last, not the one before.
/// </summary>
[Fact]
public async Task TheUpgradeFollowsACookieRewrittenByTheDiscoveryJustBeforeIt()
{
(QqqSessionClient client, FakeTransport transport, ScriptedServer server) = await BoundOverLoopbackAsync(["AWSALB=node-one; Path=/"]);
using(client)
{
Answer(server, ScriptedServer.Discovery, ["AWSALB=node-fresh; Path=/"]);
using WebSocket socket = await client.ConnectPrintingAsync(Heartbeat, HeartbeatLimit);
Assert.Equal("AWSALB=node-one", server.Requests[^1].Cookie);
Assert.Equal("sessionUUID=session; sessionId=session; AWSALB=node-fresh", UpgradeCookie(transport.Connectors[0]));
}
}
/// <summary>
/// A refused upgrade is read for what it is. A 5xx (the node is stopping, or the load balancer has no healthy node yet) is
/// a transport failure the runtime retries with backoff: the session stays signed in and the load balancer's cookie on the
/// refusal is adopted, so the retry can land on another node. A 401 or 403 is the server withdrawing authorization, and
/// the cookie on that refusal is adopted all the same, so the sign-in that follows reaches the node it names.
/// </summary>
[Theory]
[InlineData(HttpStatusCode.ServiceUnavailable, false)]
[InlineData(HttpStatusCode.BadGateway, false)]
[InlineData(HttpStatusCode.Unauthorized, true)]
[InlineData(HttpStatusCode.Forbidden, true)]
public async Task ARefusedUpgradeIsRetryableUnlessAuthorizationWasWithdrawn(HttpStatusCode status, bool authorization)
{
(QqqSessionClient client, FakeTransport transport, ScriptedServer server) = await BoundOverLoopbackAsync(["AWSALB=node-one; Path=/"]);
using(client)
{
transport.Prepare = connector =>
{
connector.UpgradeStatus = status;
connector.UpgradeHeaders = new(StringComparer.Ordinal) { ["Set-Cookie"] = ["AWSALB=node-two; Path=/"] };
connector.Connecting = _ => throw new WebSocketException(WebSocketError.NotAWebSocket, "The server returned status code '" + (int)status + "' when status code '101' was expected.");
};
server.Add(ScriptedServer.Discovery).Add(ScriptedServer.Identity);
Exception failure = await Assert.ThrowsAnyAsync<Exception>(() => client.ConnectPrintingAsync(Heartbeat, HeartbeatLimit));
if(authorization)
{ Assert.Equal(QqqSessionException.FailureKind.AuthenticationRequired, Assert.IsType<QqqSessionException>(failure).Failure); }
else
{ Assert.IsType<WebSocketException>(failure); }
Assert.True(client.IsAuthenticated);
Assert.Equal("sessionUUID=session; sessionId=session; AWSALB=node-one", UpgradeCookie(transport.Connectors[0]));
Assert.Equal(1, transport.Connectors[0].HeadersTaken);
Assert.True(transport.Connectors[0].Disposed);
await client.GetSessionIdentityAsync();
Assert.Equal("sessionUUID=session; sessionId=session; AWSALB=node-two", server.Requests[^1].Cookie);
}
}
/// <summary>
/// An upgrade that fails with no HTTP answer at all (the connection dropped, or the attempt was cancelled) has nothing to
/// read: the held cookie stays as it was, and the failure reaches the caller unchanged.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task AnUpgradeWithoutAnAnswerLeavesTheHeldCookiesAlone(bool cancelled)
{
(QqqSessionClient client, FakeTransport transport, ScriptedServer server) = await BoundOverLoopbackAsync(["AWSALB=node-one; Path=/"]);
using(client)
{
Exception thrown = cancelled ? new OperationCanceledException("Synthetic handshake timeout.") : new IOException("Synthetic connection loss.");
transport.Prepare = connector => connector.Connecting = _ => throw thrown;
server.Add(ScriptedServer.Discovery).Add(ScriptedServer.Identity);
Assert.Same(thrown, await Assert.ThrowsAnyAsync<Exception>(() => client.ConnectPrintingAsync(Heartbeat, HeartbeatLimit)));
Assert.Equal(1, transport.Connectors[0].HeadersTaken);
Assert.Equal(1, client.AffinityCookieCount);
await client.GetSessionIdentityAsync();
Assert.Equal("sessionUUID=session; sessionId=session; AWSALB=node-one", server.Requests[^1].Cookie);
}
}
/// <summary>
/// The sign-in provider never sees the load balancer's cookies and never sets them. Both requests this client makes to a
/// provider, its discovery document at sign-in and again at sign-out, go out with no Cookie header although cookies are
/// held for the server, and a Set-Cookie on the provider's answer under the load balancer's name is not adopted. The
/// debug line counts the cookies a request carries: two for the server, none for the provider.
/// </summary>
[Fact]
public async Task TheSignInProviderNeverReceivesOrSetsTheLoadBalancerCookies()
{
var debug = new List<string>();
using var server = new ScriptedServer();
Answer(server, ScriptedServer.Discovery, Balancer("node-one")).Add(ScriptedServer.OAuth);
Answer(server, ScriptedServer.Provider, ["AWSALB=provider-node; Path=/", "AWSALBTG=provider-node; Path=/"]).Add("{}", cookie: "sessionUUID=browser-session; Path=/; HttpOnly; Secure");
using var client = new QqqSessionClient(ScriptedServer.Origin, server, diagnostics: new QqqSessionDiagnostics(debug: debug.Add));
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(new Uri(ScriptedServer.Callback));
string state = attempt.AuthorizationUri.Query[1..].Split('&').Single(pair => pair.StartsWith("state=", StringComparison.Ordinal))[6..];
await client.CompleteBrowserLoginAsync(attempt, new Uri(ScriptedServer.Callback + "?code=synthetic-code&state=" + state));
Assert.Equal("id.example.invalid", server.Requests[2].Uri.Host);
Assert.True(server.Requests[2].IsPublic);
Assert.Equal(NodeOne, server.Requests[3].Cookie);
Assert.Equal(2, client.AffinityCookieCount);
server.Add("""{"nativeLogoutData":{"issuer":"https://id.example.invalid/application/","endSessionEndpoint":"https://id.example.invalid/end-session","postLogoutRedirectUri":"io.qrun.qqq-print-agent:/oauth/callback","idTokenHint":"synthetic.id-token.hint"}}""");
Answer(server, ScriptedServer.Provider, ["AWSALB=provider-node; Path=/"]);
Assert.NotNull(await client.SignOutWithProviderAsync(new Uri(ScriptedServer.Callback)));
Assert.Equal("sessionUUID=browser-session; sessionId=browser-session; " + NodeOne, server.Requests[^2].Cookie);
Assert.Equal("id.example.invalid", server.Requests[^1].Uri.Host);
Assert.True(server.Requests[^1].IsPublic);
Assert.Equal(2, client.AffinityCookieCount);
Assert.Contains(debug, line => line.StartsWith("HTTP GET https://printing.example.invalid/qqq/v1/metaData/authentication: session cookie no, load balancer cookies 2,", StringComparison.Ordinal));
Assert.Equal(2, debug.Count(line => line.StartsWith("HTTP GET https://id.example.invalid/application/.well-known/openid-configuration: session cookie no, load balancer cookies 0,", StringComparison.Ordinal)));
}
/// <summary>
/// The cookies are routing, not identity, and they are never written anywhere. The credential a host saves for a later
/// start holds the origin and the session token and nothing else; a new client that restores it starts with no load
/// balancer cookie and sends none until an answer sets one. No log line, at either level, ever names a load balancer
/// cookie or shows its value: the log counts them.
/// </summary>
[Fact]
public async Task TheCookiesAreNeverSavedWithTheSessionOrWrittenToALog()
{
var lines = new List<string>();
using ScriptedServer server = SignedIn("node-one");
using var client = new QqqSessionClient(ScriptedServer.Origin, server, diagnostics: new QqqSessionDiagnostics(lines.Add, lines.Add));
await client.SignInWithPasswordAsync("user", "password");
await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "proof"));
QqqSessionCredential saved = client.ExportCredential();
Assert.Equal(["Origin", "SessionToken"], typeof(QqqSessionCredential).GetProperties().Select(property => property.Name).Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Equal("https://printing.example.invalid/", saved.Origin.AbsoluteUri);
Assert.Equal("session-secret", saved.SessionToken);
Assert.Equal(2, client.AffinityCookieCount);
Assert.Contains(lines, line => line.Contains("load balancer cookies 2", StringComparison.Ordinal));
Assert.DoesNotContain(lines, line => line.Contains("AWSALB", StringComparison.Ordinal) || line.Contains("node-one", StringComparison.Ordinal));
using var later = new ScriptedServer();
later.Add(ScriptedServer.Discovery).Add(ScriptedServer.Table).Add("{}", cookie: Session);
using var restored = new QqqSessionClient(ScriptedServer.Origin, later);
await restored.RestoreSessionAsync(saved);
Assert.Equal(0, restored.AffinityCookieCount);
Assert.Equal("sessionUUID=session-secret; sessionId=session-secret", later.Requests[^1].Cookie);
}
/// <summary>
/// The same upgrade over a real loopback connection and the platform socket, which is the one place the answer's headers
/// are read from the platform and not from a fake. The upgrade carries the held cookie; the cookie on the accepted
/// answer, or on a 503 refusal, is the one the next request carries.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ThePlatformSocketHandsOverTheCookieOnTheUpgradeAnswer(bool accepted)
{
using var peer = new UpgradePeer();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var server = new ScriptedServer();
Answer(server, ScriptedServer.Discovery, ["AWSALB=node-one; Path=/"]).Add(ScriptedServer.Table).Add("""{"uuid":"session"}""").Add(ScriptedServer.Identity).Add(ScriptedServer.Discovery).Add(ScriptedServer.Identity);
using var client = new QqqSessionClient(peer.Origin, server, allowLoopbackHttp: true);
await client.SignInWithPasswordAsync("synthetic-user", "synthetic-password", timeout.Token);
await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "synthetic-proof"), cancellationToken: timeout.Token);
Task answered = peer.AnswerAsync(accepted, "AWSALB=node-two; Path=/", timeout.Token);
if(accepted)
{
using WebSocket socket = await client.ConnectPrintingAsync(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), timeout.Token);
Assert.Equal(WebSocketState.Open, socket.State);
}
else
{ await Assert.ThrowsAsync<WebSocketException>(() => client.ConnectPrintingAsync(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), timeout.Token)); }
await answered;
Assert.Equal("sessionUUID=session; sessionId=session; AWSALB=node-one", peer.Cookie);
await client.GetSessionIdentityAsync(timeout.Token);
Assert.Equal("sessionUUID=session; sessionId=session; AWSALB=node-two", server.Requests[^1].Cookie);
}
/// <summary>Queues a JSON reply that carries the given Set-Cookie headers, each as its own header, as a load balancer adds them.</summary>
private static ScriptedServer Answer(ScriptedServer server, string body, IEnumerable<string> cookies, HttpStatusCode status = HttpStatusCode.OK) => server.AddResponse(() =>
{
HttpResponseMessage response = ScriptedServer.Json(body, status);
response.Headers.TryAddWithoutValidation("Set-Cookie", cookies);
return response;
});
/// <summary>A fake server scripted through discovery, password sign-in and the identity read that binding a device makes, with the load balancer naming a node on the first answer.</summary>
private static ScriptedServer SignedIn(string node)
{
var server = new ScriptedServer();
Answer(server, ScriptedServer.Discovery, Balancer(node)).Add(ScriptedServer.Table);
return Answer(server, "{}", [Session]).Add(ScriptedServer.Identity);
}
/// <summary>A client over the fake transport and plain loopback HTTP that has signed in and bound a device, with the load balancer's cookies on the first answer.</summary>
private static async Task<(QqqSessionClient Client, FakeTransport Transport, ScriptedServer Server)> BoundOverLoopbackAsync(string[] balancerCookies)
{
var server = new ScriptedServer();
Answer(server, ScriptedServer.Discovery, balancerCookies).Add(ScriptedServer.Table).Add("""{"uuid":"session"}""").Add(ScriptedServer.Identity);
var transport = new FakeTransport(server);
var client = new QqqSessionClient(Loopback, null, null, allowLoopbackHttp: true, null, null, QqqCertificateExceptionOrigins.None, transport);
await client.SignInWithPasswordAsync("synthetic-user", "synthetic-password");
await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "synthetic-proof"));
return (client, transport, server);
}
/// <summary>Sends one claim, the request a lane makes most often.</summary>
private static Task<HttpResponseMessage> ClaimAsync(QqqSessionClient client) => client.SendPrintingAsync(HttpMethod.Post, Claim, new StringContent("{}", Encoding.UTF8, "application/json"));
/// <summary>The Cookie header of the upgrade request a fake connector was given.</summary>
private static string UpgradeCookie(FakeSocketConnector connector) => connector.Request!.Headers.Single(header => string.Equals(header.Key, "Cookie", StringComparison.Ordinal)).Value;
/// <summary>
/// A loopback listener that answers one WebSocket upgrade, accepting it or refusing it with 503, with a Set-Cookie header
/// on the answer, and remembers the Cookie header the upgrade carried. Nothing but this test connects to it.
/// </summary>
private sealed class UpgradePeer : IDisposable
{
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private TcpClient? _client;
/// <summary>Starts listening on a port the system chooses.</summary>
internal UpgradePeer()
{
_listener.Start();
Origin = new Uri($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/");
}
/// <summary>The origin a session client connects to.</summary>
internal Uri Origin { get; }
/// <summary>The Cookie header of the upgrade request, once it arrived.</summary>
internal string? Cookie { get; private set; }
/// <summary>Reads one upgrade request and answers it: 101 with the handshake RFC 6455 requires, or 503, and the given Set-Cookie either way.</summary>
internal async Task AnswerAsync(bool accept, string setCookie, CancellationToken token)
{
_client = await _listener.AcceptTcpClientAsync(token);
NetworkStream stream = _client.GetStream();
using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true);
string key = "";
while(await reader.ReadLineAsync(token) is { Length: > 0 } line)
{
if(line.StartsWith("Cookie:", StringComparison.OrdinalIgnoreCase))
{ Cookie = line[7..].Trim(); }
if(line.StartsWith("Sec-WebSocket-Key:", StringComparison.OrdinalIgnoreCase))
{ key = line[18..].Trim(); }
}
// RFC6455 requires SHA-1 for the public handshake challenge; this is not an authentication digest.
#pragma warning disable CA5350
string challenge = Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")));
#pragma warning restore CA5350
string head = accept
? $"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {challenge}\r\nSec-WebSocket-Protocol: qqq-printing.v1\r\n"
: "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n";
await stream.WriteAsync(Encoding.ASCII.GetBytes(head + "Set-Cookie: " + setCookie + "\r\n\r\n"), token);
await stream.FlushAsync(token);
}
/// <summary>Stops listening and closes the accepted connection.</summary>
public void Dispose()
{
_listener.Stop();
_client?.Dispose();
}
}
}