Core and the Frontend skip ids they do not know; Core never shares the overview or the activity.
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.
To Be Played is split into three repositories:
| Repository | Role |
|---|---|
| ToBePlayed-Auth (this one) | Users, login, settings, signs JWTs |
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app |
Features
- Registration with an invitation code, 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). - Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed registrations per IP,
block for 15 minutes with
429 TOO_MANY_ATTEMPTSandRetry-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
kidheader, andGET /.well-known/jwks.jsonpublishes the public key as a JWK, so Core finds the right key after a rotation. - 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 intbp_refresh(sent to/api/authonly) 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,PUTandDELETEneed anX-TBP-ClientorAuthorizationheader, and CORS allows only the frontend origin. ABearerheader still works for API clients and tests. - User settings: display name (unique, case-insensitive), language, dark mode.
- 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, 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/<display name>. 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 asstyle. The ticket's claims come from the layout:sectionsnames the shown parts (STATSwhen anySTATS_tile is shown),profileStatsthe stat groups of theSTATS_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 inaud(tbp-login,tbp-public-profile,tbp-internal,iss=tbp-auth), and both services check it. A login'ssubis the user id; the email is in no token. Until item 29 the tokens also carry the claims of before item 26 (userId,typ), so an older Core still reads them. The claims are pinned insrc/test/resources/contract/auth-core-contract.json, the same file as in Core (ToBePlayed-Deployment/scripts/check-contract.shcompares 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 is400 VALIDATION_FAILEDwithfields, a duplicate email or display name409 EMAIL_ALREADY_IN_USE/DISPLAY_NAME_ALREADY_IN_USE, anything unexpected500 INTERNAL_ERRORwithout 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 theroleclaim. 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 inrefresh_tokens.active_roleand never goes above the stored role, so a demoted admin loses the rights at the next refresh. Core checks the same claim. - 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@Asyncwork run without a user and are not checked); a denied call answers401without a login, else403.@Publicskips only the role check: login, refresh, "view as" and the Steam callback prove the caller themselves. The URL rules inSecurityConfigstay as the outer guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an@Asyncmethod, 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; Core accepts the token until it expires, max 15 min; the login then answers403 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 inadmin_audit. - Dashboard hints: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start
and end time, type
DISMISSorACCEPT). The dashboard shows the running hints one at a time, newest first. "Do not show again" on aDISMISShint or "OK" on anACCEPThint is a row inhint_done, so the hint never comes back for this user on any device. Creating and ending a hint are rows inadmin_audit. - Delete my account with the password (5 wrong tries per 15 minutes, then
429). It only setsusers.deleted_atand 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(defaulttrue) only logs.
API
| Method | Path | Auth |
|---|---|---|
POST |
/api/registration/new |
public |
POST |
/api/auth/login |
public, sets both cookies |
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=&page=&size= ({content, page, size, totalElements, totalPages}) |
PLATFORM_ADMIN |
PUT |
/api/admin/users/{id}/blocked ({blocked}; 409 SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED) |
PLATFORM_ADMIN |
DELETE |
/api/admin/users/{id} (409 SELF_ACTION, TARGET_IS_ADMIN) |
PLATFORM_ADMIN |
GET |
/api/hints ({results}: running hints not closed for good, newest first) |
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, newest first) |
PLATFORM_ADMIN |
POST |
/api/admin/hints ({textEn, textDe, kind, startsAt, endsAt}; 201 {id}, 400 INVALID_HINT) |
PLATFORM_ADMIN |
PUT |
/api/admin/hints/{id}/end (204; a planned hint never starts) |
PLATFORM_ADMIN |
DELETE |
/api/users/me ({password}; 204 and both cookies cleared, 403 INVALID_PASSWORD, 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/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_<GROUP>; 204) |
access token |
GET |
/api/public/profiles/{displayName} |
public |
GET |
/api/stats/public |
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)
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
- CI: Forgejo Actions, 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?" 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 keeps a valid access token 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.
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.
Login flow
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 (new kid only)
A-->>C: Public keys (Ed25519)
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
LoginServicechecks the password.LoginControllersets both HttpOnly cookies (AuthCookies), so no script can read them.- Core verifies the access token with Auth's public keys only.
JwksControllerpublishes them; Core loads them again only for an unknownkid. - On a
401the frontend callsPOST /api/auth/refreshonce and sends the request again.RefreshTokenServicereplaces the refresh token. A replaced token that comes back revokes the whole login.
Steam link flow
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
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
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).
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); the nightly cleanup sends every user. The call runs on the task executor, so neither the answer
nor a database connection waits for Core; a failed call only warns.
sequenceDiagram
participant A as Auth
participant C as Core
A->>A: link / unlink / block / unblock (commit)
A->>C: POST /internal/accounts/states {userId, steamId, blocked}
C->>C: blocked_user, Steam ID in user_steam_sync
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):
- Auth: in
ToBePlayed-Auth,./mvnw spring-boot:run -Dspring-boot.run.profiles=local(IDE: active profilelocal). Port 8080. - Core: in
ToBePlayed-Core,./mvnw spring-boot:run -Dspring-boot.run.profiles=local. Port 8090. - Frontend: in
ToBePlayed-Frontend,npm install, thennpm run dev, and openhttp://localhost:5173(localhost, not127.0.0.1, so the browser sends the login cookies to every local port). The development values must beVITE_API_URL=http://localhost:8080andVITE_CORE_API_URL=http://localhost:8090. - Log in with
dev@localhost/password123. On the dashboard, start a Steam sync: the library fills with 8 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). The invite code for more
users is local. 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.
./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, metrics: /actuator/prometheus.
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.
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 |
AUTH_INVITATION_CODE |
Code required for registration |
DB_PASSWORD |
Local PostgreSQL password |
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) |
Generate a keypair:
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
Tests
./mvnw clean verify
Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers.
Roadmap
- Account deletion and data export.
License
All rights reserved. The source is public for reading only.