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 3f1bd2b2..a4ec6fc0 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 @@ -106,6 +106,12 @@ public class AuthorizationServerConfig { @Value("${espi.authorization-server.client-secret:datacustodian-secret}") private String clientSecret; + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") + private String jwtIssuerUri; + + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") + private String jwtJwkSetUri; + /** * OAuth2 Authorization Server Security Filter Chain @@ -125,7 +131,11 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()) .formLogin(Customizer.withDefaults()) .oauth2AuthorizationServer(authorizationServer -> - authorizationServer.oidc(Customizer.withDefaults()) // Enable OpenID Connect 1.0 + { + if (isJwtResourceServerConfigured()) { + authorizationServer.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 + } + } ) .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .csrf(Customizer.withDefaults()) @@ -137,11 +147,13 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h ) ) // Accept access tokens for User Info and/or Client Registration - .oauth2ResourceServer(resourceServer -> resourceServer - .opaqueToken(Customizer.withDefaults()) - - //.jwt(Customizer.withDefaults()) - ) + .oauth2ResourceServer(resourceServer -> { + if (isJwtResourceServerConfigured()) { + resourceServer.jwt(Customizer.withDefaults()); + } else { + resourceServer.opaqueToken(Customizer.withDefaults()); + } + }) // HTTPS Channel Security for Production //should be able to use property server.ssl.enabled=true //todo - test this @@ -172,6 +184,11 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h return http.build(); } + private boolean isJwtResourceServerConfigured() { + return (jwtIssuerUri != null && !jwtIssuerUri.isBlank()) + || (jwtJwkSetUri != null && !jwtJwkSetUri.isBlank()); + } + /** * Default Security Filter Chain for non-OAuth2 endpoints *
@@ -406,4 +423,4 @@ private static KeyPair generateRsaKey() { } return keyPair; } -} \ No newline at end of file +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/ElectricPowerQualitySummaryMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/ElectricPowerQualitySummaryMapper.java index 6c36049d..8d84c444 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/ElectricPowerQualitySummaryMapper.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/ElectricPowerQualitySummaryMapper.java @@ -30,10 +30,10 @@ /** * MapStruct mapper for converting between ElectricPowerQualitySummaryEntity and ElectricPowerQualitySummaryDto. - * + *
* Maps only ElectricPowerQualitySummary fields. IdentifiedObject fields are NOT part of the usage.xsd * definition and are handled by AtomFeedDto/AtomEntryDto. - * + *
* Handles the conversion between the JPA entity used for persistence and the DTO
* used for JAXB XML marshalling in the Green Button API.
*/
@@ -59,6 +59,14 @@ public interface ElectricPowerQualitySummaryMapper {
* @param dto the electric power quality summary DTO
* @return the electric power quality summary entity
*/
+ @Mapping(target = "updated", ignore = true)
+ @Mapping(target = "upLink", ignore = true)
+ @Mapping(target = "selfLink", ignore = true)
+ @Mapping(target = "relatedLinks", ignore = true)
+ @Mapping(target = "relatedLinkHrefs", ignore = true)
+ @Mapping(target = "published", ignore = true)
+ @Mapping(target = "description", ignore = true)
+ @Mapping(target = "created", ignore = true)
@Mapping(target = "id", ignore = true) // IdentifiedObject field handled by Atom layer
@Mapping(target = "usagePoint", ignore = true) // Relationships handled separately
@Mapping(target = "upResource", ignore = true)
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/LineItemMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/LineItemMapper.java
index c2f89d56..7fb195bc 100644
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/LineItemMapper.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/LineItemMapper.java
@@ -48,6 +48,8 @@ public interface LineItemMapper {
* @param dto the line item DTO
* @return the line item entity
*/
+ @Mapping(target = "dateTimeFromLocalDateTime", ignore = true)
+ @Mapping(target = "dateTimeFromInstant", ignore = true)
@Mapping(target = "id", ignore = true)
@Mapping(target = "usageSummary", ignore = true)
LineItemEntity toEntity(LineItemDto dto);
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerRepository.java
index 82bb79d5..385035c9 100644
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerRepository.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerRepository.java
@@ -21,7 +21,6 @@
import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerEntity;
import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.stereotype.Repository;
import java.util.UUID;
@@ -35,7 +34,6 @@
* Removed queries: findByCustomerName, findByKind, findByPucNumber, findVipCustomers,
* findCustomersWithSpecialNeeds, findByLocale, findByPriorityRange, findByOrganisationName
*/
-@Repository
public interface CustomerRepository extends JpaRepository
* This service generates deterministic UUID5 identifiers based on href URLs
* to ensure ESPI compliance and consistency across the system.
*/
@@ -45,7 +45,7 @@ public class EspiIdGeneratorService {
/**
* Generates a NAESB ESPI compliant UUID5 based on the provided href URL.
- *
+ *
* UUID5 uses SHA-1 hashing to create deterministic identifiers, ensuring
* that the same href will always generate the same UUID.
*
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationExportService.java
new file mode 100644
index 00000000..fc3e6431
--- /dev/null
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationExportService.java
@@ -0,0 +1,73 @@
+/*
+ *
+ * Copyright (c) 2025 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.common.service.impl;
+
+import jakarta.xml.bind.JAXBContext;
+import jakarta.xml.bind.JAXBException;
+import org.greenbuttonalliance.espi.common.service.BaseExportService;
+import org.springframework.stereotype.Service;
+
+import java.util.Set;
+
+/**
+ * Export service for ESPI ApplicationInformation resource.
+ *
+ * This service handles XML marshalling for the ApplicationInformation resource defined in espi.xsd.
+ *
+ * Namespace configuration:
+ * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace
+ * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix
+ */
+@Service("applicationInformationExportService")
+public class ApplicationInformationExportService extends BaseExportService {
+
+ private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom";
+ private static final String ESPI_NAMESPACE = "http://naesb.org/espi";
+
+ /**
+ * Creates JAXBContext with Atom + ApplicationInformation domain classes.
+ *
+ * @return JAXBContext configured for ApplicationInformation resource
+ * @throws JAXBException if context creation fails
+ */
+ @Override
+ protected JAXBContext createJAXBContext() throws JAXBException {
+ return JAXBContext.newInstance(
+ // Atom protocol classes
+ org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class,
+
+ // ApplicationInformation resource class (http://naesb.org/espi)
+ org.greenbuttonalliance.espi.common.dto.usage.ApplicationInformationDto.class
+ );
+ }
+
+ /**
+ * Returns the 2 namespaces for ApplicationInformation domain.
+ *
+ * @return set containing Atom and ESPI namespaces
+ */
+ @Override
+ protected Set
+ * This service handles XML marshalling for the CustomerAccount resource defined in customer.xsd.
+ *
+ * Namespace configuration:
+ * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace
+ * - Customer namespace (http://naesb.org/espi/customer) - with "cust:" prefix
+ */
+@Service("customerAccountExportService")
+public class CustomerAccountExportService extends BaseExportService {
+
+ private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom";
+ private static final String CUSTOMER_NAMESPACE = "http://naesb.org/espi/customer";
+
+ /**
+ * Creates JAXBContext with Atom + CustomerAccount domain classes.
+ *
+ * @return JAXBContext configured for CustomerAccount resource
+ * @throws JAXBException if context creation fails
+ */
+ @Override
+ protected JAXBContext createJAXBContext() throws JAXBException {
+ return JAXBContext.newInstance(
+ // Atom protocol classes
+ org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto.class,
+
+ // CustomerAccount resource class (http://naesb.org/espi/customer)
+ org.greenbuttonalliance.espi.common.dto.customer.CustomerAccountDto.class
+ );
+ }
+
+ /**
+ * Returns the 2 namespaces for CustomerAccount domain.
+ *
+ * @return set containing Atom and Customer namespaces
+ */
+ @Override
+ protected Set
+ * This service handles XML marshalling for the ElectricPowerQualitySummary resource defined in usage.xsd.
+ *
+ * Namespace configuration:
+ * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace
+ * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix
+ */
+@Service("electricPowerQualitySummaryExportService")
+public class ElectricPowerQualitySummaryExportService extends BaseExportService {
+
+ private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom";
+ private static final String ESPI_NAMESPACE = "http://naesb.org/espi";
+
+ /**
+ * Creates JAXBContext with Atom + ElectricPowerQualitySummary domain classes.
+ *
+ * @return JAXBContext configured for ElectricPowerQualitySummary resource
+ * @throws JAXBException if context creation fails
+ */
+ @Override
+ protected JAXBContext createJAXBContext() throws JAXBException {
+ return JAXBContext.newInstance(
+ // Atom protocol classes
+ org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class,
+
+ // ElectricPowerQualitySummary resource class (http://naesb.org/espi)
+ org.greenbuttonalliance.espi.common.dto.usage.ElectricPowerQualitySummaryDto.class
+ );
+ }
+
+ /**
+ * Returns the 2 namespaces for ElectricPowerQualitySummary domain.
+ *
+ * @return set containing Atom and ESPI namespaces
+ */
+ @Override
+ protected Set
+ * This service handles XML marshalling for the IntervalBlock resource defined in usage.xsd.
+ *
+ * Namespace configuration:
+ * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace
+ * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix
+ */
+@Service("intervalBlockExportService")
+public class IntervalBlockExportService extends BaseExportService {
+
+ private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom";
+ private static final String ESPI_NAMESPACE = "http://naesb.org/espi";
+
+ /**
+ * Creates JAXBContext with Atom + IntervalBlock domain classes.
+ *
+ * @return JAXBContext configured for IntervalBlock resource
+ * @throws JAXBException if context creation fails
+ */
+ @Override
+ protected JAXBContext createJAXBContext() throws JAXBException {
+ return JAXBContext.newInstance(
+ // Atom protocol classes
+ org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class,
+
+ // IntervalBlock resource class (http://naesb.org/espi)
+ org.greenbuttonalliance.espi.common.dto.usage.IntervalBlockDto.class,
+ org.greenbuttonalliance.espi.common.dto.usage.IntervalReadingDto.class,
+ org.greenbuttonalliance.espi.common.dto.usage.ReadingQualityDto.class
+ );
+ }
+
+ /**
+ * Returns the 2 namespaces for IntervalBlock domain.
+ *
+ * @return set containing Atom and ESPI namespaces
+ */
+ @Override
+ protected Set
+ * This service handles XML marshalling for the MeterReading resource defined in usage.xsd.
+ *
+ * Namespace configuration:
+ * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace
+ * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix
+ */
+@Service("meterReadingExportService")
+public class MeterReadingExportService extends BaseExportService {
+
+ private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom";
+ private static final String ESPI_NAMESPACE = "http://naesb.org/espi";
+
+ /**
+ * Creates JAXBContext with Atom + MeterReading domain classes.
+ *
+ * @return JAXBContext configured for MeterReading resource
+ * @throws JAXBException if context creation fails
+ */
+ @Override
+ protected JAXBContext createJAXBContext() throws JAXBException {
+ return JAXBContext.newInstance(
+ // Atom protocol classes
+ org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class,
+
+ // MeterReading resource class (http://naesb.org/espi)
+ org.greenbuttonalliance.espi.common.dto.usage.MeterReadingDto.class
+ );
+ }
+
+ /**
+ * Returns the 2 namespaces for MeterReading domain.
+ *
+ * @return set containing Atom and ESPI namespaces
+ */
+ @Override
+ protected Set
+ * This service handles XML marshalling for the ReadingType resource defined in usage.xsd.
+ *
+ * Namespace configuration:
+ * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace
+ * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix
+ */
+@Service("readingTypeExportService")
+public class ReadingTypeExportService extends BaseExportService {
+
+ private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom";
+ private static final String ESPI_NAMESPACE = "http://naesb.org/espi";
+
+ /**
+ * Creates JAXBContext with Atom + ReadingType domain classes.
+ *
+ * @return JAXBContext configured for ReadingType resource
+ * @throws JAXBException if context creation fails
+ */
+ @Override
+ protected JAXBContext createJAXBContext() throws JAXBException {
+ return JAXBContext.newInstance(
+ // Atom protocol classes
+ org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class,
+
+ // ReadingType resource class (http://naesb.org/espi)
+ org.greenbuttonalliance.espi.common.dto.usage.ReadingTypeDto.class
+ );
+ }
+
+ /**
+ * Returns the 2 namespaces for ReadingType domain.
+ *
+ * @return set containing Atom and ESPI namespaces
+ */
+ @Override
+ protected Set
+ * This service handles XML marshalling for the UsageSummary resource defined in usage.xsd.
+ *
+ * Namespace configuration:
+ * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace
+ * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix
+ */
+@Service("usageSummaryExportService")
+public class UsageSummaryExportService extends BaseExportService {
+
+ private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom";
+ private static final String ESPI_NAMESPACE = "http://naesb.org/espi";
+
+ /**
+ * Creates JAXBContext with Atom + UsageSummary domain classes.
+ *
+ * @return JAXBContext configured for UsageSummary resource
+ * @throws JAXBException if context creation fails
+ */
+ @Override
+ protected JAXBContext createJAXBContext() throws JAXBException {
+ return JAXBContext.newInstance(
+ // Atom protocol classes
+ org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class,
+ org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class,
+
+ // UsageSummary resource class (http://naesb.org/espi)
+ org.greenbuttonalliance.espi.common.dto.usage.UsageSummaryDto.class
+ );
+ }
+
+ /**
+ * Returns the 2 namespaces for UsageSummary domain.
+ *
+ * @return set containing Atom and ESPI namespaces
+ */
+ @Override
+ protected Set
+ * Configures Spring Authorization Server 1.3+ for ESPI Green Button Alliance protocol:
+ * - OAuth2 authorization flows (authorization_code, client_credentials, refresh_token)
+ * - JWT token settings with ESPI-compliant scopes
+ * - Client registration for DataCustodian and ThirdParty applications
+ * - JWK source for JWT signing and validation
+ *
+ * @author Green Button Alliance
+ * @version 1.0.0
+ * @since Spring Boot 3.5
+ */
+@Configuration(proxyBeanMethods = false)
+@EnableWebSecurity
+public class AuthorizationServerConfig {
+
+ @Value("${espi.security.require-https:false}")
+ private boolean requireHttps;
+
+ @Value("${spring.security.oauth2.authorizationserver.issuer:http://localhost:9999}")
+ private String issuerUri;
+
+ @Value("${oauth2.client.defaults.redirect-uri-base:http://localhost}")
+ private String defaultRedirectUriBase;
+
+ @Value("${espi.authorization-server.introspection-endpoint:http://localhost:8080/oauth2/introspect}")
+ private String introspectionUri;
+
+ @Value("${espi.authorization-server.client-id:datacustodian}")
+ private String clientId;
+
+ @Value("${espi.authorization-server.client-secret:datacustodian-secret}")
+ private String clientSecret;
+
+ @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}")
+ private String jwtIssuerUri;
+
+ @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}")
+ private String jwtJwkSetUri;
+
+
+ /**
+ * OAuth2 Authorization Server Security Filter Chain
+ *
+ * Configures the authorization server endpoints and security:
+ * - /oauth2/authorize (authorization endpoint)
+ * - /oauth2/token (token endpoint)
+ * - /oauth2/jwks (JWK Set endpoint)
+ * - /.well-known/oauth-authorization-server (discovery endpoint)
+ */
+ @Bean
+ @Order(Ordered.HIGHEST_PRECEDENCE)
+ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+ OAuth2AuthorizationServerConfigurer authorizationServer = new OAuth2AuthorizationServerConfigurer();
+ http.with(authorizationServer, authServer -> {
+ if (isJwtResourceServerConfigured()) {
+ authServer.oidc(Customizer.withDefaults());
+ }
+ });
+
+ http
+ .securityMatcher(authorizationServer.getEndpointsMatcher())
+ .authorizeHttpRequests((authorize) -> authorize
+ .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll())
+ .formLogin(Customizer.withDefaults())
+ // Accept access tokens for User Info and/or Client Registration
+ .oauth2ResourceServer(resourceServer -> {
+ if (isJwtResourceServerConfigured()) {
+ resourceServer.jwt(Customizer.withDefaults());
+ } else {
+ resourceServer.opaqueToken(Customizer.withDefaults());
+ }
+ })
+ .csrf(Customizer.withDefaults())
+ // Redirect to the login page when not authenticated from the authorization endpoint
+ .exceptionHandling(exceptions -> exceptions
+ .defaultAuthenticationEntryPointFor(
+ new LoginUrlAuthenticationEntryPoint("/login"),
+ new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
+ )
+ )
+
+ // HTTPS Channel Security for Production
+ //should be able to use property server.ssl.enabled=true
+ //todo - test this
+// .requiresChannel(channel -> {
+// if (requireHttps) {
+// channel.anyRequest().requiresSecure();
+// }
+// })
+ // Enhanced Security Headers for ESPI Compliance
+ .headers(headers -> headers
+ .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
+ .contentTypeOptions(Customizer.withDefaults())
+ .httpStrictTransportSecurity(hsts -> hsts
+ .maxAgeInSeconds(31536000)
+ .includeSubDomains(true)
+ .preload(true)
+ )
+ .referrerPolicy(referrer -> referrer
+ .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
+ )
+ )
+ .sessionManagement(session -> session
+ .sessionCreationPolicy(org.springframework.security.config.http.SessionCreationPolicy.IF_REQUIRED)
+ .maximumSessions(1)
+ .maxSessionsPreventsLogin(false)
+ );
+
+ return http.build();
+ }
+
+ private boolean isJwtResourceServerConfigured() {
+ return (jwtIssuerUri != null && !jwtIssuerUri.isBlank())
+ || (jwtJwkSetUri != null && !jwtJwkSetUri.isBlank());
+ }
+
+ /**
+ * Registered Client Repository
+ *
+ * JDBC-backed repository for OAuth2 client registrations with support for:
+ * - Dynamic client registration
+ * - ESPI-specific client management
+ * - Database persistence
+ * - Client CRUD operations
+ */
+ @Bean
+ @Primary
+ public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) {
+ JdbcRegisteredClientRepository repository = new JdbcRegisteredClientRepository(jdbcTemplate, passwordEncoder);
+
+ // Initialize with default ESPI clients if they don't exist
+ // DataCustodian Admin Client (ROLE_DC_ADMIN)
+ RegisteredClient datacustodianAdmin = RegisteredClient.withId(UUID.randomUUID().toString())
+ .clientId("data_custodian_admin")
+ .clientSecret("{bcrypt}secret")
+ .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+ .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+ .scope("DataCustodian_Admin_Access")
+ .clientIdIssuedAt(Instant.now())
+ .tokenSettings(TokenSettings.builder()
+ .accessTokenTimeToLive(Duration.ofMinutes(60))
+ .accessTokenFormat(OAuth2TokenFormat.REFERENCE) // ESPI standard: opaque tokens
+ .build())
+ .clientSettings(ClientSettings.builder()
+ .requireAuthorizationConsent(false)
+ .build())
+ .build();
+
+ // ThirdParty Client (ROLE_USER) - Environment-aware redirect URIs
+ RegisteredClient thirdPartyClient = RegisteredClient.withId(UUID.randomUUID().toString())
+ .clientId("third_party")
+ .clientSecret("{bcrypt}secret")
+ .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+ .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+ .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+ .clientIdIssuedAt(Instant.now())
+ .redirectUri(defaultRedirectUriBase + ":8080/DataCustodian/oauth/callback")
+ .redirectUri(defaultRedirectUriBase + ":9090/ThirdParty/oauth/callback")
+ .postLogoutRedirectUri(defaultRedirectUriBase + ":8080/")
+ .scope(OidcScopes.OPENID)
+ .scope(OidcScopes.PROFILE)
+ .scope("FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13")
+ .scope("FB=4_5_15;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13")
+ .tokenSettings(TokenSettings.builder()
+ .accessTokenTimeToLive(Duration.ofMinutes(360))
+ .refreshTokenTimeToLive(Duration.ofMinutes(3600))
+ .reuseRefreshTokens(true)
+ .accessTokenFormat(OAuth2TokenFormat.REFERENCE) // ESPI standard: opaque tokens
+ .build())
+ .clientSettings(ClientSettings.builder()
+ .requireAuthorizationConsent(true)
+ .build())
+ .build();
+
+ // ThirdParty Admin Client (ROLE_TP_ADMIN)
+ RegisteredClient thirdPartyAdmin = RegisteredClient.withId(UUID.randomUUID().toString())
+ .clientId("third_party_admin")
+ .clientSecret("{bcrypt}secret")
+ .clientIdIssuedAt(Instant.now())
+ .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+ .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+ .scope("ThirdParty_Admin_Access")
+ .tokenSettings(TokenSettings.builder()
+ .accessTokenTimeToLive(Duration.ofMinutes(360))
+ .accessTokenFormat(OAuth2TokenFormat.REFERENCE) // ESPI standard: opaque tokens
+ .build())
+ .clientSettings(ClientSettings.builder()
+ .requireAuthorizationConsent(false)
+ .build())
+ .build();
+
+ // Initialize default clients if they don't exist
+ initializeDefaultClients(repository, datacustodianAdmin, thirdPartyClient, thirdPartyAdmin);
+
+ return repository;
+ }
+
+ /**
+ * Initialize default ESPI clients if they don't exist in the database
+ */
+ private void initializeDefaultClients(JdbcRegisteredClientRepository repository,
+ RegisteredClient... clients) {
+ for (RegisteredClient client : clients) {
+ if (repository.findByClientId(client.getClientId()) == null) {
+ repository.save(client);
+ }
+ }
+
+ var savedClients = repository.findAll();
+ System.out.println("Default ESPI Clients: " + savedClients.size());
+ }
+
+ @Bean
+ public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
+ return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
+ }
+
+ @Bean
+ public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
+ return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
+ }
+
+ /**
+ * Authorization Server Settings
+ *
+ * Configures OAuth2 endpoint URLs and issuer
+ */
+ @Bean
+ public AuthorizationServerSettings authorizationServerSettings() {
+ return AuthorizationServerSettings.builder()
+ .issuer(issuerUri)
+ .authorizationEndpoint("/oauth2/authorize")
+ .tokenEndpoint("/oauth2/token")
+ .jwkSetEndpoint("/oauth2/jwks")
+ .tokenRevocationEndpoint("/oauth2/revoke")
+ .tokenIntrospectionEndpoint("/oauth2/introspect")
+ .oidcClientRegistrationEndpoint("/connect/register")
+ .oidcUserInfoEndpoint("/userinfo")
+
+ .build();
+ }
+}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/ClientCertificateAuthenticationConfig.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/ClientCertificateAuthenticationConfig.java
new file mode 100644
index 00000000..7d6c2ac1
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/ClientCertificateAuthenticationConfig.java
@@ -0,0 +1,260 @@
+/*
+ *
+ * Copyright (c) 2018-2025 Green Button Alliance, Inc.
+ *
+ * Portions (c) 2013-2018 EnergyOS.org
+ *
+ * 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.datacustodian.authserver.config;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import org.greenbuttonalliance.espi.datacustodian.authserver.service.ClientCertificateService;
+import org.greenbuttonalliance.espi.datacustodian.authserver.service.ClientCertificateUserDetailsService;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider;
+import org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor;
+import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter;
+import org.springframework.validation.annotation.Validated;
+
+import java.util.List;
+
+/**
+ * Configuration for certificate-based client authentication
+ *
+ * Implements NAESB ESPI 4.0 certificate-based authentication:
+ * - X.509 client certificate validation
+ * - Certificate revocation checking (CRL/OCSP)
+ * - Certificate chain validation
+ * - Subject Distinguished Name (DN) mapping
+ * - Certificate authority trust store management
+ * - ESPI-specific certificate extensions
+ *
+ * Features:
+ * - Support for mutual TLS (mTLS) authentication
+ * - Certificate-based OAuth2 client authentication
+ * - Integration with ESPI compliance requirements
+ * - Flexible trust store configuration
+ * - Certificate renewal and rotation support
+ *
+ * @author Green Button Alliance
+ * @version 1.0.0
+ * @since Spring Boot 3.5
+ */
+@Configuration
+@ConfigurationProperties(prefix = "espi.security.certificate")
+@Validated
+public class ClientCertificateAuthenticationConfig {
+
+ @NotNull
+ private boolean enableCertificateAuthentication = false;
+
+ @NotNull
+ private boolean requireClientCertificate = false;
+
+ @NotNull
+ private boolean enableCertificateRevocationCheck = true;
+
+ @NotNull
+ private boolean enableOcspCheck = true;
+
+ @NotNull
+ private boolean enableCrlCheck = true;
+
+ @NotBlank
+ private String trustStorePath = "classpath:certificates/truststore.jks";
+
+ private String trustStorePassword = "";
+
+ @NotBlank
+ private String trustStoreType = "JKS";
+
+ @NotNull
+ @Min(1)
+ @Max(365)
+ private Integer certificateValidityDays = 30;
+
+ @NotNull
+ @Min(1)
+ @Max(90)
+ private Integer certificateRenewalWarningDays = 7;
+
+ @NotNull
+ @Min(3600)
+ @Max(86400)
+ private Integer certificateCacheExpiration = 3600; // 1 hour
+
+ private List
+ * Configures:
+ * - HTTP client settings for DataCustodian communication
+ * - Connection timeouts and retry policies
+ * - Authentication credentials
+ * - Health check intervals
+ * - Synchronization settings
+ *
+ * @author Green Button Alliance
+ * @version 1.0.0
+ * @since Spring Boot 3.5
+ */
+@Configuration
+@ConfigurationProperties(prefix = "espi.datacustodian")
+@Validated
+public class DataCustodianIntegrationConfig {
+
+ @NotBlank
+ private String baseUrl = "http://localhost:8080/DataCustodian";
+
+ @NotBlank
+ private String adminClientId = "data_custodian_admin";
+
+ private String adminClientSecret = "";
+
+ @NotNull
+ @Min(1000)
+ @Max(60000)
+ private Integer connectionTimeout = 5000;
+
+ @NotNull
+ @Min(1000)
+ @Max(300000)
+ private Integer readTimeout = 10000;
+
+ @NotNull
+ @Min(1)
+ @Max(10)
+ private Integer maxRetries = 3;
+
+ @NotNull
+ @Min(1000)
+ @Max(60000)
+ private Integer retryDelay = 2000;
+
+ @NotNull
+ @Min(30)
+ @Max(3600)
+ private Integer healthCheckInterval = 300; // 5 minutes
+
+ @NotNull
+ @Min(1)
+ @Max(24)
+ private Integer healthLogRetentionDays = 24;
+
+ @NotNull
+ @Min(1)
+ @Max(24)
+ private Integer apiLogRetentionDays = 7;
+
+ private boolean enableHealthChecks = true;
+ private boolean enableApiLogging = true;
+ private boolean enableRetries = true;
+ private boolean validateSslCertificates = true;
+
+ /**
+ * Configure RestTemplate for DataCustodian communication
+ */
+ @Bean("dataCustodianRestTemplate")
+ public RestTemplate dataCustodianRestTemplate() {
+ RestTemplate restTemplate = new RestTemplate();
+
+ // Configure HTTP client factory with timeouts
+ ClientHttpRequestFactory factory = clientHttpRequestFactory();
+ restTemplate.setRequestFactory(factory);
+
+ return restTemplate;
+ }
+
+ /**
+ * Configure HTTP client factory with custom timeouts
+ */
+ private ClientHttpRequestFactory clientHttpRequestFactory() {
+ SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+ factory.setConnectTimeout(connectionTimeout);
+ factory.setReadTimeout(readTimeout);
+ return factory;
+ }
+
+ // Getters and setters
+ public String getBaseUrl() {
+ return baseUrl;
+ }
+
+ public void setBaseUrl(String baseUrl) {
+ this.baseUrl = baseUrl;
+ }
+
+ public String getAdminClientId() {
+ return adminClientId;
+ }
+
+ public void setAdminClientId(String adminClientId) {
+ this.adminClientId = adminClientId;
+ }
+
+ public String getAdminClientSecret() {
+ return adminClientSecret;
+ }
+
+ public void setAdminClientSecret(String adminClientSecret) {
+ this.adminClientSecret = adminClientSecret;
+ }
+
+ public Integer getConnectionTimeout() {
+ return connectionTimeout;
+ }
+
+ public void setConnectionTimeout(Integer connectionTimeout) {
+ this.connectionTimeout = connectionTimeout;
+ }
+
+ public Integer getReadTimeout() {
+ return readTimeout;
+ }
+
+ public void setReadTimeout(Integer readTimeout) {
+ this.readTimeout = readTimeout;
+ }
+
+ public Integer getMaxRetries() {
+ return maxRetries;
+ }
+
+ public void setMaxRetries(Integer maxRetries) {
+ this.maxRetries = maxRetries;
+ }
+
+ public Integer getRetryDelay() {
+ return retryDelay;
+ }
+
+ public void setRetryDelay(Integer retryDelay) {
+ this.retryDelay = retryDelay;
+ }
+
+ public Integer getHealthCheckInterval() {
+ return healthCheckInterval;
+ }
+
+ public void setHealthCheckInterval(Integer healthCheckInterval) {
+ this.healthCheckInterval = healthCheckInterval;
+ }
+
+ public Integer getHealthLogRetentionDays() {
+ return healthLogRetentionDays;
+ }
+
+ public void setHealthLogRetentionDays(Integer healthLogRetentionDays) {
+ this.healthLogRetentionDays = healthLogRetentionDays;
+ }
+
+ public Integer getApiLogRetentionDays() {
+ return apiLogRetentionDays;
+ }
+
+ public void setApiLogRetentionDays(Integer apiLogRetentionDays) {
+ this.apiLogRetentionDays = apiLogRetentionDays;
+ }
+
+ public boolean isEnableHealthChecks() {
+ return enableHealthChecks;
+ }
+
+ public void setEnableHealthChecks(boolean enableHealthChecks) {
+ this.enableHealthChecks = enableHealthChecks;
+ }
+
+ public boolean isEnableApiLogging() {
+ return enableApiLogging;
+ }
+
+ public void setEnableApiLogging(boolean enableApiLogging) {
+ this.enableApiLogging = enableApiLogging;
+ }
+
+ public boolean isEnableRetries() {
+ return enableRetries;
+ }
+
+ public void setEnableRetries(boolean enableRetries) {
+ this.enableRetries = enableRetries;
+ }
+
+ public boolean isValidateSslCertificates() {
+ return validateSslCertificates;
+ }
+
+ public void setValidateSslCertificates(boolean validateSslCertificates) {
+ this.validateSslCertificates = validateSslCertificates;
+ }
+}
\ No newline at end of file
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/HttpsEnforcementConfig.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/HttpsEnforcementConfig.java
new file mode 100644
index 00000000..10b9ebeb
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/HttpsEnforcementConfig.java
@@ -0,0 +1,242 @@
+/*
+ *
+ * Copyright (c) 2018-2025 Green Button Alliance, Inc.
+ *
+ * Portions (c) 2013-2018 EnergyOS.org
+ *
+ * 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.datacustodian.authserver.config;
+
+import jakarta.annotation.PostConstruct;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory;
+import org.springframework.boot.web.server.servlet.ServletWebServerFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * HTTPS Enforcement Configuration for OpenESPI Authorization Server
+ *
+ * Provides environment-specific HTTPS enforcement with NAESB ESPI 4.0 compliance:
+ * - Production: Mandatory TLS 1.3 with ESPI 4.0 approved cipher suites
+ * - Development: Flexible HTTP/HTTPS for local development
+ * - Test: HTTP allowed for testing
+ *
+ * NAESB ESPI 4.0 Requirements:
+ * - TLS 1.3 ONLY (no TLS 1.2 or earlier)
+ * - Approved cipher suites: TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256
+ * - Perfect Forward Secrecy (PFS) mandatory
+ * - Certificate validation required
+ *
+ * @author Green Button Alliance
+ * @version 1.0.0
+ * @since Spring Boot 3.5
+ */
+@Configuration
+public class HttpsEnforcementConfig {
+
+ private static final Logger logger = LoggerFactory.getLogger(HttpsEnforcementConfig.class);
+
+ @Value("${espi.security.require-https:false}")
+ private boolean requireHttps;
+
+ @Value("${spring.profiles.active:dev}")
+ private String activeProfile;
+
+ @PostConstruct
+ public void logSecurityConfiguration() {
+ logger.info("NAESB ESPI 4.0 Security Configuration:");
+ logger.info(" Active Profile: {}", activeProfile);
+ logger.info(" HTTPS Required: {}", requireHttps);
+ logger.info(" TLS Version: TLS 1.3 ONLY (NAESB ESPI 4.0 Standard)");
+ logger.info(" Cipher Suites: TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256");
+
+ if ("prod".equals(activeProfile) && !requireHttps) {
+ logger.error("CRITICAL: Production profile detected but HTTPS not enforced!");
+ logger.error("NAESB ESPI 4.0 REQUIRES TLS 1.3 enforcement in production");
+ logger.error("Set espi.security.require-https=true for production deployment");
+ throw new IllegalStateException("NAESB ESPI 4.0 requires HTTPS enforcement in production");
+ }
+
+ if (requireHttps) {
+ logger.info("NAESB ESPI 4.0 TLS 1.3 enforcement enabled");
+ logger.info(" - All HTTP requests will be redirected to HTTPS");
+ logger.info(" - Client redirect URIs must use HTTPS (except localhost for development)");
+ logger.info(" - Perfect Forward Secrecy (PFS) enforced");
+ logger.info(" - Certificate validation mandatory");
+ } else {
+ logger.info("HTTPS enforcement disabled - HTTP allowed for development/testing only");
+ logger.warn("WARNING: This configuration is NOT suitable for production deployment");
+ }
+ }
+
+ /**
+ * Production HTTPS Security Filter Chain
+ *
+ * Enforces TLS 1.3 for all requests in production environment (NAESB ESPI 4.0)
+ */
+ @Bean
+ @Profile("prod")
+ @Order(0)
+ public SecurityFilterChain httpsEnforcementFilterChain(HttpSecurity http) throws Exception {
+ logger.info("Configuring production HTTPS enforcement filter chain");
+
+ http
+ .securityMatcher("/**")
+ //should be able to use property server.ssl.enabled=true
+ //todo - test this
+// .requiresChannel(channel ->
+// channel.anyRequest().requiresSecure()
+// )
+ .headers(headers -> headers
+ .httpStrictTransportSecurity(hstsConfig -> hstsConfig
+ .maxAgeInSeconds(31536000) // 1 year
+ .includeSubDomains(true)
+ .preload(true)
+ )
+ .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
+ .contentTypeOptions(Customizer.withDefaults())
+ .addHeaderWriter((request, response) -> {
+ // NAESB ESPI 4.0 Enhanced Security Headers
+
+ response.setHeader("Strict-Transport-Security",
+ "max-age=31536000; includeSubDomains; preload");
+ response.setHeader("X-Content-Type-Options", "nosniff");
+ response.setHeader("X-Frame-Options", "DENY");
+ response.setHeader("X-XSS-Protection", "1; mode=block");
+ response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
+ response.setHeader("Content-Security-Policy",
+ "default-src 'self'; " +
+ "script-src 'self' 'unsafe-inline'; " +
+ "style-src 'self' 'unsafe-inline'; " +
+ "img-src 'self' data:; " +
+ "font-src 'self'; " +
+ "connect-src 'self'; " +
+ "frame-ancestors 'none'");
+ // NAESB ESPI 4.0 Compliance Headers
+ response.setHeader("X-ESPI-Version", "4.0");
+ response.setHeader("X-TLS-Version", "TLSv1.3");
+ response.setHeader("X-Cipher-Suites", "TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_AES_128_GCM_SHA256");
+ })
+ );
+
+ return http.build();
+ }
+
+ /**
+ * Development Security Configuration
+ *
+ * Allows HTTP for development while still providing security headers
+ */
+ @Bean
+ @Profile({"dev", "dev-mysql", "dev-postgresql", "local"})
+ @Order(0)
+ public SecurityFilterChain developmentSecurityFilterChain(HttpSecurity http) throws Exception {
+ logger.info("Configuring development security filter chain (HTTP allowed)");
+
+ http
+ .securityMatcher("/**")
+ .headers(headers -> headers
+ .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) // Less restrictive for development
+ .contentTypeOptions(Customizer.withDefaults())
+ .addHeaderWriter((request, response) -> {
+ // Development-friendly headers
+ response.setHeader("X-Content-Type-Options", "nosniff");
+ response.setHeader("X-Frame-Options", "SAMEORIGIN");
+ response.setHeader("X-XSS-Protection", "1; mode=block");
+ response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
+ response.setHeader("Pragma", "no-cache");
+ })
+ );
+
+ return http.build();
+ }
+
+ /**
+ * HTTPS Redirect Configuration for Mixed Environments
+ *
+ * Provides HTTP to HTTPS redirect when HTTPS is available but not enforced
+ */
+ @Bean
+ @Profile("!test")
+ public ServletWebServerFactory servletContainer() {
+ TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory() {
+ @Override
+ public void setPort(int port) {
+ super.setPort(port);
+
+ if (requireHttps && port != 443 && port != 8443) {
+ logger.warn("HTTPS required but non-standard HTTPS port {} configured", port);
+ logger.warn("Ensure SSL is properly configured for this port");
+ }
+ }
+ };
+ return factory;
+ }
+
+ /**
+ * NAESB ESPI 4.0 Configuration Validation
+ */
+ @PostConstruct
+ public void validateConfiguration() {
+ if ("prod".equals(activeProfile)) {
+ if (!requireHttps) {
+ throw new IllegalStateException(
+ "NAESB ESPI 4.0 requires TLS 1.3 enforcement in production. " +
+ "Set espi.security.require-https=true"
+ );
+ }
+ logger.info("NAESB ESPI 4.0 TLS 1.3 enforcement validated successfully");
+ logger.info("Production deployment meets ESPI 4.0 security requirements");
+ }
+
+ if (requireHttps) {
+ logger.info("NAESB ESPI 4.0 TLS 1.3 enforcement active for profile: {}", activeProfile);
+ logger.info("Cipher suites restricted to ESPI 4.0 approved algorithms");
+ }
+
+ // Validate Java version supports TLS 1.3
+ String javaVersion = System.getProperty("java.version");
+ logger.info("Java version: {} (TLS 1.3 support required for NAESB ESPI 4.0)", javaVersion);
+
+ if (requireHttps) {
+ // Additional runtime validation for production
+ validateTls13Support();
+ }
+ }
+
+ /**
+ * Validate TLS 1.3 support at runtime
+ */
+ private void validateTls13Support() {
+ try {
+ javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLSv1.3");
+ logger.info("TLS 1.3 support validated - SSLContext available");
+ } catch (Exception e) {
+ logger.error("CRITICAL: TLS 1.3 not supported on this Java runtime");
+ logger.error("NAESB ESPI 4.0 requires TLS 1.3 support");
+ throw new IllegalStateException("TLS 1.3 support required for NAESB ESPI 4.0 compliance", e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/OAuth2ClientManagementConfig.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/OAuth2ClientManagementConfig.java
new file mode 100644
index 00000000..68311c98
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/OAuth2ClientManagementConfig.java
@@ -0,0 +1,154 @@
+/*
+ *
+ * Copyright (c) 2018-2025 Green Button Alliance, Inc.
+ *
+ * Portions (c) 2013-2018 EnergyOS.org
+ *
+ * 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.datacustodian.authserver.config;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.validation.annotation.Validated;
+
+/**
+ * Configuration for OAuth2 Client Management features
+ *
+ * Enables and configures:
+ * - Scheduled tasks for metrics calculation
+ * - Password encoding for client secrets
+ * - Client management security settings
+ * - Rate limiting and session management defaults
+ *
+ * @author Green Button Alliance
+ * @version 1.0.0
+ * @since Spring Boot 3.5
+ */
+@Configuration
+@EnableScheduling
+@ConfigurationProperties(prefix = "espi.oauth2.client-management")
+@Validated
+public class OAuth2ClientManagementConfig {
+
+ @NotNull
+ @Min(1)
+ @Max(100)
+ private Integer defaultRateLimitPerMinute = 100;
+
+ @NotNull
+ @Min(1)
+ @Max(1000)
+ private Integer defaultMaxConcurrentSessions = 5;
+
+ @NotNull
+ @Min(1)
+ @Max(100)
+ private Integer maxFailuresBeforeLock = 10;
+
+ @NotNull
+ @Min(1)
+ @Max(43200) // 30 days in minutes
+ private Integer defaultLockDurationMinutes = 60;
+
+ @NotNull
+ @Min(1)
+ @Max(3650) // 10 years
+ private Integer metricsRetentionDays = 365;
+
+ private boolean enableAutomaticMetricsCalculation = true;
+ private boolean enableAutomaticCleanup = true;
+ private boolean enableSecurityMonitoring = true;
+
+ /**
+ * Password encoder for client secrets
+ */
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder(12); // Strong password encoding
+ }
+
+ // Getters and setters
+ public Integer getDefaultRateLimitPerMinute() {
+ return defaultRateLimitPerMinute;
+ }
+
+ public void setDefaultRateLimitPerMinute(Integer defaultRateLimitPerMinute) {
+ this.defaultRateLimitPerMinute = defaultRateLimitPerMinute;
+ }
+
+ public Integer getDefaultMaxConcurrentSessions() {
+ return defaultMaxConcurrentSessions;
+ }
+
+ public void setDefaultMaxConcurrentSessions(Integer defaultMaxConcurrentSessions) {
+ this.defaultMaxConcurrentSessions = defaultMaxConcurrentSessions;
+ }
+
+ public Integer getMaxFailuresBeforeLock() {
+ return maxFailuresBeforeLock;
+ }
+
+ public void setMaxFailuresBeforeLock(Integer maxFailuresBeforeLock) {
+ this.maxFailuresBeforeLock = maxFailuresBeforeLock;
+ }
+
+ public Integer getDefaultLockDurationMinutes() {
+ return defaultLockDurationMinutes;
+ }
+
+ public void setDefaultLockDurationMinutes(Integer defaultLockDurationMinutes) {
+ this.defaultLockDurationMinutes = defaultLockDurationMinutes;
+ }
+
+ public Integer getMetricsRetentionDays() {
+ return metricsRetentionDays;
+ }
+
+ public void setMetricsRetentionDays(Integer metricsRetentionDays) {
+ this.metricsRetentionDays = metricsRetentionDays;
+ }
+
+ public boolean isEnableAutomaticMetricsCalculation() {
+ return enableAutomaticMetricsCalculation;
+ }
+
+ public void setEnableAutomaticMetricsCalculation(boolean enableAutomaticMetricsCalculation) {
+ this.enableAutomaticMetricsCalculation = enableAutomaticMetricsCalculation;
+ }
+
+ public boolean isEnableAutomaticCleanup() {
+ return enableAutomaticCleanup;
+ }
+
+ public void setEnableAutomaticCleanup(boolean enableAutomaticCleanup) {
+ this.enableAutomaticCleanup = enableAutomaticCleanup;
+ }
+
+ public boolean isEnableSecurityMonitoring() {
+ return enableSecurityMonitoring;
+ }
+
+ public void setEnableSecurityMonitoring(boolean enableSecurityMonitoring) {
+ this.enableSecurityMonitoring = enableSecurityMonitoring;
+ }
+}
\ No newline at end of file
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/CertificateManagementController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/CertificateManagementController.java
new file mode 100644
index 00000000..ba9b6ded
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/CertificateManagementController.java
@@ -0,0 +1,450 @@
+/*
+ *
+ * Copyright (c) 2018-2025 Green Button Alliance, Inc.
+ *
+ * Portions (c) 2013-2018 EnergyOS.org
+ *
+ * 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.datacustodian.authserver.controller;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import org.greenbuttonalliance.espi.datacustodian.authserver.service.ClientCertificateService;
+import org.greenbuttonalliance.espi.datacustodian.authserver.service.ClientCertificateService.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import java.io.ByteArrayInputStream;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * REST Controller for certificate management operations
+ *
+ * Provides API endpoints for:
+ * - Certificate upload and storage
+ * - Certificate validation and verification
+ * - Certificate information retrieval
+ * - Certificate revocation
+ * - Certificate renewal monitoring
+ *
+ * @author Green Button Alliance
+ * @version 1.0.0
+ * @since Spring Boot 3.5
+ */
+@RestController
+@RequestMapping("/api/v1/certificates")
+@PreAuthorize("hasRole('ADMIN') or hasRole('DC_ADMIN')")
+@Validated
+public class CertificateManagementController {
+
+ private static final Logger logger = LoggerFactory.getLogger(CertificateManagementController.class);
+
+ private final ClientCertificateService certificateService;
+
+ @Autowired
+ public CertificateManagementController(ClientCertificateService certificateService) {
+ this.certificateService = certificateService;
+ }
+
+ /**
+ * Upload client certificate
+ *
+ * POST /api/v1/certificates/clients/{clientId}/upload
+ */
+ @PostMapping("/clients/{clientId}/upload")
+ public ResponseEntity> uploadCertificate(
+ @PathVariable @NotBlank String clientId,
+ @RequestParam("certificate") MultipartFile certificateFile,
+ @RequestParam(value = "uploaded_by", required = false) String uploadedBy) {
+
+ logger.info("Uploading certificate for client: {}", clientId);
+
+ try {
+ if (certificateFile.isEmpty()) {
+ return ResponseEntity.badRequest()
+ .body(new ErrorResponse("invalid_file", "Certificate file is empty"));
+ }
+
+ // Parse certificate
+ X509Certificate certificate = parseCertificate(certificateFile.getBytes());
+ if (certificate == null) {
+ return ResponseEntity.badRequest()
+ .body(new ErrorResponse("invalid_certificate", "Unable to parse certificate"));
+ }
+
+ // Validate certificate
+ ClientCertificateService.CertificateValidationResult validation =
+ certificateService.validateClientCertificate(certificate);
+
+ if (!validation.isValid()) {
+ return ResponseEntity.badRequest()
+ .body(new ErrorResponse("certificate_validation_failed", validation.getErrorMessage()));
+ }
+
+ // Store certificate
+ String uploader = uploadedBy != null ? uploadedBy : "system";
+ certificateService.storeClientCertificate(clientId, certificate, uploader);
+
+ // Build response
+ CertificateUploadResponse response = new CertificateUploadResponse();
+ response.setClientId(clientId);
+ response.setSerialNumber(certificate.getSerialNumber().toString());
+ response.setSubjectDn(certificate.getSubjectDN().toString());
+ response.setIssuerDn(certificate.getIssuerDN().toString());
+ response.setNotBefore(certificate.getNotBefore().toInstant());
+ response.setNotAfter(certificate.getNotAfter().toInstant());
+ response.setUploadedAt(Instant.now());
+ response.setStatus("active");
+
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+
+ } catch (Exception e) {
+ logger.error("Error uploading certificate for client: {}", clientId, e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(new ErrorResponse("upload_failed", "Certificate upload failed"));
+ }
+ }
+
+ /**
+ * Get client certificate information
+ *
+ * GET /api/v1/certificates/clients/{clientId}
+ */
+ @GetMapping("/clients/{clientId}")
+ public ResponseEntity> getCertificate(@PathVariable @NotBlank String clientId) {
+ logger.debug("Getting certificate info for client: {}", clientId);
+
+ try {
+ ClientCertificateService.ClientCertificateInfo certificateInfo = certificateService.getClientCertificateInfo(clientId);
+
+ if (certificateInfo == null) {
+ return ResponseEntity.notFound().build();
+ }
+
+ return ResponseEntity.ok(certificateInfo);
+
+ } catch (Exception e) {
+ logger.error("Error getting certificate for client: {}", clientId, e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(new ErrorResponse("retrieval_failed", "Failed to retrieve certificate"));
+ }
+ }
+
+ /**
+ * Revoke client certificate
+ *
+ * DELETE /api/v1/certificates/clients/{clientId}
+ */
+ @DeleteMapping("/clients/{clientId}")
+ public ResponseEntity> revokeCertificate(
+ @PathVariable @NotBlank String clientId,
+ @Valid @RequestBody RevokeCertificateRequest request) {
+
+ logger.info("Revoking certificate for client: {}, reason: {}", clientId, request.getReason());
+
+ try {
+ certificateService.revokeCertificate(clientId, request.getReason(), request.getRevokedBy());
+
+ Map
+ * GET /api/v1/certificates/expiring
+ */
+ @GetMapping("/expiring")
+ public ResponseEntity> getExpiringCertificates() {
+ logger.debug("Getting certificates expiring soon");
+
+ try {
+ List
+ * POST /api/v1/certificates/validate
+ */
+ @PostMapping("/validate")
+ public ResponseEntity> validateCertificate(@RequestParam("certificate") MultipartFile certificateFile) {
+ logger.debug("Validating certificate format");
+
+ try {
+ if (certificateFile.isEmpty()) {
+ return ResponseEntity.badRequest()
+ .body(new ErrorResponse("invalid_file", "Certificate file is empty"));
+ }
+
+ // Parse certificate
+ X509Certificate certificate = parseCertificate(certificateFile.getBytes());
+ if (certificate == null) {
+ return ResponseEntity.badRequest()
+ .body(new ErrorResponse("invalid_certificate", "Unable to parse certificate"));
+ }
+
+ // Validate certificate
+ ClientCertificateService.CertificateValidationResult validation =
+ certificateService.validateClientCertificate(certificate);
+
+ CertificateValidationResponse response = new CertificateValidationResponse();
+ response.setValid(validation.isValid());
+ response.setSerialNumber(certificate.getSerialNumber().toString());
+ response.setSubjectDn(certificate.getSubjectDN().toString());
+ response.setIssuerDn(certificate.getIssuerDN().toString());
+ response.setNotBefore(certificate.getNotBefore().toInstant());
+ response.setNotAfter(certificate.getNotAfter().toInstant());
+ response.setValidatedAt(Instant.now());
+
+ if (!validation.isValid()) {
+ response.setErrorMessage(validation.getErrorMessage());
+ }
+
+ return ResponseEntity.ok(response);
+
+ } catch (Exception e) {
+ logger.error("Error validating certificate", e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(new ErrorResponse("validation_failed", "Certificate validation failed"));
+ }
+ }
+
+ // Helper methods
+
+ private X509Certificate parseCertificate(byte[] certificateData) {
+ try {
+ CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certificateData));
+ } catch (Exception e) {
+ logger.error("Error parsing certificate", e);
+ return null;
+ }
+ }
+
+ // DTO Classes
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class RevokeCertificateRequest {
+ @NotBlank(message = "Reason is required")
+ @Size(max = 200, message = "Reason must not exceed 200 characters")
+ @JsonProperty("reason")
+ private String reason;
+
+ @JsonProperty("revoked_by")
+ @Size(max = 100, message = "Revoked by must not exceed 100 characters")
+ private String revokedBy;
+
+ // Getters and setters
+ public String getReason() { return reason; }
+ public void setReason(String reason) { this.reason = reason; }
+
+ public String getRevokedBy() { return revokedBy; }
+ public void setRevokedBy(String revokedBy) { this.revokedBy = revokedBy; }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class CertificateUploadResponse {
+ @JsonProperty("client_id")
+ private String clientId;
+
+ @JsonProperty("serial_number")
+ private String serialNumber;
+
+ @JsonProperty("subject_dn")
+ private String subjectDn;
+
+ @JsonProperty("issuer_dn")
+ private String issuerDn;
+
+ @JsonProperty("not_before")
+ private Instant notBefore;
+
+ @JsonProperty("not_after")
+ private Instant notAfter;
+
+ @JsonProperty("uploaded_at")
+ private Instant uploadedAt;
+
+ @JsonProperty("status")
+ private String status;
+
+ // Getters and setters
+ public String getClientId() { return clientId; }
+ public void setClientId(String clientId) { this.clientId = clientId; }
+
+ public String getSerialNumber() { return serialNumber; }
+ public void setSerialNumber(String serialNumber) { this.serialNumber = serialNumber; }
+
+ public String getSubjectDn() { return subjectDn; }
+ public void setSubjectDn(String subjectDn) { this.subjectDn = subjectDn; }
+
+ public String getIssuerDn() { return issuerDn; }
+ public void setIssuerDn(String issuerDn) { this.issuerDn = issuerDn; }
+
+ public Instant getNotBefore() { return notBefore; }
+ public void setNotBefore(Instant notBefore) { this.notBefore = notBefore; }
+
+ public Instant getNotAfter() { return notAfter; }
+ public void setNotAfter(Instant notAfter) { this.notAfter = notAfter; }
+
+ public Instant getUploadedAt() { return uploadedAt; }
+ public void setUploadedAt(Instant uploadedAt) { this.uploadedAt = uploadedAt; }
+
+ public String getStatus() { return status; }
+ public void setStatus(String status) { this.status = status; }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class CertificateValidationResponse {
+ @JsonProperty("valid")
+ private boolean valid;
+
+ @JsonProperty("serial_number")
+ private String serialNumber;
+
+ @JsonProperty("subject_dn")
+ private String subjectDn;
+
+ @JsonProperty("issuer_dn")
+ private String issuerDn;
+
+ @JsonProperty("not_before")
+ private Instant notBefore;
+
+ @JsonProperty("not_after")
+ private Instant notAfter;
+
+ @JsonProperty("validated_at")
+ private Instant validatedAt;
+
+ @JsonProperty("error_message")
+ private String errorMessage;
+
+ // Getters and setters
+ public boolean isValid() { return valid; }
+ public void setValid(boolean valid) { this.valid = valid; }
+
+ public String getSerialNumber() { return serialNumber; }
+ public void setSerialNumber(String serialNumber) { this.serialNumber = serialNumber; }
+
+ public String getSubjectDn() { return subjectDn; }
+ public void setSubjectDn(String subjectDn) { this.subjectDn = subjectDn; }
+
+ public String getIssuerDn() { return issuerDn; }
+ public void setIssuerDn(String issuerDn) { this.issuerDn = issuerDn; }
+
+ public Instant getNotBefore() { return notBefore; }
+ public void setNotBefore(Instant notBefore) { this.notBefore = notBefore; }
+
+ public Instant getNotAfter() { return notAfter; }
+ public void setNotAfter(Instant notAfter) { this.notAfter = notAfter; }
+
+ public Instant getValidatedAt() { return validatedAt; }
+ public void setValidatedAt(Instant validatedAt) { this.validatedAt = validatedAt; }
+
+ public String getErrorMessage() { return errorMessage; }
+ public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class ExpiringCertificatesResponse {
+ @JsonProperty("certificates")
+ private List
+ * Implements RFC 7591 (OAuth 2.0 Dynamic Client Registration Protocol) with ESPI extensions.
+ * Provides endpoints for:
+ * - Dynamic client registration (/connect/register)
+ * - ESPI-specific client registration validation
+ * - Green Button Alliance compliance checks
+ *
+ * @author Green Button Alliance
+ * @version 1.0.0
+ * @since Spring Boot 3.5
+ */
+@RestController
+@RequestMapping("/connect")
+public class ClientRegistrationController {
+
+ private static final Logger logger = LoggerFactory.getLogger(ClientRegistrationController.class);
+
+ private final RegisteredClientRepository clientRepository;
+ private final SecureRandom secureRandom = new SecureRandom();
+
+ @Value("${espi.security.require-https-redirect-uris:false}")
+ private boolean requireHttpsRedirectUris;
+
+ // ESPI-specific scopes
+ private static final Set
+ * POST /connect/register
+ */
+ @PostMapping("/register")
+ public ResponseEntity> registerClient(@RequestBody ClientRegistrationRequest request) {
+ logger.debug("Processing client registration request for client_name: {}", request.getClientName());
+
+ try {
+ // Validate registration request
+ validateRegistrationRequest(request);
+
+ // Generate client credentials
+ String clientId = generateClientId();
+ String clientSecret = generateClientSecret();
+
+ // Build registered client
+ RegisteredClient.Builder clientBuilder = RegisteredClient.withId(UUID.randomUUID().toString())
+ .clientId(clientId)
+ .clientSecret("{noop}" + clientSecret) // TODO: Use proper password encoder
+ .clientName(request.getClientName())
+ .clientIdIssuedAt(Instant.now());
+
+ // Set client secret expiration if provided
+ if (request.getClientSecretExpiresAt() != null && request.getClientSecretExpiresAt() > 0) {
+ clientBuilder.clientSecretExpiresAt(Instant.ofEpochSecond(request.getClientSecretExpiresAt()));
+ }
+
+ // Configure authentication methods
+ configureAuthenticationMethods(clientBuilder, request);
+
+ // Configure grant types
+ configureGrantTypes(clientBuilder, request);
+
+ // Configure redirect URIs
+ configureRedirectUris(clientBuilder, request);
+
+ // Configure scopes
+ configureScopes(clientBuilder, request);
+
+ // Configure client and token settings
+ configureClientSettings(clientBuilder, request);
+ configureTokenSettings(clientBuilder, request);
+
+ RegisteredClient registeredClient = clientBuilder.build();
+
+ // Save the client
+ clientRepository.save(registeredClient);
+
+ logger.info("Successfully registered client: {} with client_id: {}", request.getClientName(), clientId);
+
+ // Return client registration response
+ ClientRegistrationResponse response = buildRegistrationResponse(registeredClient, clientSecret);
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+
+ } catch (IllegalArgumentException e) {
+ logger.warn("Client registration validation failed: {}", e.getMessage());
+ return ResponseEntity.badRequest().body(new ErrorResponse("invalid_client_metadata", e.getMessage()));
+ } catch (Exception e) {
+ logger.error("Client registration failed", e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(new ErrorResponse("server_error", "Internal server error"));
+ }
+ }
+
+ /**
+ * Get registered client information
+ *
+ * GET /connect/register/{client_id}
+ */
+ @GetMapping("/register/{clientId}")
+ public ResponseEntity> getClient(@PathVariable String clientId) {
+ logger.debug("Retrieving client information for client_id: {}", clientId);
+
+ RegisteredClient client = clientRepository.findByClientId(clientId);
+ if (client == null) {
+ return ResponseEntity.notFound().build();
+ }
+
+ ClientRegistrationResponse response = buildRegistrationResponse(client, null); // Don't return secret
+ return ResponseEntity.ok(response);
+ }
+
+ /**
+ * Validate client registration request
+ */
+ private void validateRegistrationRequest(ClientRegistrationRequest request) {
+ if (!StringUtils.hasText(request.getClientName())) {
+ throw new IllegalArgumentException("client_name is required");
+ }
+
+ if (request.getRedirectUris() == null || request.getRedirectUris().isEmpty()) {
+ throw new IllegalArgumentException("redirect_uris is required");
+ }
+
+ // Validate redirect URIs
+ for (String uri : request.getRedirectUris()) {
+ if (!isValidRedirectUri(uri)) {
+ throw new IllegalArgumentException("Invalid redirect_uri: " + uri);
+ }
+ }
+
+ // Validate ESPI-specific requirements
+ validateEspiRequirements(request);
+ }
+
+ /**
+ * Validate ESPI-specific requirements
+ */
+ private void validateEspiRequirements(ClientRegistrationRequest request) {
+ // Ensure requested scopes are ESPI-compliant
+ if (request.getScope() != null) {
+ Set