From 4e340a93be23b95d4ebd4d0b92eefcfe90799d52 Mon Sep 17 00:00:00 2001 From: John Thompson Date: Sat, 28 Feb 2026 10:03:22 -0500 Subject: [PATCH 1/6] feat - migration of auth server to data custodian, update deps, remove module. Closes #112 --- openespi-datacustodian/pom.xml | 13 +- .../config/AuthorizationServerConfig.java | 283 ++++++ ...ClientCertificateAuthenticationConfig.java | 260 ++++++ .../DataCustodianIntegrationConfig.java | 238 +++++ .../config/HttpsEnforcementConfig.java | 242 +++++ .../config/OAuth2ClientManagementConfig.java | 154 ++++ .../CertificateManagementController.java | 450 +++++++++ .../ClientRegistrationController.java | 580 ++++++++++++ .../controller/ConsentController.java | 393 ++++++++ .../DataCustodianIntegrationController.java | 608 +++++++++++++ .../OAuth2ClientManagementController.java | 852 ++++++++++++++++++ .../controller/OAuthAdminController.java | 403 +++++++++ .../controller/UserInfoController.java | 557 ++++++++++++ .../authserver/dto/ClientManagementDTOs.java | 725 +++++++++++++++ .../JdbcRegisteredClientRepository.java | 339 +++++++ .../service/ClientCertificateService.java | 510 +++++++++++ .../ClientCertificateUserDetailsService.java | 210 +++++ .../service/ClientMetricsService.java | 402 +++++++++ .../authserver/service/ConsentService.java | 299 ++++++ .../DataCustodianIntegrationService.java | 649 +++++++++++++ .../service/EspiTokenCustomizer.java | 161 ++++ .../authserver/service/UserInfoService.java | 381 ++++++++ .../config/ResourceServerConfig.java | 150 --- .../config/SecurityConfiguration.java | 22 +- .../config/WebConfiguration.java | 18 +- .../h2/V4_0_0__create_oauth2_schema.sql | 129 +++ .../mysql/V1_0_0__create_oauth2_schema.sql | 113 +++ ...0_0__add_espi4_compliance_enhancements.sql | 225 +++++ ...0_0__add_default_data_and_test_clients.sql | 314 +++++++ .../V4_0_0__add_datacustodian_integration.sql | 196 ++++ .../V5_0_0__add_oidc_userinfo_support.sql | 226 +++++ ...add_certificate_authentication_support.sql | 211 +++++ .../V1_0_0__create_oauth2_schema.sql | 205 +++++ ...0_0__add_espi4_compliance_enhancements.sql | 396 ++++++++ ...0_0__add_default_data_and_test_clients.sql | 383 ++++++++ .../V4_0_0__add_datacustodian_integration.sql | 239 +++++ .../V5_0_0__add_oidc_userinfo_support.sql | 254 ++++++ ...add_certificate_authentication_support.sql | 246 +++++ pom.xml | 2 +- 39 files changed, 11851 insertions(+), 187 deletions(-) create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/AuthorizationServerConfig.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/ClientCertificateAuthenticationConfig.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/DataCustodianIntegrationConfig.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/HttpsEnforcementConfig.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/OAuth2ClientManagementConfig.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/CertificateManagementController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/ClientRegistrationController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/ConsentController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/DataCustodianIntegrationController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/OAuth2ClientManagementController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/OAuthAdminController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/UserInfoController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/dto/ClientManagementDTOs.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/repository/JdbcRegisteredClientRepository.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/service/ClientCertificateService.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/service/ClientCertificateUserDetailsService.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/service/ClientMetricsService.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/service/ConsentService.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/service/DataCustodianIntegrationService.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/service/EspiTokenCustomizer.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/service/UserInfoService.java delete mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/ResourceServerConfig.java create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/h2/V4_0_0__create_oauth2_schema.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/mysql/V1_0_0__create_oauth2_schema.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/mysql/V2_0_0__add_espi4_compliance_enhancements.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/mysql/V3_0_0__add_default_data_and_test_clients.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/mysql/V4_0_0__add_datacustodian_integration.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/mysql/V5_0_0__add_oidc_userinfo_support.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/mysql/V6_0_0__add_certificate_authentication_support.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/postgresql/V1_0_0__create_oauth2_schema.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/postgresql/V2_0_0__add_espi4_compliance_enhancements.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/postgresql/V3_0_0__add_default_data_and_test_clients.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/postgresql/V4_0_0__add_datacustodian_integration.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/postgresql/V5_0_0__add_oidc_userinfo_support.sql create mode 100644 openespi-datacustodian/src/main/resources/db/vendor/postgresql/V6_0_0__add_certificate_authentication_support.sql diff --git a/openespi-datacustodian/pom.xml b/openespi-datacustodian/pom.xml index d4b94e52..4cd204cc 100644 --- a/openespi-datacustodian/pom.xml +++ b/openespi-datacustodian/pom.xml @@ -149,6 +149,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-security-oauth2-authorization-server + org.springframework.boot spring-boot-starter-security-oauth2-resource-server @@ -268,12 +272,17 @@ org.springframework.boot - spring-boot-starter-security-oauth2-resource-server-test + spring-boot-starter-security-test test org.springframework.boot - spring-boot-starter-security-test + spring-boot-starter-security-oauth2-authorization-server-test + test + + + org.springframework.boot + spring-boot-starter-security-oauth2-resource-server-test test diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/AuthorizationServerConfig.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/AuthorizationServerConfig.java new file mode 100644 index 00000000..d18cef8b --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/AuthorizationServerConfig.java @@ -0,0 +1,283 @@ +/* + * + * 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 org.greenbuttonalliance.espi.datacustodian.authserver.repository.JdbcRegisteredClientRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; + +/** + * OAuth2 Authorization Server Configuration for OpenESPI + *

+ * 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 +@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; + + + /** + * 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(1) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) { + OAuth2AuthorizationServerConfigurer authorizationServerCfg = new OAuth2AuthorizationServerConfigurer(); + http + .securityMatcher(authorizationServerCfg.getEndpointsMatcher()) + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()) + .formLogin(Customizer.withDefaults()) + // Accept access tokens for User Info and/or Client Registration + .oauth2ResourceServer(resourceServer -> resourceServer + .opaqueToken(Customizer.withDefaults()) + ) + .oauth2AuthorizationServer(authorizationServer -> + authorizationServer.oidc(Customizer.withDefaults()) // Enable OpenID Connect 1.0 + ) + .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(); + } + + /** + * 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(); + } +} \ No newline at end of file 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 trustedCertificateAuthorities; + private List allowedCertificateExtensions; + private List requiredCertificateExtensions; + + /** + * X.509 Authentication Filter for client certificate authentication + */ + @Bean + public X509AuthenticationFilter x509AuthenticationFilter( + AuthenticationConfiguration authConfig, + ClientCertificateUserDetailsService userDetailsService) throws Exception { + + X509AuthenticationFilter filter = new X509AuthenticationFilter(); + filter.setContinueFilterChainOnUnsuccessfulAuthentication(true); + filter.setAuthenticationManager(authConfig.getAuthenticationManager()); + filter.setPrincipalExtractor(new SubjectDnX509PrincipalExtractor()); + + return filter; + } + + /** + * Pre-authenticated authentication provider for certificate authentication + */ + @Bean + public PreAuthenticatedAuthenticationProvider preAuthenticatedAuthenticationProvider( + ClientCertificateUserDetailsService userDetailsService) { + + PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider(); + provider.setPreAuthenticatedUserDetailsService(userDetailsService); + provider.setThrowExceptionWhenTokenRejected(false); + + return provider; + } + + /** + * Certificate-based user details service + */ + @Bean + public ClientCertificateUserDetailsService clientCertificateUserDetailsService( + ClientCertificateService certificateService) { + return new ClientCertificateUserDetailsService(certificateService); + } + + // Getters and setters + public boolean isEnableCertificateAuthentication() { + return enableCertificateAuthentication; + } + + public void setEnableCertificateAuthentication(boolean enableCertificateAuthentication) { + this.enableCertificateAuthentication = enableCertificateAuthentication; + } + + public boolean isRequireClientCertificate() { + return requireClientCertificate; + } + + public void setRequireClientCertificate(boolean requireClientCertificate) { + this.requireClientCertificate = requireClientCertificate; + } + + public boolean isEnableCertificateRevocationCheck() { + return enableCertificateRevocationCheck; + } + + public void setEnableCertificateRevocationCheck(boolean enableCertificateRevocationCheck) { + this.enableCertificateRevocationCheck = enableCertificateRevocationCheck; + } + + public boolean isEnableOcspCheck() { + return enableOcspCheck; + } + + public void setEnableOcspCheck(boolean enableOcspCheck) { + this.enableOcspCheck = enableOcspCheck; + } + + public boolean isEnableCrlCheck() { + return enableCrlCheck; + } + + public void setEnableCrlCheck(boolean enableCrlCheck) { + this.enableCrlCheck = enableCrlCheck; + } + + public String getTrustStorePath() { + return trustStorePath; + } + + public void setTrustStorePath(String trustStorePath) { + this.trustStorePath = trustStorePath; + } + + public String getTrustStorePassword() { + return trustStorePassword; + } + + public void setTrustStorePassword(String trustStorePassword) { + this.trustStorePassword = trustStorePassword; + } + + public String getTrustStoreType() { + return trustStoreType; + } + + public void setTrustStoreType(String trustStoreType) { + this.trustStoreType = trustStoreType; + } + + public Integer getCertificateValidityDays() { + return certificateValidityDays; + } + + public void setCertificateValidityDays(Integer certificateValidityDays) { + this.certificateValidityDays = certificateValidityDays; + } + + public Integer getCertificateRenewalWarningDays() { + return certificateRenewalWarningDays; + } + + public void setCertificateRenewalWarningDays(Integer certificateRenewalWarningDays) { + this.certificateRenewalWarningDays = certificateRenewalWarningDays; + } + + public Integer getCertificateCacheExpiration() { + return certificateCacheExpiration; + } + + public void setCertificateCacheExpiration(Integer certificateCacheExpiration) { + this.certificateCacheExpiration = certificateCacheExpiration; + } + + public List getTrustedCertificateAuthorities() { + return trustedCertificateAuthorities; + } + + public void setTrustedCertificateAuthorities(List trustedCertificateAuthorities) { + this.trustedCertificateAuthorities = trustedCertificateAuthorities; + } + + public List getAllowedCertificateExtensions() { + return allowedCertificateExtensions; + } + + public void setAllowedCertificateExtensions(List allowedCertificateExtensions) { + this.allowedCertificateExtensions = allowedCertificateExtensions; + } + + public List getRequiredCertificateExtensions() { + return requiredCertificateExtensions; + } + + public void setRequiredCertificateExtensions(List requiredCertificateExtensions) { + this.requiredCertificateExtensions = requiredCertificateExtensions; + } +} \ No newline at end of file diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/DataCustodianIntegrationConfig.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/DataCustodianIntegrationConfig.java new file mode 100644 index 00000000..91d26f73 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/DataCustodianIntegrationConfig.java @@ -0,0 +1,238 @@ +/* + * + * 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.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.client.RestTemplate; + +/** + * Configuration for DataCustodian integration + *

+ * 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 response = new HashMap<>(); + response.put("status", "revoked"); + response.put("client_id", clientId); + response.put("message", "Certificate revoked successfully"); + response.put("revoked_at", Instant.now().toString()); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + logger.error("Error revoking certificate for client: {}", clientId, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("revocation_failed", "Failed to revoke certificate")); + } + } + + /** + * Get certificates expiring soon + *

+ * GET /api/v1/certificates/expiring + */ + @GetMapping("/expiring") + public ResponseEntity getExpiringCertificates() { + logger.debug("Getting certificates expiring soon"); + + try { + List expiringCertificates = + certificateService.getCertificatesExpiringSoon(); + + ExpiringCertificatesResponse response = new ExpiringCertificatesResponse(); + response.setCertificates(expiringCertificates); + response.setCount(expiringCertificates.size()); + response.setRetrievedAt(Instant.now()); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + logger.error("Error getting expiring certificates", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("retrieval_failed", "Failed to retrieve expiring certificates")); + } + } + + /** + * Validate certificate format and content + *

+ * 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 certificates; + + @JsonProperty("count") + private int count; + + @JsonProperty("retrieved_at") + private Instant retrievedAt; + + // Getters and setters + public List getCertificates() { return certificates; } + public void setCertificates(List certificates) { this.certificates = certificates; } + + public int getCount() { return count; } + public void setCount(int count) { this.count = count; } + + public Instant getRetrievedAt() { return retrievedAt; } + public void setRetrievedAt(Instant retrievedAt) { this.retrievedAt = retrievedAt; } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ErrorResponse { + @JsonProperty("status") + private String status = "error"; + + @JsonProperty("error") + private String error; + + @JsonProperty("message") + private String message; + + public ErrorResponse(String error, String message) { + this.error = error; + this.message = message; + } + + // Getters and setters + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public String getError() { return error; } + public void setError(String error) { this.error = error; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + } +} \ No newline at end of file diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/ClientRegistrationController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/ClientRegistrationController.java new file mode 100644 index 00000000..b18d8738 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/ClientRegistrationController.java @@ -0,0 +1,580 @@ +/* + * + * 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.*; + +/** + * OIDC Dynamic Client Registration Controller + *

+ * 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 ESPI_SCOPES = Set.of( + "openid", "profile", + "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13", + "FB=4_5_15;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13", + "DataCustodian_Admin_Access", + "ThirdParty_Admin_Access" + ); + + public ClientRegistrationController(RegisteredClientRepository clientRepository) { + this.clientRepository = clientRepository; + } + + /** + * OIDC Dynamic Client Registration Endpoint + *

+ * 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 requestedScopes = new HashSet<>(Arrays.asList(request.getScope().split(" "))); + for (String scope : requestedScopes) { + if (!ESPI_SCOPES.contains(scope)) { + logger.warn("Non-ESPI scope requested: {}", scope); + // Note: We don't reject here, just log the warning + } + } + } + + // Validate grant types for ESPI compliance + if (request.getGrantTypes() != null) { + Set allowedGrantTypes = Set.of("authorization_code", "client_credentials", "refresh_token"); + for (String grantType : request.getGrantTypes()) { + if (!allowedGrantTypes.contains(grantType)) { + throw new IllegalArgumentException("Unsupported grant_type for ESPI: " + grantType); + } + } + } + } + + /** + * Configure authentication methods + */ + private void configureAuthenticationMethods(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + if (request.getTokenEndpointAuthMethod() != null) { + switch (request.getTokenEndpointAuthMethod()) { + case "client_secret_basic" -> builder.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); + case "client_secret_post" -> builder.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST); + case "none" -> builder.clientAuthenticationMethod(ClientAuthenticationMethod.NONE); + default -> throw new IllegalArgumentException("Unsupported token_endpoint_auth_method: " + request.getTokenEndpointAuthMethod()); + } + } else { + // Default to client_secret_basic for ESPI + builder.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); + } + } + + /** + * Configure grant types + */ + private void configureGrantTypes(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + if (request.getGrantTypes() != null && !request.getGrantTypes().isEmpty()) { + for (String grantType : request.getGrantTypes()) { + switch (grantType) { + case "authorization_code" -> builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE); + case "client_credentials" -> builder.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS); + case "refresh_token" -> builder.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN); + default -> throw new IllegalArgumentException("Unsupported grant_type: " + grantType); + } + } + } else { + // Default to authorization_code for ESPI + builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE); + builder.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN); + } + } + + /** + * Configure redirect URIs + */ + private void configureRedirectUris(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + for (String uri : request.getRedirectUris()) { + builder.redirectUri(uri); + } + + if (request.getPostLogoutRedirectUris() != null) { + for (String uri : request.getPostLogoutRedirectUris()) { + builder.postLogoutRedirectUri(uri); + } + } + } + + /** + * Configure scopes + */ + private void configureScopes(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + if (StringUtils.hasText(request.getScope())) { + String[] scopes = request.getScope().split(" "); + for (String scope : scopes) { + builder.scope(scope.trim()); + } + } else { + // Default ESPI scopes + builder.scope("openid"); + builder.scope("profile"); + builder.scope("FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13"); + } + } + + /** + * Configure client settings + */ + private void configureClientSettings(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + ClientSettings.Builder settingsBuilder = ClientSettings.builder(); + + // ESPI clients typically require consent for customer data access + boolean requireConsent = true; + if (request.getGrantTypes() != null && request.getGrantTypes().contains("client_credentials")) { + requireConsent = false; // Admin clients don't need consent + } + settingsBuilder.requireAuthorizationConsent(requireConsent); + + builder.clientSettings(settingsBuilder.build()); + } + + /** + * Configure token settings + */ + private void configureTokenSettings(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + TokenSettings.Builder settingsBuilder = TokenSettings.builder(); + + // ESPI standard uses opaque access tokens + settingsBuilder.accessTokenFormat(OAuth2TokenFormat.REFERENCE); + + // Set token lifetimes + settingsBuilder.accessTokenTimeToLive(Duration.ofMinutes(360)); // 6 hours for ESPI + settingsBuilder.refreshTokenTimeToLive(Duration.ofMinutes(3600)); // 60 hours + settingsBuilder.reuseRefreshTokens(true); + + builder.tokenSettings(settingsBuilder.build()); + } + + /** + * Build client registration response + */ + private ClientRegistrationResponse buildRegistrationResponse(RegisteredClient client, String clientSecret) { + ClientRegistrationResponse response = new ClientRegistrationResponse(); + response.setClientId(client.getClientId()); + response.setClientSecret(clientSecret); // Only set on initial registration + response.setClientName(client.getClientName()); + response.setClientIdIssuedAt(client.getClientIdIssuedAt() != null ? + client.getClientIdIssuedAt().getEpochSecond() : null); + response.setClientSecretExpiresAt(client.getClientSecretExpiresAt() != null ? + client.getClientSecretExpiresAt().getEpochSecond() : 0); + + // Authentication method + if (!client.getClientAuthenticationMethods().isEmpty()) { + response.setTokenEndpointAuthMethod( + client.getClientAuthenticationMethods().iterator().next().getValue()); + } + + // Grant types + response.setGrantTypes(client.getAuthorizationGrantTypes().stream() + .map(grantType -> grantType.getValue()) + .toList()); + + // Redirect URIs + response.setRedirectUris(new ArrayList<>(client.getRedirectUris())); + if (!client.getPostLogoutRedirectUris().isEmpty()) { + response.setPostLogoutRedirectUris(new ArrayList<>(client.getPostLogoutRedirectUris())); + } + + // Scopes + response.setScope(String.join(" ", client.getScopes())); + + return response; + } + + /** + * Generate client ID + */ + private String generateClientId() { + return "espi_client_" + System.currentTimeMillis() + "_" + + Integer.toHexString(secureRandom.nextInt()); + } + + /** + * Generate client secret + */ + private String generateClientSecret() { + byte[] secretBytes = new byte[32]; + secureRandom.nextBytes(secretBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(secretBytes); + } + + /** + * Validate redirect URI with ESPI-specific rules + */ + private boolean isValidRedirectUri(String uri) { + // Basic URI validation + if (!StringUtils.hasText(uri)) { + return false; + } + + // Must be absolute URI + if (!uri.startsWith("http://") && !uri.startsWith("https://")) { + return false; + } + + // ESPI Security: Enforce HTTPS in production + if (requireHttpsRedirectUris && uri.startsWith("http://")) { + // Allow localhost HTTP for development/testing only + if (!uri.contains("localhost") && !uri.contains("127.0.0.1")) { + logger.warn("Rejecting HTTP redirect URI in production: {}", uri); + return false; + } + } + + // Additional ESPI validation rules + try { + java.net.URI parsedUri = java.net.URI.create(uri); + + // Reject javascript: and data: schemes for security + String scheme = parsedUri.getScheme(); + if ("javascript".equalsIgnoreCase(scheme) || "data".equalsIgnoreCase(scheme)) { + logger.warn("Rejecting dangerous URI scheme: {}", scheme); + return false; + } + + // Reject malicious patterns + if (uri.contains("