Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -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] 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user