# 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>`; 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=` 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.