From 7fa6512cadde3976a8bf42c57d1c70d995f36b92 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:30:11 -0500 Subject: [PATCH 1/8] fix(authserver): seed demo OAuth clients only on local/dev/test profiles The data_custodian_admin, third_party, third_party_admin and datacustodian clients are development fixtures. They are now seeded only when espi.authserver.seed.demo-clients=true, which the local, dev-mysql, dev-postgresql and testcontainers profiles set. Other profiles start with none of them, matching the production onboarding model: Third Parties register through RFC 7591. - datacustodian (introspection) and data_custodian_admin can instead be provisioned from ESPI_DATACUSTODIAN_CLIENT_SECRET and ESPI_DATACUSTODIAN_ADMIN_CLIENT_SECRET, stored hashed. - With demo seeding off, an existing demo row that still accepts its development secret gets a random secret at startup. - DEPLOYMENT_GUIDE lists the new settings. Co-Authored-By: Claude Opus 5.5 --- openespi-authserver/DEPLOYMENT_GUIDE.md | 10 ++ .../config/AuthorizationServerConfig.java | 88 +++++++++- .../main/resources/application-dev-mysql.yml | 5 +- .../resources/application-dev-postgresql.yml | 5 +- .../src/main/resources/application-local.yml | 5 +- .../src/main/resources/application.yml | 11 ++ .../config/DemoClientSeedingTest.java | 155 ++++++++++++++++++ .../resources/application-testcontainers.yml | 3 + 8 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/config/DemoClientSeedingTest.java 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..06f1a10c 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,24 @@ 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) { + 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 +908,52 @@ private void seedRegistrarClient(RegisteredClientRepository repository, Password } } + /** + * 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..10464b95 --- /dev/null +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/config/DemoClientSeedingTest.java @@ -0,0 +1,155 @@ +/* + * + * 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; + +/** + * 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"); + } + + @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("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: From 293c8c910415ad406228bc803d0af111e3c982c6 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:33:06 -0500 Subject: [PATCH 2/8] fix(authserver): refuse demo-client seeding on a non-loopback issuer dev-mysql is the default profile in application.yml, so a deployment that does not set SPRING_PROFILES_ACTIVE would run with demo seeding on. Startup now fails when espi.authserver.seed.demo-clients=true and the issuer host is not localhost, 127.0.0.1 or ::1. Co-Authored-By: Claude Opus 5.5 --- .../config/AuthorizationServerConfig.java | 23 +++++++++++++++++++ .../config/DemoClientSeedingTest.java | 18 +++++++++++++++ 2 files changed, 41 insertions(+) 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 06f1a10c..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 @@ -772,6 +772,7 @@ public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTe // 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). @@ -908,6 +909,28 @@ 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 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 index 10464b95..aa4f5fdf 100644 --- 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 @@ -36,6 +36,8 @@ 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 @@ -63,6 +65,7 @@ void setUp() { .build(); config = new AuthorizationServerConfig(); ReflectionTestUtils.setField(config, "defaultRedirectUriBase", "http://localhost"); + ReflectionTestUtils.setField(config, "issuerUri", "http://localhost:9999"); } @AfterEach @@ -96,6 +99,21 @@ void demoClientsSeededWhenEnabled() { .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() { From 8ff7f3e8f4e575bb5bc7550762e27c9159f9b48e Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:37:39 -0500 Subject: [PATCH 3/8] fix(authserver): restrict each OAuth grant to its own kind of scope (#251) Spring Authorization Server only checks that a requested scope is registered for the client, whatever the grant. ESPI keeps customer data and administrative tokens apart, so: - client_credentials cannot be granted an ESPI customer data scope (any FB=... other than the FB=36_40 registration-token renewal scope); - authorization_code cannot be granted an administrative scope (ThirdParty_Admin_Access, DataCustodian_Admin_Access, FB=36_40, authserver.admin, client.create). The client receives invalid_scope at its redirect URI, as Spring does for its own scope check. EspiGrantScopePolicy runs after Spring's default validators on the token and authorization endpoints. Tested with unit tests and a Docker-backed SecurityIntegrationTest case using a client registered for both grants. Co-Authored-By: Claude Opus 5.5 --- .../config/AuthorizationServerConfig.java | 20 ++- .../grant/EspiGrantScopePolicy.java | 110 ++++++++++++++ .../grant/EspiGrantScopePolicyTest.java | 137 ++++++++++++++++++ .../integration/SecurityIntegrationTest.java | 61 ++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java create mode 100644 openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java 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..ac4a0ba8 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 @@ -424,7 +424,16 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h // URL in return_to) and back. So delegating only the consent path covers both — the // login step is handled transparently inside DC. authorizationServerConfigurer.authorizationEndpoint(authorize -> authorize - .consentPage("/authorize/delegate")); + .consentPage("/authorize/delegate") + // #251: customer authorization never yields an administrative scope. + .authenticationProviders(providers -> providers.forEach(provider -> { + if (provider instanceof org.springframework.security.oauth2.server.authorization.authentication + .OAuth2AuthorizationCodeRequestAuthenticationProvider codeRequest) { + codeRequest.setAuthenticationValidator( + org.greenbuttonalliance.espi.authserver.grant.EspiGrantScopePolicy + .authorizationCodeValidator()); + } + }))); // Advertise the RFC 7591 endpoint (wired on its own chain, see clientRegistrationSecurityFilterChain) // in /.well-known/oauth-authorization-server. The stock clientRegistrationEndpoint(...) configurer @@ -448,6 +457,15 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h http.getSharedObject(org.springframework.context.ApplicationContext.class) .getBean(OAuth2AuthorizationService.class); authorizationServerConfigurer.tokenEndpoint(tokenEndpoint -> tokenEndpoint + // #251: client_credentials never yields an ESPI customer data (FB) scope. + .authenticationProviders(providers -> providers.forEach(provider -> { + if (provider instanceof org.springframework.security.oauth2.server.authorization.authentication + .OAuth2ClientCredentialsAuthenticationProvider clientCredentials) { + clientCredentials.setAuthenticationValidator( + org.greenbuttonalliance.espi.authserver.grant.EspiGrantScopePolicy + .clientCredentialsValidator()); + } + })) .accessTokenResponseHandler( new org.greenbuttonalliance.espi.authserver.service .EspiTokenResponseSuccessHandler(authorizationServiceForHandler))); diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java new file mode 100644 index 00000000..2830b4a0 --- /dev/null +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java @@ -0,0 +1,110 @@ +/* + * + * 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.grant; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationValidator; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationContext; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationValidator; +import org.springframework.util.StringUtils; + +import java.util.Set; +import java.util.function.Consumer; + +/** + * Which grant may yield which scopes (#251). Spring Authorization Server only checks that a requested + * scope is registered for the client, whatever the grant, so a client registered for both + * {@code client_credentials} and a customer Function Block scope could otherwise mint a customer-data + * token with no customer consent. ESPI separates the token kinds: + *
    + *
  • client_credentials (client access token, registration-token renewal, infrastructure + * clients) may never carry a customer data scope: any {@code FB=...} scope other than the + * {@value #REGISTRATION_RENEWAL_SCOPE} renewal scope is {@code invalid_scope}.
  • + *
  • authorization_code (customer consent) may never carry an administrative scope + * ({@link #ADMINISTRATIVE_SCOPES}).
  • + *
+ * Refresh tokens can only narrow the scopes of the grant they came from, so they need no rule here. + */ +public final class EspiGrantScopePolicy { + + /** Scope that renews an expired registration access token (ESPI {@code FB=36_40}). */ + public static final String REGISTRATION_RENEWAL_SCOPE = "FB=36_40"; + + /** Scopes only a client may hold for itself, never granted through a customer's consent. */ + public static final Set ADMINISTRATIVE_SCOPES = Set.of( + "ThirdParty_Admin_Access", + "DataCustodian_Admin_Access", + REGISTRATION_RENEWAL_SCOPE, + "authserver.admin", + "client.create"); + + private EspiGrantScopePolicy() { + } + + /** True for an ESPI customer data scope: a Function Block scope that is not the renewal scope. */ + public static boolean isCustomerDataScope(String scope) { + return scope != null && scope.startsWith("FB=") && !REGISTRATION_RENEWAL_SCOPE.equals(scope); + } + + /** Spring's default client_credentials scope check, followed by the ESPI rule. */ + public static Consumer clientCredentialsValidator() { + return OAuth2ClientCredentialsAuthenticationValidator.DEFAULT_SCOPE_VALIDATOR + .andThen(EspiGrantScopePolicy::rejectCustomerDataScopes); + } + + /** Spring's default authorization request checks, followed by the ESPI rule. */ + public static Consumer authorizationCodeValidator() { + return new OAuth2AuthorizationCodeRequestAuthenticationValidator() + .andThen(EspiGrantScopePolicy::rejectAdministrativeScopes); + } + + static void rejectCustomerDataScopes(OAuth2ClientCredentialsAuthenticationContext context) { + OAuth2ClientCredentialsAuthenticationToken request = context.getAuthentication(); + if (request.getScopes().stream().anyMatch(EspiGrantScopePolicy::isCustomerDataScope)) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, + "client_credentials cannot grant an ESPI customer data (Function Block) scope", null)); + } + } + + static void rejectAdministrativeScopes(OAuth2AuthorizationCodeRequestAuthenticationContext context) { + OAuth2AuthorizationCodeRequestAuthenticationToken request = context.getAuthentication(); + if (request.getScopes().stream().noneMatch(ADMINISTRATIVE_SCOPES::contains)) { + return; + } + // Same shape as Spring's own invalid_scope error: redirected back to the client's redirect URI. + String redirectUri = StringUtils.hasText(request.getRedirectUri()) + ? request.getRedirectUri() + : context.getRegisteredClient().getRedirectUris().iterator().next(); + OAuth2AuthorizationCodeRequestAuthenticationToken result = new OAuth2AuthorizationCodeRequestAuthenticationToken( + request.getAuthorizationUri(), request.getClientId(), (Authentication) request.getPrincipal(), + redirectUri, request.getState(), request.getScopes(), request.getAdditionalParameters()); + result.setAuthenticated(true); + throw new OAuth2AuthorizationCodeRequestAuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, + "OAuth 2.0 Parameter: scope (administrative scopes are not granted through customer authorization)", + null), result); + } +} diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java new file mode 100644 index 00000000..fa0f5fca --- /dev/null +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java @@ -0,0 +1,137 @@ +/* + * + * 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.grant; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationContext; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; + +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * #251: each grant yields only its own kind of scope, even for a client registered for both kinds. + */ +@DisplayName("ESPI per-grant scope policy (#251)") +class EspiGrantScopePolicyTest { + + private static final String CUSTOMER_SCOPE = "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13"; + + /** A client registered for both grants and both kinds of scope: the case Spring alone would allow. */ + private static final RegisteredClient CLIENT = RegisteredClient.withId("id-1") + .clientId("tp-1") + .clientSecret("{noop}x") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .redirectUri("https://tp.example/cb") + .scope(CUSTOMER_SCOPE) + .scope("ThirdParty_Admin_Access") + .scope(EspiGrantScopePolicy.REGISTRATION_RENEWAL_SCOPE) + .build(); + + private static OAuth2ClientCredentialsAuthenticationContext clientCredentials(Set scopes) { + OAuth2ClientAuthenticationToken principal = + new OAuth2ClientAuthenticationToken(CLIENT, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "x"); + return OAuth2ClientCredentialsAuthenticationContext + .with(new OAuth2ClientCredentialsAuthenticationToken(principal, scopes, Map.of())) + .registeredClient(CLIENT) + .build(); + } + + private static OAuth2AuthorizationCodeRequestAuthenticationContext authorizationRequest(Set scopes) { + OAuth2AuthorizationCodeRequestAuthenticationToken request = new OAuth2AuthorizationCodeRequestAuthenticationToken( + "https://as.example/oauth2/authorize", CLIENT.getClientId(), + new TestingAuthenticationToken("customer", null), "https://tp.example/cb", "state-1", scopes, Map.of()); + return OAuth2AuthorizationCodeRequestAuthenticationContext.with(request).registeredClient(CLIENT).build(); + } + + @Test + @DisplayName("client_credentials: a customer FB scope is invalid_scope") + void clientCredentialsRejectsCustomerScope() { + assertThatThrownBy(() -> EspiGrantScopePolicy.clientCredentialsValidator() + .accept(clientCredentials(Set.of(CUSTOMER_SCOPE)))) + .isInstanceOfSatisfying(OAuth2AuthenticationException.class, + e -> assertThat(e.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE)); + } + + @ParameterizedTest + @ValueSource(strings = {"ThirdParty_Admin_Access", EspiGrantScopePolicy.REGISTRATION_RENEWAL_SCOPE}) + @DisplayName("client_credentials: the client access token and renewal scopes are allowed") + void clientCredentialsAllowsAdministrativeScopes(String scope) { + assertThatCode(() -> EspiGrantScopePolicy.clientCredentialsValidator().accept(clientCredentials(Set.of(scope)))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("client_credentials: Spring's own check still runs (an unregistered scope is invalid_scope)") + void clientCredentialsKeepsDefaultCheck() { + assertThatThrownBy(() -> EspiGrantScopePolicy.clientCredentialsValidator() + .accept(clientCredentials(Set.of("DataCustodian_Admin_Access")))) + .isInstanceOfSatisfying(OAuth2AuthenticationException.class, + e -> assertThat(e.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE)); + } + + @ParameterizedTest + @ValueSource(strings = {"ThirdParty_Admin_Access", EspiGrantScopePolicy.REGISTRATION_RENEWAL_SCOPE}) + @DisplayName("authorization_code: an administrative scope is invalid_scope, redirected to the client") + void authorizationCodeRejectsAdministrativeScope(String scope) { + assertThatThrownBy(() -> EspiGrantScopePolicy.authorizationCodeValidator() + .accept(authorizationRequest(Set.of(CUSTOMER_SCOPE, scope)))) + .isInstanceOfSatisfying(OAuth2AuthorizationCodeRequestAuthenticationException.class, e -> assertThat(e) + .satisfies(ex -> assertThat(ex.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE)) + .satisfies(ex -> assertThat(ex.getAuthorizationCodeRequestAuthentication().getRedirectUri()) + .isEqualTo("https://tp.example/cb")) + .satisfies(ex -> assertThat(ex.getAuthorizationCodeRequestAuthentication().getState()) + .isEqualTo("state-1"))); + } + + @Test + @DisplayName("authorization_code: a customer FB scope is allowed") + void authorizationCodeAllowsCustomerScope() { + assertThatCode(() -> EspiGrantScopePolicy.authorizationCodeValidator() + .accept(authorizationRequest(Set.of(CUSTOMER_SCOPE)))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("only FB scopes other than FB=36_40 count as customer data scopes") + void customerDataScopeClassification() { + assertThat(Set.of(CUSTOMER_SCOPE, "FB=1_3_4_5_13_14_39")).allMatch(EspiGrantScopePolicy::isCustomerDataScope); + assertThat(Set.of("FB=36_40", "ThirdParty_Admin_Access", "openid")).noneMatch(EspiGrantScopePolicy::isCustomerDataScope); + } +} diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java index 165cc80a..43b1807d 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java @@ -280,6 +280,67 @@ void shouldValidateRequestedScopes() throws Exception { } } + @Nested + @DisplayName("ESPI per-grant scope policy (#251)") + class PerGrantScopePolicyTests { + + private String dualClientId; + private String dualClientSecret; + + /** Registered for both grants and both kinds of scope: what Spring AS alone would let through. */ + @BeforeEach + void seedDualGrantClient() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + dualClientId = "sec-it-dual-" + suffix; + dualClientSecret = "sec-it-dual-secret-" + suffix; + registeredClientRepository.save(RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId(dualClientId) + .clientName("Security IT dual-grant ThirdParty") + .clientSecret("{noop}" + dualClientSecret) + .clientIdIssuedAt(Instant.now()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .redirectUri(REDIRECT_URI) + .scope(ESPI_SCOPE_HOURLY) + .scope("ThirdParty_Admin_Access") + .clientSettings(ClientSettings.builder() + .requireAuthorizationConsent(true) + .requireProofKey(false) + .build()) + .tokenSettings(TokenSettings.builder() + .accessTokenFormat(OAuth2TokenFormat.REFERENCE) + .build()) + .build()); + } + + @Test + @DisplayName("client_credentials cannot obtain a customer FB scope, even when registered for it") + void clientCredentialsCannotGetCustomerScope() throws Exception { + mockMvc.perform(clientCredentials(dualClientId, dualClientSecret, ESPI_SCOPE_HOURLY)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("invalid_scope")); + + mockMvc.perform(clientCredentials(dualClientId, dualClientSecret, "ThirdParty_Admin_Access")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("authorization_code cannot obtain an administrative scope, even when registered for it") + void authorizationCodeCannotGetAdministrativeScope() throws Exception { + mockMvc.perform(authorize(dualClientId, REDIRECT_URI, "ThirdParty_Admin_Access")) + .andExpect(status().isFound()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .startsWith(REDIRECT_URI) + .contains("error=invalid_scope")); + + mockMvc.perform(authorize(dualClientId, REDIRECT_URI, ESPI_SCOPE_HOURLY)) + .andExpect(status().isFound()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .contains("/authorize/delegate")); + } + } + @Nested @DisplayName("CSRF Protection Tests") class CsrfProtectionTests { From 1a102a325e7896b95e5e52cbe111fa615f690713 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:42:46 -0500 Subject: [PATCH 4/8] fix(authserver): make the per-grant scope rules allowlists (#251) The Data Custodian parses scopes tolerantly (any segment order, trimmed, case-insensitive FB key), so a prefix check on "FB=" could be bypassed with a different spelling, and Upload_Admin_Access was missing from the administrative list. Both rules now fail closed: - client_credentials: only the exact administrative scope strings, including Upload_Admin_Access; - authorization_code: only openid/profile, or a scope that parses like EspiScope to Function Blocks other than the reserved 36 and 40. Unparseable or unknown scopes are rejected. Co-Authored-By: Claude Opus 5.5 --- .../grant/EspiGrantScopePolicy.java | 86 +++++++++++++++---- .../grant/EspiGrantScopePolicyTest.java | 32 ++++++- 2 files changed, 97 insertions(+), 21 deletions(-) diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java index 2830b4a0..da012dea 100644 --- a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicy.java @@ -32,20 +32,25 @@ import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationValidator; import org.springframework.util.StringUtils; +import java.util.Locale; +import java.util.Optional; import java.util.Set; +import java.util.TreeSet; import java.util.function.Consumer; /** * Which grant may yield which scopes (#251). Spring Authorization Server only checks that a requested * scope is registered for the client, whatever the grant, so a client registered for both * {@code client_credentials} and a customer Function Block scope could otherwise mint a customer-data - * token with no customer consent. ESPI separates the token kinds: + * token with no customer consent. ESPI keeps the token kinds apart, and both rules are allowlists that + * fail closed: *
    *
  • client_credentials (client access token, registration-token renewal, infrastructure - * clients) may never carry a customer data scope: any {@code FB=...} scope other than the - * {@value #REGISTRATION_RENEWAL_SCOPE} renewal scope is {@code invalid_scope}.
  • - *
  • authorization_code (customer consent) may never carry an administrative scope - * ({@link #ADMINISTRATIVE_SCOPES}).
  • + * clients) may yield only the exact {@link #ADMINISTRATIVE_SCOPES} strings. + *
  • authorization_code (customer consent) may yield only {@code openid} / {@code profile} + * and ESPI customer data scopes: scopes that, parsed the way the Data Custodian parses them + * ({@code EspiScope}: {@code ;}-separated, trimmed, case-insensitive {@code FB} key, {@code _}-separated + * integers), name at least one Function Block and none of the {@link #RESERVED_FUNCTION_BLOCKS}.
  • *
* Refresh tokens can only narrow the scopes of the grant they came from, so they need no rule here. */ @@ -54,45 +59,92 @@ public final class EspiGrantScopePolicy { /** Scope that renews an expired registration access token (ESPI {@code FB=36_40}). */ public static final String REGISTRATION_RENEWAL_SCOPE = "FB=36_40"; - /** Scopes only a client may hold for itself, never granted through a customer's consent. */ + /** The only scopes {@code client_credentials} may yield. Never granted through a customer's consent. */ public static final Set ADMINISTRATIVE_SCOPES = Set.of( "ThirdParty_Admin_Access", "DataCustodian_Admin_Access", + "Upload_Admin_Access", REGISTRATION_RENEWAL_SCOPE, "authserver.admin", "client.create"); + /** Function Blocks of the registration-renewal scope, which a customer can never grant. */ + public static final Set RESERVED_FUNCTION_BLOCKS = Set.of(36, 40); + + /** Non-ESPI scopes a customer authorization request may carry. */ + private static final Set CUSTOMER_IDENTITY_SCOPES = Set.of("openid", "profile"); + private EspiGrantScopePolicy() { } - /** True for an ESPI customer data scope: a Function Block scope that is not the renewal scope. */ - public static boolean isCustomerDataScope(String scope) { - return scope != null && scope.startsWith("FB=") && !REGISTRATION_RENEWAL_SCOPE.equals(scope); + /** True when {@code client_credentials} may yield the scope. */ + public static boolean isClientCredentialsScope(String scope) { + return ADMINISTRATIVE_SCOPES.contains(scope); + } + + /** True when customer authorization ({@code authorization_code}) may yield the scope. */ + public static boolean isAuthorizationCodeScope(String scope) { + if (CUSTOMER_IDENTITY_SCOPES.contains(scope)) { + return true; + } + return functionBlocks(scope) + .filter(fbs -> !fbs.isEmpty() && fbs.stream().noneMatch(RESERVED_FUNCTION_BLOCKS::contains)) + .isPresent(); + } + + /** + * The Function Blocks the Data Custodian would read from {@code scope} (mirrors {@code EspiScope.parse}), + * or empty when the scope cannot be parsed, which callers treat as not allowed. + */ + static Optional> functionBlocks(String scope) { + if (scope == null || scope.isBlank()) { + return Optional.empty(); + } + Set fbs = new TreeSet<>(); + for (String segment : scope.split(";")) { + String term = segment.trim(); + int eq = term.indexOf('='); + if (eq < 0 || !term.substring(0, eq).trim().toLowerCase(Locale.ROOT).equals("fb")) { + continue; + } + for (String token : term.substring(eq + 1).split("_")) { + String fb = token.trim(); + if (fb.isEmpty()) { + continue; + } + try { + fbs.add(Integer.parseInt(fb)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + } + return Optional.of(fbs); } /** Spring's default client_credentials scope check, followed by the ESPI rule. */ public static Consumer clientCredentialsValidator() { return OAuth2ClientCredentialsAuthenticationValidator.DEFAULT_SCOPE_VALIDATOR - .andThen(EspiGrantScopePolicy::rejectCustomerDataScopes); + .andThen(EspiGrantScopePolicy::requireClientCredentialsScopes); } /** Spring's default authorization request checks, followed by the ESPI rule. */ public static Consumer authorizationCodeValidator() { return new OAuth2AuthorizationCodeRequestAuthenticationValidator() - .andThen(EspiGrantScopePolicy::rejectAdministrativeScopes); + .andThen(EspiGrantScopePolicy::requireAuthorizationCodeScopes); } - static void rejectCustomerDataScopes(OAuth2ClientCredentialsAuthenticationContext context) { + static void requireClientCredentialsScopes(OAuth2ClientCredentialsAuthenticationContext context) { OAuth2ClientCredentialsAuthenticationToken request = context.getAuthentication(); - if (request.getScopes().stream().anyMatch(EspiGrantScopePolicy::isCustomerDataScope)) { + if (!request.getScopes().stream().allMatch(EspiGrantScopePolicy::isClientCredentialsScope)) { throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, - "client_credentials cannot grant an ESPI customer data (Function Block) scope", null)); + "client_credentials grants only administrative scopes, never ESPI customer data scopes", null)); } } - static void rejectAdministrativeScopes(OAuth2AuthorizationCodeRequestAuthenticationContext context) { + static void requireAuthorizationCodeScopes(OAuth2AuthorizationCodeRequestAuthenticationContext context) { OAuth2AuthorizationCodeRequestAuthenticationToken request = context.getAuthentication(); - if (request.getScopes().stream().noneMatch(ADMINISTRATIVE_SCOPES::contains)) { + if (request.getScopes().stream().allMatch(EspiGrantScopePolicy::isAuthorizationCodeScope)) { return; } // Same shape as Spring's own invalid_scope error: redirected back to the client's redirect URI. @@ -104,7 +156,7 @@ static void rejectAdministrativeScopes(OAuth2AuthorizationCodeRequestAuthenticat redirectUri, request.getState(), request.getScopes(), request.getAdditionalParameters()); result.setAuthenticated(true); throw new OAuth2AuthorizationCodeRequestAuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, - "OAuth 2.0 Parameter: scope (administrative scopes are not granted through customer authorization)", + "OAuth 2.0 Parameter: scope (customer authorization grants only ESPI customer data scopes)", null), result); } } diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java index fa0f5fca..7667501b 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/grant/EspiGrantScopePolicyTest.java @@ -128,10 +128,34 @@ void authorizationCodeAllowsCustomerScope() { .doesNotThrowAnyException(); } + /** Spellings the Data Custodian's tolerant parser still reads as customer Function Blocks. */ + @ParameterizedTest + @ValueSource(strings = {"IntervalDuration=3600;FB=4_5_15", " FB=4_5_15", "fb=4_5_15", "FB =4", "Foo;FB=1_3_4"}) + @DisplayName("client_credentials: any spelling of a customer scope is invalid_scope (allowlist, fails closed)") + void clientCredentialsRejectsAnySpellingOfCustomerScope(String scope) { + assertThat(EspiGrantScopePolicy.isClientCredentialsScope(scope)).isFalse(); + assertThat(EspiGrantScopePolicy.isAuthorizationCodeScope(scope)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"ThirdParty_Admin_Access", "DataCustodian_Admin_Access", "Upload_Admin_Access", + "FB=36_40", "fb=36_40", "FB=36", "FB=40;IntervalDuration=900", "FB=4_36", "authserver.admin", + "client.create", "FB=abc", "FB=", "FB_1", "anything_else"}) + @DisplayName("authorization_code: administrative, reserved-FB, unparseable and unknown scopes are not allowed") + void authorizationCodeAllowlist(String scope) { + assertThat(EspiGrantScopePolicy.isAuthorizationCodeScope(scope)).isFalse(); + } + @Test - @DisplayName("only FB scopes other than FB=36_40 count as customer data scopes") - void customerDataScopeClassification() { - assertThat(Set.of(CUSTOMER_SCOPE, "FB=1_3_4_5_13_14_39")).allMatch(EspiGrantScopePolicy::isCustomerDataScope); - assertThat(Set.of("FB=36_40", "ThirdParty_Admin_Access", "openid")).noneMatch(EspiGrantScopePolicy::isCustomerDataScope); + @DisplayName("scope classification: each grant's allowlist") + void scopeClassification() { + assertThat(Set.of(CUSTOMER_SCOPE, "FB=1_3_4_5_13_14_39", "openid", "profile")) + .allMatch(EspiGrantScopePolicy::isAuthorizationCodeScope) + .noneMatch(EspiGrantScopePolicy::isClientCredentialsScope); + assertThat(EspiGrantScopePolicy.ADMINISTRATIVE_SCOPES) + .allMatch(EspiGrantScopePolicy::isClientCredentialsScope) + .noneMatch(EspiGrantScopePolicy::isAuthorizationCodeScope); + assertThat(Set.of(" ThirdParty_Admin_Access", "thirdparty_admin_access", "FB=36_40;", "openid")) + .noneMatch(EspiGrantScopePolicy::isClientCredentialsScope); } } From 928aaa14d00d620db5dc982b6586ea2f3307b07e Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:44:42 -0500 Subject: [PATCH 5/8] feat(local): align local AS/TP OAuth config and add start/stop/smoke scripts (#254) Co-Authored-By: Claude Opus 5.5 --- .../config/AuthorizationServerConfig.java | 25 ++++++- .../src/main/resources/application-local.yml | 13 ++++ .../integration/SecurityIntegrationTest.java | 8 ++- .../src/main/resources/application-local.yml | 14 +++- scripts/local/smoke.sh | 68 +++++++++++++++++++ scripts/local/start-all.sh | 65 ++++++++++++++++++ scripts/local/stop-all.sh | 27 ++++++++ 7 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 scripts/local/smoke.sh create mode 100644 scripts/local/start-all.sh create mode 100644 scripts/local/stop-all.sh 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 b7a7ab07..e0cc99a9 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 @@ -208,6 +208,24 @@ public class AuthorizationServerConfig { @Value("${oauth2.client.defaults.redirect-uri-base:http://localhost}") private String defaultRedirectUriBase; + /** + * Redirect URIs of the seeded {@code third_party} client. Empty (the default) keeps the historical + * {@code :8080/DataCustodian/oauth/callback} and {@code :9090/ThirdParty/oauth/callback}. + * The {@code local} profile lists the Third Party's real Spring OAuth2 client callbacks (#254). + */ + @Value("${espi.authserver.seed.third-party.redirect-uris:}") + private List thirdPartyRedirectUris; + + /** The configured {@code third_party} redirect URIs, or the historical defaults when none are set. */ + List thirdPartyRedirectUris() { + List configured = thirdPartyRedirectUris == null ? List.of() + : thirdPartyRedirectUris.stream().map(String::trim).filter(uri -> !uri.isEmpty()).toList(); + return configured.isEmpty() + ? List.of(defaultRedirectUriBase + ":8080/DataCustodian/oauth/callback", + defaultRedirectUriBase + ":9090/ThirdParty/oauth/callback") + : configured; + } + @Value("${espi.authorization-server.introspection-endpoint:http://localhost:8080/oauth2/introspect}") private String introspectionUri; @@ -710,13 +728,16 @@ public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTe .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + // ESPI client access token for the Authorization and Bulk APIs (#251, #256). Customer FB + // scopes cannot be obtained this way (EspiGrantScopePolicy). + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .clientIdIssuedAt(Instant.now()) - .redirectUri(defaultRedirectUriBase + ":8080/DataCustodian/oauth/callback") - .redirectUri(defaultRedirectUriBase + ":9090/ThirdParty/oauth/callback") + .redirectUris(uris -> uris.addAll(thirdPartyRedirectUris())) .postLogoutRedirectUri(defaultRedirectUriBase + ":8080/") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .scopes(scopes -> scopes.addAll(ESPI_THIRD_PARTY_SCOPES)) + .scope("ThirdParty_Admin_Access") .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(360)) .refreshTokenTimeToLive(Duration.ofMinutes(3600)) diff --git a/openespi-authserver/src/main/resources/application-local.yml b/openespi-authserver/src/main/resources/application-local.yml index f471d2ae..487b33a6 100644 --- a/openespi-authserver/src/main/resources/application-local.yml +++ b/openespi-authserver/src/main/resources/application-local.yml @@ -79,3 +79,16 @@ espi: # Seed the demo OAuth clients (well-known secrets; never in production). espi.authserver.seed.demo-clients: true + +# ---- Local manual-test kit (#254). DEV ONLY: every value below is published in this repository. ---- +# AS admin console login admin / admin (bcrypt of "admin"); a passkey is enrolled at first login. +# Environment variables still win, so a developer can use their own. +espi.authserver.admin.bootstrap.username: ${ESPI_BOOTSTRAP_ADMIN_USERNAME:admin} +espi.authserver.admin.bootstrap.password-hash: ${ESPI_BOOTSTRAP_ADMIN_PASSWORD_HASH:$2a$10$lBNJLX4/6hEqECVzSTxCqeOM3Ve.w3FxG2F2y.KeN8kkS9Y/0/f6e} +# espi_admin (authserver.admin bearer access) and espi_registrar (RFC 7591 initial access tokens) +espi.authserver.admin-client.secret: ${ESPI_ADMIN_CLIENT_SECRET:local-admin-secret} +espi.authserver.registrar.client-secret: ${ESPI_REGISTRAR_CLIENT_SECRET:local-registrar-secret} +# third_party redirect URIs = the local Third Party's Spring OAuth2 client callbacks +espi.authserver.seed.third-party.redirect-uris: >- + http://localhost:8082/ThirdParty/login/oauth2/code/datacustodian-monthly, + http://localhost:8082/ThirdParty/login/oauth2/code/datacustodian-daily diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java index 165cc80a..6c23c70b 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java @@ -103,15 +103,17 @@ class SecurityIntegrationTest extends AbstractAuthserverIntegrationTest { /** Per-test ESPI authorization_code client with an https redirect URI (Spring AS rejects "localhost"). */ private String authCodeClientId; + private String authCodeClientSecret; @BeforeEach void seedAuthorizationCodeClient() { String suffix = UUID.randomUUID().toString().substring(0, 8); authCodeClientId = "sec-it-tp-" + suffix; + authCodeClientSecret = "sec-it-secret-" + suffix; // JdbcRegisteredClientRepository requires unique secrets registeredClientRepository.save(RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(authCodeClientId) .clientName("Security IT ThirdParty") - .clientSecret("{noop}sec-it-secret-" + suffix) // JdbcRegisteredClientRepository requires unique secrets + .clientSecret("{noop}" + authCodeClientSecret) .clientIdIssuedAt(Instant.now()) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) @@ -241,8 +243,8 @@ void shouldValidateGrantTypeAuthorization() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("invalid_grant")); - // third_party is authorization_code/refresh_token only - mockMvc.perform(clientCredentials(TP_CLIENT_ID, TP_CLIENT_SECRET, ESPI_SCOPE_HOURLY)) + // the per-test ThirdParty client is authorization_code only + mockMvc.perform(clientCredentials(authCodeClientId, authCodeClientSecret, ESPI_SCOPE_HOURLY)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("unauthorized_client")); } diff --git a/openespi-thirdparty/src/main/resources/application-local.yml b/openespi-thirdparty/src/main/resources/application-local.yml index aa109e6d..496bfe09 100644 --- a/openespi-thirdparty/src/main/resources/application-local.yml +++ b/openespi-thirdparty/src/main/resources/application-local.yml @@ -65,4 +65,16 @@ espi: client-id: third_party # Must match the AS's seeded dev client (AuthorizationServerConfig: {noop}tp-secret); used to # introspect actuator admin tokens. - client-secret: tp-secret \ No newline at end of file + client-secret: tp-secret + +# OAuth2 client registrations aligned with the AS's local third_party seed (#254, configuration part of +# #146): same client id and secret, redirect URIs the AS registers on the local profile, and the exact +# ESPI scopes the AS allows. No openid/profile: the AS issues opaque ESPI tokens only. +spring.security.oauth2.client.registration.datacustodian-monthly.client-secret: tp-secret +spring.security.oauth2.client.registration.datacustodian-monthly.scope: "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13" +spring.security.oauth2.client.registration.datacustodian-daily.client-secret: tp-secret +spring.security.oauth2.client.registration.datacustodian-daily.scope: "FB=4_5_15;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13" +# The ESPI client access token (Authorization and Bulk APIs) is client_credentials, never customer consent. +spring.security.oauth2.client.registration.datacustodian-admin.client-secret: tp-secret +spring.security.oauth2.client.registration.datacustodian-admin.authorization-grant-type: client_credentials +spring.security.oauth2.client.registration.datacustodian-admin.scope: ThirdParty_Admin_Access diff --git a/scripts/local/smoke.sh b/scripts/local/smoke.sh new file mode 100644 index 00000000..0a833bc9 --- /dev/null +++ b/scripts/local/smoke.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# Non-interactive check of the three locally running applications (start them with start-all.sh). +# Uses only the DEV ONLY credentials of the `local` profile (docs/LOCAL_TESTING.md). Exit code 0 = all PASS. +set -uo pipefail + +AS=http://localhost:9999 +DC=http://localhost:8081/DataCustodian +TP=http://localhost:8082/ThirdParty +pass=0 +fail=0 + +check() { # check + if [[ "$3" == "$2" ]]; then + echo "PASS $1"; pass=$((pass + 1)) + else + echo "FAIL $1 (expected HTTP $2, got $3)"; fail=$((fail + 1)) + fi +} + +status() { curl -s -o /dev/null -w '%{http_code}' "$@"; } + +token() { # token -> prints the access token, or nothing + curl -s -u "$1:$2" -d grant_type=client_credentials --data-urlencode "scope=$3" "$AS/oauth2/token" \ + | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' +} + +echo "== Health" +check "AS health" 200 "$(status "$AS/actuator/health")" +check "DC health" 200 "$(status "$DC/actuator/health")" +check "TP health" 200 "$(status "$TP/actuator/health")" + +echo "== Client credentials tokens (AS)" +DC_ADMIN=$(token data_custodian_admin dc-secret DataCustodian_Admin_Access) +TP_CLIENT=$(token third_party tp-secret ThirdParty_Admin_Access) +AS_ADMIN=$(token espi_admin local-admin-secret authserver.admin) +REGISTRAR=$(token espi_registrar local-registrar-secret client.create) +check "data_custodian_admin -> DataCustodian_Admin_Access" yes "$([[ -n "$DC_ADMIN" ]] && echo yes || echo no)" +check "third_party -> ThirdParty_Admin_Access (client access token)" yes "$([[ -n "$TP_CLIENT" ]] && echo yes || echo no)" +check "espi_admin -> authserver.admin" yes "$([[ -n "$AS_ADMIN" ]] && echo yes || echo no)" +check "espi_registrar -> client.create (RFC 7591 initial access token)" yes "$([[ -n "$REGISTRAR" ]] && echo yes || echo no)" +check "third_party cannot get a customer FB scope with client_credentials" 400 \ + "$(status -u third_party:tp-secret -d grant_type=client_credentials \ + --data-urlencode 'scope=FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13' "$AS/oauth2/token")" + +echo "== Introspection" +active=$(curl -s -u datacustodian:datacustodian-secret -d "token=$DC_ADMIN" "$AS/oauth2/introspect") +check "DC introspection client sees the DC admin token as active" yes \ + "$([[ "$active" == *'"active":true'* ]] && echo yes || echo no)" + +echo "== Data Custodian ESPI feeds (DataCustodian_Admin_Access)" +for resource in ApplicationInformation Authorization UsagePoint MeterReading ReadingType IntervalBlock \ + UsageSummary ElectricPowerQualitySummary Customer CustomerAccount; do + check "GET $resource feed" 200 "$(status -H "Authorization: Bearer $DC_ADMIN" "$DC/espi/1_1/resource/$resource")" +done +check "GET Authorization feed with the Third Party client access token" 200 \ + "$(status -H "Authorization: Bearer $TP_CLIENT" "$DC/espi/1_1/resource/Authorization")" +check "GET UsagePoint feed without a token" 401 "$(status "$DC/espi/1_1/resource/UsagePoint")" + +echo "== Admin actuator endpoints" +check "AS /actuator/info (authserver.admin)" 200 "$(status -H "Authorization: Bearer $AS_ADMIN" "$AS/actuator/info")" +check "DC /actuator/info (DataCustodian_Admin_Access)" 200 "$(status -H "Authorization: Bearer $DC_ADMIN" "$DC/actuator/info")" +check "TP /actuator/info (ThirdParty_Admin_Access)" 200 "$(status -H "Authorization: Bearer $TP_CLIENT" "$TP/actuator/info")" +check "AS /actuator/info without a token" 401 "$(status "$AS/actuator/info")" + +echo +echo "$pass passed, $fail failed" +(( fail == 0 )) diff --git a/scripts/local/start-all.sh b/scripts/local/start-all.sh new file mode 100644 index 00000000..28da7f43 --- /dev/null +++ b/scripts/local/start-all.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# Start the Authorization Server, Data Custodian and Third Party on the `local` profile (H2, in-memory), +# wait until each reports healthy, and print the URLs. See docs/LOCAL_TESTING.md (#254). +# +# scripts/local/start-all.sh build once (mvn install -DskipTests), then start +# scripts/local/start-all.sh --no-build start from the last build +# +# Logs: .local-run/.log Stop: scripts/local/stop-all.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RUN_DIR="$ROOT/.local-run" +TIMEOUT_SECONDS="${ESPI_START_TIMEOUT:-240}" +mkdir -p "$RUN_DIR" + +# name | module | health URL +APPS=( + "authserver|openespi-authserver|http://localhost:9999/actuator/health" + "datacustodian|openespi-datacustodian|http://localhost:8081/DataCustodian/actuator/health" + "thirdparty|openespi-thirdparty|http://localhost:8082/ThirdParty/actuator/health" +) + +if [[ "${1:-}" != "--no-build" ]]; then + echo "Building all modules (mvn install -DskipTests) ..." + (cd "$ROOT" && mvn -q install -DskipTests) +fi + +for app in "${APPS[@]}"; do + IFS='|' read -r name module health <<< "$app" + if curl -fs -o /dev/null "$health"; then + echo "$name is already running ($health)" + continue + fi + echo "Starting $name (log: .local-run/$name.log) ..." + (cd "$ROOT/$module" && nohup mvn -q spring-boot:run -Dspring-boot.run.profiles=local \ + > "$RUN_DIR/$name.log" 2>&1 &) +done + +deadline=$((SECONDS + TIMEOUT_SECONDS)) +for app in "${APPS[@]}"; do + IFS='|' read -r name module health <<< "$app" + printf "Waiting for %s " "$name" + until curl -fs -o /dev/null "$health"; do + if (( SECONDS > deadline )); then + echo; echo "FAILED: $name not healthy after ${TIMEOUT_SECONDS}s. Last log lines:" + tail -n 30 "$RUN_DIR/$name.log" || true + exit 1 + fi + printf "." + sleep 3 + done + echo " up" +done + +cat < /dev/null 2>&1; then + pids=$(lsof -ti "tcp:$port" -sTCP:LISTEN 2>/dev/null || true) + if [[ -n "$pids" ]]; then + echo "Stopping port $port (pid $pids)" + kill $pids 2>/dev/null || true + else + echo "Nothing listening on $port" + fi + else + pids=$(netstat -ano 2>/dev/null | awk -v p=":$port" '$2 ~ p"$" && $4 == "LISTENING" {print $5}' | sort -u) + if [[ -n "$pids" ]]; then + for pid in $pids; do + echo "Stopping port $port (pid $pid)" + taskkill //PID "$pid" //T //F > /dev/null 2>&1 || true + done + else + echo "Nothing listening on $port" + fi + fi +done From cc2a4f2ab743958ab2b6d0fb9119322d9ea1df76 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:48:34 -0500 Subject: [PATCH 6/8] feat(local): PowerShell scripts, LOCAL_TESTING runbook, LF for shell scripts (#254) - scripts/local/start-all.ps1, stop-all.ps1, smoke.ps1 alongside the Bash versions; both smoke checks pass 25/25 against a fresh start. - docs/LOCAL_TESTING.md: start/check/stop, URLs, logins, the seeded OAuth clients, curl recipes (tokens, introspection, feeds, actuator, RFC 7591 registration), captured ESPI XML, and the open blockers. - .gitattributes keeps *.sh at LF so they run on Windows checkouts. - .local-run/ (script logs) is git-ignored. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 4 ++ .gitignore | 3 ++ docs/LOCAL_TESTING.md | 100 ++++++++++++++++++++++++++++++++++++ scripts/local/smoke.ps1 | 79 ++++++++++++++++++++++++++++ scripts/local/smoke.sh | 0 scripts/local/start-all.ps1 | 67 ++++++++++++++++++++++++ scripts/local/start-all.sh | 2 +- scripts/local/stop-all.ps1 | 13 +++++ scripts/local/stop-all.sh | 0 9 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 docs/LOCAL_TESTING.md create mode 100644 scripts/local/smoke.ps1 mode change 100644 => 100755 scripts/local/smoke.sh create mode 100644 scripts/local/start-all.ps1 mode change 100644 => 100755 scripts/local/start-all.sh create mode 100644 scripts/local/stop-all.ps1 mode change 100644 => 100755 scripts/local/stop-all.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3768573b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Shell scripts must keep LF line endings, or bash fails on checkouts with core.autocrlf=true (Windows). +*.sh text eol=lf +# PowerShell scripts are fine with CRLF on Windows. +*.ps1 text eol=crlf diff --git a/.gitignore b/.gitignore index 97b70f46..253b33e0 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ package-lock.json # Dev-only ESPI XML capture output (EspiXmlCaptureFilter, #119) — never committed dev-xml-capture/ + +# Logs of scripts/local/start-all (#254) +.local-run/ diff --git a/docs/LOCAL_TESTING.md b/docs/LOCAL_TESTING.md new file mode 100644 index 00000000..ce206e7e --- /dev/null +++ b/docs/LOCAL_TESTING.md @@ -0,0 +1,100 @@ +# Local manual testing + +Run the Authorization Server (AS), Data Custodian (DC) and Third Party (TP) together on your machine and test them by hand. Everything here uses the `local` Spring profile: in-memory H2 databases (all data resets on restart) and **DEV ONLY** credentials that are published in this repository. + +> **Never use these credentials outside your machine.** The AS seeds the demo clients only when `espi.authserver.seed.demo-clients=true` (the local/dev/test profiles), and refuses to start with it on unless the issuer is `localhost`. + +Prerequisites: Java 25, Maven 3.9+, `curl`. No Docker is needed. + +## Start, check, stop + +| | Bash (Linux, macOS, Git Bash) | PowerShell 7 (Windows) | +|---|---|---| +| Build and start all three, wait until healthy | `scripts/local/start-all.sh` | `scripts\local\start-all.ps1` | +| Start without rebuilding | `scripts/local/start-all.sh --no-build` | `scripts\local\start-all.ps1 -NoBuild` | +| Non-interactive check (tokens, introspection, feeds, actuator) | `scripts/local/smoke.sh` | `scripts\local\smoke.ps1` | +| Stop all three (by port) | `scripts/local/stop-all.sh` | `scripts\local\stop-all.ps1` | + +Logs go to `.local-run/.log`. Run the smoke check before testing by hand; it exits non-zero if anything fails. + +## URLs + +| App | URL | In-memory database | +|---|---|---| +| Authorization Server | http://localhost:9999 | `jdbc:h2:mem:oauth2_authserver` | +| Data Custodian | http://localhost:8081/DataCustodian | `jdbc:h2:mem:datacustodian` | +| Third Party | http://localhost:8082/ThirdParty | `jdbc:h2:mem:thirdparty` | + +## Logins + +| App | User | Notes | +|---|---|---| +| AS admin | `admin` / `admin` at http://localhost:9999/login | A passkey is enrolled at first login (`/webauthn/register`), by design. Override with `ESPI_BOOTSTRAP_ADMIN_USERNAME` / `ESPI_BOOTSTRAP_ADMIN_PASSWORD_HASH`. | +| DC admin | `admin` / `admin` | Sandbox custodian user (dev/test profiles only). | +| DC customer | *not yet* | Sample customer and usage data: #253. | +| TP user | *not yet* | TP username/password login: #249. | + +## OAuth clients (AS) + +| client_id | Secret | Grant | Scope | Purpose | +|---|---|---|---|---| +| `third_party` | `tp-secret` | authorization_code, refresh_token | ESPI FB scopes (`FB=4_5_15;IntervalDuration=3600;…`) | The TP's customer flow (customer access token) | +| `third_party` | `tp-secret` | client_credentials | `ThirdParty_Admin_Access` | The TP's client access token: ESPI Authorization (and later Bulk) APIs, TP actuator | +| `data_custodian_admin` | `dc-secret` | client_credentials | `DataCustodian_Admin_Access` | DC operator: every ESPI resource, DC actuator | +| `third_party_admin` | `tpadmin-secret` | client_credentials | `ThirdParty_Admin_Access` | Legacy separate TP admin client | +| `datacustodian` | `datacustodian-secret` | client_credentials | – | DC token introspection | +| `espi_admin` | `local-admin-secret` | client_credentials | `authserver.admin` | AS admin APIs, AS actuator | +| `espi_registrar` | `local-registrar-secret` | client_credentials | `client.create` | RFC 7591 initial access token | + +Each grant yields only its own kind of scope: `client_credentials` never yields a customer FB scope, and customer authorization never yields an administrative scope (#251). + +The TP's Spring OAuth2 registrations match these: `datacustodian-monthly` and `datacustodian-daily` (authorization_code, redirect `http://localhost:8082/ThirdParty/login/oauth2/code/`) and `datacustodian-admin` (client_credentials). + +## curl recipes + +```bash +AS=http://localhost:9999; DC=http://localhost:8081/DataCustodian; TP=http://localhost:8082/ThirdParty + +# A client_credentials token (pick any client row above) +DC_ADMIN=$(curl -s -u data_custodian_admin:dc-secret -d grant_type=client_credentials \ + -d scope=DataCustodian_Admin_Access $AS/oauth2/token | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') +TP_CLIENT=$(curl -s -u third_party:tp-secret -d grant_type=client_credentials \ + -d scope=ThirdParty_Admin_Access $AS/oauth2/token | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') + +# Introspection (as the DC does it) +curl -s -u datacustodian:datacustodian-secret -d "token=$DC_ADMIN" $AS/oauth2/introspect + +# ESPI feeds (Atom XML) +curl -s -H "Authorization: Bearer $DC_ADMIN" $DC/espi/1_1/resource/UsagePoint +curl -s -H "Authorization: Bearer $TP_CLIENT" $DC/espi/1_1/resource/Authorization # only this TP's rows + +# Admin actuator endpoints +curl -s -H "Authorization: Bearer $DC_ADMIN" $DC/actuator/info +curl -s -H "Authorization: Bearer $TP_CLIENT" $TP/actuator/info + +# RFC 7591 registration: initial access token, then register +IAT=$(curl -s -u espi_registrar:local-registrar-secret -d grant_type=client_credentials \ + -d scope=client.create $AS/oauth2/token | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') +curl -s -H "Authorization: Bearer $IAT" -H 'Content-Type: application/json' $AS/oauth2/register -d '{ + "client_name": "My Third Party", "grant_types": ["authorization_code", "refresh_token"], + "redirect_uris": ["https://tp.example/callback"], + "scope": "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13" }' + +# Start the customer flow as the TP does (follow the redirects in a browser) +open "$TP/oauth2/authorization/datacustodian-monthly" +``` + +## Captured ESPI XML + +On the `local` profile the DC writes every ESPI resource response to `dev-xml-capture/run-/` at the repository root (`GET_UsagePoint.xml`, …), and an `.error.txt` with the status, body or exception chain for any 4xx/5xx or unhandled exception. The folder is git-ignored. + +## Known limitations + +The customer flow does not complete yet. Open blockers, in the order they are hit (see the roadmap, #255): + +- **#253:** no sample customer or usage data on the DC, so feeds are empty. +- **#249:** the TP has no user login page. +- **#232:** the AS → DC delegation (`/authorize/delegate`) drops the authorization parameters and returns 400. +- **#146 / #202:** the TP's token handling and the resource URIs in the token response. +- **#197 / #89:** the TP cannot yet read and display the DC's feeds. +- **#211:** a customer narrowing the requested data at consent gets `invalid_scope`. diff --git a/scripts/local/smoke.ps1 b/scripts/local/smoke.ps1 new file mode 100644 index 00000000..67fe58f1 --- /dev/null +++ b/scripts/local/smoke.ps1 @@ -0,0 +1,79 @@ +<# + Non-interactive check of the three locally running applications (start them with start-all.ps1). + Uses only the DEV ONLY credentials of the `local` profile (docs/LOCAL_TESTING.md). Exit code 0 = all PASS. + Requires PowerShell 7+. +#> +$AS = 'http://localhost:9999' +$DC = 'http://localhost:8081/DataCustodian' +$TP = 'http://localhost:8082/ThirdParty' +$script:pass = 0 +$script:fail = 0 + +function Check([string]$description, $expected, $actual) { + if ("$actual" -eq "$expected") { Write-Host "PASS $description"; $script:pass++ } + else { Write-Host "FAIL $description (expected $expected, got $actual)"; $script:fail++ } +} + +function Basic([string]$id, [string]$secret) { + @{ Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${id}:${secret}")) } +} + +function Bearer([string]$token) { @{ Authorization = "Bearer $token" } } + +function Status([string]$uri, [hashtable]$headers = @{}, [string]$method = 'GET', $body = $null) { + try { + (Invoke-WebRequest -Uri $uri -Method $method -Headers $headers -Body $body -UseBasicParsing ` + -SkipHttpErrorCheck -MaximumRedirection 0 -ErrorAction Stop).StatusCode + } catch { 'no response' } +} + +function Token([string]$id, [string]$secret, [string]$scope) { + try { + (Invoke-RestMethod -Uri "$AS/oauth2/token" -Method Post -Headers (Basic $id $secret) ` + -Body @{ grant_type = 'client_credentials'; scope = $scope }).access_token + } catch { $null } +} + +Write-Host '== Health' +Check 'AS health' 200 (Status "$AS/actuator/health") +Check 'DC health' 200 (Status "$DC/actuator/health") +Check 'TP health' 200 (Status "$TP/actuator/health") + +Write-Host '== Client credentials tokens (AS)' +$dcAdmin = Token 'data_custodian_admin' 'dc-secret' 'DataCustodian_Admin_Access' +$tpClient = Token 'third_party' 'tp-secret' 'ThirdParty_Admin_Access' +$asAdmin = Token 'espi_admin' 'local-admin-secret' 'authserver.admin' +$registrar = Token 'espi_registrar' 'local-registrar-secret' 'client.create' +Check 'data_custodian_admin -> DataCustodian_Admin_Access' $true ([bool]$dcAdmin) +Check 'third_party -> ThirdParty_Admin_Access (client access token)' $true ([bool]$tpClient) +Check 'espi_admin -> authserver.admin' $true ([bool]$asAdmin) +Check 'espi_registrar -> client.create (RFC 7591 initial access token)' $true ([bool]$registrar) +Check 'third_party cannot get a customer FB scope with client_credentials' 400 ` + (Status "$AS/oauth2/token" (Basic 'third_party' 'tp-secret') 'POST' ` + @{ grant_type = 'client_credentials'; scope = 'FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13' }) + +Write-Host '== Introspection' +$introspection = try { + Invoke-RestMethod -Uri "$AS/oauth2/introspect" -Method Post -Headers (Basic 'datacustodian' 'datacustodian-secret') ` + -Body @{ token = $dcAdmin } +} catch { $null } +Check 'DC introspection client sees the DC admin token as active' $true ([bool]$introspection.active) + +Write-Host '== Data Custodian ESPI feeds (DataCustodian_Admin_Access)' +foreach ($resource in 'ApplicationInformation', 'Authorization', 'UsagePoint', 'MeterReading', 'ReadingType', + 'IntervalBlock', 'UsageSummary', 'ElectricPowerQualitySummary', 'Customer', 'CustomerAccount') { + Check "GET $resource feed" 200 (Status "$DC/espi/1_1/resource/$resource" (Bearer $dcAdmin)) +} +Check 'GET Authorization feed with the Third Party client access token' 200 ` + (Status "$DC/espi/1_1/resource/Authorization" (Bearer $tpClient)) +Check 'GET UsagePoint feed without a token' 401 (Status "$DC/espi/1_1/resource/UsagePoint") + +Write-Host '== Admin actuator endpoints' +Check 'AS /actuator/info (authserver.admin)' 200 (Status "$AS/actuator/info" (Bearer $asAdmin)) +Check 'DC /actuator/info (DataCustodian_Admin_Access)' 200 (Status "$DC/actuator/info" (Bearer $dcAdmin)) +Check 'TP /actuator/info (ThirdParty_Admin_Access)' 200 (Status "$TP/actuator/info" (Bearer $tpClient)) +Check 'AS /actuator/info without a token' 401 (Status "$AS/actuator/info") + +Write-Host '' +Write-Host "$($script:pass) passed, $($script:fail) failed" +exit ([int]($script:fail -gt 0)) diff --git a/scripts/local/smoke.sh b/scripts/local/smoke.sh old mode 100644 new mode 100755 diff --git a/scripts/local/start-all.ps1 b/scripts/local/start-all.ps1 new file mode 100644 index 00000000..c1dade53 --- /dev/null +++ b/scripts/local/start-all.ps1 @@ -0,0 +1,67 @@ +<# + Start the Authorization Server, Data Custodian and Third Party on the `local` profile (H2, in-memory), + wait until each reports healthy, and print the URLs. See docs/LOCAL_TESTING.md (#254). + + scripts\local\start-all.ps1 build once (mvn install -DskipTests), then start + scripts\local\start-all.ps1 -NoBuild start from the last build + + Logs: .local-run\.log Stop: scripts\local\stop-all.ps1 +#> +param([switch]$NoBuild, [int]$TimeoutSeconds = 240) +$ErrorActionPreference = 'Stop' + +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$runDir = Join-Path $root '.local-run' +New-Item -ItemType Directory -Force $runDir | Out-Null + +$apps = @( + @{ Name = 'authserver'; Module = 'openespi-authserver'; Health = 'http://localhost:9999/actuator/health' }, + @{ Name = 'datacustodian'; Module = 'openespi-datacustodian'; Health = 'http://localhost:8081/DataCustodian/actuator/health' }, + @{ Name = 'thirdparty'; Module = 'openespi-thirdparty'; Health = 'http://localhost:8082/ThirdParty/actuator/health' } +) + +function Test-Healthy([string]$url) { + try { (Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 5).StatusCode -eq 200 } catch { $false } +} + +if (-not $NoBuild) { + Write-Host 'Building all modules (mvn install -DskipTests) ...' + Push-Location $root + try { & mvn -q install -DskipTests; if ($LASTEXITCODE -ne 0) { throw 'Build failed' } } finally { Pop-Location } +} + +foreach ($app in $apps) { + if (Test-Healthy $app.Health) { Write-Host "$($app.Name) is already running ($($app.Health))"; continue } + Write-Host "Starting $($app.Name) (log: .local-run\$($app.Name).log) ..." + $log = Join-Path $runDir "$($app.Name).log" + Start-Process -FilePath 'cmd.exe' -WindowStyle Hidden ` + -WorkingDirectory (Join-Path $root $app.Module) ` + -ArgumentList '/c', "mvn -q spring-boot:run -Dspring-boot.run.profiles=local > `"$log`" 2>&1" +} + +$deadline = (Get-Date).AddSeconds($TimeoutSeconds) +foreach ($app in $apps) { + Write-Host -NoNewline "Waiting for $($app.Name) " + while (-not (Test-Healthy $app.Health)) { + if ((Get-Date) -gt $deadline) { + Write-Host '' + Write-Host "FAILED: $($app.Name) not healthy after ${TimeoutSeconds}s. Last log lines:" + Get-Content (Join-Path $runDir "$($app.Name).log") -Tail 30 -ErrorAction SilentlyContinue + exit 1 + } + Write-Host -NoNewline '.' + Start-Sleep -Seconds 3 + } + Write-Host ' up' +} + +Write-Host @' + +All three applications are up (local profile, in-memory H2: data resets on restart). + + Authorization Server http://localhost:9999 admin login: /login (admin / admin, then a passkey) + Data Custodian http://localhost:8081/DataCustodian (admin / admin) + Third Party http://localhost:8082/ThirdParty + +Next: scripts\local\smoke.ps1 Runbook: docs\LOCAL_TESTING.md Stop: scripts\local\stop-all.ps1 +'@ diff --git a/scripts/local/start-all.sh b/scripts/local/start-all.sh old mode 100644 new mode 100755 index 28da7f43..31109646 --- a/scripts/local/start-all.sh +++ b/scripts/local/start-all.sh @@ -57,7 +57,7 @@ cat < +foreach ($port in 9999, 8081, 8082) { + $owners = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique + if (-not $owners) { Write-Host "Nothing listening on $port"; continue } + foreach ($owner in $owners) { + Write-Host "Stopping port $port (pid $owner)" + & taskkill /PID $owner /T /F | Out-Null + } +} diff --git a/scripts/local/stop-all.sh b/scripts/local/stop-all.sh old mode 100644 new mode 100755 From 59b9d7e7821e14cc9010cf4e89708815e1d32e22 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:50:48 -0500 Subject: [PATCH 7/8] test(#251): request an administrative scope in the client_credentials introspection test The GHSA-mh65 test minted a client_credentials token for the placeholder scope FB_1, which the per-grant allowlist now rejects. It requests ThirdParty_Admin_Access instead. Co-Authored-By: Claude Opus 5.5 --- .../AdminEndpointAuthorizationIntegrationTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminEndpointAuthorizationIntegrationTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminEndpointAuthorizationIntegrationTest.java index 9568299c..98ba5a0d 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminEndpointAuthorizationIntegrationTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminEndpointAuthorizationIntegrationTest.java @@ -87,6 +87,8 @@ class AdminEndpointAuthorizationIntegrationTest extends AbstractAuthserverIntegr private static final String REDIRECT_URI = "https://tp.example/cb"; private static final String SCOPE = "FB_1"; + /** client_credentials yields only administrative scopes (#251, EspiGrantScopePolicy). */ + private static final String CLIENT_CREDENTIALS_SCOPE = "ThirdParty_Admin_Access"; @Autowired private MockMvc mockMvc; @@ -131,6 +133,7 @@ void seedVictimClient() { .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri(REDIRECT_URI) .scope(SCOPE) + .scope(CLIENT_CREDENTIALS_SCOPE) .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(true) .requireProofKey(false) @@ -357,7 +360,7 @@ void tokenAndIntrospectionStillWork() throws Exception { MvcResult token = mockMvc.perform(post("/oauth2/token") .with(httpBasic(victimClientId, victimClientSecret)) .param("grant_type", "client_credentials") - .param("scope", SCOPE)) + .param("scope", CLIENT_CREDENTIALS_SCOPE)) .andExpect(status().isOk()) .andExpect(jsonPath("$.access_token").isNotEmpty()) .andReturn(); From d7d9502d61a3c5d948bf1eaca9edbfe4c18443b0 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 18:57:17 -0500 Subject: [PATCH 8/8] test(#254): third_party demo client also has the client_credentials grant Co-Authored-By: Claude Opus 5.5 --- .../authserver/AuthorizationServerApplicationTests.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/AuthorizationServerApplicationTests.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/AuthorizationServerApplicationTests.java index 05fbe9e5..c2ad8688 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/AuthorizationServerApplicationTests.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/AuthorizationServerApplicationTests.java @@ -138,7 +138,7 @@ void seedsDataCustodianAdminClient() { } @Test - @DisplayName("seeds third_party: authorization_code + refresh_token, ESPI scopes, opaque, 6 h, consent, no PKCE") + @DisplayName("seeds third_party: authorization_code + refresh_token + client_credentials, ESPI scopes, opaque, 6 h, consent, no PKCE") void seedsThirdPartyClient() { assertThat(registeredClientRepository.findByClientId("third_party")) .isNotNull() @@ -150,15 +150,17 @@ void seedsThirdPartyClient() { c -> c.getClientSettings().isRequireAuthorizationConsent(), c -> c.getClientSettings().isRequireProofKey()) .containsExactly( + // client_credentials: the ESPI client access token (#251, #254) Set.of(AuthorizationGrantType.AUTHORIZATION_CODE, - AuthorizationGrantType.REFRESH_TOKEN), + AuthorizationGrantType.REFRESH_TOKEN, + AuthorizationGrantType.CLIENT_CREDENTIALS), OAuth2TokenFormat.REFERENCE, Duration.ofHours(6), true, false)) .extracting(RegisteredClient::getScopes) .satisfies(scopes -> assertThat(scopes).contains( - "openid", "profile", + "openid", "profile", "ThirdParty_Admin_Access", "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13", "FB=4_5_15;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13")); }