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.
This commit is contained in:
parent
20efa40e6c
commit
ab2424b648
108
docs/specs/keycloak-auth/design.md
Normal file
108
docs/specs/keycloak-auth/design.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# 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.
|
||||||
71
docs/specs/keycloak-auth/requirements.md
Normal file
71
docs/specs/keycloak-auth/requirements.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# Login with Keycloak: requirements
|
||||||
|
|
||||||
|
Status: implemented for the backend and the web app on branch `feature/keycloak-auth`; the mobile app still uses the
|
||||||
|
legacy userKey login (see `tasks.md`). Written after the implementation, from the decisions made while building it.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
People can play without signing up, and can sign up or log in when they want to. Every player has an identity that
|
||||||
|
cannot be taken over by someone else, and one system handles all of it.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- **Guest**: a player who has not signed up. A guest is a Keycloak user with the `guest` role.
|
||||||
|
- **Account**: a Keycloak user who signed up with a username and password.
|
||||||
|
- **Legacy client**: an app version that identifies itself with a userKey from `GET /register` instead of a token
|
||||||
|
(the mobile app released before this work).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### R1. Playing without signing up
|
||||||
|
- R1.1 A first-time visitor of the web app is logged in as a new guest without seeing any form; the page briefly
|
||||||
|
passes through Keycloak and comes back to where the visitor started.
|
||||||
|
- R1.2 A returning guest keeps the same identity for 30 days after their last visit, also after closing the browser.
|
||||||
|
- R1.3 Guests who have not been back for 30 days, and are at least a day old, are deleted from Keycloak.
|
||||||
|
- R1.4 Starting guest logins is rate limited per IP address, since each one creates a user.
|
||||||
|
|
||||||
|
### R2. Identities are secure
|
||||||
|
- R2.1 The server, not the client, decides who a connection belongs to, based on a token signed by Keycloak.
|
||||||
|
- R2.2 A token that is forged, expired, issued by another server, issued to another client, or is not an access
|
||||||
|
token is rejected.
|
||||||
|
- R2.3 Knowing another player's id (e.g. from a game) is not enough to act as that player.
|
||||||
|
- R2.4 A connection that does not authenticate within 5 seconds is closed; messages sent before authentication are
|
||||||
|
ignored.
|
||||||
|
|
||||||
|
### R3. Accounts
|
||||||
|
- R3.1 A guest can sign up or log in from the home page; afterwards they play as that account.
|
||||||
|
- R3.2 A logged-in user can log out; they then continue as a new guest.
|
||||||
|
- R3.3 Logging in and signing up are only offered on the home page, so nobody changes identity during a game.
|
||||||
|
|
||||||
|
### R4. Several devices
|
||||||
|
- R4.1 A user logged in on several devices or tabs is connected on all of them at once; all of them receive game
|
||||||
|
updates.
|
||||||
|
- R4.2 A user counts as offline only when their last connection closes.
|
||||||
|
- R4.3 Reconnecting does not drop the user's new connection when the old one closes late.
|
||||||
|
|
||||||
|
### R5. Names
|
||||||
|
- R5.1 Players see each other's names during a game. Accounts show their username; guests show as "Guest" and four
|
||||||
|
characters of their generated username (e.g. "Guest 4F2A", "Misafir 4F2A" in Turkish).
|
||||||
|
- R5.2 The account menu on the home page shows the user's own name.
|
||||||
|
|
||||||
|
### R6. Sessions stay valid
|
||||||
|
- R6.1 Access tokens are renewed before they expire without interrupting the game connection.
|
||||||
|
- R6.2 If the session can no longer be renewed, the user continues as a new guest.
|
||||||
|
|
||||||
|
### R7. Old app versions keep working
|
||||||
|
- R7.1 While `LEGACY_USERKEY_AUTH` is on (the default), legacy clients connect with `?userKey=` and use `/register`
|
||||||
|
as before.
|
||||||
|
- R7.2 A legacy client cannot use the id of a Keycloak user.
|
||||||
|
- R7.3 When the setting is off, userKey connections and `/register` are refused.
|
||||||
|
|
||||||
|
### R8. One auth system
|
||||||
|
- R8.1 Keycloak is the only place where users and sessions live; the backend only checks tokens and does not call
|
||||||
|
Keycloak per request.
|
||||||
|
- R8.2 Keycloak, its configuration and its extension are part of this repository and can be run locally with
|
||||||
|
Docker Compose.
|
||||||
|
|
||||||
|
## Out of scope (see `TODO.md`)
|
||||||
|
- Login in the mobile app.
|
||||||
|
- Turning a guest into an account without changing identity.
|
||||||
|
- Social logins.
|
||||||
|
- Deleting accounts.
|
||||||
17
docs/specs/keycloak-auth/tasks.md
Normal file
17
docs/specs/keycloak-auth/tasks.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Login with Keycloak: tasks
|
||||||
|
|
||||||
|
Tasks 1 to 4 were built before this spec was written; their commits are listed for reference.
|
||||||
|
|
||||||
|
- [x] 1. **Keycloak**: guest authenticator and cleanup task, realm export, Docker image, compose service, nginx
|
||||||
|
proxy and rate limit, integration tests. (`7c99a76`)
|
||||||
|
- [x] 2. **Backend**: token verifier, WebSocket `auth` handshake, several sockets per user, player names in games,
|
||||||
|
`LEGACY_USERKEY_AUTH`, tests. (`30ad51c`)
|
||||||
|
- [x] 3. **Local packages**: apps and `@mancala/core` link the packages in this repo instead of the npm releases, so
|
||||||
|
the auth channels and types can be shared. (`e165b0a`)
|
||||||
|
- [x] 4. **Web app**: `AuthService` with `oidc-client-ts`, startup flow, account menu, token renewal over the open
|
||||||
|
connection, names on the game page; auth channels and types moved to core. (`f5dcf93`)
|
||||||
|
- [ ] 5. **Mobile app**: `mancala-mobile` client in the realm and in `KEYCLOAK_CLIENT_IDS`, `react-native-app-auth`,
|
||||||
|
guest login on first launch, token login instead of `?userKey=`, account screens, names; tested on a device.
|
||||||
|
Details in `TODO.md`.
|
||||||
|
- [ ] 6. **Legacy off**: once old app versions are no longer used, set `LEGACY_USERKEY_AUTH=false`, which also
|
||||||
|
removes `/register`.
|
||||||
Loading…
Reference in New Issue
Block a user