diff --git a/openespi-authserver/DEPLOYMENT_GUIDE.md b/openespi-authserver/DEPLOYMENT_GUIDE.md
index c3bdf393..1e90b8f3 100644
--- a/openespi-authserver/DEPLOYMENT_GUIDE.md
+++ b/openespi-authserver/DEPLOYMENT_GUIDE.md
@@ -164,6 +164,16 @@ export DATACUSTODIAN_CLIENT_SECRET="secure_datacustodian_secret"
# Dynamic client registration (RFC 7591) — omit to keep registration disabled
export ESPI_REGISTRAR_CLIENT_SECRET="secure_registrar_secret"
+# OAuth clients the AS provisions from configuration (stored hashed). A production AS seeds no
+# demo clients and no Third Parties: Third Parties onboard through RFC 7591 registration.
+# datacustodian - the Data Custodian's token-introspection client
+# data_custodian_admin - the DC operator's DataCustodian_Admin_Access client (omit if unused)
+export ESPI_DATACUSTODIAN_CLIENT_SECRET="secure_introspection_secret"
+export ESPI_DATACUSTODIAN_ADMIN_CLIENT_SECRET="secure_dc_admin_secret"
+# Never set espi.authserver.seed.demo-clients=true outside local/dev/test: the demo clients'
+# secrets are published in the source. With it off, any demo row left from an earlier release
+# has its published secret replaced with a random one at startup.
+
# SSL Configuration
export KEYSTORE_PATH="/etc/ssl/private/authserver.p12"
export KEYSTORE_PASSWORD="secure_keystore_password"
diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java
index 9f8651b5..b7a7ab07 100644
--- a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java
+++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java
@@ -167,6 +167,27 @@ public class AuthorizationServerConfig {
@Value("${espi.authserver.admin-client.secret:${ESPI_ADMIN_CLIENT_SECRET:}}")
private String adminClientSecret;
+ /**
+ * Seed the demo clients ({@code data_custodian_admin}, {@code third_party}, {@code third_party_admin},
+ * {@code datacustodian}) whose {@code {noop}} secrets are written in this public source file.
+ * Off by default; only the local/dev/test profiles turn it on. A production AS starts
+ * with no Third Party clients (Third Parties onboard through RFC 7591) and provisions its
+ * infrastructure clients from configured secrets below.
+ */
+ @Value("${espi.authserver.seed.demo-clients:false}")
+ private boolean seedDemoClients;
+
+ /**
+ * Secret of the Data Custodian's introspection client ({@code datacustodian}), stored hashed. Empty means
+ * the client is not provisioned from configuration (on a production AS the DC then cannot introspect).
+ */
+ @Value("${espi.authserver.datacustodian-client.secret:${ESPI_DATACUSTODIAN_CLIENT_SECRET:}}")
+ private String dataCustodianClientSecret;
+
+ /** Secret of the Data Custodian operator's admin client ({@code data_custodian_admin}), stored hashed. */
+ @Value("${espi.authserver.datacustodian-admin-client.secret:${ESPI_DATACUSTODIAN_ADMIN_CLIENT_SECRET:}}")
+ private String dataCustodianAdminClientSecret;
+
/** #205 WebAuthn relying party ID: the registrable domain of the AS (no scheme or port). */
@Value("${espi.authserver.admin.webauthn.rp-id:localhost}")
private String webAuthnRpId;
@@ -748,9 +769,25 @@ public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTe
.build())
.build();
- // Seed default clients, and reconcile existing ones to the code definition (see below).
- initializeDefaultClients(repository, datacustodianAdmin, thirdPartyClient, thirdPartyAdmin,
- dataCustodianIntrospection);
+ // Demo clients carry well-known {noop} secrets (this file is public), so they are seeded only when
+ // espi.authserver.seed.demo-clients=true (local/dev/test profiles). Production gets none of them.
+ if (seedDemoClients) {
+ requireLoopbackIssuerForDemoClients(issuerUri);
+ log.warn("Seeding DEMO OAuth clients with well-known secrets (espi.authserver.seed.demo-clients=true)"
+ + " - NEVER enable this in production");
+ // Seed default clients, and reconcile existing ones to the code definition (see below).
+ initializeDefaultClients(repository, datacustodianAdmin, thirdPartyClient, thirdPartyAdmin,
+ dataCustodianIntrospection);
+ } else {
+ log.info("Demo OAuth clients not seeded (espi.authserver.seed.demo-clients=false)");
+ // Rows seeded by an earlier release still accept the published secrets: retire them.
+ retireWellKnownSecrets(repository, passwordEncoder, datacustodianAdmin, thirdPartyClient,
+ thirdPartyAdmin, dataCustodianIntrospection);
+ }
+
+ // Infrastructure clients a real deployment needs, provisioned from configured secrets (hashed).
+ seedConfiguredClient(repository, passwordEncoder, dataCustodianIntrospection, dataCustodianClientSecret);
+ seedConfiguredClient(repository, passwordEncoder, datacustodianAdmin, dataCustodianAdminClientSecret);
seedRegistrarClient(repository, passwordEncoder);
@@ -872,6 +909,74 @@ private void seedRegistrarClient(RegisteredClientRepository repository, Password
}
}
+ /**
+ * Demo seeding is a developer convenience, and {@code dev-mysql} is the default profile, so a deployment
+ * that forgets {@code SPRING_PROFILES_ACTIVE} would otherwise get the published secrets. Refuse to start
+ * unless the issuer is a loopback host ({@code localhost}, {@code 127.0.0.1}, {@code [::1]}).
+ */
+ static void requireLoopbackIssuerForDemoClients(String issuer) {
+ String host = null;
+ try {
+ host = issuer == null ? null : java.net.URI.create(issuer.trim()).getHost();
+ } catch (IllegalArgumentException ignored) {
+ // treated as non-loopback below
+ }
+ boolean loopback = host != null && (host.equalsIgnoreCase("localhost") || host.equals("127.0.0.1")
+ || host.equals("[::1]") || host.equals("::1"));
+ if (!loopback) {
+ throw new IllegalStateException("espi.authserver.seed.demo-clients=true is only allowed when the "
+ + "issuer is a loopback host, but the issuer is '" + issuer + "'. The demo clients' secrets "
+ + "are published in the source: set an explicit non-dev profile (SPRING_PROFILES_ACTIVE) "
+ + "or espi.authserver.seed.demo-clients=false.");
+ }
+ }
+
+ /**
+ * With demo seeding off, an existing demo-client row (seeded by an earlier release) that still accepts
+ * its published {@code {noop}} secret gets a random, hashed secret instead, so the published secret no
+ * longer authenticates. A row whose secret was already changed is left alone; a configured secret
+ * ({@link #seedConfiguredClient}) is applied afterwards.
+ */
+ void retireWellKnownSecrets(RegisteredClientRepository repository, PasswordEncoder passwordEncoder,
+ RegisteredClient... demoClients) {
+ java.security.SecureRandom random = new java.security.SecureRandom();
+ for (RegisteredClient demo : demoClients) {
+ RegisteredClient existing = repository.findByClientId(demo.getClientId());
+ String wellKnown = demo.getClientSecret().replaceFirst("^\\{noop\\}", "");
+ if (existing != null && existing.getClientSecret() != null
+ && passwordEncoder.matches(wellKnown, existing.getClientSecret())) {
+ byte[] bytes = new byte[32];
+ random.nextBytes(bytes);
+ String replacement = java.util.HexFormat.of().formatHex(bytes);
+ repository.save(RegisteredClient.from(existing)
+ .clientSecret(passwordEncoder.encode(replacement)).build());
+ log.warn("Retired the published demo secret of client '{}' (demo seeding is off); "
+ + "configure a secret for it if it is still needed.", demo.getClientId());
+ }
+ }
+ }
+
+ /**
+ * Provision an infrastructure client from a configured secret, stored hashed. A blank secret does
+ * nothing. An existing row (for example a demo row from an earlier start) keeps its id and settings but
+ * has its secret replaced when the configured secret no longer matches, so configuring a secret also
+ * retires the well-known demo secret.
+ */
+ void seedConfiguredClient(RegisteredClientRepository repository, PasswordEncoder passwordEncoder,
+ RegisteredClient definition, String secret) {
+ if (secret == null || secret.isBlank()) {
+ return;
+ }
+ RegisteredClient existing = repository.findByClientId(definition.getClientId());
+ if (existing == null) {
+ repository.save(RegisteredClient.from(definition).clientSecret(passwordEncoder.encode(secret)).build());
+ log.info("Provisioned client '{}' from configured secret.", definition.getClientId());
+ } else if (!passwordEncoder.matches(secret, existing.getClientSecret())) {
+ repository.save(RegisteredClient.from(existing).clientSecret(passwordEncoder.encode(secret)).build());
+ log.info("Rotated the secret of client '{}' to the configured value.", definition.getClientId());
+ }
+ }
+
/**
* Seed the default ESPI clients, and reconcile any that already exist so that
* changes to a default client's code definition reach an already-seeded database.
diff --git a/openespi-authserver/src/main/resources/application-dev-mysql.yml b/openespi-authserver/src/main/resources/application-dev-mysql.yml
index 8f9445a9..db8b3fba 100644
--- a/openespi-authserver/src/main/resources/application-dev-mysql.yml
+++ b/openespi-authserver/src/main/resources/application-dev-mysql.yml
@@ -87,4 +87,7 @@ espi:
# JWT Development Settings
jwt:
access-token-validity: PT6H # 6 hours
- refresh-token-validity: PT24H # 24 hours
\ No newline at end of file
+ refresh-token-validity: PT24H # 24 hours
+
+# Seed the demo OAuth clients (well-known secrets; never in production).
+espi.authserver.seed.demo-clients: true
diff --git a/openespi-authserver/src/main/resources/application-dev-postgresql.yml b/openespi-authserver/src/main/resources/application-dev-postgresql.yml
index 6c67783c..b20bfc09 100644
--- a/openespi-authserver/src/main/resources/application-dev-postgresql.yml
+++ b/openespi-authserver/src/main/resources/application-dev-postgresql.yml
@@ -68,4 +68,7 @@ management:
endpoints:
web:
exposure:
- include: health,info,metrics
\ No newline at end of file
+ include: health,info,metrics
+
+# Seed the demo OAuth clients (well-known secrets; never in production).
+espi.authserver.seed.demo-clients: true
diff --git a/openespi-authserver/src/main/resources/application-local.yml b/openespi-authserver/src/main/resources/application-local.yml
index 48e5fd41..f471d2ae 100644
--- a/openespi-authserver/src/main/resources/application-local.yml
+++ b/openespi-authserver/src/main/resources/application-local.yml
@@ -75,4 +75,7 @@ espi:
# JWT Local Settings
jwt:
access-token-validity: PT1H # 1 hour for testing
- refresh-token-validity: PT6H # 6 hours for testing
\ No newline at end of file
+ refresh-token-validity: PT6H # 6 hours for testing
+
+# Seed the demo OAuth clients (well-known secrets; never in production).
+espi.authserver.seed.demo-clients: true
diff --git a/openespi-authserver/src/main/resources/application.yml b/openespi-authserver/src/main/resources/application.yml
index 34ad8382..e2976caf 100644
--- a/openespi-authserver/src/main/resources/application.yml
+++ b/openespi-authserver/src/main/resources/application.yml
@@ -148,6 +148,17 @@ espi:
# stored bcrypt-encoded; leave unset to keep dynamic registration disabled (registrar not seeded).
registrar:
client-secret: ${ESPI_REGISTRAR_CLIENT_SECRET:}
+ # Demo OAuth clients (data_custodian_admin, third_party, third_party_admin, datacustodian) have
+ # well-known secrets written in the public source. OFF by default; only local/dev/test profiles
+ # turn it on. A production AS starts with no Third Party clients (they register via RFC 7591).
+ seed:
+ demo-clients: false
+ # Infrastructure clients for a real deployment, provisioned from secrets (stored bcrypt-encoded).
+ # Leave unset to not provision them. datacustodian = the DC's token-introspection client.
+ datacustodian-client:
+ secret: ${ESPI_DATACUSTODIAN_CLIENT_SECRET:}
+ datacustodian-admin-client:
+ secret: ${ESPI_DATACUSTODIAN_ADMIN_CLIENT_SECRET:}
# Application Information
info:
diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/config/DemoClientSeedingTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/config/DemoClientSeedingTest.java
new file mode 100644
index 00000000..aa4f5fdf
--- /dev/null
+++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/config/DemoClientSeedingTest.java
@@ -0,0 +1,173 @@
+/*
+ *
+ * Copyright (c) 2026 Green Button Alliance, Inc.
+ *
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.greenbuttonalliance.espi.authserver.config;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.security.crypto.factory.PasswordEncoderFactories;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The demo OAuth clients carry secrets written in this public repository, so a production AS must not
+ * seed them: they are seeded only when {@code espi.authserver.seed.demo-clients=true} (local/dev/test
+ * profiles). Infrastructure clients are provisioned from configured secrets, stored hashed.
+ */
+@DisplayName("Demo-client seeding is opt-in; infrastructure clients come from configured secrets")
+class DemoClientSeedingTest {
+
+ private static final String[] DEMO_CLIENT_IDS =
+ {"data_custodian_admin", "third_party", "third_party_admin", "datacustodian"};
+
+ private final PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
+
+ private EmbeddedDatabase database;
+
+ private AuthorizationServerConfig config;
+
+ @BeforeEach
+ void setUp() {
+ database = new EmbeddedDatabaseBuilder()
+ .setType(EmbeddedDatabaseType.H2)
+ .setName("demo-seed-" + UUID.randomUUID())
+ .addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+ .build();
+ config = new AuthorizationServerConfig();
+ ReflectionTestUtils.setField(config, "defaultRedirectUriBase", "http://localhost");
+ ReflectionTestUtils.setField(config, "issuerUri", "http://localhost:9999");
+ }
+
+ @AfterEach
+ void tearDown() {
+ database.shutdown();
+ }
+
+ private RegisteredClientRepository repository() {
+ return config.registeredClientRepository(new JdbcTemplate(database), passwordEncoder);
+ }
+
+ @Test
+ @DisplayName("by default (production) none of the demo clients is seeded")
+ void demoClientsNotSeededByDefault() {
+ RegisteredClientRepository repository = repository();
+
+ assertThat(DEMO_CLIENT_IDS).allSatisfy(clientId -> assertThat(repository.findByClientId(clientId))
+ .as("demo client %s must not exist on a default (production) start", clientId)
+ .isNull());
+ }
+
+ @Test
+ @DisplayName("with seed.demo-clients=true (local/dev/test) every demo client is seeded")
+ void demoClientsSeededWhenEnabled() {
+ ReflectionTestUtils.setField(config, "seedDemoClients", true);
+
+ RegisteredClientRepository repository = repository();
+
+ assertThat(DEMO_CLIENT_IDS).allSatisfy(clientId -> assertThat(repository.findByClientId(clientId))
+ .as("demo client %s", clientId)
+ .isNotNull());
+ }
+
+ @Test
+ @DisplayName("demo seeding refuses to start on a non-loopback issuer (e.g. a forgotten production profile)")
+ void demoSeedingRefusedOnRealIssuer() {
+ ReflectionTestUtils.setField(config, "seedDemoClients", true);
+ ReflectionTestUtils.setField(config, "issuerUri", "https://auth.utility.example");
+
+ assertThatThrownBy(this::repository)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("loopback");
+ assertThatCode(() -> AuthorizationServerConfig.requireLoopbackIssuerForDemoClients("http://127.0.0.1:9999"))
+ .doesNotThrowAnyException();
+ assertThatThrownBy(() -> AuthorizationServerConfig.requireLoopbackIssuerForDemoClients("http://localhost.evil.example"))
+ .isInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ @DisplayName("a configured secret provisions the DC clients with that secret, hashed, not the demo one")
+ void configuredSecretsProvisionInfrastructureClients() {
+ ReflectionTestUtils.setField(config, "dataCustodianClientSecret", "introspect-from-vault");
+ ReflectionTestUtils.setField(config, "dataCustodianAdminClientSecret", "admin-from-vault");
+
+ RegisteredClientRepository repository = repository();
+
+ assertThat(repository.findByClientId("datacustodian"))
+ .isNotNull()
+ .satisfies(client -> assertThat(client.getClientSecret()).doesNotStartWith("{noop}"))
+ .satisfies(client -> assertThat(passwordEncoder.matches("introspect-from-vault", client.getClientSecret())).isTrue())
+ .satisfies(client -> assertThat(passwordEncoder.matches("datacustodian-secret", client.getClientSecret())).isFalse());
+ assertThat(repository.findByClientId("data_custodian_admin"))
+ .isNotNull()
+ .satisfies(client -> assertThat(passwordEncoder.matches("admin-from-vault", client.getClientSecret())).isTrue())
+ .satisfies(client -> assertThat(client.getScopes()).containsExactly("DataCustodian_Admin_Access"));
+ assertThat(repository.findByClientId("third_party")).isNull();
+ assertThat(repository.findByClientId("third_party_admin")).isNull();
+ }
+
+ @Test
+ @DisplayName("demo rows from an earlier release stop accepting their published secrets once seeding is off")
+ void existingDemoRowsAreRetired() {
+ ReflectionTestUtils.setField(config, "seedDemoClients", true);
+ repository();
+
+ ReflectionTestUtils.setField(config, "seedDemoClients", false);
+ RegisteredClientRepository repository = repository();
+
+ assertThat(repository.findByClientId("data_custodian_admin"))
+ .satisfies(client -> assertThat(passwordEncoder.matches("dc-secret", client.getClientSecret())).isFalse());
+ assertThat(repository.findByClientId("third_party"))
+ .satisfies(client -> assertThat(passwordEncoder.matches("tp-secret", client.getClientSecret())).isFalse());
+ assertThat(repository.findByClientId("third_party_admin"))
+ .satisfies(client -> assertThat(passwordEncoder.matches("tpadmin-secret", client.getClientSecret())).isFalse());
+ assertThat(repository.findByClientId("datacustodian"))
+ .satisfies(client -> assertThat(passwordEncoder.matches("datacustodian-secret", client.getClientSecret())).isFalse());
+ }
+
+ @Test
+ @DisplayName("a configured secret replaces a well-known demo secret left from an earlier start")
+ void configuredSecretRetiresDemoSecret() {
+ ReflectionTestUtils.setField(config, "seedDemoClients", true);
+ RegisteredClient demo = repository().findByClientId("data_custodian_admin");
+
+ ReflectionTestUtils.setField(config, "seedDemoClients", false);
+ ReflectionTestUtils.setField(config, "dataCustodianAdminClientSecret", "admin-from-vault");
+ RegisteredClient rotated = repository().findByClientId("data_custodian_admin");
+
+ assertThat(rotated)
+ .isNotNull()
+ .satisfies(client -> assertThat(client.getId()).isEqualTo(demo.getId()))
+ .satisfies(client -> assertThat(passwordEncoder.matches("dc-secret", client.getClientSecret())).isFalse())
+ .satisfies(client -> assertThat(passwordEncoder.matches("admin-from-vault", client.getClientSecret())).isTrue());
+ }
+}
diff --git a/openespi-authserver/src/test/resources/application-testcontainers.yml b/openespi-authserver/src/test/resources/application-testcontainers.yml
index a39dcbc5..2321ebdc 100644
--- a/openespi-authserver/src/test/resources/application-testcontainers.yml
+++ b/openespi-authserver/src/test/resources/application-testcontainers.yml
@@ -48,6 +48,9 @@ espi:
authserver:
registrar:
client-secret: registrar-test-secret
+ # The integration tests use the demo clients (third_party, data_custodian_admin, ...).
+ seed:
+ demo-clients: true
# Logging Configuration for Tests
logging: