diff --git a/apps/backend/README.md b/apps/backend/README.md index 656bead..a312021 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -25,4 +25,25 @@ yarn start ### Development Dependencies -This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions. \ No newline at end of file +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": "" } } +``` + +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=` and the `/register` endpoint. Turn off once no released client uses it, because a userKey is not a secret the server can check | diff --git a/apps/backend/package.json b/apps/backend/package.json index ac5e34b..3ba8b50 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -21,6 +21,7 @@ "@types/ws": "^7.4.5", "cors": "^2.8.5", "express": "^4.17.1", + "jose": "5.10.0", "mancala.js": "^0.0.2-beta.3", "morgan": "^1.10.0", "uuid": "^8.3.2", diff --git a/apps/backend/src/auth/AuthConfig.ts b/apps/backend/src/auth/AuthConfig.ts new file mode 100644 index 0000000..3a543c2 --- /dev/null +++ b/apps/backend/src/auth/AuthConfig.ts @@ -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", + }; +} diff --git a/apps/backend/src/auth/AuthUser.ts b/apps/backend/src/auth/AuthUser.ts new file mode 100644 index 0000000..5965ed3 --- /dev/null +++ b/apps/backend/src/auth/AuthUser.ts @@ -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; +} diff --git a/apps/backend/src/auth/KeycloakTokenVerifier.ts b/apps/backend/src/auth/KeycloakTokenVerifier.ts new file mode 100644 index 0000000..43f918c --- /dev/null +++ b/apps/backend/src/auth/KeycloakTokenVerifier.ts @@ -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 { + 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, + }; + } +} diff --git a/apps/backend/src/auth/TokenVerifier.ts b/apps/backend/src/auth/TokenVerifier.ts new file mode 100644 index 0000000..9406a46 --- /dev/null +++ b/apps/backend/src/auth/TokenVerifier.ts @@ -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; +} diff --git a/apps/backend/src/auth/channels.ts b/apps/backend/src/auth/channels.ts new file mode 100644 index 0000000..11e2d40 --- /dev/null +++ b/apps/backend/src/auth/channels.ts @@ -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; diff --git a/apps/backend/src/auth/index.ts b/apps/backend/src/auth/index.ts new file mode 100644 index 0000000..43eeddb --- /dev/null +++ b/apps/backend/src/auth/index.ts @@ -0,0 +1,4 @@ +export * from "./AuthUser"; +export * from "./AuthConfig"; +export * from "./TokenVerifier"; +export * from "./KeycloakTokenVerifier"; diff --git a/apps/backend/src/game/GameManager.ts b/apps/backend/src/game/GameManager.ts index 2350240..bc10c95 100644 --- a/apps/backend/src/game/GameManager.ts +++ b/apps/backend/src/game/GameManager.ts @@ -19,7 +19,7 @@ import { GameMove } from "@mancala/core"; import { GameCrashManager } from "./GameCrashManager"; import { MatchMaker } from "../matchmaker/MatchMaker"; import { GameUsersConnectionInfo } from "@mancala/core"; -import { BackendGame } from "../models/Game"; +import { BackendGame, PlayerConnectionInfo, PlayerInfo } from "../models/Game"; export class GameManager { gameStore: GameStore; @@ -142,18 +142,21 @@ export class GameManager { }); } - public fireOnPlayerConnected(playerId: string) { } - public createMancalaGame(userKey1: string, userKey2: string): BackendGame { const mancalaGame = new CommonMancalaGame(generateKey(), userKey1, userKey2); const random = Math.random(); mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2; + const players: Record = { + [userKey1]: this.getPlayerInfo(userKey1), + [userKey2]: this.getPlayerInfo(userKey2), + }; const game: BackendGame = { id: mancalaGame.id, mancalaGame, - gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromUsers(mancalaGame.player1Id, mancalaGame.player2Id), - spectatorIds: [] + gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromPlayers(players[userKey1], players[userKey2]), + spectatorIds: [], + players, }; this.gameStore.set(game.id, game); @@ -162,16 +165,22 @@ export class GameManager { return game; } - public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo { - return this.getGameUsersConnectionInfoFromUsers(game.mancalaGame.player1Id, game.mancalaGame.player2Id); + private getPlayerInfo(userId: string): PlayerInfo { + const user = this.rtmt.getUser(userId); + return { userId, name: user?.name, isGuest: user?.isGuest ?? true }; } - public getGameUsersConnectionInfoFromUsers(user1Id: string, user2Id: string): GameUsersConnectionInfo { - const isPlayer1Online = this.rtmt.isClientOnline(user1Id); - const isPlayer2Online = this.rtmt.isClientOnline(user2Id); + public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo { + const { player1Id, player2Id } = game.mancalaGame; + 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 { - user1ConnectionInfo: { userId: user1Id, isOnline: isPlayer1Online }, - user2ConnectionInfo: { userId: user2Id, isOnline: isPlayer2Online } + user1ConnectionInfo: toConnectionInfo(player1), + user2ConnectionInfo: toConnectionInfo(player2) }; } diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index e5d51e7..493cbdc 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -5,13 +5,21 @@ import { GameStoreImpl } from "./game/gamestore/GameStoreImpl"; import { MatchMakerImpl } from "./matchmaker/MatchMakerImpl"; import { ExpressApp } from "./server/ExpressApp"; 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 matchMaker = new MatchMakerImpl({ rtmt }); 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 }); -rtmt.initWebSocket(server.server, (userKey: string) => gameManager.fireOnPlayerConnected(userKey)); +rtmt.initWebSocket(server.server); diff --git a/apps/backend/src/models/Game.ts b/apps/backend/src/models/Game.ts index 01854f5..b5d851d 100644 --- a/apps/backend/src/models/Game.ts +++ b/apps/backend/src/models/Game.ts @@ -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 { spectatorIds: string[]; -} \ No newline at end of file + // Keyed by user id, captured when the game is created. + players: Record; +} diff --git a/apps/backend/src/rtmt/rtmt.ts b/apps/backend/src/rtmt/rtmt.ts index fe735d7..f1845bf 100644 --- a/apps/backend/src/rtmt/rtmt.ts +++ b/apps/backend/src/rtmt/rtmt.ts @@ -1,3 +1,5 @@ +import { AuthUser } from "../auth"; + export type OnMessage = (clientID: string, message: Object) => any export type OnClientConnectionChange = (clientID: string, isOnline : boolean) => any @@ -5,5 +7,6 @@ export interface RTMT { sendMessage: (clientID: string, channel: string, message: Object) => any; listenMessage: (channel: string, callback: OnMessage) => any; isClientOnline(clientID: string): boolean; + getUser(clientID: string): AuthUser | undefined; listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void; -} \ No newline at end of file +} diff --git a/apps/backend/src/rtmt/rtmt_websocket.ts b/apps/backend/src/rtmt/rtmt_websocket.ts index fd3078d..a527cd9 100644 --- a/apps/backend/src/rtmt/rtmt_websocket.ts +++ b/apps/backend/src/rtmt/rtmt_websocket.ts @@ -4,66 +4,154 @@ import WebSocket from "ws" import * as http from 'http'; import { channel_ping, channel_pong } from "@mancala/core"; 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 { - private messageChannels: Map + private messageChannels = new Map() - private wsServer: WebSocket.Server | null + private wsServer: WebSocket.Server | null = null - public clients = new Map() + // A user can be connected from several devices or tabs at once. + private sockets = new Map>() + + private users = new Map() private pingInterval?: NodeJS.Timeout = undefined; + private readonly tokenVerifier: TokenVerifier; + + private readonly legacyUserKeyAuth: boolean; + + private readonly authTimeoutMs: number; + onUserConnectionChange?: OnClientConnectionChange; - constructor() { - this.messageChannels = new Map() - this.wsServer = null + constructor(params: { tokenVerifier: TokenVerifier, legacyUserKeyAuth: boolean, authTimeoutMs?: number }) { + this.tokenVerifier = params.tokenVerifier; + 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 }) this.wsServer = wsServer - this.clients = new Map() - - 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) - }) + wsServer.on("connection", (ws: ClientSocket, req: http.IncomingMessage) => this.onConnection(ws, req)) 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() { this.stopPingInterval(); } sendMessage(clientID: string, channel: string, message: Object) { if (this.wsServer) { - const client = this.clients.get(clientID) - if (client) { - this.sendMessageInternal(client, channel, message); + const userSockets = this.sockets.get(clientID) + if (userSockets) { + userSockets.forEach((ws) => this.sendMessageInternal(ws, channel, message)); } else { console.log("(RTMT) Client not connected!"); } @@ -73,6 +161,7 @@ export class RTMTWS implements RTMT { } sendMessageInternal(ws: WebSocket, channel: string, message: Object) { + if (ws.readyState !== WebSocket.OPEN) return; const data = encode({ channel: channel, message: message }) ws.send(data) } @@ -81,20 +170,32 @@ export class RTMTWS implements RTMT { this.messageChannels.set(channel, callback) } - onWebSocketMessage(clientID: string, messageBytes: string) { - const message = decode(messageBytes) - this.onMessage(clientID, message.channel, message.message) + private onWebSocketMessage(ws: ClientSocket, messageBytes: string) { + let 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) { - if (channel === channel_pong) { - const ws = this.clients.get(clientID); - if (ws) { - this.heartbeat(ws); - } - return; - } const callback = this.messageChannels.get(channel) if (callback) { callback(clientID, message) @@ -106,16 +207,13 @@ export class RTMTWS implements RTMT { startPingInterval(wsServer: WebSocket.Server) { this.stopPingInterval(); this.pingInterval = setInterval(() => { - const sendMessageInternal = this.sendMessageInternal; - wsServer.clients.forEach(function each(ws) { - //@ts-ignore + wsServer.clients.forEach((ws: ClientSocket) => { if (ws.isAlive === false) { console.log("(RTMT) Terminated WebSocket Connection"); return ws.terminate(); } - //@ts-ignore ws.isAlive = false; - sendMessageInternal(ws, channel_ping, {}); + this.sendMessageInternal(ws, channel_ping, {}); }); }, RTMT_WS_PING_INTERVAL); } @@ -124,20 +222,19 @@ export class RTMTWS implements RTMT { if (this.pingInterval) clearInterval(this.pingInterval); } - heartbeat(ws: WebSocket) { - this.setWebSocketAsAlive(ws); - } - - setWebSocketAsAlive(ws: WebSocket) { - //@ts-ignore + setWebSocketAsAlive(ws: ClientSocket) { ws.isAlive = true; } 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) { this.onUserConnectionChange = onUserConnectionChange; } -} \ No newline at end of file +} diff --git a/apps/backend/src/server/ExpressApp.ts b/apps/backend/src/server/ExpressApp.ts index e8a0d72..bebe6a2 100644 --- a/apps/backend/src/server/ExpressApp.ts +++ b/apps/backend/src/server/ExpressApp.ts @@ -9,8 +9,10 @@ import { GameStore } from "../game/gamestore/GameStore"; export class ExpressApp { app: Express.Application; gameStore: GameStore; - constructor(params: { gameStore: GameStore }) { + legacyUserKeyAuth: boolean; + constructor(params: { gameStore: GameStore, legacyUserKeyAuth: boolean }) { this.gameStore = params.gameStore; + this.legacyUserKeyAuth = params.legacyUserKeyAuth; this.app = this.createExpressApp(); } private createAccessLogMiddleware() { @@ -26,9 +28,12 @@ export class ExpressApp { app.get("/", (req: Request, res: Response) => { res.send("Server up and running! ci test1"); }); - app.get("/register/", (req: Request, res: Response) => { - res.send(generateKey()); - }); + // Issues userKeys for legacy clients; token clients get their identity from Keycloak. + if (this.legacyUserKeyAuth) { + app.get("/register/", (req: Request, res: Response) => { + res.send(generateKey()); + }); + } app.get("/game/:id", (req: Request, res: Response) => { const gameId = req.params.id; diff --git a/apps/backend/tests/auth/KeycloakTokenVerifier.test.ts b/apps/backend/tests/auth/KeycloakTokenVerifier.test.ts new file mode 100644 index 0000000..8cbc735 --- /dev/null +++ b/apps/backend/tests/auth/KeycloakTokenVerifier.test.ts @@ -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); + }); +}); diff --git a/apps/backend/tests/rtmt/rtmt_websocket.test.ts b/apps/backend/tests/rtmt/rtmt_websocket.test.ts new file mode 100644 index 0000000..872cc63 --- /dev/null +++ b/apps/backend/tests/rtmt/rtmt_websocket.test.ts @@ -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:". +const fakeVerifier: TokenVerifier = { + async verify(token: string): Promise { + 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; + 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 { + 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((resolve) => server.listen(0, resolve)); + }; + + const connect = async (query = ""): Promise => { + 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 => { + 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); + }); + }); +}); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index e1334ed..cb46546 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2020", "module": "commonjs", "outDir": "./dist", "rootDir": "./src", diff --git a/apps/backend/yarn.lock b/apps/backend/yarn.lock index 1d46b16..b54a4a8 100644 --- a/apps/backend/yarn.lock +++ b/apps/backend/yarn.lock @@ -2466,6 +2466,11 @@ jest@^27.0.5: import-local "^3.0.2" 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: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" diff --git a/apps/compose.yaml b/apps/compose.yaml index bacfccd..8aacd5e 100644 --- a/apps/compose.yaml +++ b/apps/compose.yaml @@ -1,6 +1,13 @@ services: backend: 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: - "5000:6000" frontend: