Requirements, design decisions and tasks for the Keycloak login, written after the implementation; mobile login and turning off legacy userKey login remain open.
8.2 KiB
Login with Keycloak: design
See requirements.md for what is built; requirement ids (R1.1, …) are referenced below.
Decisions
| Decision | Reason |
|---|---|
| Keycloak is the only auth system; guests are Keycloak users too | Considered first: a separate auth service issuing guest tokens next to Keycloak, and guest tokens issued by the backend. Both meant two auth systems. With guests inside Keycloak, the backend trusts one token issuer (R8.1). |
| Guests via a custom authenticator (Java SPI extension), not the admin API | Creating guests through the admin API needs a privileged service account and a second component; an authenticator runs inside the normal login flow, so guests get ordinary sessions and refresh tokens. |
The guest flow is triggered by a guest=true parameter on the authorization request |
Keycloak keeps extra request parameters as client notes (client_request_param_guest), so the authenticator can read it without UI. Requests without it get the login form. |
| Guests are marked with a realm role, not a user attribute | Roles are in the access token by default (realm_access.roles) and need no user profile changes; the cleanup task can list role members. |
Guests request offline_access |
Offline sessions outlive the SSO session (30 days idle), which keeps a guest's identity across browser restarts (R1.2). |
| "Verify profile" is disabled for the realm | Otherwise Keycloak asks every new guest for an email and name. Registration still asks for the required fields. |
The backend verifies tokens offline with the realm's public keys (jose) |
No call to Keycloak per connection; Keycloak being down only stops new logins (R8.1). |
The WebSocket authenticates with a first auth message, not a URL parameter |
Tokens in URLs end up in proxy logs; browsers cannot set headers on WebSockets. |
Keycloak user ids are prefixed with kc: |
Legacy userKeys are UUIDs chosen by the client; the prefix makes it impossible for a legacy client to claim a Keycloak user's id (R7.2). |
Web login with oidc-client-ts |
Standard OIDC with PKCE and refresh tokens, without tying the app to a Keycloak-specific library. |
| Login, sign up and log out only on the home page | Changing identity during a game would lose the player's seat (R3.3). |
Keycloak (apps/keycloak)
- Image:
Dockerfilebuilds the extension with Maven and copies it with the realm file intoquay.io/keycloak/keycloak:26.7.5, started with--import-realm(the realm is imported only if it does not exist). - Realm
mancala(realm/mancala-realm.json):- Client
mancala-web: public, authorization code with PKCE S256, redirect URIshttp://localhost:1234/*andhttps://playmancala.app/*, the same post-logout redirects. - Browser flow
browser with guest: cookie (alternative) →mancala-guest-authenticator(alternative) → username/password form (alternative). A returning user with a session is let through by the cookie step, soguest=truewith an existing session reuses that identity. - Realm role
guest; offline session idle timeout 30 days; registration and password reset on; brute force protection on; required actions as Keycloak's defaults withVERIFY_PROFILEdisabled.
- Client
- Extension (
extension/, packageapp.playmancala.keycloak.guest):GuestAuthenticator: withguest=truecreatesguest-+ 8 random hex characters, grants theguestrole and succeeds; otherwiseattempted().GuestCleanupTask(R1.3): scheduled through Keycloak'sTimerProvider; deletes guests created more thancleanup-min-age-secondsago (default 86400) that have neither a user session nor an offline session; runs everycleanup-interval-seconds(default 3600), at mostcleanup-max-deletes-per-run(default 1000) per run. Options are set asKC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__….
- Compose:
keycloakservice on127.0.0.1:8080,KC_HOSTNAMEfromKEYCLOAK_HOSTNAME, proxy headers on. - nginx (
config/nginx.conf):auth.playmancala.appproxies to Keycloak; the authorization endpoint is limited to 10 requests per minute per IP with bursts of 20 (R1.4).
Backend (apps/backend/src/auth, rtmt/rtmt_websocket.ts)
-
KeycloakTokenVerifierchecks signature (RS256, keys fromKEYCLOAK_JWKS_URLor the issuer's certs endpoint, cached byjose),iss,exp,typ: Bearerand thatazpis one ofKEYCLOAK_CLIENT_IDS(R2.2). It returns{ id: "kc:" + sub, name: preferred_username, isGuest: has role guest }. -
WebSocket protocol (channels in
@mancala/core):Channel Direction Payload authclient → server { token }, right after connecting and after every token refreshon_authserver → client { user: AuthUser }on_auth_errorserver → client { reason }, then the socket is closed with code 4001Messages before
on_authare ignored; noauthwithin 5 seconds closes the socket (R2.4). A socket may send a new token for the same user but not switch users. -
Connections:
Map<userId, Set<WebSocket>>; messages go to every socket of a user; the user is offline when the set is empty; a closing socket only removes itself (R4). -
Legacy: with
LEGACY_USERKEY_AUTH=true,?userKey=<uuid>binds the socket to that id directly and/registerissues ids; non-UUID keys (e.g.kc:…) are refused (R7). -
Games store each player's name and guest flag when they start and send them in the connection info (R5.1).
-
Malformed messages are ignored instead of throwing.
| Variable | Default |
|---|---|
KEYCLOAK_ISSUER |
http://localhost:8080/realms/mancala |
KEYCLOAK_JWKS_URL |
issuer + /protocol/openid-connect/certs |
KEYCLOAK_CLIENT_IDS |
mancala-web |
LEGACY_USERKEY_AUTH |
true |
Shared client code (packages/core)
RTMTWS.connectWebSocket(url, getToken?): with a token provider it sendsauthon every (re)connect and reportsconnected(and emitsopen) only afteron_auth;reauthenticate(token)sends a renewed token on the open connection (R6.1);userholds the authenticated user.getPlayerDisplayName(player, t)formats names, including guests (R5.1).- English and Turkish strings for the account menu.
Web app (apps/frontend)
AuthServicewraps anoidc-client-tsUserManager: tokens in local storage,automaticSilentRenewwith the refresh token,scope: "openid offline_access".loginAsGuest(): redirect withguest=true;login():prompt=login, so the form appears even with a guest session;signUp():prompt=create;logout(): end-session redirect.- The page the user started from is passed as
stateand restored after the callback (/auth/callback).
- Startup (
MancalaApp): finish a login redirect if this is the callback, else load the stored session (renewing an expired one), else redirect as a guest; then connect the WebSocket withgetAccessTokenas token provider. If the session cannot be renewed, start over as a guest (R6.2). Renewed tokens are passed toreauthenticate. AccountMenuon the home page: guests see Log In and Sign Up, accounts see their name and Log Out (R3, R5.2).KEYCLOAK_URLis a build variable (defaulthttp://localhost:8080).
Mobile app
Unchanged apart from calling connectWebSocket(url) without a token provider; it keeps the legacy userKey login.
Testing
- Extension: integration tests start Keycloak with the extension and the real realm file in a container
(
testcontainers-keycloak): guest login without a form and with theguestrole and an offline refresh token, a new user per guest login, the login form withoutguest=true, and the cleanup task. - Backend:
KeycloakTokenVerifieragainst locally signed tokens (valid, guest, wrong key, issuer, expiry, ID and refresh tokens, other client, unsigned); WebSocket tests for the handshake, timeout, token refresh, several sockets per user, late close after reconnect, legacy on and off, and malformed messages. - End to end in Chrome against Keycloak, the backend and the dev server: first visit as guest, two guests matched with names, reload keeping the identity, guest logging in to an account, logging out to a new guest, and token renewal over an open connection with a 75-second token lifetime.