mancala/docs/specs/keycloak-auth/design.md
Halit Aksoy ab2424b648 docs: add Keycloak login spec
Requirements, design decisions and tasks for the Keycloak login, written
after the implementation; mobile login and turning off legacy userKey
login remain open.
2026-10-16 00:40:57 +03:00

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: Dockerfile builds the extension with Maven and copies it with the realm file into quay.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 URIs http://localhost:1234/* and https://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, so guest=true with 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 with VERIFY_PROFILE disabled.
  • Extension (extension/, package app.playmancala.keycloak.guest):
    • GuestAuthenticator: with guest=true creates guest- + 8 random hex characters, grants the guest role and succeeds; otherwise attempted().
    • GuestCleanupTask (R1.3): scheduled through Keycloak's TimerProvider; deletes guests created more than cleanup-min-age-seconds ago (default 86400) that have neither a user session nor an offline session; runs every cleanup-interval-seconds (default 3600), at most cleanup-max-deletes-per-run (default 1000) per run. Options are set as KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__….
  • Compose: keycloak service on 127.0.0.1:8080, KC_HOSTNAME from KEYCLOAK_HOSTNAME, proxy headers on.
  • nginx (config/nginx.conf): auth.playmancala.app proxies 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)

  • KeycloakTokenVerifier checks signature (RS256, keys from KEYCLOAK_JWKS_URL or the issuer's certs endpoint, cached by jose), iss, exp, typ: Bearer and that azp is one of KEYCLOAK_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
    auth client → server { token }, right after connecting and after every token refresh
    on_auth server → client { user: AuthUser }
    on_auth_error server → client { reason }, then the socket is closed with code 4001

    Messages before on_auth are ignored; no auth within 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 /register issues 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 sends auth on every (re)connect and reports connected (and emits open) only after on_auth; reauthenticate(token) sends a renewed token on the open connection (R6.1); user holds the authenticated user.
  • getPlayerDisplayName(player, t) formats names, including guests (R5.1).
  • English and Turkish strings for the account menu.

Web app (apps/frontend)

  • AuthService wraps an oidc-client-ts UserManager: tokens in local storage, automaticSilentRenew with the refresh token, scope: "openid offline_access".
    • loginAsGuest(): redirect with guest=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 state and 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 with getAccessToken as token provider. If the session cannot be renewed, start over as a guest (R6.2). Renewed tokens are passed to reauthenticate.
  • AccountMenu on the home page: guests see Log In and Sign Up, accounts see their name and Log Out (R3, R5.2).
  • KEYCLOAK_URL is a build variable (default http://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 the guest role and an offline refresh token, a new user per guest login, the login form without guest=true, and the cleanup task.
  • Backend: KeycloakTokenVerifier against 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.