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.
This commit is contained in:
parent
f0ffd6ba87
commit
f0246cca61
@ -0,0 +1,7 @@
|
|||||||
|
package app.playmancala.auth;
|
||||||
|
|
||||||
|
public class InvalidTokenException extends Exception {
|
||||||
|
public InvalidTokenException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String> allowedClientIds;
|
||||||
|
private final JwtConsumer consumer;
|
||||||
|
|
||||||
|
// `jwksUrl` defaults to the issuer's certs endpoint.
|
||||||
|
public static KeycloakTokenVerifier create(String issuer, List<String> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package app.playmancala.loop;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface Cancellable {
|
||||||
|
void cancel();
|
||||||
|
}
|
||||||
@ -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> T call(Callable<T> 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Long> moveTimes,
|
||||||
|
int spectatorCount,
|
||||||
|
GameClock clock,
|
||||||
|
long serverTime) {
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Integer> 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) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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<Integer> 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<MoveRecord> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
public record GameUsersConnectionInfo(UserConnectionInfo user1ConnectionInfo, UserConnectionInfo user2ConnectionInfo) {
|
||||||
|
}
|
||||||
@ -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<Integer> board,
|
||||||
|
String turnPlayerId,
|
||||||
|
int spectatorCount,
|
||||||
|
int moveCount,
|
||||||
|
long startedAt) {
|
||||||
|
}
|
||||||
@ -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<Integer> boardSnapshot,
|
||||||
|
List<GameStep> gameSteps,
|
||||||
|
long at,
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) Long remainingMs) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package app.playmancala.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public enum Seat {
|
||||||
|
@JsonProperty("player1") PLAYER1,
|
||||||
|
@JsonProperty("player2") PLAYER2
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<JwtClaims> customize) {
|
||||||
|
return sign(customize, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sign(Consumer<JwtClaims> 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<Arguments> 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 + "."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Timer> 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.<Timer>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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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] 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
|
- [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).
|
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.
|
`ManualScheduler`; `KeycloakTokenVerifier` with unit tests.
|
||||||
- [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`,
|
- [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`,
|
||||||
`GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported
|
`GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user