Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions openespi-authserver/DEPLOYMENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <strong>Off by default</strong>; 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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 <em>reconcile</em> any that already exist so that
* changes to a default client's code definition reach an already-seeded database.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,7 @@ espi:
# JWT Development Settings
jwt:
access-token-validity: PT6H # 6 hours
refresh-token-validity: PT24H # 24 hours
refresh-token-validity: PT24H # 24 hours

# Seed the demo OAuth clients (well-known secrets; never in production).
espi.authserver.seed.demo-clients: true
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,7 @@ management:
endpoints:
web:
exposure:
include: health,info,metrics
include: health,info,metrics

# Seed the demo OAuth clients (well-known secrets; never in production).
espi.authserver.seed.demo-clients: true
5 changes: 4 additions & 1 deletion openespi-authserver/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
11 changes: 11 additions & 0 deletions openespi-authserver/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading