feat(dev): add the local profile with generated keys and a dev user

Profile "local" starts without keys: LocalKeysEnvironmentPostProcessor
makes a new signing key per start and refuses to run next to prod. A
Flyway callback in db/local adds dev@localhost as PLATFORM_ADMIN with a
linked Steam ID.
This commit is contained in:
lobo committed 2026-09-28 13:07:40 +02:00
1 parent 32d4f40eec
commit f86f3c2e63
8 files changed
+285 -1

No files matched your search

+24 -1
View File
@@ -204,7 +204,30 @@ days is `UPDATE users SET deleted_at = NULL` (see the Deployment README).
## Run locally
Needs Java 25 and Docker.
### 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). 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.
```bash
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
@@ -0,0 +1,55 @@
package auth_service.configuration;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Map;
import java.util.UUID;
/**
* Only in the local profile: a new Ed25519 key pair and kid at each start, so no signing key is in the repo. A new
* kid lets Core's JWKS reload find the new key by itself.
*/
public class LocalKeysEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
static final String LOCAL_PROFILE = "local";
private static final String PROD_PROFILE = "prod";
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
if (!environment.matchesProfiles(LOCAL_PROFILE)) {
return;
}
// local also seeds an admin with a public password and opens CORS to localhost.
if (environment.matchesProfiles(PROD_PROFILE)) {
throw new IllegalStateException("The local profile must never run together with prod");
}
KeyPair keys = generateKeys();
Base64.Encoder base64 = Base64.getEncoder();
environment.getPropertySources().addFirst(new MapPropertySource("localKeys", Map.of(
"security.jwt.private-key", base64.encodeToString(keys.getPrivate().getEncoded()),
"security.jwt.public-key", base64.encodeToString(keys.getPublic().getEncoded()),
"security.jwt.kid", LOCAL_PROFILE + "-" + UUID.randomUUID())));
}
// After the config files, which decide the active profiles.
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
private static KeyPair generateKeys() {
try {
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1 @@
org.springframework.boot.EnvironmentPostProcessor=auth_service.configuration.LocalKeysEnvironmentPostProcessor
+18
View File
@@ -0,0 +1,18 @@
# Local development without keys or third parties: start with the profile "local" (alone). Postgres comes from
# compose.yaml, the signing keys from LocalKeysEnvironmentPostProcessor. See the readme, "Run locally".
spring:
flyway:
locations: classpath:db/migration,classpath:db/local
app:
cors:
allowed-origins: "http://localhost:5173"
auth:
# Plain http; Safari drops Secure cookies on localhost.
cookie-secure: false
core:
internal-url: "http://localhost:8090"
auth:
registration:
invitation-code: local
@@ -0,0 +1,15 @@
-- Local profile only. A Flyway callback, not a migration: it runs after every migrate and leaves no history row,
-- so the same database still starts with "dev". dev@localhost / password123, PLATFORM_ADMIN, with the Steam ID of
-- Core's recorded answers. Display name and Steam ID are unique: when another user has one, the dev user comes
-- without it.
INSERT INTO users (email, password, enabled, display_name, steam_id, steam_id_verified, role)
SELECT 'dev@localhost', '$2a$10$3FApyf15qVEvFLEvl/d7aecSMZlslQdXiYlDgbOa4DfGWu4l0VCY.', true,
CASE WHEN NOT EXISTS (SELECT 1 FROM users WHERE lower(display_name) = 'dev') THEN 'Dev' END,
CASE WHEN NOT EXISTS (SELECT 1 FROM users WHERE steam_id = 76561198000000001) THEN 76561198000000001 END,
NOT EXISTS (SELECT 1 FROM users WHERE steam_id = 76561198000000001),
'PLATFORM_ADMIN'
ON CONFLICT DO NOTHING;
INSERT INTO user_settings (user_id)
SELECT id FROM users WHERE email = 'dev@localhost'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,51 @@
package auth_service.configuration;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.mock.env.MockEnvironment;
import java.security.KeyFactory;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LocalKeysEnvironmentPostProcessorTest extends AbstractUnitTest {
private final LocalKeysEnvironmentPostProcessor processor = new LocalKeysEnvironmentPostProcessor();
@Test
void localProfile_GetsAFreshKeyPairAndKid() throws Exception {
MockEnvironment local = new MockEnvironment();
local.setActiveProfiles("local");
processor.postProcessEnvironment(local, new SpringApplication());
assertThat(local.getProperty("security.jwt.kid")).startsWith("local-");
byte[] privateKey = Base64.getDecoder().decode(local.getProperty("security.jwt.private-key"));
assertThat(KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(privateKey))).isNotNull();
assertThat(local.getProperty("security.jwt.public-key")).isNotBlank();
}
@Test
void otherProfiles_KeepTheirConfiguredKeys() {
MockEnvironment prod = new MockEnvironment().withProperty("security.jwt.kid", "tbp-auth-1");
prod.setActiveProfiles("prod");
processor.postProcessEnvironment(prod, new SpringApplication());
assertThat(prod.getProperty("security.jwt.kid")).isEqualTo("tbp-auth-1");
assertThat(prod.getProperty("security.jwt.private-key")).isNull();
}
@Test
void localTogetherWithProd_RefusesToStart() {
MockEnvironment both = new MockEnvironment();
both.setActiveProfiles("prod", "local");
assertThatThrownBy(() -> processor.postProcessEnvironment(both, new SpringApplication()))
.isInstanceOf(IllegalStateException.class);
}
}
@@ -0,0 +1,60 @@
package auth_service.configuration;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.shared.api.AuthCookies;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import static org.hamcrest.Matchers.startsWith;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The committed local profile: it starts without keys, and the seeded dev user logs in.
*/
@Tag("IntegrationTest")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.account-cleanup.cron=-")
@AutoConfigureMockMvc
@ActiveProfiles("local")
class LocalProfileTest {
// Its own database, not the one of AbstractIntegrationTest: the seeded admin would stay there for every other
// test class.
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
static {
postgres.start();
}
@Autowired
private MockMvc mockMvc;
@Test
void devUser_LogsInAsAdminWithItsSteamId() throws Exception {
Cookie session = mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"dev@localhost\",\"password\":\"password123\"}"))
.andExpect(status().isOk())
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE);
mockMvc.perform(get("/api/users/me").cookie(session))
.andExpect(jsonPath("$.role").value("PLATFORM_ADMIN"))
.andExpect(jsonPath("$.steamId").value("76561198000000001"))
.andExpect(jsonPath("$.steamIdVerified").value(true));
mockMvc.perform(get("/.well-known/jwks.json"))
.andExpect(jsonPath("$.keys[0].kid", startsWith("local-")));
}
}
@@ -0,0 +1,61 @@
package auth_service.configuration;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
* The dev and local profiles share the Postgres of compose.yaml (readme, "Run locally").
*/
@Tag("IntegrationTest")
class LocalSeedFlywayTest {
private static final String DEFAULT_LOCATIONS = "classpath:db/migration";
private static final String LOCAL_LOCATIONS = "classpath:db/migration,classpath:db/local";
@Test
void devAfterLocal_StartsOnTheSameDatabase() {
try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")) {
postgres.start();
flyway(postgres, LOCAL_LOCATIONS).migrate();
assertThatCode(() -> flyway(postgres, DEFAULT_LOCATIONS).migrate()).doesNotThrowAnyException();
}
}
@Test
void localOnADatabaseWithAUserNamedDev_StillAddsTheDevUser() throws Exception {
try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")) {
postgres.start();
flyway(postgres, DEFAULT_LOCATIONS).migrate();
try (Connection connection = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
Statement statement = connection.createStatement()) {
statement.execute("INSERT INTO users (email, password, display_name) VALUES ('me@example.com', 'x', 'dev')");
flyway(postgres, LOCAL_LOCATIONS).migrate();
ResultSet rows = statement.executeQuery("SELECT count(*) FROM users WHERE email = 'dev@localhost'");
rows.next();
assertThat(rows.getInt(1)).isEqualTo(1);
}
}
}
// Spring Boot's defaults for this repo (application.yml): baseline-on-migrate, validate on migrate.
private static Flyway flyway(PostgreSQLContainer<?> postgres, String locations) {
return Flyway.configure()
.dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())
.locations(locations.split(","))
.baselineOnMigrate(true)
.load();
}
}