diff --git a/readme.md b/readme.md index c10c79a..808184f 100644 --- a/readme.md +++ b/readme.md @@ -1,15 +1,17 @@ # To Be Played – Auth Service -Identity service of **To Be Played**, a Steam backlog tracker. It handles registration, login, user settings and the -public profile, and it signs the JWTs that the Core service trusts. +Identity service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added +by hand or imported from GOG Galaxy, Playnite, Grouvee and HowLongToBeat files. It handles registration, login, user +settings and the public profile, and it signs the JWTs that the Core service trusts. -To Be Played is split into three repositories: +To Be Played is split into four repositories: | Repository | Role | |---|---| | **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs | | ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs | | ToBePlayed-Frontend | React single-page app | +| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans | ## Features @@ -25,14 +27,16 @@ To Be Played is split into three repositories: wrong password, and every failed login gets the same error. - JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the - public key as a JWK, so Core finds the right key after a rotation. + public key as a JWK. Auth publishes only the current key; Core finds it by `kid`. A rotation (new `JWT_KID`) ends + every access token signed with the old key at once. The frontend renews them with the refresh cookie. - Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in `tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in `tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced token that comes back revokes all tokens of that login. Logout revokes them too. - CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the frontend origin. A `Bearer` header still works for API clients and tests. -- User settings: display name (unique, case-insensitive), language, dark mode. +- User settings: display name (unique, case-insensitive), language, dark mode, and the chart form per stat group + (`stat_charts`, V31): bars, pie, donut, line or list. `StatCharts` holds the allowed forms per group. - Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a @@ -174,21 +178,21 @@ To Be Played is split into three repositories: | `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token | | `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_`; `204`) | access token | | `GET` | `/api/public/profiles/{displayName}` | public | +| `PUT` | `/api/users/settings/stat-chart` (`{group, chart}`; `chart` null = back to the default; `204`; `400 VALIDATION_FAILED` for an unknown group or form) | access token | | `PUT` | `/api/users/settings/owner-lists` (`{enabled}`; `204`; opt-in to be shown by name in owner and user lists, only together with the public profile) | access token | | `GET` | `/api/stats/users?key=TOTAL_USERS\|STEAM_LINKED&page=` (`{listed: [{displayName}], listedTotal, privateCount, includesYou, page, totalPages}`; never an id; not cached) | access token | | `GET` | `/api/stats/public` | public | +| `GET` | `/.well-known/jwks.json` (the current public key as a JWK, `Cache-Control: max-age=300`) | public | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | -Endpoints use Spring's API versioning with a query parameter: `?version=1.0` (default `1.0`). - ## Tech stack - Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads - PostgreSQL 17, Flyway migrations - JJWT with Ed25519 keys -- Spring Boot Actuator + Micrometer (Prometheus) +- Spring Boot Actuator (`/actuator/health` only) - Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit -- CI: Forgejo Actions, multi-stage Docker image +- CI: Gitea Actions (`.gitea/workflows/publish-auth.yml`), multi-stage Docker image ## Architecture @@ -250,8 +254,8 @@ sequenceDiagram B->>A: POST /api/auth/login A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth) B->>C: API call with tbp_access - C->>A: GET /.well-known/jwks.json (new kid only) - A-->>C: Public keys (Ed25519) + C->>A: GET /.well-known/jwks.json (unknown kid only) + A-->>C: Current public key (Ed25519, one JWK) C-->>B: 200, or 401 when the access token expired B->>A: POST /api/auth/refresh with tbp_refresh A-->>B: New tbp_access and tbp_refresh, old refresh token rotated @@ -263,8 +267,8 @@ sequenceDiagram 1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script can read them. -2. Core verifies the access token with Auth's public keys only. `JwksController` publishes them; Core loads them - again only for an unknown `kid`. +2. Core verifies the access token with Auth's public keys only. `JwksController` publishes the current key only; Core + loads it again only for an unknown `kid`. 3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again. `RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login. @@ -332,7 +336,7 @@ sequenceDiagram A->>M: up to 20 due rows (account mails first, at most APP_MAIL_DAILY_LIMIT per 24 h, broadcast rows keep 10 free) M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min end - Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days + Note over A: after 10 tries FAILED and a warning in the log, SENT rows are removed after 30 days ``` ### Registration flow @@ -429,7 +433,7 @@ other, then start them in this order (Core also starts without Auth and finds it 3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173` (`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`. -4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8 +4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30 games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers. In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this @@ -449,18 +453,24 @@ Needs Java 25, Docker and your own keys. Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the service listens on `http://localhost:8080`. Health: `/actuator/health` (the only open actuator endpoint). -The cookies are host-only unless `APP_AUTH_COOKIE_DOMAIN` is set; production sets it to the parent domain, so Core -gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no -cookie settings. Open the frontend on `localhost` (not `127.0.0.1`), so the browser sends the cookies to every -local port. +Auth has no `application-dev.yml`: any profile but `local` (also none) runs the same way and takes the keys from the +environment. `local` must not be combined with `prod`. + +Only `tbp_access` gets the domain from `APP_AUTH_COOKIE_DOMAIN` (blank = host-only; production sets the parent +domain, so Core gets it too). `tbp_refresh` (`Path=/api/auth`) and `tbp_steam_link` are always host-only. The cookies +are `Secure` (`app.auth.cookie-secure`, default `true`). Chrome and Firefox accept `Secure` cookies on +`http://localhost`; Safari drops them, so the `local` profile sets `cookie-secure: false`. With your own keys on Safari +set `APP_AUTH_COOKIE_SECURE=false`. Open the frontend on `localhost`, not `127.0.0.1`. Configuration comes from environment variables: | Variable | Purpose | |---|---| | `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) | -| `JWT_KID` | Key id written into the token header | -| `DB_PASSWORD` | Local PostgreSQL password | +| `JWT_KID` | Key id in the token header (default `tbp-auth-1`); Auth refuses to start when it is blank | +| `APP_AUTH_COOKIE_DOMAIN` | Domain of `tbp_access`, blank = host-only | +| `APP_ACCOUNT_CLEANUP_DRY_RUN` | Default `true`: the nightly cleanup deletes nothing (it still sends the account states to Core); the 7-day purge of unconfirmed registrations runs anyway | +| `APP_BUILD_COMMIT`, `APP_BUILD_TIME` | Feed `GET /api/version`; defaults `dev` and empty. CI passes `GIT_SHA` and `BUILD_TIME` as Docker build args | | `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) | | `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) | | `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |