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