feat(backend): authenticate WebSocket clients with Keycloak tokens

- New auth module verifies Keycloak access tokens offline against the
  realm's signing keys (issuer, RS256, typ=Bearer, allowed azp); guests
  are recognised by the guest realm role
- Clients send an auth message after connecting and get on_auth or
  on_auth_error; unauthenticated sockets are closed after 5 seconds and
  their messages ignored; a socket can refresh its token but not switch user
- A user can be connected from several sockets; a late close of an old
  socket no longer drops the new one
- Malformed messages are ignored instead of throwing
- Games store player names and guest flags and send them with the
  connection info
- Legacy ?userKey= clients and /register stay available behind
  LEGACY_USERKEY_AUTH; legacy keys must be UUIDs so they cannot take a
  Keycloak user's kc: id
- TypeScript target raised to es2020 for async/await
This commit is contained in:
Halit Aksoy 2026-10-14 01:52:07 +03:00
parent 7c99a76af7
commit 30ad51c493
19 changed files with 645 additions and 84 deletions

View File

@ -26,3 +26,24 @@ yarn start
### Development Dependencies ### Development Dependencies
This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions. This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions.
## Authentication
Clients log in with Keycloak (see `apps/keycloak`), either as a guest or with an account, and send the access token
over the WebSocket right after connecting:
```json
{ "channel": "auth", "message": { "token": "<Keycloak access token>" } }
```
The server answers on `on_auth` with the user (`{ id, name, isGuest }`) or on `on_auth_error` and closes the socket
with code 4001. Other messages are ignored until the socket is authenticated, and connections that do not
authenticate within 5 seconds are closed. Send `auth` again with the new token after refreshing it.
User ids of Keycloak users are the token subject prefixed with `kc:`.
| Variable | Default | Purpose |
|---|---|---|
| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/mancala` | Token issuer; must match the `iss` claim exactly |
| `KEYCLOAK_JWKS_URL` | issuer + `/protocol/openid-connect/certs` | Where to fetch signing keys, if the backend reaches Keycloak at a different address |
| `KEYCLOAK_CLIENT_IDS` | `mancala-web` | Comma-separated clients whose tokens are accepted |
| `LEGACY_USERKEY_AUTH` | `true` | Also accept old clients that connect with `?userKey=<uuid>` and the `/register` endpoint. Turn off once no released client uses it, because a userKey is not a secret the server can check |

View File

@ -21,6 +21,7 @@
"@types/ws": "^7.4.5", "@types/ws": "^7.4.5",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.17.1", "express": "^4.17.1",
"jose": "5.10.0",
"mancala.js": "^0.0.2-beta.3", "mancala.js": "^0.0.2-beta.3",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",

View File

@ -0,0 +1,16 @@
export interface AuthConfig {
keycloakIssuer: string;
keycloakJwksUrl?: string;
keycloakClientIds: string[];
// Accept old clients that identify themselves with ?userKey= instead of a token.
legacyUserKeyAuth: boolean;
}
export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig {
return {
keycloakIssuer: env.KEYCLOAK_ISSUER || "http://localhost:8080/realms/mancala",
keycloakJwksUrl: env.KEYCLOAK_JWKS_URL || undefined,
keycloakClientIds: (env.KEYCLOAK_CLIENT_IDS || "mancala-web").split(",").map((id) => id.trim()).filter(Boolean),
legacyUserKeyAuth: (env.LEGACY_USERKEY_AUTH ?? "true") === "true",
};
}

View File

@ -0,0 +1,7 @@
export interface AuthUser {
// Stable user id used everywhere in the game (player ids, rtmt client ids).
id: string;
// Display name shown to other players. Undefined for legacy userKey clients.
name?: string;
isGuest: boolean;
}

View File

@ -0,0 +1,72 @@
import { createRemoteJWKSet, errors, JWTPayload, JWTVerifyGetKey, jwtVerify } from "jose";
import { AuthUser } from "./AuthUser";
import { InvalidTokenError, TokenVerifier } from "./TokenVerifier";
export const KEYCLOAK_USER_ID_PREFIX = "kc:";
export const GUEST_ROLE = "guest";
export interface KeycloakTokenVerifierOptions {
// Realm issuer, e.g. https://auth.playmancala.app/realms/mancala
issuer: string;
// Clients whose access tokens are accepted (checked against the azp claim).
allowedClientIds: string[];
// Where to fetch signing keys from. Defaults to the issuer's certs endpoint.
jwksUrl?: string;
// Overrides key lookup, for tests.
keys?: JWTVerifyGetKey;
}
type KeycloakAccessToken = JWTPayload & {
typ?: string;
azp?: string;
preferred_username?: string;
realm_access?: { roles?: string[] };
};
/**
* Verifies Keycloak access tokens offline against the realm's published signing keys.
* Player ids are the token subject prefixed with "kc:", so they can never collide with
* ids that legacy userKey clients choose for themselves.
*/
export class KeycloakTokenVerifier implements TokenVerifier {
private readonly issuer: string;
private readonly allowedClientIds: string[];
private readonly keys: JWTVerifyGetKey;
constructor(options: KeycloakTokenVerifierOptions) {
this.issuer = options.issuer;
this.allowedClientIds = options.allowedClientIds;
this.keys = options.keys ??
createRemoteJWKSet(new URL(options.jwksUrl ?? `${options.issuer}/protocol/openid-connect/certs`));
}
async verify(token: string): Promise<AuthUser> {
let payload: KeycloakAccessToken;
try {
({ payload } = await jwtVerify(token, this.keys, {
issuer: this.issuer,
algorithms: ["RS256"],
requiredClaims: ["sub", "exp"],
}));
} catch (err) {
if (err instanceof errors.JOSEError) {
throw new InvalidTokenError(err.code);
}
throw err;
}
// Keycloak ID tokens and refresh tokens are signed by the same keys, only accept access tokens.
if (payload.typ !== "Bearer") {
throw new InvalidTokenError("not an access token");
}
if (!payload.azp || !this.allowedClientIds.includes(payload.azp)) {
throw new InvalidTokenError("token issued to an unknown client");
}
return {
id: KEYCLOAK_USER_ID_PREFIX + payload.sub,
name: payload.preferred_username,
isGuest: payload.realm_access?.roles?.includes(GUEST_ROLE) ?? false,
};
}
}

View File

@ -0,0 +1,13 @@
import { AuthUser } from "./AuthUser";
export class InvalidTokenError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidTokenError";
}
}
export interface TokenVerifier {
// Resolves to the token's user, or rejects with InvalidTokenError.
verify(token: string): Promise<AuthUser>;
}

View File

@ -0,0 +1,11 @@
// WebSocket auth protocol. Move to @mancala/core together with the client implementation.
// client -> server: { token: string } — Keycloak access token. Send right after connecting, and again after refreshing.
// Other messages are ignored until on_auth arrives.
export const channel_auth = "auth";
// server -> client: { user: AuthUser } — the socket is authenticated as this user.
export const channel_on_auth = "on_auth";
// server -> client: { reason: string } — sent right before the socket is closed with CLOSE_AUTH_FAILED.
export const channel_on_auth_error = "on_auth_error";
export const CLOSE_AUTH_FAILED = 4001;

View File

@ -0,0 +1,4 @@
export * from "./AuthUser";
export * from "./AuthConfig";
export * from "./TokenVerifier";
export * from "./KeycloakTokenVerifier";

View File

@ -19,7 +19,7 @@ import { GameMove } from "@mancala/core";
import { GameCrashManager } from "./GameCrashManager"; import { GameCrashManager } from "./GameCrashManager";
import { MatchMaker } from "../matchmaker/MatchMaker"; import { MatchMaker } from "../matchmaker/MatchMaker";
import { GameUsersConnectionInfo } from "@mancala/core"; import { GameUsersConnectionInfo } from "@mancala/core";
import { BackendGame } from "../models/Game"; import { BackendGame, PlayerConnectionInfo, PlayerInfo } from "../models/Game";
export class GameManager { export class GameManager {
gameStore: GameStore; gameStore: GameStore;
@ -142,18 +142,21 @@ export class GameManager {
}); });
} }
public fireOnPlayerConnected(playerId: string) { }
public createMancalaGame(userKey1: string, userKey2: string): BackendGame { public createMancalaGame(userKey1: string, userKey2: string): BackendGame {
const mancalaGame = new CommonMancalaGame(generateKey(), userKey1, userKey2); const mancalaGame = new CommonMancalaGame(generateKey(), userKey1, userKey2);
const random = Math.random(); const random = Math.random();
mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2; mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2;
const players: Record<string, PlayerInfo> = {
[userKey1]: this.getPlayerInfo(userKey1),
[userKey2]: this.getPlayerInfo(userKey2),
};
const game: BackendGame = { const game: BackendGame = {
id: mancalaGame.id, id: mancalaGame.id,
mancalaGame, mancalaGame,
gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromUsers(mancalaGame.player1Id, mancalaGame.player2Id), gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromPlayers(players[userKey1], players[userKey2]),
spectatorIds: [] spectatorIds: [],
players,
}; };
this.gameStore.set(game.id, game); this.gameStore.set(game.id, game);
@ -162,16 +165,22 @@ export class GameManager {
return game; return game;
} }
public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo { private getPlayerInfo(userId: string): PlayerInfo {
return this.getGameUsersConnectionInfoFromUsers(game.mancalaGame.player1Id, game.mancalaGame.player2Id); const user = this.rtmt.getUser(userId);
return { userId, name: user?.name, isGuest: user?.isGuest ?? true };
} }
public getGameUsersConnectionInfoFromUsers(user1Id: string, user2Id: string): GameUsersConnectionInfo { public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo {
const isPlayer1Online = this.rtmt.isClientOnline(user1Id); const { player1Id, player2Id } = game.mancalaGame;
const isPlayer2Online = this.rtmt.isClientOnline(user2Id); return this.getGameUsersConnectionInfoFromPlayers(game.players[player1Id], game.players[player2Id]);
}
private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo {
const toConnectionInfo = (player: PlayerInfo): PlayerConnectionInfo =>
({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) });
return { return {
user1ConnectionInfo: { userId: user1Id, isOnline: isPlayer1Online }, user1ConnectionInfo: toConnectionInfo(player1),
user2ConnectionInfo: { userId: user2Id, isOnline: isPlayer2Online } user2ConnectionInfo: toConnectionInfo(player2)
}; };
} }

View File

@ -5,13 +5,21 @@ import { GameStoreImpl } from "./game/gamestore/GameStoreImpl";
import { MatchMakerImpl } from "./matchmaker/MatchMakerImpl"; import { MatchMakerImpl } from "./matchmaker/MatchMakerImpl";
import { ExpressApp } from "./server/ExpressApp"; import { ExpressApp } from "./server/ExpressApp";
import { WebServer } from "./server/WebServer"; import { WebServer } from "./server/WebServer";
import { KeycloakTokenVerifier, readAuthConfig } from "./auth";
const rtmt = new RTMTWS(); const authConfig = readAuthConfig();
const tokenVerifier = new KeycloakTokenVerifier({
issuer: authConfig.keycloakIssuer,
jwksUrl: authConfig.keycloakJwksUrl,
allowedClientIds: authConfig.keycloakClientIds,
});
const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth });
const gameStore = new GameStoreImpl(); const gameStore = new GameStoreImpl();
const matchMaker = new MatchMakerImpl({ rtmt }); const matchMaker = new MatchMakerImpl({ rtmt });
const gameManager = new GameManager({ rtmt, gameStore, matchMaker }) const gameManager = new GameManager({ rtmt, gameStore, matchMaker })
const expressApp = new ExpressApp({ gameStore }); const expressApp = new ExpressApp({ gameStore, legacyUserKeyAuth: authConfig.legacyUserKeyAuth });
const server = new WebServer({ expressApp: expressApp.app }); const server = new WebServer({ expressApp: expressApp.app });
rtmt.initWebSocket(server.server, (userKey: string) => gameManager.fireOnPlayerConnected(userKey)); rtmt.initWebSocket(server.server);

View File

@ -1,5 +1,17 @@
import { Game } from "@mancala/core"; import { Game, UserConnectionInfo } from "@mancala/core";
export interface PlayerInfo {
userId: string;
// Undefined for legacy userKey clients.
name?: string;
isGuest: boolean;
}
// Sent to clients in place of UserConnectionInfo; the extra fields are ignored by older clients.
export type PlayerConnectionInfo = UserConnectionInfo & PlayerInfo;
export interface BackendGame extends Game { export interface BackendGame extends Game {
spectatorIds: string[]; spectatorIds: string[];
// Keyed by user id, captured when the game is created.
players: Record<string, PlayerInfo>;
} }

View File

@ -1,3 +1,5 @@
import { AuthUser } from "../auth";
export type OnMessage = (clientID: string, message: Object) => any export type OnMessage = (clientID: string, message: Object) => any
export type OnClientConnectionChange = (clientID: string, isOnline : boolean) => any export type OnClientConnectionChange = (clientID: string, isOnline : boolean) => any
@ -5,5 +7,6 @@ export interface RTMT {
sendMessage: (clientID: string, channel: string, message: Object) => any; sendMessage: (clientID: string, channel: string, message: Object) => any;
listenMessage: (channel: string, callback: OnMessage) => any; listenMessage: (channel: string, callback: OnMessage) => any;
isClientOnline(clientID: string): boolean; isClientOnline(clientID: string): boolean;
getUser(clientID: string): AuthUser | undefined;
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void; listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void;
} }

View File

@ -4,66 +4,154 @@ import WebSocket from "ws"
import * as http from 'http'; import * as http from 'http';
import { channel_ping, channel_pong } from "@mancala/core"; import { channel_ping, channel_pong } from "@mancala/core";
import { RTMT_WS_PING_INTERVAL } from "../consts/config"; import { RTMT_WS_PING_INTERVAL } from "../consts/config";
import { decode, encode } from "./encode_decode_message"; import { decode, encode, Message } from "./encode_decode_message";
import { AuthUser, InvalidTokenError, TokenVerifier } from "../auth";
import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "../auth/channels";
const DEFAULT_AUTH_TIMEOUT_MS = 5000;
const LEGACY_USER_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type ClientSocket = WebSocket & {
isAlive?: boolean;
// Set once the socket is authenticated.
userId?: string;
authTimeout?: NodeJS.Timeout;
};
export class RTMTWS implements RTMT { export class RTMTWS implements RTMT {
private messageChannels: Map<String, OnMessage> private messageChannels = new Map<string, OnMessage>()
private wsServer: WebSocket.Server | null private wsServer: WebSocket.Server | null = null
public clients = new Map<string, WebSocket>() // A user can be connected from several devices or tabs at once.
private sockets = new Map<string, Set<ClientSocket>>()
private users = new Map<string, AuthUser>()
private pingInterval?: NodeJS.Timeout = undefined; private pingInterval?: NodeJS.Timeout = undefined;
private readonly tokenVerifier: TokenVerifier;
private readonly legacyUserKeyAuth: boolean;
private readonly authTimeoutMs: number;
onUserConnectionChange?: OnClientConnectionChange; onUserConnectionChange?: OnClientConnectionChange;
constructor() { constructor(params: { tokenVerifier: TokenVerifier, legacyUserKeyAuth: boolean, authTimeoutMs?: number }) {
this.messageChannels = new Map<String, OnMessage>() this.tokenVerifier = params.tokenVerifier;
this.wsServer = null this.legacyUserKeyAuth = params.legacyUserKeyAuth;
this.authTimeoutMs = params.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS;
} }
initWebSocket(server: http.Server, onopen: (userKey: string) => any) { initWebSocket(server: http.Server) {
const wsServer = new WebSocket.Server({ server }) const wsServer = new WebSocket.Server({ server })
this.wsServer = wsServer this.wsServer = wsServer
this.clients = new Map<string, WebSocket>() wsServer.on("connection", (ws: ClientSocket, req: http.IncomingMessage) => this.onConnection(ws, req))
wsServer.on("connection", (ws: WebSocket, req: Request) => {
console.log("(RTMT) Client Connected");
const regexResult = req.url.split(RegExp("\/\?userKey="));
const clientID = regexResult[1]
this.clients.set(clientID, ws)
this.onUserConnectionChange?.(clientID, true);
ws.on("message", (messageBytes: string) => {
this.onWebSocketMessage(clientID, messageBytes)
})
ws.on("close", (code: number, reason: string) => {
console.log("(RTMT) WS Closed! code : " + code + " reason : " + reason);
this.clients.delete(clientID)
this.onUserConnectionChange?.(clientID, false);
})
ws.on("error", (err: Error) => {
console.log("(RTMT) WS Closed with error! error : " + err.message);
this.clients.delete(clientID)
})
this.setWebSocketAsAlive(ws);
onopen(clientID)
})
this.startPingInterval(wsServer); this.startPingInterval(wsServer);
} }
private onConnection(ws: ClientSocket, req: http.IncomingMessage) {
console.log("(RTMT) Client Connected");
this.setWebSocketAsAlive(ws);
ws.on("message", (data: WebSocket.Data) => {
this.onWebSocketMessage(ws, data.toString())
})
ws.on("close", (code: number, reason: string) => {
console.log("(RTMT) WS Closed! code : " + code + " reason : " + reason);
if (ws.authTimeout) clearTimeout(ws.authTimeout);
this.unbindSocket(ws);
})
ws.on("error", (err: Error) => {
// "close" follows, which cleans up.
console.log("(RTMT) WS Closed with error! error : " + err.message);
})
const legacyUserKey = new URL(req.url ?? "/", "http://localhost").searchParams.get("userKey");
if (legacyUserKey !== null) {
if (this.legacyUserKeyAuth && LEGACY_USER_KEY_PATTERN.test(legacyUserKey)) {
this.bindSocket(ws, { id: legacyUserKey, isGuest: true });
} else {
this.rejectSocket(ws, "userKey login is not accepted");
}
return;
}
ws.authTimeout = setTimeout(() => {
if (!ws.userId) this.rejectSocket(ws, "authentication timeout");
}, this.authTimeoutMs);
}
private authenticate(ws: ClientSocket, payload: any) {
const token = payload?.token;
if (typeof token !== "string") {
this.rejectSocket(ws, "missing token");
return;
}
this.tokenVerifier.verify(token).then((user) => {
if (ws.readyState !== WebSocket.OPEN) return;
if (ws.userId && ws.userId !== user.id) {
this.rejectSocket(ws, "token belongs to another user");
return;
}
if (ws.authTimeout) clearTimeout(ws.authTimeout);
if (ws.userId) {
// Token refresh on an already authenticated socket.
this.users.set(user.id, user);
} else {
this.bindSocket(ws, user);
}
this.sendMessageInternal(ws, channel_on_auth, { user });
}).catch((err) => {
if (!(err instanceof InvalidTokenError)) console.error("(RTMT) Token verification failed", err);
this.rejectSocket(ws, err instanceof InvalidTokenError ? "invalid token" : "authentication failed");
});
}
private rejectSocket(ws: ClientSocket, reason: string) {
if (ws.readyState !== WebSocket.OPEN) return;
this.sendMessageInternal(ws, channel_on_auth_error, { reason });
ws.close(CLOSE_AUTH_FAILED, reason);
}
private bindSocket(ws: ClientSocket, user: AuthUser) {
ws.userId = user.id;
this.users.set(user.id, user);
let userSockets = this.sockets.get(user.id);
if (!userSockets) {
userSockets = new Set();
this.sockets.set(user.id, userSockets);
}
userSockets.add(ws);
// Fired for every new socket so each device gets the current game state.
this.onUserConnectionChange?.(user.id, true);
}
private unbindSocket(ws: ClientSocket) {
const userId = ws.userId;
if (!userId) return;
const userSockets = this.sockets.get(userId);
if (!userSockets || !userSockets.delete(ws)) return;
if (userSockets.size === 0) {
this.sockets.delete(userId);
this.users.delete(userId);
this.onUserConnectionChange?.(userId, false);
}
}
dispose() { dispose() {
this.stopPingInterval(); this.stopPingInterval();
} }
sendMessage(clientID: string, channel: string, message: Object) { sendMessage(clientID: string, channel: string, message: Object) {
if (this.wsServer) { if (this.wsServer) {
const client = this.clients.get(clientID) const userSockets = this.sockets.get(clientID)
if (client) { if (userSockets) {
this.sendMessageInternal(client, channel, message); userSockets.forEach((ws) => this.sendMessageInternal(ws, channel, message));
} else { } else {
console.log("(RTMT) Client not connected!"); console.log("(RTMT) Client not connected!");
} }
@ -73,6 +161,7 @@ export class RTMTWS implements RTMT {
} }
sendMessageInternal(ws: WebSocket, channel: string, message: Object) { sendMessageInternal(ws: WebSocket, channel: string, message: Object) {
if (ws.readyState !== WebSocket.OPEN) return;
const data = encode({ channel: channel, message: message }) const data = encode({ channel: channel, message: message })
ws.send(data) ws.send(data)
} }
@ -81,20 +170,32 @@ export class RTMTWS implements RTMT {
this.messageChannels.set(channel, callback) this.messageChannels.set(channel, callback)
} }
onWebSocketMessage(clientID: string, messageBytes: string) { private onWebSocketMessage(ws: ClientSocket, messageBytes: string) {
const message = decode(messageBytes) let message: Message;
this.onMessage(clientID, message.channel, message.message) try {
message = decode(messageBytes);
} catch (err) {
console.log("(RTMT) Ignoring malformed message");
return;
}
if (!message || typeof message.channel !== "string") return;
if (message.channel === channel_pong) {
this.setWebSocketAsAlive(ws);
return;
}
if (message.channel === channel_auth) {
this.authenticate(ws, message.message);
return;
}
if (!ws.userId) {
console.log("(RTMT) Ignoring message from unauthenticated client on channel " + message.channel);
return;
}
this.onMessage(ws.userId, message.channel, message.message);
} }
onMessage(clientID: string, channel: string, message: Object) { onMessage(clientID: string, channel: string, message: Object) {
if (channel === channel_pong) {
const ws = this.clients.get(clientID);
if (ws) {
this.heartbeat(ws);
}
return;
}
const callback = this.messageChannels.get(channel) const callback = this.messageChannels.get(channel)
if (callback) { if (callback) {
callback(clientID, message) callback(clientID, message)
@ -106,16 +207,13 @@ export class RTMTWS implements RTMT {
startPingInterval(wsServer: WebSocket.Server) { startPingInterval(wsServer: WebSocket.Server) {
this.stopPingInterval(); this.stopPingInterval();
this.pingInterval = setInterval(() => { this.pingInterval = setInterval(() => {
const sendMessageInternal = this.sendMessageInternal; wsServer.clients.forEach((ws: ClientSocket) => {
wsServer.clients.forEach(function each(ws) {
//@ts-ignore
if (ws.isAlive === false) { if (ws.isAlive === false) {
console.log("(RTMT) Terminated WebSocket Connection"); console.log("(RTMT) Terminated WebSocket Connection");
return ws.terminate(); return ws.terminate();
} }
//@ts-ignore
ws.isAlive = false; ws.isAlive = false;
sendMessageInternal(ws, channel_ping, {}); this.sendMessageInternal(ws, channel_ping, {});
}); });
}, RTMT_WS_PING_INTERVAL); }, RTMT_WS_PING_INTERVAL);
} }
@ -124,17 +222,16 @@ export class RTMTWS implements RTMT {
if (this.pingInterval) clearInterval(this.pingInterval); if (this.pingInterval) clearInterval(this.pingInterval);
} }
heartbeat(ws: WebSocket) { setWebSocketAsAlive(ws: ClientSocket) {
this.setWebSocketAsAlive(ws);
}
setWebSocketAsAlive(ws: WebSocket) {
//@ts-ignore
ws.isAlive = true; ws.isAlive = true;
} }
isClientOnline(clientID: string): boolean { isClientOnline(clientID: string): boolean {
return this.clients.has(clientID); return this.sockets.has(clientID);
}
getUser(clientID: string): AuthUser | undefined {
return this.users.get(clientID);
} }
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange) { listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange) {

View File

@ -9,8 +9,10 @@ import { GameStore } from "../game/gamestore/GameStore";
export class ExpressApp { export class ExpressApp {
app: Express.Application; app: Express.Application;
gameStore: GameStore; gameStore: GameStore;
constructor(params: { gameStore: GameStore }) { legacyUserKeyAuth: boolean;
constructor(params: { gameStore: GameStore, legacyUserKeyAuth: boolean }) {
this.gameStore = params.gameStore; this.gameStore = params.gameStore;
this.legacyUserKeyAuth = params.legacyUserKeyAuth;
this.app = this.createExpressApp(); this.app = this.createExpressApp();
} }
private createAccessLogMiddleware() { private createAccessLogMiddleware() {
@ -26,9 +28,12 @@ export class ExpressApp {
app.get("/", (req: Request, res: Response) => { app.get("/", (req: Request, res: Response) => {
res.send("Server up and running! ci test1"); res.send("Server up and running! ci test1");
}); });
app.get("/register/", (req: Request, res: Response) => { // Issues userKeys for legacy clients; token clients get their identity from Keycloak.
res.send(generateKey()); if (this.legacyUserKeyAuth) {
}); app.get("/register/", (req: Request, res: Response) => {
res.send(generateKey());
});
}
app.get("/game/:id", (req: Request, res: Response) => { app.get("/game/:id", (req: Request, res: Response) => {
const gameId = req.params.id; const gameId = req.params.id;

View File

@ -0,0 +1,61 @@
import { createLocalJWKSet, exportJWK, generateKeyPair, JWK, JWTPayload, KeyLike, SignJWT } from "jose";
import { InvalidTokenError, KeycloakTokenVerifier } from "../../src/auth";
const ISSUER = "http://keycloak.test/realms/mancala";
describe("KeycloakTokenVerifier", () => {
let privateKey: KeyLike;
let otherPrivateKey: KeyLike;
let verifier: KeycloakTokenVerifier;
beforeAll(async () => {
const keyPair = await generateKeyPair("RS256");
privateKey = keyPair.privateKey;
otherPrivateKey = (await generateKeyPair("RS256")).privateKey;
const publicJwk: JWK = { ...(await exportJWK(keyPair.publicKey)), kid: "test-key", alg: "RS256" };
verifier = new KeycloakTokenVerifier({
issuer: ISSUER,
allowedClientIds: ["mancala-web"],
keys: createLocalJWKSet({ keys: [publicJwk] }),
});
});
const sign = (claims: JWTPayload, options: { key?: KeyLike, expiresIn?: string, issuer?: string } = {}) =>
new SignJWT({ typ: "Bearer", azp: "mancala-web", preferred_username: "alice", ...claims })
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
.setSubject("user-1")
.setIssuer(options.issuer ?? ISSUER)
.setIssuedAt()
.setExpirationTime(options.expiresIn ?? "5m")
.sign(options.key ?? privateKey);
it("returns the user of a valid access token", async () => {
const user = await verifier.verify(await sign({ realm_access: { roles: ["offline_access"] } }));
expect(user).toEqual({ id: "kc:user-1", name: "alice", isGuest: false });
});
it("marks users with the guest role as guests", async () => {
const user = await verifier.verify(await sign({ preferred_username: "guest-1a2b3c4d", realm_access: { roles: ["guest"] } }));
expect(user).toEqual({ id: "kc:user-1", name: "guest-1a2b3c4d", isGuest: true });
});
it.each([
["signed with another key", () => sign({}, { key: otherPrivateKey })],
["from another issuer", () => sign({}, { issuer: "http://evil.test/realms/mancala" })],
["expired", () => sign({}, { expiresIn: "-1m" })],
["an ID token", () => sign({ typ: "ID" })],
["a refresh token", () => sign({ typ: "Refresh" })],
["issued to another client", () => sign({ azp: "some-other-app" })],
["not a JWT", async () => "not-a-token"],
])("rejects a token %s", async (_, makeToken) => {
await expect(verifier.verify(await makeToken())).rejects.toBeInstanceOf(InvalidTokenError);
});
it("rejects unsigned tokens", async () => {
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url");
const payload = Buffer.from(JSON.stringify({
iss: ISSUER, sub: "user-1", typ: "Bearer", azp: "mancala-web", exp: Math.floor(Date.now() / 1000) + 300,
})).toString("base64url");
await expect(verifier.verify(`${header}.${payload}.`)).rejects.toBeInstanceOf(InvalidTokenError);
});
});

View File

@ -0,0 +1,209 @@
import * as http from "http";
import { AddressInfo } from "net";
import WebSocket from "ws";
import { AuthUser, InvalidTokenError, TokenVerifier } from "../../src/auth";
import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "../../src/auth/channels";
import { RTMTWS } from "../../src/rtmt/rtmt_websocket";
const LEGACY_KEY = "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11";
// Accepts tokens of the form "valid:<id>".
const fakeVerifier: TokenVerifier = {
async verify(token: string): Promise<AuthUser> {
if (!token.startsWith("valid:")) throw new InvalidTokenError("bad token");
const id = token.slice("valid:".length);
return { id, name: `name-of-${id}`, isGuest: false };
},
};
type Received = { channel: string, message: any };
class TestClient {
readonly messages: Received[] = [];
readonly closed: Promise<number>;
constructor(readonly ws: WebSocket) {
ws.on("message", (data) => this.messages.push(JSON.parse(data.toString())));
this.closed = new Promise((resolve) => ws.on("close", (code) => resolve(code)));
}
send(channel: string, message: any) {
this.ws.send(JSON.stringify({ channel, message }));
}
async waitFor(channel: string): Promise<any> {
for (let i = 0; i < 100; i++) {
const found = this.messages.find((m) => m.channel === channel);
if (found) return found.message;
await sleep(20);
}
throw new Error(`no message on ${channel}, got ${JSON.stringify(this.messages)}`);
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("RTMTWS", () => {
let server: http.Server;
let rtmt: RTMTWS;
let clients: TestClient[];
let connectionChanges: [string, boolean][];
const start = async (legacyUserKeyAuth: boolean) => {
server = http.createServer();
rtmt = new RTMTWS({ tokenVerifier: fakeVerifier, legacyUserKeyAuth, authTimeoutMs: 300 });
const changes: [string, boolean][] = [];
connectionChanges = changes;
rtmt.listenOnClientConnectionChange((id, isOnline) => changes.push([id, isOnline]));
rtmt.initWebSocket(server);
await new Promise<void>((resolve) => server.listen(0, resolve));
};
const connect = async (query = ""): Promise<TestClient> => {
const port = (server.address() as AddressInfo).port;
const ws = new WebSocket(`ws://localhost:${port}/${query}`);
const client = new TestClient(ws);
clients.push(client);
await new Promise((resolve, reject) => { ws.on("open", resolve); ws.on("error", reject); });
return client;
};
const connectAs = async (id: string): Promise<TestClient> => {
const client = await connect();
client.send(channel_auth, { token: `valid:${id}` });
await client.waitFor(channel_on_auth);
return client;
};
beforeEach(() => { clients = []; });
afterEach(async () => {
clients.forEach((c) => c.ws.terminate());
rtmt.dispose();
await new Promise((resolve) => server.close(resolve));
});
describe("token auth", () => {
beforeEach(() => start(false));
it("authenticates a socket and routes its messages as that user", async () => {
const received: [string, any][] = [];
rtmt.listenMessage("hello", (id, message) => received.push([id, message]));
const client = await connect();
client.send(channel_auth, { token: "valid:alice" });
expect(await client.waitFor(channel_on_auth)).toEqual({ user: { id: "alice", name: "name-of-alice", isGuest: false } });
client.send("hello", { n: 1 });
await sleep(50);
expect(received).toEqual([["alice", { n: 1 }]]);
expect(rtmt.isClientOnline("alice")).toBe(true);
expect(rtmt.getUser("alice")?.name).toBe("name-of-alice");
});
it("ignores messages before authentication", async () => {
const received: string[] = [];
rtmt.listenMessage("hello", (id) => received.push(id));
const client = await connect();
client.send("hello", {});
await sleep(50);
expect(received).toEqual([]);
});
it("closes the socket on an invalid token", async () => {
const client = await connect();
client.send(channel_auth, { token: "forged" });
expect(await client.waitFor(channel_on_auth_error)).toEqual({ reason: "invalid token" });
expect(await client.closed).toBe(CLOSE_AUTH_FAILED);
});
it("closes the socket when no token arrives in time", async () => {
const client = await connect();
expect(await client.closed).toBe(CLOSE_AUTH_FAILED);
});
it("accepts a refreshed token for the same user but not for another user", async () => {
const client = await connectAs("alice");
client.messages.length = 0;
client.send(channel_auth, { token: "valid:alice" });
await client.waitFor(channel_on_auth);
client.send(channel_auth, { token: "valid:mallory" });
expect(await client.waitFor(channel_on_auth_error)).toEqual({ reason: "token belongs to another user" });
expect(await client.closed).toBe(CLOSE_AUTH_FAILED);
});
it("rejects userKey connections when legacy auth is off", async () => {
const client = await connect(`?userKey=${LEGACY_KEY}`);
expect(await client.closed).toBe(CLOSE_AUTH_FAILED);
});
it("survives malformed messages", async () => {
const client = await connectAs("alice");
client.ws.send("{not json");
client.ws.send("null");
client.messages.length = 0;
client.send(channel_auth, { token: "valid:alice" });
await client.waitFor(channel_on_auth);
});
});
describe("several sockets per user", () => {
beforeEach(() => start(false));
it("delivers messages to every socket of the user", async () => {
const phone = await connectAs("alice");
const laptop = await connectAs("alice");
rtmt.sendMessage("alice", "news", { n: 1 });
expect(await phone.waitFor("news")).toEqual({ n: 1 });
expect(await laptop.waitFor("news")).toEqual({ n: 1 });
});
it("keeps the user online until the last socket closes", async () => {
const phone = await connectAs("alice");
const laptop = await connectAs("alice");
phone.ws.close();
await phone.closed;
await sleep(50);
expect(rtmt.isClientOnline("alice")).toBe(true);
expect(connectionChanges).not.toContainEqual(["alice", false]);
laptop.ws.close();
await laptop.closed;
await sleep(50);
expect(rtmt.isClientOnline("alice")).toBe(false);
expect(connectionChanges).toContainEqual(["alice", false]);
});
it("does not drop a reconnected socket when the old one closes late", async () => {
const oldSocket = await connectAs("alice");
const newSocket = await connectAs("alice");
oldSocket.ws.terminate();
await oldSocket.closed;
await sleep(50);
rtmt.sendMessage("alice", "news", {});
expect(await newSocket.waitFor("news")).toEqual({});
});
});
describe("legacy userKey auth", () => {
beforeEach(() => start(true));
it("accepts a UUID userKey", async () => {
await connect(`?userKey=${LEGACY_KEY}`);
await sleep(50);
expect(rtmt.isClientOnline(LEGACY_KEY)).toBe(true);
expect(rtmt.getUser(LEGACY_KEY)).toEqual({ id: LEGACY_KEY, isGuest: true });
});
it("cannot claim a Keycloak user's id", async () => {
const client = await connect("?userKey=kc:0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11");
expect(await client.closed).toBe(CLOSE_AUTH_FAILED);
});
it("cannot switch a legacy socket to a token identity", async () => {
const client = await connect(`?userKey=${LEGACY_KEY}`);
client.send(channel_auth, { token: "valid:kc:alice" });
expect(await client.closed).toBe(CLOSE_AUTH_FAILED);
});
});
});

View File

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es2020",
"module": "commonjs", "module": "commonjs",
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",

View File

@ -2466,6 +2466,11 @@ jest@^27.0.5:
import-local "^3.0.2" import-local "^3.0.2"
jest-cli "^27.5.1" jest-cli "^27.5.1"
jose@5.10.0:
version "5.10.0"
resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be"
integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==
js-tokens@^4.0.0: js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"

View File

@ -1,6 +1,13 @@
services: services:
backend: backend:
image: jhalitaksoy/mancala-backend:latest image: jhalitaksoy/mancala-backend:latest
environment:
# Must match the iss claim, i.e. the public Keycloak URL; https://auth.playmancala.app/realms/mancala in production
KEYCLOAK_ISSUER: ${KEYCLOAK_HOSTNAME:-http://localhost:8080}/realms/mancala
# Fetch keys over the compose network instead of the public URL
KEYCLOAK_JWKS_URL: http://keycloak:8080/realms/mancala/protocol/openid-connect/certs
KEYCLOAK_CLIENT_IDS: mancala-web
LEGACY_USERKEY_AUTH: ${LEGACY_USERKEY_AUTH:-true}
ports: ports:
- "5000:6000" - "5000:6000"
frontend: frontend: