# To Be Played – Auth Service 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 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 - Registration by email confirmation (a single-use link, valid 24 hours, on whose page the owner chooses the password; unconfirmed requests are deleted after 7 days) and then admin approval, login with email and password (BCrypt). Emails are stored trimmed and in lower case (V19, unique on `lower(email)`); a password has 8 characters and at most 72 bytes (BCrypt's limit). - **Password reset**: "forgot password" mails a link (`/reset-password#`, single use, valid 1 hour, only the SHA-256 stored, a new mail replaces the old link). Only an ACTIVE user gets a working link; every other address gets the same `204` and no mail. A reset ends all logins; "change password" (current password + new one) ends all logins but the current one. A PLATFORM_ADMIN can send the same mail to an active user. Both send a "password changed" mail. - Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed or 3 taken registrations per IP, block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a 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. 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, 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 foreign Steam login link cannot move someone's Steam ID into another account. IDs typed in before this stay, but unverified: a verified link from another account takes them away. Unlinking is possible. - Appearance settings synced across devices: accent color, corner style, wide layout, reduce animations, background and dashboard banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs. - Opt-in public profile at `/u/`. Auth answers with a short-lived **profile ticket** (EdDSA JWT, 10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as `style`. The ticket also names the user's `role`, so Core can show the free looks of an admin. The ticket's claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_` tile is shown), `profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout (`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it. A login's `sub` is the user id; the email is in no token. A token without `aud` is refused. The claims are pinned in `src/test/resources/contract/auth-core-contract.json`, the same file as in Core (`ToBePlayed-Deployment/scripts/check-contract.sh` compares both). - Errors: every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with `fields`, a duplicate email or display name `409 EMAIL_ALREADY_IN_USE` / `DISPLAY_NAME_ALREADY_IN_USE`, anything unexpected `500 INTERNAL_ERROR` without details (`shared/api/GlobalExceptionController`). - **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in `refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next refresh. Core checks the same claim. **Role change**: an admin sets the role of another user on the admin users page (`PUT /api/admin/users/{id}/role`). The change is a row in `role_change` (actor, old and new role, time; shown in the "Roles" dialog) and in `admin_audit` (`ROLE_CHANGE`), sets `users.role_changed_at` and clears "view as" of the open logins. An access token issued before `role_changed_at` counts as not logged in (`401`) at Auth and at Core, within 30 s (cache TTL); the frontend then refreshes and repeats the request, and the refresh reads the stored role. A role set by SQL (the first admin) sets no `role_changed_at`; it works from the next refresh. There is never 0 admins: every write that can remove an admin first locks the active admins (`shared/account/ActiveAdmins`, `SELECT ... FOR UPDATE`), the actor must still be one of them, and the last admin cannot delete the own account. - **Role markers on services**: every service method a controller calls carries `@RequiresRole(Role.X)` or `@Public`, on the method or on its class (the method wins). A Spring Security method interceptor checks the active role while a request is served (scheduled jobs and `@Async` work run without a user and are not checked); a denied call answers `401` without a login, else `403`. `@Public` skips only the role check: login, refresh, "view as" and the Steam callback prove the caller themselves. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a service method calling another service's method that needs more. - **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock (`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth and at Core; the login then answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account or on another admin. Every action and every "view as" is a row in `admin_audit`. Accept or deny an account that waits (`users.status` PENDING; the login answers `403 ACCOUNT_PENDING` after the right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user and sets `deleted_at`, so the login answers like a deleted account and the nightly cleanup removes it after 30 days. A waiting account cannot be blocked or deleted. Pending and denied users are nowhere public (`shared/account/ActiveUser`). - **Dashboard hints**: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first. "Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`. A hint can target groups, combined with AND (none = all users): without Steam, role (this role and higher, by the active role of the token), new users (`users.registration_date` less than N days ago), empty library (Auth only sends a flag; the Frontend checks it with Core). The text may use `**bold**` and `[text](url)`; the Frontend renders it, never as HTML. Four automatic hints (`NO_STEAM`, `EMPTY_LIBRARY`, `PILE_GREW`, `LINK_QUEUE`) are on by default; a PLATFORM_ADMIN turns each on or off (`auto_hint`, audited). `GET /api/hints` lists the keys that are on and not hidden for the user; the Frontend checks the conditions. X hides one for 7 days, "Do not show again" for good (`auto_hint_hidden`). - **What's new**: a PLATFORM_ADMIN publishes news entries (plain text, English, German optional; edit and delete, no drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it" sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all entries. Editing an entry does not make it unseen again. Create, update and delete are rows in `admin_audit`. - **Welcome page**: `user_settings.welcome_seen_at` is null until the user sees the welcome page; "Let's go" and "Skip" set it (`PUT /api/users/settings/welcome-seen`). V24 sets it for every existing user, so only users registered after the deploy get the page. - **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link to the admin page), and to the user "confirm your address" (`CONFIRM_EMAIL`, sent first; the admin mail follows the click), then "your account is active" or "your registration was denied", later "your account is blocked" and "your account will be deleted from ", and "reset your password" (`PASSWORD_RESET`, the link) and "your password was changed" (`PASSWORD_CHANGED`, after a reset or a change). The registration sends the browser language (`de` for a German browser, else `en`), which also becomes the user's app language. Plain text only. See "Mail flow". - **Broadcast mail**: a PLATFORM_ADMIN writes one mail with subject and text in English and German (plain text), and sends it to all active users or to picked ones, each in their app language. Every active user gets it. "Send test to me" mails the draft to the admin only, in the admin's language. One `mail_broadcast` row keeps the texts and the counts (recipients, sent, failed); `BROADCAST_SEND` goes to `admin_audit`. The sender takes account mails first and stays under `APP_MAIL_DAILY_LIMIT` per 24 h, 10 of them kept for account mails. A recipient who is blocked or deleted before the send counts as failed. - **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets `users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a new Steam link. A nightly job (04:15, Europe/Berlin) removes users flagged 30 days ago, in Core first and then in Auth. It calls Core's `/internal/accounts/*` with a **service token** (EdDSA JWT, 60 s, `aud=tbp-internal`). `app.account-cleanup.dry-run` (default `true`) only logs. ## API | Method | Path | Auth | |---|---|---| | `POST` | `/api/registration/new` | public | | `POST` | `/api/registration/confirm` (`{token, password}`; `204`; `410 CONFIRMATION_INVALID`) | public | | `POST` | `/api/registration/resend` (`{email}`; always `204`) | public | | `POST` | `/api/auth/login` | public, sets both cookies | | `POST` | `/api/auth/password-reset` (`{email}`; always `204`; `429 TOO_MANY_ATTEMPTS`: 5 per IP and 15 min) | public | | `POST` | `/api/auth/password-reset/confirm` (`{token, password}`; `204`; `410 RESET_INVALID`; `429`) | public | | `POST` | `/api/auth/password` (`{currentPassword, newPassword}`; `204`; `403 INVALID_PASSWORD`; `429`; under `/api/auth` for the refresh cookie, which keeps the current login) | access token | | `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies | | `POST` | `/api/auth/logout` | refresh cookie, clears both cookies | | `GET` | `/api/users/me` | access token | | `PUT` | `/api/users/settings` | access token | | `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie | | `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie | | `POST` | `/api/auth/view-as` (`{role}`, null = own role; new cookies; `403 ROLE_NOT_ALLOWED` above the own role) | refresh cookie | | `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` UNCONFIRMED, PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN | | `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN | | `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`, also for an unconfirmed request) | PLATFORM_ADMIN | | `PUT` | `/api/admin/users/{id}/role` (`{role, expectedRole}`; `204`; `400` for an unknown role; `404 USER_NOT_FOUND`; `409 SELF_ACTION`, `ACTOR_NOT_ADMIN`, `ROLE_CHANGED` (the role is not `expectedRole`), `ALREADY_DELETED`, `TARGET_IS_BLOCKED`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN | | `GET` | `/api/admin/users/{id}/role-changes` (`{results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}`, newest first, 50 at most; `actorEmail` null after the actor is purged) | PLATFORM_ADMIN | | `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN | | `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN | | `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN | | `POST` | `/api/admin/users/{id}/password-reset` (`204`; `409 NOT_ACTIVE`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN | | `GET` | `/api/hints` (`{results}`: running hints for this user not closed for good, newest first, with `emptyLibraryOnly`; `automatic`: keys of the automatic hints that are on and not hidden) | access token | | `PUT` | `/api/hints/auto/{key}/snooze` (hide 7 days; `204`; `404 HINT_NOT_FOUND`) | access token | | `PUT` | `/api/hints/auto/{key}/done` (hide for good; `204`; `404 HINT_NOT_FOUND`) | access token | | `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | access token | | `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount` and the `target*` fields, newest first; `automatic`: `[{key, enabled}]`) | PLATFORM_ADMIN | | `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}` and optional `targetNoSteam`, `targetRole` (this role and higher), `targetNewDays` (1-365), `targetEmptyLibrary`; `201 {id}`, `400 INVALID_HINT` for the time range; `400 VALIDATION_FAILED` for a bad `targetNewDays` or `targetRole`) | PLATFORM_ADMIN | | `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN | | `PUT` | `/api/admin/hints/auto/{key}` (`{enabled}`; `204`; `404 HINT_NOT_FOUND`) | PLATFORM_ADMIN | | `GET` | `/api/news` (`{results}` with `seen`, newest first) | access token | | `PUT` | `/api/news/seen` (`204`) | access token | | `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN | | `POST` | `/api/admin/news` (`{titleEn, titleDe, textEn, textDe}`; `201 {id}`) | PLATFORM_ADMIN | | `PUT` | `/api/admin/news/{id}` (same body; `204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN | | `DELETE` | `/api/admin/news/{id}` (`204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN | | `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `409 LAST_ADMIN` (the only active admin), `429`) | access token | | `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `PUT` | `/api/users/settings/appearance` | access token | | `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token | | `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`). Auth checks only the id shape `[A-Z_]{1,30}`: Core and the Frontend skip ids they do not know, and Core never shares the overview or the activity | access token | | `PUT` | `/api/users/settings/welcome-seen` (`204`; stamps `user_settings.welcome_seen_at`, sent as `settings.welcomeSeenAt` in `/me`, null until then) | access token | | `GET` | `/api/admin/broadcasts` (`{results}` with the counts, newest first) | PLATFORM_ADMIN | | `GET` | `/api/admin/broadcasts/recipient-count?userIds=` (`{count}`; no `userIds` = all) | PLATFORM_ADMIN | | `POST` | `/api/admin/broadcasts` (`{subjectEn, subjectDe, bodyEn, bodyDe, userIds}`; `201 {recipientCount}`; `409 NO_RECIPIENTS`) | PLATFORM_ADMIN | | `POST` | `/api/admin/broadcasts/test` (same body; `204`) | PLATFORM_ADMIN | | `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 | ## 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 (`/actuator/health` only) - Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit - CI: Gitea Actions (`.gitea/workflows/publish-auth.yml`), multi-stage Docker image ## Architecture Package by feature (`modules/login`, `modules/registration`, `modules/user`, ...), and inside each feature three layers: | Layer | Package | Rule | |---|---|---| | API | `api` | REST controllers, request/response records, `ApiMapper`. Calls services, never repositories. | | Domain | `model`, `service`, `exception` | Plain Java models and business logic. Uses repositories, never API classes, entities or `EntityMapper`. | | Persistence | `persistence`, `repo` | JPA entities, Spring Data repositories, `EntityMapper`; `repo` holds `JdbcTemplate` queries. Entities never reach the API layer. | `user` owns the tables `users` and `user_settings` through the one JPA entity `UserEntity`. `login` and `registration` read and write `users` with `JdbcTemplate` (refresh tokens stay JPA). Every query for "a user who may log in and is shown to others" uses `shared/account/ActiveUser`. A settings change is one `UserRepository.update(userId, change)`: load the user, change the model, write back the display name and the settings. A new setting needs no new repository method. Three Caffeine caches in `CacheConfig`: `users` (by userId) and `publicStats` expire 10 minutes after the write, `activeUsers` (the filter's "blocked or deleted, and when did the role change?" check per request) after 30 seconds. Every write evicts what it changes. A request that read the database just before a block or delete can put the old answer back, so the short TTL is the real limit: a blocked or deleted user, or a token from before a role change, works for up to 30 s. `profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package. `version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image. `account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core through `CoreAccountService`. `admin` (users page, audit) works the same way. It deletes through the port `shared/account/AccountDeleter`, and the login module writes "view as" to the audit table through `shared/audit/AdminAuditLog`, because modules never use each other. `hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through `shared/audit/AdminAuditLog`. `news` (what's new entries and their admin API) is built the same way as `hint`; the seen stamp is a column of `user_settings`, read and written by SQL in its own repo. `mail` writes each mail as a finished text into `mail_outbox` (`JdbcTemplate`, `repo`), in the transaction of the event, and `MailOutboxService` sends the rows every minute over SMTP. Other modules reach it through the port `shared/mail/AccountMailer`. Without `SPRING_MAIL_HOST` mail is off: the rows wait and go out once a host is set. Broadcasts (`BroadcastService`, `mail_broadcast`) queue one row per recipient; the same job sends them after the account mails and under the daily limit. ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`, `AbstractIntegrationTest`) live in `auth_service.shared.test`. Integration and persistence tests share one PostgreSQL container (`TestPostgres`), and the base classes carry every annotation, so each kind starts one Spring context. A test with its own annotations starts another one. **Mirrored in Core.** These files exist in both services with small differences; a change in one needs the same change in the other: `ClientHeaderFilter`, `RoleAuthorizationManager`, `MethodSecurityConfig`, `Role`, `RequiresRole`, `Public` (Core also has `Internal`), `UserPrincipal`, the login token check in `JwtService.parseLoginToken`, the ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/auth-core-contract.json` (`ToBePlayed-Deployment/scripts/check-contract.sh` compares the two). ### Login flow ```mermaid sequenceDiagram participant B as Browser (Frontend) participant A as Auth participant C as Core 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 (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 B->>C: Same API call again Note over B,A: View as (admin only) B->>A: POST /api/auth/view-as {role} with tbp_refresh A-->>B: New tbp_access (role claim) and tbp_refresh, active role stored for this login ``` 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 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. ### Steam link flow ```mermaid sequenceDiagram participant B as Browser (Frontend) participant A as Auth participant S as Steam B->>A: POST /api/users/steam-link (logged in) A-->>B: Steam login URL, Set-Cookie tbp_steam_link (state, 10 min) B->>S: Sign in on steamcommunity.com S-->>B: Redirect to /api/steam-link/callback?state=...&openid.* B->>A: GET callback with tbp_steam_link A->>S: check_authentication (openid.* sent back) S-->>A: is_valid:true A-->>B: Redirect to /settings?steam=linked B->>A: POST /api/auth/refresh (new steamId claim for Core) ``` `SteamLinkService` checks the state against the binding cookie before it asks Steam anything, and uses each state once. `SteamOpenIdService` checks mode, endpoint, `return_to` and the signed fields, then asks Steam. `UserService.linkVerifiedSteamId` stores the ID and takes it from an account that only typed it in. ### Delete account flow ```mermaid sequenceDiagram participant B as Browser (Frontend) participant A as Auth participant C as Core B->>A: DELETE /api/users/me {password} A->>A: users.deleted_at = now, revoke refresh tokens, mail row (one transaction) A->>C: POST /internal/accounts/deleted (service token) A-->>B: 204, both cookies cleared Note over A: an old access token gets 401 within 30 s (activeUsers cache) Note over A,C: nightly 04:15 A->>C: PUT /internal/accounts/deleted (all flagged users) A->>C: PUT /internal/accounts/states (every user) A->>C: POST /internal/accounts/purge (flagged 30+ days) A->>A: DELETE users (only when Core answered and no dry run) A->>C: POST /internal/accounts/prune (all user ids) ``` A failed call to Core does not fail the delete: the next night sends the whole flagged set again. Undo within 30 days is `UPDATE users SET deleted_at = NULL` (see the Deployment README). ### Mail flow Each mail is a row in `mail_outbox`, written in the transaction of its event, so the event never fails because of the mail server and no mail is lost when Auth restarts. The text is rendered then, in the user's language (`de`, else English); the admin mail is German. ```mermaid sequenceDiagram participant B as Browser (Frontend) participant A as Auth participant M as SMTP (Hostinger) B->>A: admin sends a broadcast A->>A: mail_broadcast row + one outbox row per active user B->>A: register / admin decides / admin blocks / delete account A->>A: event + mail_outbox row (PENDING), one transaction loop every minute (MailOutboxService) 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 ``` ### Registration flow A registration is a request: the account is UNCONFIRMED until the address owner opens the link in the mail (24 hours, single use, only its SHA-256 is stored; a resend replaces it) and chooses the password there, then PENDING until a PLATFORM_ADMIN decides. The registration form has no password: an unconfirmed user has none and cannot log in (login answers the same `401` as for an unknown address), so nobody can set the password of an address that is not theirs. The admin list shows an unconfirmed request as such; it cannot be accepted, the admin can delete it for good. The badge counts PENDING only and the admin mail follows the confirmation. The nightly cleanup deletes it after 7 days (not while a resent link is still valid), also in dry run. Registering again with an unconfirmed address is a resend: a new link, no second row. At most 3 confirmation mails per address and hour (over all IPs, registration and resend together); over that the answer stays the same and no mail is sent. Unconfirmed requests count toward the 20 that wait. While 20 wait, the registration answers `409 PENDING_LIMIT_REACHED` and stores nothing. Two requests at the same moment can make it 21. ```mermaid sequenceDiagram participant U as New user participant A as Auth participant D as Admin U->>A: POST /api/registration/new A->>A: users.status = UNCONFIRMED, one mail row A-->>U: mail "confirm your address" (link) U->>A: POST /api/registration/confirm {token, password} A->>A: users.status = PENDING, password hash stored A-->>D: mail "a new registration waits" U->>A: login: 403 ACCOUNT_PENDING Note over A: nightly: UNCONFIRMED older than 7 days is deleted D->>A: PUT /api/admin/users/{id}/decision {accepted} alt accepted A->>A: status = ACTIVE A-->>U: mail "your account is active" else denied A->>A: status = DENIED, deleted_at = now (nightly cleanup after 30 days) A-->>U: mail "your registration was denied" end ``` ### Password reset flow The link is valid for 1 hour and works once: the reset is one `UPDATE ... WHERE password_reset_hash = ? AND expires > now AND ` that also clears the hash. At most 3 reset mails per address and hour (over all IPs) and 5 requests per IP per 15 minutes; failed confirms are limited to 10 per IP. Other logins keep their access token until it expires (up to 15 minutes); their refresh tokens end at once. ```mermaid sequenceDiagram participant U as User participant A as Auth participant D as Admin U->>A: POST /api/auth/password-reset {email} A->>A: active user: store token hash + expiry, one mail row A-->>U: 204 (same for any address) A-->>U: mail "reset your password" (link with token) U->>A: POST /api/auth/password-reset/confirm {token, password} A->>A: new password, hash cleared, all refresh tokens revoked A-->>U: 204 and mail "your password was changed" Note over D,A: POST /api/admin/users/{id}/password-reset sends the same mail (active users only) and writes the audit log ``` ### Steam ID and block sync to Core Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit (`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly cleanup sends every user. A change of the profile tiles sends it too. The state carries `listed` (active, public profile, opt-in and a display name: `ListedUser`), which Core uses for its owner lists, `publicProfile` (active, public profile and a display name: `PublicUser`) and `profileSections` (the profile parts the user shows, by the names of the profile ticket: `ProfileSections`), which Core uses to let others follow the user and to leave out in the follower note what the profile hides (#27). The call runs on the task executor, so neither the answer nor a database connection waits for Core; a failed call is tried again after 10 s, 1 min and 5 min (in memory), then it only warns. ```mermaid sequenceDiagram participant A as Auth participant C as Core A->>A: link / unlink / block / unblock / name change (commit) A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections} C->>C: blocked_user, listed_user, public_user (+ sections), account_role_change, Steam ID in user_steam_sync, display name in account_name Note over A,C: nightly 04:15 A->>C: PUT /internal/accounts/states (every user) ``` ## Run locally ### Local development without keys (profile `local`) The whole stack runs on your machine with no API keys and no calls to Steam, Twitch/IGDB or speedrun.com. Needs Java 25, Node 24 and Docker. Clone `ToBePlayed-Auth`, `ToBePlayed-Core` and `ToBePlayed-Frontend` next to each other, then start them in this order (Core also starts without Auth and finds it later): 1. **Auth**: in `ToBePlayed-Auth`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local` (IDE: active profile `local`). Port 8080. 2. **Core**: in `ToBePlayed-Core`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`. Port 8090. 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 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 profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools restart), log in again. Never use `local` on a server: the dev user's password is public. The Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). A new local user waits until the dev user accepts it on the admin page. What Core mocks and how to change the recorded answers: Core readme, "Run locally". ### With your own keys (profile `dev`) Needs Java 25, Docker and your own keys. ```bash ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev ``` 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). 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 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`) | | `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off | | `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host | | `APP_MAIL_ADMIN_TO` | Gets "a new registration waits". Needed with a host | | `APP_MAIL_DAILY_LIMIT` | Mails per rolling 24 h, all kinds together (default 100, Hostinger's free mailbox; 1000 Business Starter, 3000 Business Standard / Premium). Broadcast rows keep 10 free | Generate a keypair: ```bash openssl genpkey -algorithm ed25519 -out private.pem openssl pkey -in private.pem -outform DER | base64 -w0 # JWT_PRIVATE_KEY openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY ``` ### Request timing log `RequestTimingFilter` writes one INFO line to stdout per API request: method, handler path pattern, status and duration, e.g. `GET /api/users/me 200 12 ms`. The pattern is `-` when no handler matched (e.g. a `401` before routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception that leaves the filter chain is logged as `500`. ## Tests ```bash ./mvnw clean verify ``` Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers. ## Roadmap See `ROADMAP.md` in the ToBePlayed-Deployment repo. ## License All rights reserved. The source is public for reading only.