# 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). - 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_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, 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 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. - 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/`. 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 ticket is not a login: both services reject every token with a `typ` claim on normal endpoints. ## 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 | | `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `PUT` | `/api/users/settings/appearance` | access token | | `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/stats/public` | 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` | JPA entities, Spring Data repositories, `EntityMapper`. Entities never reach the API layer. | `profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package. 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`; all integration tests share one PostgreSQL container. ### 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 (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 ``` 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`. 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. ## Run locally Needs Java 25 and Docker. ```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`, 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`) | 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 ``` ## Tests ```bash ./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.