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: