Relay Auth Handshake
How the launcher-issued launch token becomes a game token, and how the TCP relay enforces authentication before lobby or game packets are accepted.
The relay does not trust a raw TCP connection from the game executable. Before any lobby or game traffic is accepted, the client has to prove it was launched by the authenticated web launcher. The auth chain has three stages: a session-authenticated launcher request creates a short-lived launch token, the game exchanges that launch token for a game token, and the TCP relay accepts only a first packet of type AUTH carrying that game token.
The web-facing endpoints live in Server/Endpoints/TokenEndpoints.cs. /api/launch-token is protected by normal session auth and issues a single-use launch token that only lives for about one minute. /api/game-token/exchange is the bridge into the game process. It takes the launch token in JSON, consumes it, and returns a longer-lived game token together with user metadata like display name and expiry.
The intended contract is encoded clearly in Server/Tests/AuthIntegrationTests.cs. LaunchToken_IssueAndExchange expects an l_ launch token to exchange into a g_ game token. LaunchToken_ConsumedTwice_SecondFails verifies that the launch token is single-use. FullTokenChain_LoginToGameToken lays out the whole happy path: login creates session auth, session auth creates a launch token, the game exchanges it for a game token, and the relay handshake validates that game token.
Inside the game process, RestClient.cpp performs the exchange. ensureGameToken is intentionally small and synchronous:
Bool ensureGameToken()
{
std::lock_guard<std::mutex> lk(s_tokenMutex);
if (g_authGameToken[0] != '\0') return TRUE; // already have one
if (g_authLaunchToken[0] == '\0') return FALSE; // nothing to exchange
if (g_relayServerHost[0] == '\0') return FALSE; // -relayserver missing
AsciiString body = "{\"launchToken\":\"";
body.concat(g_authLaunchToken);
body.concat("\"}");
Response r = post(defaultBaseUrl(), "/api/game-token/exchange", body);
if (!r.ok()) return FALSE;
char token[128] = {0};
if (!extractStringField(r.body.str(), "gameToken", token, sizeof(token)))
return FALSE;
memcpy(g_authGameToken, token, strlen(token));
return TRUE;
}
The comment calls out that this is the single fan-out point — both the relay handshake in LANAPI::relayConnect and the REST Bearer header in GameTelemetry read from g_authGameToken, so this mutex-guarded write is the only place the global is populated.
LANAPI::relayConnect in Core/GameEngine/Source/GameNetwork/LANAPI.cpp is where the TCP side starts. Before it even opens the relay socket, it calls RestClient::ensureGameToken() so the cached game token exists if the executable was launched properly. After the TCP connection succeeds, the client immediately sends an AUTH packet with the wire format [4:size][4:sessionID=0][1:type=3][token bytes]. Only after that does it send the filter-code packet used to partition lobbies.
The client does not wait for a positive ACK. It only peeks briefly for a rejection. If the relay answers with RELAY_TYPE_AUTH_REJECT, relayConnect closes the socket and fails the connection. Otherwise it assumes the server accepted the token and proceeds. That design keeps the successful path quiet and only uses a response packet for failure.
The relay enforces the same contract in Server/RelayServer.cs. ClientSession::RunTcpAsync handles the AUTH handshake before entering the normal packet loop. The very first packet must be smaller than the old 2 KB auth cap, must decode as RELAY_TYPE_AUTH, and must contain a nonempty token. The server then calls the injected validateGameToken callback. If validation fails, RejectAuthAsync sends an AUTH_REJECT packet with a reason code and closes the connection.
On success, the relay records IsAuthenticated, AuthUserId, AuthDisplayName, PlayerName, and the initial accounting timestamps. Only then does it enter the main packet loop, where the larger packet cap applies and lobby, gameplay, cosmetics, and telemetry-related flows can proceed. In other words, authentication is not a later lobby feature. It is the gate that decides whether the session exists at all.
Why The Split Exists
The short-lived launch token protects the launcher-to-game handoff. The longer-lived game token protects the live TCP session. That keeps the launcher from having to pass a full session token into the game process, while still letting the relay authenticate a running game client without consulting browser cookies or web-session state.
Quirks
- Success is silent. The client only waits for
AUTH_REJECT, not an explicit accept packet. That keeps the happy path from paying a round-trip before the first real lobby message, but also means a game that expects an "authenticated" signal needs to infer it from absence-of-rejection after a brief timeout. - Launch tokens are single-use by design. Which is why the game caches the exchanged game token immediately in a process-global rather than re-requesting per connection. A second
ensureGameTokencall is a fast no-op; a second exchange against the same launch token would 401. - The filter-code packet is sent after AUTH, not before. Lobby partitioning only matters once the client is trusted. Out-of-order clients that send filter-code first are disconnected by the relay because the first packet didn't parse as
RELAY_TYPE_AUTH. - Relay auth is the first packet contract, not a later application-level message. Invalid clients are rejected before they ever join lobby state. Sniffer traces that start "after the handshake" miss the most important packet in the flow.
- The relay records
AuthUserIdandAuthDisplayNamefrom the validator. Subsequent chat and telemetry use those server-side, not whateverPlayerNamethe client claims in lobby packets. A client sending a spoofed display name in the filter-code payload sees their own lobby name change but not anyone else's view of them. - Game token length is capped at 128 bytes.
g_authGameTokenis achar[128]andensureGameTokentruncates to 127 characters + NUL. A token longer than that fails silently without warning — but the server tests (AuthIntegrationTests.cs) fix the token format so this can't happen in practice. - Mutex is on the client side only.
ensureGameTokenguards against two threads simultaneously triggering the exchange. The relay doesn't care about client-side synchronization; the single-use launch token enforces at-most-one-exchange by itself.