Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
14 changed files with 920 additions and 2 deletions
Showing only changes of commit 7c99a76af7 - Show all commits

1
apps/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.env

View File

@ -7,3 +7,38 @@ services:
image: jhalitaksoy/mancala-frontend:latest
ports:
- "3000:3000"
keycloak-db:
image: postgres:17.6
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?set KEYCLOAK_DB_PASSWORD in apps/.env}
volumes:
- keycloak-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 5s
timeout: 5s
retries: 10
keycloak:
image: jhalitaksoy/mancala-keycloak:latest
build: ./keycloak
environment:
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
# Public URL of Keycloak; https://auth.playmancala.app in production
KC_HOSTNAME: ${KEYCLOAK_HOSTNAME:-http://localhost:8080}
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: xforwarded
# Only used to create the master admin on the very first start
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD in apps/.env}
ports:
- "127.0.0.1:8080:8080"
depends_on:
keycloak-db:
condition: service_healthy
volumes:
keycloak-db:

View File

@ -0,0 +1 @@
extension/target

1
apps/keycloak/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
extension/target/

22
apps/keycloak/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# Keycloak with the mancala extension and realm baked in.
# Build context: apps/keycloak
FROM maven:3.9.11-eclipse-temurin-21 AS extension
WORKDIR /build
COPY extension/pom.xml .
COPY extension/src src
RUN mvn -B -q package -Dmaven.test.skip=true
# Keep the Keycloak version in sync with keycloak.version in extension/pom.xml
FROM quay.io/keycloak/keycloak:26.7.5 AS builder
ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
COPY --from=extension /build/target/mancala-keycloak-extension.jar /opt/keycloak/providers/
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.7.5
COPY --from=builder /opt/keycloak/ /opt/keycloak/
# Imported on startup only if the realm does not exist yet.
COPY realm/mancala-realm.json /opt/keycloak/data/import/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
CMD ["start", "--optimized", "--import-realm"]

62
apps/keycloak/README.md Normal file
View File

@ -0,0 +1,62 @@
# Mancala Keycloak
Keycloak is the only auth system for Mancala. Players can either log in with an account or play as a guest,
and both come back as normal Keycloak tokens from the `mancala` realm.
## Guest login
`extension/` adds a `mancala-guest-authenticator` to Keycloak. When an authorization request has `guest=true`,
it creates a new user named `guest-xxxxxxxx`, gives it the `guest` realm role and logs it in without showing a
form. Requests without the parameter fall through to the normal login form.
```
/realms/mancala/protocol/openid-connect/auth?client_id=mancala-web&response_type=code
&scope=openid%20offline_access&redirect_uri=...&code_challenge=...&code_challenge_method=S256
&guest=true
```
- Clients can tell guests apart by `"guest"` in the access token's `realm_access.roles`.
- Guests should request `offline_access` so they keep their identity after the SSO session ends
(offline sessions expire after 30 days without use).
- A scheduled task deletes guests that are older than a day and have no session or offline session left.
Its timing can be changed with server options such as
`KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS` (default 3600),
`..._CLEANUP_MIN_AGE_SECONDS` (default 86400) and `..._CLEANUP_MAX_DELETES_PER_RUN` (default 1000).
- Each guest login creates a user, so the auth endpoint is rate limited per IP in `config/nginx.conf`.
## Realm
`realm/mancala-realm.json` holds the realm: the `mancala-web` public client (PKCE S256), the
`browser with guest` flow, the `guest` role, and the required actions with "Verify Profile" turned off so guests
are not asked for an email or a name.
Keycloak imports the file only if the realm does not exist yet. After changing it, apply the change in the admin
console too, or delete the realm and restart Keycloak.
## Running
```sh
cd apps
cat > .env <<EOF
KEYCLOAK_DB_PASSWORD=change-me
KEYCLOAK_ADMIN_PASSWORD=change-me
EOF
docker compose up -d --build keycloak
```
Keycloak is then at http://localhost:8080 and the admin console at http://localhost:8080/admin.
In production set `KEYCLOAK_HOSTNAME=https://auth.playmancala.app`; nginx proxies that host to port 8080.
## Tests
```sh
cd apps/keycloak/extension
mvn test
```
The tests start Keycloak with the extension and the real realm file in a container, so they need Docker.
## Upgrading Keycloak
The extension uses Keycloak's authenticator SPI, which Keycloak marks as internal. When upgrading, change the
version in `extension/pom.xml` and in `Dockerfile` together and run the tests.

View File

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>app.playmancala</groupId>
<artifactId>mancala-keycloak-extension</artifactId>
<version>0.1.0</version>
<packaging>jar</packaging>
<name>Mancala Keycloak Extension</name>
<description>Guest login authenticator and guest cleanup task for the mancala realm</description>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Keep in sync with the image tag in ../Dockerfile -->
<keycloak.version>26.7.5</keycloak.version>
<testcontainers-keycloak.version>4.3.1</testcontainers-keycloak.version>
<junit.version>5.13.4</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>testcontainers-keycloak</artifactId>
<version>${testcontainers-keycloak.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>mancala-keycloak-extension</finalName>
<testResources>
<testResource>
<directory>src/test/resources</directory>
</testResource>
<testResource>
<directory>../realm</directory>
</testResource>
</testResources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,98 @@
package app.playmancala.keycloak.guest;
import java.security.SecureRandom;
import java.util.HexFormat;
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
/**
* Logs the user in as a freshly created guest when the authorization request carries {@code guest=true}.
* Any other request is passed on ({@code attempted}) so the next alternative, normally the login form, runs.
* Guests are marked with the {@value #GUEST_ROLE} realm role, which ends up in the token's realm_access.roles.
*/
public class GuestAuthenticator implements Authenticator {
public static final String GUEST_PARAM = "guest";
public static final String GUEST_ROLE = "guest";
public static final String USERNAME_PREFIX = "guest-";
private static final Logger LOG = Logger.getLogger(GuestAuthenticator.class);
private static final SecureRandom RANDOM = new SecureRandom();
private static final int MAX_USERNAME_ATTEMPTS = 5;
@Override
public void authenticate(AuthenticationFlowContext context) {
String guestParam = context.getAuthenticationSession().getClientNote(
AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + GUEST_PARAM);
if (!"true".equals(guestParam)) {
context.attempted();
return;
}
KeycloakSession session = context.getSession();
RealmModel realm = context.getRealm();
RoleModel guestRole = realm.getRole(GUEST_ROLE);
if (guestRole == null) {
LOG.errorf("Realm '%s' has no '%s' role, guest login is disabled", realm.getName(), GUEST_ROLE);
context.attempted();
return;
}
String username = generateUsername(session, realm);
if (username == null) {
LOG.errorf("Could not find a free guest username in realm '%s'", realm.getName());
context.attempted();
return;
}
UserModel user = session.users().addUser(realm, username);
user.setEnabled(true);
user.grantRole(guestRole);
LOG.debugf("Created guest user '%s' in realm '%s'", username, realm.getName());
context.setUser(user);
context.success();
}
private String generateUsername(KeycloakSession session, RealmModel realm) {
for (int i = 0; i < MAX_USERNAME_ATTEMPTS; i++) {
byte[] bytes = new byte[4];
RANDOM.nextBytes(bytes);
String username = USERNAME_PREFIX + HexFormat.of().formatHex(bytes);
if (session.users().getUserByUsername(realm, username) == null) {
return username;
}
}
return null;
}
@Override
public void action(AuthenticationFlowContext context) {
// No form, nothing to submit.
}
@Override
public boolean requiresUser() {
return false;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return true;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
}
@Override
public void close() {
}
}

View File

@ -0,0 +1,98 @@
package app.playmancala.keycloak.guest;
import java.time.Duration;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.timer.TimerProvider;
/**
* Registers {@link GuestAuthenticator} and schedules {@link GuestCleanupTask}.
*
* Cleanup can be tuned with server options, e.g.
* {@code --spi-authenticator--mancala-guest-authenticator--cleanup-interval-seconds=3600}.
*/
public class GuestAuthenticatorFactory implements AuthenticatorFactory {
public static final String PROVIDER_ID = "mancala-guest-authenticator";
private static final GuestAuthenticator SINGLETON = new GuestAuthenticator();
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.DISABLED
};
private long cleanupIntervalMillis;
private long cleanupMinAgeMillis;
private int cleanupMaxDeletesPerRun;
@Override
public void init(Config.Scope config) {
cleanupIntervalMillis = Duration.ofSeconds(config.getLong("cleanup-interval-seconds", 3600L)).toMillis();
cleanupMinAgeMillis = Duration.ofSeconds(config.getLong("cleanup-min-age-seconds", 86400L)).toMillis();
cleanupMaxDeletesPerRun = config.getInt("cleanup-max-deletes-per-run", 1000);
}
@Override
public void postInit(KeycloakSessionFactory factory) {
GuestCleanupTask task = new GuestCleanupTask(cleanupMinAgeMillis, cleanupMaxDeletesPerRun);
KeycloakModelUtils.runJobInTransaction(factory, session ->
session.getProvider(TimerProvider.class).scheduleTask(task, cleanupIntervalMillis, task.getTaskName()));
}
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getDisplayType() {
return "Mancala guest login";
}
@Override
public String getReferenceCategory() {
return "guest";
}
@Override
public String getHelpText() {
return "Creates and logs in a new guest user when the authorization request contains guest=true.";
}
@Override
public boolean isConfigurable() {
return false;
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
@Override
public void close() {
}
}

View File

@ -0,0 +1,73 @@
package app.playmancala.keycloak.guest;
import java.util.ArrayList;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserManager;
import org.keycloak.models.UserModel;
import org.keycloak.timer.ScheduledTask;
/**
* Deletes guest users that are older than the minimum age and have neither a regular nor an offline session left.
* Offline sessions expire after the realm's offline session idle timeout, so a guest who stops playing is removed
* roughly that long after their last visit.
*/
public class GuestCleanupTask implements ScheduledTask {
private static final Logger LOG = Logger.getLogger(GuestCleanupTask.class);
private static final int PAGE_SIZE = 200;
private final long minAgeMillis;
private final int maxDeletesPerRun;
public GuestCleanupTask(long minAgeMillis, int maxDeletesPerRun) {
this.minAgeMillis = minAgeMillis;
this.maxDeletesPerRun = maxDeletesPerRun;
}
@Override
public void run(KeycloakSession session) {
long createdBefore = Time.currentTimeMillis() - minAgeMillis;
session.realms().getRealmsStream().forEach(realm -> cleanupRealm(session, realm, createdBefore));
}
private void cleanupRealm(KeycloakSession session, RealmModel realm, long createdBefore) {
RoleModel guestRole = realm.getRole(GuestAuthenticator.GUEST_ROLE);
if (guestRole == null) return;
// User and session queries require the session to be bound to the realm they run in.
session.getContext().setRealm(realm);
List<UserModel> staleGuests = new ArrayList<>();
for (int first = 0; staleGuests.size() < maxDeletesPerRun; first += PAGE_SIZE) {
List<UserModel> page = session.users().getRoleMembersStream(realm, guestRole, first, PAGE_SIZE).toList();
if (page.isEmpty()) break;
page.stream()
.filter(user -> isStale(session, realm, user, createdBefore))
.limit(maxDeletesPerRun - staleGuests.size())
.forEach(staleGuests::add);
}
UserManager userManager = new UserManager(session);
staleGuests.forEach(user -> userManager.removeUser(realm, user));
if (!staleGuests.isEmpty()) {
LOG.infof("Removed %d inactive guest users from realm '%s'", staleGuests.size(), realm.getName());
}
}
private boolean isStale(KeycloakSession session, RealmModel realm, UserModel user, long createdBefore) {
Long created = user.getCreatedTimestamp();
if (created == null || created > createdBefore) return false;
return session.sessions().getUserSessionsStream(realm, user).findAny().isEmpty()
&& session.sessions().getOfflineUserSessionsStream(realm, user).findAny().isEmpty();
}
@Override
public String getTaskName() {
return "mancala-guest-cleanup";
}
}

View File

@ -0,0 +1 @@
app.playmancala.keycloak.guest.GuestAuthenticatorFactory

View File

@ -0,0 +1,205 @@
package app.playmancala.keycloak.guest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.CookieManager;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.representations.idm.UserRepresentation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dasniko.testcontainers.keycloak.KeycloakContainer;
/**
* Runs the extension and the real realm export in a Keycloak container. Needs Docker.
*/
class GuestLoginTest {
private static final String REALM = "mancala";
private static final String CLIENT_ID = "mancala-web";
private static final String REDIRECT_URI = "http://localhost:1234/auth/callback";
private static final ObjectMapper JSON = new ObjectMapper();
private static KeycloakContainer keycloak;
@BeforeAll
static void startKeycloak() {
keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.7.5")
.withProviderClassesFrom("target/classes")
.withRealmImportFile("/mancala-realm.json")
.withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS", "2")
.withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_MIN_AGE_SECONDS", "0");
keycloak.start();
}
@AfterAll
static void stopKeycloak() {
if (keycloak != null) keycloak.stop();
}
@Test
void guestRequestLogsInAsNewGuestWithoutShowingAForm() throws Exception {
JsonNode tokens = loginAsGuest();
JsonNode accessToken = decodeJwt(tokens.get("access_token").asText());
assertTrue(accessToken.get("preferred_username").asText().startsWith(GuestAuthenticator.USERNAME_PREFIX));
assertTrue(realmRoles(accessToken).contains(GuestAuthenticator.GUEST_ROLE));
assertEquals("Offline", decodeJwt(tokens.get("refresh_token").asText()).get("typ").asText());
}
@Test
void eachGuestLoginCreatesADifferentUser() throws Exception {
String first = decodeJwt(loginAsGuest().get("access_token").asText()).get("sub").asText();
String second = decodeJwt(loginAsGuest().get("access_token").asText()).get("sub").asText();
assertNotEquals(first, second);
}
@Test
void requestWithoutGuestParamShowsLoginForm() throws Exception {
HttpResponse<String> response = newHttpClient().send(
HttpRequest.newBuilder(authorizationUri(Map.of(), randomUrlSafe(32))).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("id=\"kc-form-login\""));
}
@Test
void cleanupRemovesGuestsWithoutSessionsAndKeepsEveryoneElse() throws Exception {
String activeGuest = decodeJwt(loginAsGuest().get("access_token").asText()).get("preferred_username").asText();
String idleGuest = createUser("guest-idle-" + UUID.randomUUID(), true);
String regularUser = createUser("player-" + UUID.randomUUID(), false);
RealmResource realm = keycloak.getKeycloakAdminClient().realm(REALM);
long deadline = System.currentTimeMillis() + 20_000;
while (!realm.users().searchByUsername(idleGuest, true).isEmpty()) {
assertTrue(System.currentTimeMillis() < deadline, "idle guest was not cleaned up");
Thread.sleep(500);
}
assertFalse(realm.users().searchByUsername(activeGuest, true).isEmpty(), "guest with a session was removed");
assertFalse(realm.users().searchByUsername(regularUser, true).isEmpty(), "regular user was removed");
}
private static String createUser(String username, boolean guest) {
RealmResource realm = keycloak.getKeycloakAdminClient().realm(REALM);
UserRepresentation user = new UserRepresentation();
user.setUsername(username);
user.setEnabled(true);
realm.users().create(user).close();
if (guest) {
String id = realm.users().searchByUsername(username, true).get(0).getId();
realm.users().get(id).roles().realmLevel()
.add(List.of(realm.roles().get(GuestAuthenticator.GUEST_ROLE).toRepresentation()));
}
return username;
}
/** Runs the authorization code flow with PKCE, as the web client will, and returns the token response. */
private static JsonNode loginAsGuest() throws Exception {
HttpClient http = newHttpClient();
String verifier = randomUrlSafe(32);
String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(
MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
HttpResponse<String> authResponse = http.send(
HttpRequest.newBuilder(authorizationUri(Map.of("guest", "true"), challenge)).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(302, authResponse.statusCode(), "expected redirect back to the client, got: " + authResponse.body());
String location = authResponse.headers().firstValue("Location").orElseThrow();
assertTrue(location.startsWith(REDIRECT_URI), location);
String code = queryParams(URI.create(location)).get("code");
assertNotNull(code, location);
HttpResponse<String> tokenResponse = http.send(
HttpRequest.newBuilder(URI.create(realmUrl() + "/protocol/openid-connect/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form(Map.of(
"grant_type", "authorization_code",
"client_id", CLIENT_ID,
"redirect_uri", REDIRECT_URI,
"code", code,
"code_verifier", verifier))))
.build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, tokenResponse.statusCode(), tokenResponse.body());
return JSON.readTree(tokenResponse.body());
}
private static URI authorizationUri(Map<String, String> extraParams, String codeChallenge) {
Map<String, String> params = new java.util.HashMap<>(Map.of(
"client_id", CLIENT_ID,
"response_type", "code",
"redirect_uri", REDIRECT_URI,
"scope", "openid offline_access",
"state", randomUrlSafe(8),
"code_challenge", codeChallenge,
"code_challenge_method", "S256"));
params.putAll(extraParams);
return URI.create(realmUrl() + "/protocol/openid-connect/auth?" + form(params));
}
private static String realmUrl() {
return keycloak.getAuthServerUrl() + "/realms/" + REALM;
}
private static HttpClient newHttpClient() {
return HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.cookieHandler(new CookieManager())
.connectTimeout(Duration.ofSeconds(10))
.build();
}
private static JsonNode decodeJwt(String jwt) throws Exception {
return JSON.readTree(Base64.getUrlDecoder().decode(jwt.split("\\.")[1]));
}
private static List<String> realmRoles(JsonNode accessToken) {
List<String> roles = new java.util.ArrayList<>();
accessToken.path("realm_access").path("roles").forEach(role -> roles.add(role.asText()));
return roles;
}
private static Map<String, String> queryParams(URI uri) {
return java.util.Arrays.stream(uri.getRawQuery().split("&"))
.map(pair -> pair.split("=", 2))
.collect(Collectors.toMap(p -> p[0], p -> java.net.URLDecoder.decode(p[1], StandardCharsets.UTF_8)));
}
private static String form(Map<String, String> params) {
return params.entrySet().stream()
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
}
private static String randomUrlSafe(int bytes) {
byte[] buffer = new byte[bytes];
new SecureRandom().nextBytes(buffer);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buffer);
}
}

View File

@ -0,0 +1,226 @@
{
"realm": "mancala",
"displayName": "Mancala",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"rememberMe": true,
"verifyEmail": false,
"bruteForceProtected": true,
"offlineSessionIdleTimeout": 2592000,
"browserFlow": "browser with guest",
"requiredActions": [
{
"alias": "TERMS_AND_CONDITIONS",
"name": "Terms and Conditions",
"providerId": "TERMS_AND_CONDITIONS",
"enabled": false,
"defaultAction": false,
"priority": 20,
"config": {}
},
{
"alias": "UPDATE_PROFILE",
"name": "Update Profile",
"providerId": "UPDATE_PROFILE",
"enabled": true,
"defaultAction": false,
"priority": 40,
"config": {}
},
{
"alias": "VERIFY_EMAIL",
"name": "Verify Email",
"providerId": "VERIFY_EMAIL",
"enabled": true,
"defaultAction": false,
"priority": 50,
"config": {}
},
{
"alias": "CONFIGURE_TOTP",
"name": "Configure OTP",
"providerId": "CONFIGURE_TOTP",
"enabled": true,
"defaultAction": false,
"priority": 54,
"config": {}
},
{
"alias": "UPDATE_PASSWORD",
"name": "Update Password",
"providerId": "UPDATE_PASSWORD",
"enabled": true,
"defaultAction": false,
"priority": 57,
"config": {}
},
{
"alias": "delete_account",
"name": "Delete Account",
"providerId": "delete_account",
"enabled": false,
"defaultAction": false,
"priority": 60,
"config": {}
},
{
"alias": "UPDATE_EMAIL",
"name": "Update Email",
"providerId": "UPDATE_EMAIL",
"enabled": false,
"defaultAction": false,
"priority": 70,
"config": {}
},
{
"alias": "webauthn-register",
"name": "Webauthn Register",
"providerId": "webauthn-register",
"enabled": true,
"defaultAction": false,
"priority": 80,
"config": {}
},
{
"alias": "webauthn-register-passwordless",
"name": "Webauthn Register Passwordless",
"providerId": "webauthn-register-passwordless",
"enabled": true,
"defaultAction": false,
"priority": 90,
"config": {}
},
{
"alias": "VERIFY_PROFILE",
"name": "Verify Profile",
"providerId": "VERIFY_PROFILE",
"enabled": false,
"defaultAction": false,
"priority": 100,
"config": {}
},
{
"alias": "delete_credential",
"name": "Delete Credential",
"providerId": "delete_credential",
"enabled": true,
"defaultAction": false,
"priority": 110,
"config": {}
},
{
"alias": "idp_link",
"name": "Linking Identity Provider",
"providerId": "idp_link",
"enabled": true,
"defaultAction": false,
"priority": 120,
"config": {}
},
{
"alias": "CONFIGURE_RECOVERY_AUTHN_CODES",
"name": "Recovery Authentication Codes",
"providerId": "CONFIGURE_RECOVERY_AUTHN_CODES",
"enabled": true,
"defaultAction": false,
"priority": 130,
"config": {}
},
{
"alias": "update_user_locale",
"name": "Update User Locale",
"providerId": "update_user_locale",
"enabled": true,
"defaultAction": false,
"priority": 1000,
"config": {}
}
],
"roles": {
"realm": [
{
"name": "guest",
"description": "Anonymous player created by the mancala guest authenticator"
}
]
},
"clients": [
{
"clientId": "mancala-web",
"name": "Mancala Web",
"enabled": true,
"publicClient": true,
"protocol": "openid-connect",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"redirectUris": [
"http://localhost:1234/*",
"https://playmancala.app/*"
],
"webOrigins": [
"http://localhost:1234",
"https://playmancala.app"
],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:1234/*##https://playmancala.app/*"
}
}
],
"authenticationFlows": [
{
"alias": "browser with guest",
"description": "Browser flow that can log in as a new guest when the request has guest=true",
"providerId": "basic-flow",
"topLevel": true,
"builtIn": false,
"authenticationExecutions": [
{
"authenticator": "auth-cookie",
"requirement": "ALTERNATIVE",
"priority": 10,
"authenticatorFlow": false,
"userSetupAllowed": false
},
{
"authenticator": "mancala-guest-authenticator",
"requirement": "ALTERNATIVE",
"priority": 20,
"authenticatorFlow": false,
"userSetupAllowed": false
},
{
"flowAlias": "browser with guest forms",
"requirement": "ALTERNATIVE",
"priority": 30,
"authenticatorFlow": true,
"userSetupAllowed": false
}
]
},
{
"alias": "browser with guest forms",
"description": "Username and password form",
"providerId": "basic-flow",
"topLevel": false,
"builtIn": false,
"authenticationExecutions": [
{
"authenticator": "auth-username-password-form",
"requirement": "REQUIRED",
"priority": 10,
"authenticatorFlow": false,
"userSetupAllowed": false
}
]
}
]
}

View File

@ -1,3 +1,5 @@
limit_req_zone $binary_remote_addr zone=keycloak_auth:10m rate=10r/m;
server {
listen 80 default_server;
listen [::]:80 default_server;
@ -86,9 +88,27 @@ server {
index index.html index.htm index.nginx-debian.html;
server_name auth.playmancala.app; # managed by Certbot
# Keycloak sends large cookies and headers.
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Every guest login creates a Keycloak user, so limit how often one IP can start a login.
location = /realms/mancala/protocol/openid-connect/auth {
limit_req zone=keycloak_auth burst=20 nodelay;
limit_req_status 429;
proxy_pass http://localhost:8080;
}
location / {
try_files $uri $uri/ =404;
proxy_pass http://localhost:8080;
}
listen [::]:443 ssl; # managed by Certbot