Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -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 |
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
16
apps/backend/src/auth/AuthConfig.ts
Normal file
16
apps/backend/src/auth/AuthConfig.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
7
apps/backend/src/auth/AuthUser.ts
Normal file
7
apps/backend/src/auth/AuthUser.ts
Normal 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;
|
||||||
|
}
|
||||||
72
apps/backend/src/auth/KeycloakTokenVerifier.ts
Normal file
72
apps/backend/src/auth/KeycloakTokenVerifier.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/backend/src/auth/TokenVerifier.ts
Normal file
13
apps/backend/src/auth/TokenVerifier.ts
Normal 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>;
|
||||||
|
}
|
||||||
11
apps/backend/src/auth/channels.ts
Normal file
11
apps/backend/src/auth/channels.ts
Normal 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;
|
||||||
4
apps/backend/src/auth/index.ts
Normal file
4
apps/backend/src/auth/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./AuthUser";
|
||||||
|
export * from "./AuthConfig";
|
||||||
|
export * from "./TokenVerifier";
|
||||||
|
export * from "./KeycloakTokenVerifier";
|
||||||
@ -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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
@ -4,55 +4,143 @@ 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))
|
||||||
|
this.startPingInterval(wsServer);
|
||||||
|
}
|
||||||
|
|
||||||
wsServer.on("connection", (ws: WebSocket, req: Request) => {
|
private onConnection(ws: ClientSocket, req: http.IncomingMessage) {
|
||||||
console.log("(RTMT) Client Connected");
|
console.log("(RTMT) Client Connected");
|
||||||
const regexResult = req.url.split(RegExp("\/\?userKey="));
|
this.setWebSocketAsAlive(ws);
|
||||||
const clientID = regexResult[1]
|
|
||||||
this.clients.set(clientID, ws)
|
ws.on("message", (data: WebSocket.Data) => {
|
||||||
this.onUserConnectionChange?.(clientID, true);
|
this.onWebSocketMessage(ws, data.toString())
|
||||||
ws.on("message", (messageBytes: string) => {
|
|
||||||
this.onWebSocketMessage(clientID, messageBytes)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.on("close", (code: number, reason: string) => {
|
ws.on("close", (code: number, reason: string) => {
|
||||||
console.log("(RTMT) WS Closed! code : " + code + " reason : " + reason);
|
console.log("(RTMT) WS Closed! code : " + code + " reason : " + reason);
|
||||||
this.clients.delete(clientID)
|
if (ws.authTimeout) clearTimeout(ws.authTimeout);
|
||||||
this.onUserConnectionChange?.(clientID, false);
|
this.unbindSocket(ws);
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.on("error", (err: Error) => {
|
ws.on("error", (err: Error) => {
|
||||||
|
// "close" follows, which cleans up.
|
||||||
console.log("(RTMT) WS Closed with error! error : " + err.message);
|
console.log("(RTMT) WS Closed with error! error : " + err.message);
|
||||||
this.clients.delete(clientID)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.setWebSocketAsAlive(ws);
|
const legacyUserKey = new URL(req.url ?? "/", "http://localhost").searchParams.get("userKey");
|
||||||
onopen(clientID)
|
if (legacyUserKey !== null) {
|
||||||
})
|
if (this.legacyUserKeyAuth && LEGACY_USER_KEY_PATTERN.test(legacyUserKey)) {
|
||||||
this.startPingInterval(wsServer);
|
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() {
|
||||||
@ -61,9 +149,9 @@ export class RTMTWS implements RTMT {
|
|||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
|
// Issues userKeys for legacy clients; token clients get their identity from Keycloak.
|
||||||
|
if (this.legacyUserKeyAuth) {
|
||||||
app.get("/register/", (req: Request, res: Response) => {
|
app.get("/register/", (req: Request, res: Response) => {
|
||||||
res.send(generateKey());
|
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;
|
||||||
|
|||||||
61
apps/backend/tests/auth/KeycloakTokenVerifier.test.ts
Normal file
61
apps/backend/tests/auth/KeycloakTokenVerifier.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
209
apps/backend/tests/rtmt/rtmt_websocket.test.ts
Normal file
209
apps/backend/tests/rtmt/rtmt_websocket.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es2020",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user