Public Access
GET /api/version is public. CI passes GIT_SHA and BUILD_TIME as Docker build args; a local build answers dev.
188 lines
9.3 KiB
Markdown
188 lines
9.3 KiB
Markdown
# 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/<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. Its `profileStats` claim
|
||
names the stat groups the user chose to show there (none by default). 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 |
|
||
| `PUT` | `/api/users/settings/profile-stats` (`{groups}`: `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; `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` | 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.
|
||
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
|
||
|
||
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.
|