From f0246cca61862700438481267f09a9f5784323b6 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:09:16 +0300 Subject: [PATCH] feat(backend): add the wire types, the game loop and token verification Records for the JSON the clients read, leaving out only the fields TypeScript leaves undefined. GameLoop is the single thread that owns live state; ManualScheduler replaces Jest's fake timers in tests. Keycloak tokens are verified with jose4j and the same checks as before. --- .../auth/InvalidTokenException.java | 7 ++ .../auth/KeycloakTokenVerifier.java | 87 ++++++++++++++ .../app/playmancala/auth/TokenVerifier.java | 8 ++ .../app/playmancala/loop/Cancellable.java | 6 + .../java/app/playmancala/loop/GameLoop.java | 88 ++++++++++++++ .../java/app/playmancala/loop/Scheduler.java | 15 +++ .../java/app/playmancala/model/AuthUser.java | 11 ++ .../java/app/playmancala/model/Channels.java | 36 ++++++ .../app/playmancala/model/ClientGame.java | 18 +++ .../java/app/playmancala/model/EndReason.java | 20 ++++ .../model/FinishedGameSummary.java | 24 ++++ .../java/app/playmancala/model/GameClock.java | 12 ++ .../app/playmancala/model/GameEndMessage.java | 16 +++ .../app/playmancala/model/GameRecord.java | 36 ++++++ .../app/playmancala/model/GameResult.java | 18 +++ .../model/GameUsersConnectionInfo.java | 4 + .../playmancala/model/LiveGameSummary.java | 15 +++ .../app/playmancala/model/MoveRecord.java | 17 +++ .../app/playmancala/model/PlayerSummary.java | 15 +++ .../main/java/app/playmancala/model/Seat.java | 8 ++ .../playmancala/model/UserConnectionInfo.java | 15 +++ .../auth/KeycloakTokenVerifierTest.java | 112 ++++++++++++++++++ .../app/playmancala/loop/ManualScheduler.java | 85 +++++++++++++ .../playmancala/loop/ManualSchedulerTest.java | 26 ++++ docs/specs/quarkus-backend/tasks.md | 2 +- 25 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/auth/InvalidTokenException.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifier.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/loop/Cancellable.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/loop/GameLoop.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/loop/Scheduler.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/AuthUser.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/Channels.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/ClientGame.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/EndReason.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/FinishedGameSummary.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/GameClock.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/GameEndMessage.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/GameRecord.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/GameResult.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/LiveGameSummary.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/MoveRecord.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/PlayerSummary.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/Seat.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/model/UserConnectionInfo.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualScheduler.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualSchedulerTest.java diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/auth/InvalidTokenException.java b/apps/backend-quarkus/src/main/java/app/playmancala/auth/InvalidTokenException.java new file mode 100644 index 0000000..a4915f0 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/auth/InvalidTokenException.java @@ -0,0 +1,7 @@ +package app.playmancala.auth; + +public class InvalidTokenException extends Exception { + public InvalidTokenException(String message) { + super(message); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java b/apps/backend-quarkus/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java new file mode 100644 index 0000000..825a8f6 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/auth/KeycloakTokenVerifier.java @@ -0,0 +1,87 @@ +package app.playmancala.auth; + +import java.util.List; +import java.util.Map; + +import org.jose4j.http.Get; +import org.jose4j.jwa.AlgorithmConstraints; +import org.jose4j.jwk.HttpsJwks; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.jose4j.jwt.consumer.JwtConsumer; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; +import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver; +import org.jose4j.keys.resolvers.VerificationKeyResolver; + +import app.playmancala.model.AuthUser; + +// 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. +public class KeycloakTokenVerifier implements TokenVerifier { + public static final String KEYCLOAK_USER_ID_PREFIX = "kc:"; + public static final String GUEST_ROLE = "guest"; + + private static final int JWKS_TIMEOUT_MS = 5_000; + // Keys are cached this long, and kept this long while Keycloak cannot be reached. + private static final long JWKS_CACHE_SECONDS = 600; + // A token with an unknown key id refetches the keys at most this often. + private static final long JWKS_REFRESH_COOLDOWN_MS = 30_000; + + private final List allowedClientIds; + private final JwtConsumer consumer; + + // `jwksUrl` defaults to the issuer's certs endpoint. + public static KeycloakTokenVerifier create(String issuer, List allowedClientIds, String jwksUrl) { + HttpsJwks jwks = new HttpsJwks(jwksUrl != null ? jwksUrl : issuer + "/protocol/openid-connect/certs"); + Get get = new Get(); + get.setConnectTimeout(JWKS_TIMEOUT_MS); + get.setReadTimeout(JWKS_TIMEOUT_MS); + jwks.setSimpleHttpGet(get); + jwks.setDefaultCacheDuration(JWKS_CACHE_SECONDS); + jwks.setRetainCacheOnErrorDuration(JWKS_CACHE_SECONDS * 1000); + jwks.setRefreshReprieveThreshold(JWKS_REFRESH_COOLDOWN_MS); + return new KeycloakTokenVerifier(issuer, allowedClientIds, new HttpsJwksVerificationKeyResolver(jwks)); + } + + public KeycloakTokenVerifier(String issuer, List allowedClientIds, VerificationKeyResolver keys) { + this.allowedClientIds = List.copyOf(allowedClientIds); + this.consumer = new JwtConsumerBuilder() + .setExpectedIssuer(issuer) + .setRequireSubject() + .setRequireExpirationTime() + // Keycloak access tokens are checked by azp below, not by audience. + .setSkipDefaultAudienceValidation() + .setJwsAlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, AlgorithmIdentifiers.RSA_USING_SHA256) + .setVerificationKeyResolver(keys) + .build(); + } + + @Override + public AuthUser verify(String token) throws InvalidTokenException { + JwtClaims claims; + try { + claims = consumer.processToClaims(token); + } catch (InvalidJwtException e) { + throw new InvalidTokenException(e.getMessage()); + } + + // Keycloak ID tokens and refresh tokens are signed by the same keys, only accept access tokens. + if (!"Bearer".equals(claims.getClaimValue("typ"))) { + throw new InvalidTokenException("not an access token"); + } + Object azp = claims.getClaimValue("azp"); + if (!(azp instanceof String clientId) || !allowedClientIds.contains(clientId)) { + throw new InvalidTokenException("token issued to an unknown client"); + } + + Object username = claims.getClaimValue("preferred_username"); + return new AuthUser(KEYCLOAK_USER_ID_PREFIX + claims.getClaimValue("sub"), + username instanceof String name ? name : null, hasGuestRole(claims.getClaimValue("realm_access"))); + } + + private static boolean hasGuestRole(Object realmAccess) { + return realmAccess instanceof Map access && access.get("roles") instanceof List roles + && roles.contains(GUEST_ROLE); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifier.java b/apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifier.java new file mode 100644 index 0000000..26be99c --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/auth/TokenVerifier.java @@ -0,0 +1,8 @@ +package app.playmancala.auth; + +import app.playmancala.model.AuthUser; + +public interface TokenVerifier { + // The token's user. May block (fetching signing keys); call it off the game loop. + AuthUser verify(String token) throws InvalidTokenException; +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/loop/Cancellable.java b/apps/backend-quarkus/src/main/java/app/playmancala/loop/Cancellable.java new file mode 100644 index 0000000..2e5344e --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/loop/Cancellable.java @@ -0,0 +1,6 @@ +package app.playmancala.loop; + +@FunctionalInterface +public interface Cancellable { + void cancel(); +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/loop/GameLoop.java b/apps/backend-quarkus/src/main/java/app/playmancala/loop/GameLoop.java new file mode 100644 index 0000000..72a02f3 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/loop/GameLoop.java @@ -0,0 +1,88 @@ +package app.playmancala.loop; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.jboss.logging.Logger; + +// The one thread that owns all live game state: games, matchmaking, previews and the WebSocket registry. Like the +// Node backend's event loop, it lets that state go without locks. Never block it on I/O. +public final class GameLoop implements Scheduler, AutoCloseable { + private static final Logger LOG = Logger.getLogger(GameLoop.class); + + private final ScheduledExecutorService executor; + private volatile Thread thread; + + public GameLoop() { + executor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread loopThread = new Thread(runnable, "game-loop"); + loopThread.setDaemon(true); + thread = loopThread; + return loopThread; + }); + } + + @Override + public long now() { + return System.currentTimeMillis(); + } + + @Override + public void execute(Runnable task) { + executor.execute(guarded(task)); + } + + @Override + public Cancellable schedule(Runnable task, long delayMs) { + Future future = executor.schedule(guarded(task), delayMs, TimeUnit.MILLISECONDS); + return () -> future.cancel(false); + } + + @Override + public Cancellable scheduleAtFixedRate(Runnable task, long periodMs) { + Future future = executor.scheduleAtFixedRate(guarded(task), periodMs, periodMs, TimeUnit.MILLISECONDS); + return () -> future.cancel(false); + } + + // Runs the task on the loop and waits for its result; for threads that need to read live state. + public T call(Callable task) { + if (Thread.currentThread() == thread) { + try { + return task.call(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + try { + return executor.submit(task).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for the game loop", e); + } catch (ExecutionException e) { + if (e.getCause() instanceof RuntimeException runtime) throw runtime; + throw new IllegalStateException(e.getCause()); + } + } + + @Override + public void close() { + executor.shutdownNow(); + } + + // An exception must not stop a repeating task or reach the executor, which would swallow it. + private static Runnable guarded(Runnable task) { + return () -> { + try { + task.run(); + } catch (Throwable e) { + LOG.error("(GameLoop) Task failed", e); + } + }; + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/loop/Scheduler.java b/apps/backend-quarkus/src/main/java/app/playmancala/loop/Scheduler.java new file mode 100644 index 0000000..c20381e --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/loop/Scheduler.java @@ -0,0 +1,15 @@ +package app.playmancala.loop; + +// What the game logic needs from the game loop: running tasks and timers on the thread that owns the game state, and +// the time. Tests use ManualScheduler, whose clock only moves when the test advances it. +public interface Scheduler { + // Epoch milliseconds. + long now(); + + void execute(Runnable task); + + Cancellable schedule(Runnable task, long delayMs); + + // First run after one period. + Cancellable scheduleAtFixedRate(Runnable task, long periodMs); +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/AuthUser.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/AuthUser.java new file mode 100644 index 0000000..2b7dd37 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/AuthUser.java @@ -0,0 +1,11 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +// A logged in user. `id` is also the player id in games; `name` is null for legacy userKey clients. +public record AuthUser( + String id, + @JsonInclude(JsonInclude.Include.NON_NULL) String name, + @JsonProperty("isGuest") boolean isGuest) { +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/Channels.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/Channels.java new file mode 100644 index 0000000..3336683 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/Channels.java @@ -0,0 +1,36 @@ +package app.playmancala.model; + +// WebSocket channel names; must match packages/core/src/rtmt/channel_names.ts. +public final class Channels { + public static final String NEW_GAME = "new_game"; + public static final String CANCEL_NEW_GAME = "cancel_new_game"; + public static final String ON_GAME_START = "on_game_start"; + public static final String GAME_MOVE = "game_move"; + public static final String ON_GAME_UPDATE = "on_game_update"; + public static final String LEAVE_GAME = "leave_game"; + public static final String ON_GAME_END = "on_game_end"; + public static final String ON_GAME_CRASHED = "on_game_crashed"; + public static final String ON_GAME_USER_LEAVE = "on_game_user_leave"; + public static final String PING = "ping"; + public static final String PONG = "pong"; + public static final String ON_USER_CONNECTION_CHANGE = "channel_on_user_connection_change"; + public static final String LISTEN_GAME_EVENTS = "channel_listen_game_events"; + public static final String UNLISTEN_GAME_EVENTS = "channel_unlisten_game_events"; + // Auth handshake. client -> server: { token } right after connecting and again after a token refresh; + // other messages are ignored until the server answers on_auth with { user }. + public static final String AUTH = "auth"; + public static final String ON_AUTH = "on_auth"; + // server -> client: { reason }, sent right before the server closes the socket with CLOSE_AUTH_FAILED. + public static final String ON_AUTH_ERROR = "on_auth_error"; + public static final int CLOSE_AUTH_FAILED = 4001; + // Home page previews. client -> server: subscribe_live_games / unsubscribe_live_games; + // server -> client: on_live_games with { games: LiveGameSummary[] }. + public static final String SUBSCRIBE_LIVE_GAMES = "subscribe_live_games"; + public static final String UNSUBSCRIBE_LIVE_GAMES = "unsubscribe_live_games"; + public static final String ON_LIVE_GAMES = "on_live_games"; + // server -> client: { gameId }, when new_game is sent while the user has a game in progress. + public static final String ON_NEW_GAME_REJECTED = "on_new_game_rejected"; + + private Channels() { + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/ClientGame.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/ClientGame.java new file mode 100644 index 0000000..f4ffebc --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/ClientGame.java @@ -0,0 +1,18 @@ +package app.playmancala.model; + +import java.util.List; + +import app.playmancala.engine.MancalaGame; + +// A live game as players and spectators receive it; leaves out spectator ids and other server-side state. +// It refers to the live MancalaGame, so serialise it on the game loop. +public record ClientGame( + String id, + MancalaGame mancalaGame, + GameUsersConnectionInfo gameUsersConnectionInfo, + long startedAt, + List moveTimes, + int spectatorCount, + GameClock clock, + long serverTime) { +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/EndReason.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/EndReason.java new file mode 100644 index 0000000..e419d04 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/EndReason.java @@ -0,0 +1,20 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum EndReason { + @JsonProperty("completed") COMPLETED, + @JsonProperty("left") LEFT, + @JsonProperty("timeout") TIMEOUT, + // Only in games saved before the game clock. + @JsonProperty("inactive") INACTIVE; + + // The value stored in the games.end_reason column. + public String wireName() { + return name().toLowerCase(); + } + + public static EndReason fromWireName(String value) { + return valueOf(value.toUpperCase()); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/FinishedGameSummary.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/FinishedGameSummary.java new file mode 100644 index 0000000..afd6ead --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/FinishedGameSummary.java @@ -0,0 +1,24 @@ +package app.playmancala.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +// A finished game in lists. `board` is the final board; `result` is null (and sent as null) without a winner. +@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt", + "moveCount", "peakSpectators", "clockInitialMs" }) +public record FinishedGameSummary( + String id, + PlayerSummary player1, + PlayerSummary player2, + List board, + GameResult result, + EndReason endReason, + @JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId, + long startedAt, + long endedAt, + int moveCount, + int peakSpectators, + @JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs) { +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameClock.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameClock.java new file mode 100644 index 0000000..301b923 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameClock.java @@ -0,0 +1,12 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +// The players' clocks (docs/specs/game-clock): each player's time left at the start of the current turn, and when +// that turn started. `turnStartedAt` is null when no clock runs, i.e. the game has ended. +public record GameClock( + long initialMs, + long player1Ms, + long player2Ms, + @JsonInclude(JsonInclude.Include.NON_NULL) Long turnStartedAt) { +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameEndMessage.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameEndMessage.java new file mode 100644 index 0000000..c300b97 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameEndMessage.java @@ -0,0 +1,16 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +// on_game_end, sent to the players and spectators of a live game when it ends for any reason. `result` is null (and +// sent as null) when the game ended without a winner. +@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId" }) +public record GameEndMessage( + String gameId, + GameResult result, + EndReason endReason, + GameClock clock, + @JsonInclude(JsonInclude.Include.NON_NULL) String winnerId, + @JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId) { +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameRecord.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameRecord.java new file mode 100644 index 0000000..325ab9c --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameRecord.java @@ -0,0 +1,36 @@ +package app.playmancala.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +// A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it +// is a public id. +@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt", + "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators" }) +public record GameRecord( + String id, + PlayerSummary player1, + PlayerSummary player2, + List board, + GameResult result, + EndReason endReason, + @JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId, + long startedAt, + long endedAt, + int moveCount, + int peakSpectators, + @JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs, + List moves, + int totalSpectators) { + + public FinishedGameSummary toSummary() { + return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, + moveCount, peakSpectators, clockInitialMs); + } + + public boolean hasPlayer(String userId) { + return player1.userId().equals(userId) || player2.userId().equals(userId); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameResult.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameResult.java new file mode 100644 index 0000000..43a3488 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameResult.java @@ -0,0 +1,18 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum GameResult { + @JsonProperty("player1") PLAYER1, + @JsonProperty("player2") PLAYER2, + @JsonProperty("draw") DRAW; + + // The value stored in the games.result column. + public String wireName() { + return name().toLowerCase(); + } + + public static GameResult fromWireName(String value) { + return value == null ? null : valueOf(value.toUpperCase()); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java new file mode 100644 index 0000000..78976af --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/GameUsersConnectionInfo.java @@ -0,0 +1,4 @@ +package app.playmancala.model; + +public record GameUsersConnectionInfo(UserConnectionInfo user1ConnectionInfo, UserConnectionInfo user2ConnectionInfo) { +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/LiveGameSummary.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/LiveGameSummary.java new file mode 100644 index 0000000..85088d1 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/LiveGameSummary.java @@ -0,0 +1,15 @@ +package app.playmancala.model; + +import java.util.List; + +// A live game for previews. Ids are public ids. +public record LiveGameSummary( + String id, + PlayerSummary player1, + PlayerSummary player2, + List board, + String turnPlayerId, + int spectatorCount, + int moveCount, + long startedAt) { +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/MoveRecord.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/MoveRecord.java new file mode 100644 index 0000000..5685248 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/MoveRecord.java @@ -0,0 +1,17 @@ +package app.playmancala.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import app.playmancala.engine.GameStep; + +// A move of a saved game. `remainingMs` is the mover's clock after the move; null for games before the game clock. +public record MoveRecord( + String playerId, + int moveIndex, + List boardSnapshot, + List gameSteps, + long at, + @JsonInclude(JsonInclude.Include.NON_NULL) Long remainingMs) { +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/PlayerSummary.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/PlayerSummary.java new file mode 100644 index 0000000..e9ee937 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/PlayerSummary.java @@ -0,0 +1,15 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +// A player as captured when a game is created. In saved games and lists, `userId` is a public id (PublicIds). +public record PlayerSummary( + String userId, + @JsonInclude(JsonInclude.Include.NON_NULL) String name, + @JsonProperty("isGuest") boolean isGuest) { + + public static PlayerSummary unknown(String userId) { + return new PlayerSummary(userId, null, true); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/Seat.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/Seat.java new file mode 100644 index 0000000..651f4d3 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/Seat.java @@ -0,0 +1,8 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum Seat { + @JsonProperty("player1") PLAYER1, + @JsonProperty("player2") PLAYER2 +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/model/UserConnectionInfo.java b/apps/backend-quarkus/src/main/java/app/playmancala/model/UserConnectionInfo.java new file mode 100644 index 0000000..9916780 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/model/UserConnectionInfo.java @@ -0,0 +1,15 @@ +package app.playmancala.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record UserConnectionInfo( + String userId, + @JsonInclude(JsonInclude.Include.NON_NULL) String name, + @JsonProperty("isGuest") boolean isGuest, + @JsonProperty("isOnline") boolean isOnline) { + + public static UserConnectionInfo of(PlayerSummary player, boolean isOnline) { + return new UserConnectionInfo(player.userId(), player.name(), player.isGuest(), isOnline); + } +} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java new file mode 100644 index 0000000..22d2eb6 --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/auth/KeycloakTokenVerifierTest.java @@ -0,0 +1,112 @@ +package app.playmancala.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import org.jose4j.jwk.JsonWebKey; +import org.jose4j.jwk.RsaJsonWebKey; +import org.jose4j.jwk.RsaJwkGenerator; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.keys.resolvers.JwksVerificationKeyResolver; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import app.playmancala.model.AuthUser; + +class KeycloakTokenVerifierTest { + private static final String ISSUER = "http://keycloak.test/realms/mancala"; + + private static RsaJsonWebKey key; + private static RsaJsonWebKey otherKey; + private static KeycloakTokenVerifier verifier; + + @BeforeAll + static void createKeys() throws Exception { + key = RsaJwkGenerator.generateJwk(2048); + key.setKeyId("test-key"); + otherKey = RsaJwkGenerator.generateJwk(2048); + otherKey.setKeyId("test-key"); + JsonWebKey publicKey = JsonWebKey.Factory.newJwk(key.toParams(JsonWebKey.OutputControlLevel.PUBLIC_ONLY)); + verifier = new KeycloakTokenVerifier(ISSUER, List.of("mancala-web"), + new JwksVerificationKeyResolver(List.of(publicKey))); + } + + private static String sign(Consumer customize) { + return sign(customize, key); + } + + private static String sign(Consumer customize, RsaJsonWebKey signingKey) { + try { + JwtClaims claims = new JwtClaims(); + claims.setIssuer(ISSUER); + claims.setSubject("user-1"); + claims.setIssuedAtToNow(); + claims.setExpirationTimeMinutesInTheFuture(5); + claims.setClaim("typ", "Bearer"); + claims.setClaim("azp", "mancala-web"); + claims.setClaim("preferred_username", "alice"); + customize.accept(claims); + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toJson()); + jws.setKey(signingKey.getPrivateKey()); + jws.setKeyIdHeaderValue(signingKey.getKeyId()); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); + return jws.getCompactSerialization(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @Test + void returnsTheUserOfAValidAccessToken() throws Exception { + AuthUser user = verifier.verify(sign(claims -> claims.setClaim("realm_access", Map.of("roles", List.of("offline_access"))))); + assertEquals(new AuthUser("kc:user-1", "alice", false), user); + } + + @Test + void marksUsersWithTheGuestRoleAsGuests() throws Exception { + AuthUser user = verifier.verify(sign(claims -> { + claims.setClaim("preferred_username", "guest-1a2b3c4d"); + claims.setClaim("realm_access", Map.of("roles", List.of("guest"))); + })); + assertEquals(new AuthUser("kc:user-1", "guest-1a2b3c4d", true), user); + } + + static Stream invalidTokens() { + return Stream.of( + Arguments.of("signed with another key", sign(claims -> { }, otherKey)), + Arguments.of("from another issuer", sign(claims -> claims.setIssuer("http://evil.test/realms/mancala"))), + Arguments.of("expired", sign(claims -> claims.setExpirationTimeMinutesInTheFuture(-1))), + Arguments.of("an ID token", sign(claims -> claims.setClaim("typ", "ID"))), + Arguments.of("a refresh token", sign(claims -> claims.setClaim("typ", "Refresh"))), + Arguments.of("issued to another client", sign(claims -> claims.setClaim("azp", "some-other-app"))), + Arguments.of("without a subject", sign(claims -> claims.unsetClaim("sub"))), + Arguments.of("not a JWT", "not-a-token")); + } + + @ParameterizedTest(name = "rejects a token {0}") + @MethodSource("invalidTokens") + void rejectsInvalidTokens(String description, String token) { + assertThrows(InvalidTokenException.class, () -> verifier.verify(token)); + } + + @Test + void rejectsUnsignedTokens() { + Base64.Encoder base64 = Base64.getUrlEncoder().withoutPadding(); + String header = base64.encodeToString("{\"alg\":\"none\"}".getBytes()); + String payload = base64.encodeToString(("{\"iss\":\"" + ISSUER + "\",\"sub\":\"user-1\",\"typ\":\"Bearer\"," + + "\"azp\":\"mancala-web\",\"exp\":" + (System.currentTimeMillis() / 1000 + 300) + "}").getBytes()); + assertThrows(InvalidTokenException.class, () -> verifier.verify(header + "." + payload + ".")); + } +} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualScheduler.java b/apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualScheduler.java new file mode 100644 index 0000000..8ee6b63 --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualScheduler.java @@ -0,0 +1,85 @@ +package app.playmancala.loop; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +// A Scheduler for tests, in place of Jest's fake timers: tasks run at once, timers when advance() reaches them. +public final class ManualScheduler implements Scheduler { + private long now; + private long sequence; + private final List timers = new ArrayList<>(); + + private final class Timer implements Cancellable { + final Runnable task; + final long periodMs; + final long order = sequence++; + long dueAt; + boolean cancelled; + + Timer(Runnable task, long dueAt, long periodMs) { + this.task = task; + this.dueAt = dueAt; + this.periodMs = periodMs; + } + + @Override + public void cancel() { + cancelled = true; + timers.remove(this); + } + } + + public ManualScheduler(long now) { + this.now = now; + } + + @Override + public long now() { + return now; + } + + public void setNow(long now) { + this.now = now; + } + + @Override + public void execute(Runnable task) { + task.run(); + } + + @Override + public Cancellable schedule(Runnable task, long delayMs) { + Timer timer = new Timer(task, now + Math.max(0, delayMs), 0); + timers.add(timer); + return timer; + } + + @Override + public Cancellable scheduleAtFixedRate(Runnable task, long periodMs) { + Timer timer = new Timer(task, now + periodMs, periodMs); + timers.add(timer); + return timer; + } + + // Moves the clock forward, running the timers that come due in order, each at its own time. + public void advance(long ms) { + long target = now + ms; + while (true) { + Timer next = timers.stream() + .filter(timer -> timer.dueAt <= target) + .min(Comparator.comparingLong(timer -> timer.dueAt).thenComparingLong(timer -> timer.order)) + .orElse(null); + if (next == null) break; + now = next.dueAt; + if (next.periodMs > 0) next.dueAt += next.periodMs; + else timers.remove(next); + next.task.run(); + } + now = target; + } + + public int pendingTimers() { + return timers.size(); + } +} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualSchedulerTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualSchedulerTest.java new file mode 100644 index 0000000..aac52b8 --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/loop/ManualSchedulerTest.java @@ -0,0 +1,26 @@ +package app.playmancala.loop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class ManualSchedulerTest { + @Test + void runsTimersInOrderAtTheirOwnTime() { + ManualScheduler scheduler = new ManualScheduler(1_000); + List runs = new ArrayList<>(); + scheduler.scheduleAtFixedRate(() -> runs.add("tick@" + scheduler.now()), 100); + scheduler.schedule(() -> runs.add("once@" + scheduler.now()), 150); + Cancellable cancelled = scheduler.schedule(() -> runs.add("cancelled"), 50); + cancelled.cancel(); + + scheduler.advance(99); + assertEquals(List.of(), runs); + scheduler.advance(201); + assertEquals(List.of("tick@1100", "once@1150", "tick@1200", "tick@1300"), runs); + assertEquals(1_300, scheduler.now()); + } +} diff --git a/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md index 8c2c1ef..6551a56 100644 --- a/docs/specs/quarkus-backend/tasks.md +++ b/docs/specs/quarkus-backend/tasks.md @@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 1. **Spec**: requirements, design and tasks. - [x] 2. **Project and engine**: Maven project in `apps/backend-quarkus` (Quarkus 3.40, Java 25); the engine port with unit tests; `scripts/record-engine-games.js` and the parity test (R2). -- [ ] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`, +- [x] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`, `ManualScheduler`; `KeycloakTokenVerifier` with unit tests. - [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, `GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported