Files
ToBePlayed-Auth/readme.md
T
lobo 6b6ac44551 feat(user): add welcome-seen-at timestamp for welcome page tracking
Null means the welcome page was not seen yet. Existing users are backfilled
at migration time so only newly registered users see the page.
2026-10-01 10:44:22 +02:00

407 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, platform messages (hints, mail, news), signs JWTs |
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app |
## Features
- Registration by 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).
- 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, 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. 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'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 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; Core accepts the token
until it expires, max 15 min; 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`.
- **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 "we got your registration", then "your account is active" or "your
registration was denied", later "your account is blocked" and "your account will be deleted from <date>". 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".
- **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/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=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` 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`) | 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`) | 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 |
| `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`, `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 |
| `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`.
`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.
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 (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
```
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.
### 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: 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
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 PENDING until a PLATFORM_ADMIN decides. 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 = PENDING, two mail rows
A-->>D: mail "a new registration waits"
A-->>U: mail "we got your registration"
U->>A: login: 403 ACCOUNT_PENDING
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
```
### 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.
```mermaid
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):
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 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). 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).
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 |
| `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`) |
| `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 |
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
See `ROADMAP.md` in the ToBePlayed-Deployment repo.
## License
All rights reserved. The source is public for reading only.