diff --git a/.github/CI_CD_SETUP.md b/.github/CI_CD_SETUP.md index 784298b4..ce98c137 100644 --- a/.github/CI_CD_SETUP.md +++ b/.github/CI_CD_SETUP.md @@ -28,8 +28,8 @@ default to `permissions: contents: read`; jobs add only what they need. An install-once job that shares `~/.m2/repository/org/greenbuttonalliance` as an artifact was rejected. It would add a serial job, plus artifact upload and download, that cost more than the 30 s compile it saves. - - `openespi-authserver` runs only `-Dgroups=testcontainers-it` until **#129** fixes the - unit tests on the H2 profiles. + - `openespi-authserver` runs its full suite (unit tests plus the Testcontainers integration + tests) since **#129**. - Testcontainers suites use the Docker daemon on `ubuntu-latest`. - On failure the job uploads `surefire-reports-`. It always uploads that module's `jacoco.xml` for the `quality` job. @@ -190,7 +190,7 @@ create and approve pull requests**. The release workflow needs this to open its ```bash mvn install -DskipTests -pl openespi-datacustodian -am && mvn verify -pl openespi-datacustodian -mvn verify -pl openespi-authserver -Dgroups=testcontainers-it -DfailIfNoTests=false # until #129 +mvn install -DskipTests -pl openespi-authserver -am && mvn verify -pl openespi-authserver mvn install -DskipTests && mvn org.owasp:dependency-check-maven:aggregate docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color # lint workflows ``` diff --git a/.github/scripts/release-meta.sh b/.github/scripts/release-meta.sh index 9182af3d..0fdf0cc3 100644 --- a/.github/scripts/release-meta.sh +++ b/.github/scripts/release-meta.sh @@ -48,12 +48,9 @@ fi next_version="${major}.${minor}.$((patch + 1))-SNAPSHOT" -# Extra Maven test arguments per module. +# Extra Maven test arguments per module. None today: every module runs its full suite +# (authserver's -Dgroups=testcontainers-it restriction was removed by #129). test_args="" -if [[ "$module" == authserver ]]; then - # TODO(#129): AS unit tests are broken on the H2 test profiles; only the testcontainers-it suite runs. - test_args="-Dgroups=testcontainers-it -DfailIfNoTests=false" -fi # Previous release tag of the SAME module (highest version below this one), for release notes. previous_tag="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adf5465f..d8584ad6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,6 @@ jobs: - module: openespi-datacustodian - module: openespi-thirdparty - module: openespi-authserver - # TODO(#129): AS unit tests are broken on the H2 test profiles; only the Docker-backed - # @Tag("testcontainers-it") suite runs until #129 is fixed. Remove this line then. - test-args: -Dgroups=testcontainers-it -DfailIfNoTests=false steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a52a3159..cd561112 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: run: bash .github/scripts/set-module-version.sh "$DIR" "$VERSION" "$VERSION_PROPERTY" # Equivalent to `mvn verify -pl -am`, split so that module-specific test arguments - # (authserver's -Dgroups, #129) do not leak into the upstream modules' test runs. + # (TEST_ARGS from release-meta.sh, currently none) do not leak into the upstream modules' test runs. - name: Verify upstream modules (tests on) if: env.UPSTREAM != '' run: mvn install -pl "$UPSTREAM" -am # -am only adds the parent pom diff --git a/CLAUDE.md b/CLAUDE.md index fec7c3a3..31d8063f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,11 +33,10 @@ cd openespi-common && mvn test # There are no failsafe *IT classes and no separate integration-test profile. mvn test -pl openespi-common -# What CI runs per module (matrix in .github/workflows/ci.yml): install upstream, then verify +# What CI runs per module (matrix in .github/workflows/ci.yml): install upstream, then verify. +# For openespi-authserver this is the full suite (unit + MySQL Testcontainers tests) since #129; +# add -Dgroups=testcontainers-it to run only the Docker-backed integration tests. mvn install -DskipTests -pl -am && mvn verify -pl - -# Authorization Server: only the TestContainers group runs cleanly today (#129) -mvn verify -pl openespi-authserver -Dgroups=testcontainers-it -DfailIfNoTests=false ``` ### Run Applications @@ -269,7 +268,7 @@ ESPI uses Atom XML feeds for data exchange. Key patterns: - All tests must pass before committing: `mvn test` - Run tests for affected module and its dependents: `mvn test -pl -am` - TestContainers-based tests (Docker required) run inside the normal `mvn test`; there are no failsafe `*IT` classes and no separate integration-test profile (removed in #193) -- Note: openespi-authserver unit tests are currently excluded from CI (#129); CI runs only its `@Tag("testcontainers-it")` tests +- openespi-authserver runs its full suite in CI (#129); its Docker-backed tests are tagged `@Tag("testcontainers-it")` (via `AbstractAuthserverIntegrationTest`) so they can be run alone with `-Dgroups=testcontainers-it` - **IMPORTANT**: Use AssertJ chained assertions - combine all assertions for a single object into one assertion chain - Good: `assertThat(retrieved).isPresent().hasValueSatisfying(device -> assertThat(device).extracting(...).containsExactly(...))` - Bad: Multiple separate `assertThat()` statements for the same object diff --git a/README.md b/README.md index b0c1cd8e..e79772cb 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,8 @@ mvn verify -pl openespi-common -am mvn test -Dtest=ComplexRelationshipMySQLIntegrationTest -pl openespi-common mvn test -Dtest=ComplexRelationshipPostgreSQLIntegrationTest -pl openespi-common -# Authorization Server: only the Testcontainers suite runs until #129 (same as CI) -mvn verify -pl openespi-authserver -am -Dgroups=testcontainers-it -DfailIfNoTests=false +# Authorization Server: full suite, unit + Testcontainers (MySQL) integration tests (same as CI) +mvn verify -pl openespi-authserver -am ``` CI, release and versioning: see [`.github/CI_CD_SETUP.md`](.github/CI_CD_SETUP.md) and [`RELEASING.md`](RELEASING.md). diff --git a/RELEASING.md b/RELEASING.md index 9e293bc0..5186df45 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -49,7 +49,7 @@ The parent pom, `openespi-parent`, is not released on its own. - sets the release version with `versions:set -pl `, **in CI only**; nothing is committed; - runs the upstream modules' tests and then `mvn verify -pl `. This is equivalent - to `-am verify`. `authserver` uses `-Dgroups=testcontainers-it` until #129; + to `-am verify`. Every module, `authserver` included, runs its full test suite; - collects the jar. For applications this is the Spring Boot fat jar. 3. **github-release**: creates the GitHub Release `` with: - generated notes, starting from the previous tag of the same module; diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/AuthorizationServerApplicationTests.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/AuthorizationServerApplicationTests.java index fd5c3f0e..05fbe9e5 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/AuthorizationServerApplicationTests.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/AuthorizationServerApplicationTests.java @@ -20,36 +20,165 @@ package org.greenbuttonalliance.espi.authserver; +import org.greenbuttonalliance.espi.authserver.grant.GrantContextEnrichingAuthorizationService; +import org.greenbuttonalliance.espi.authserver.integration.AbstractAuthserverIntegrationTest; +import org.greenbuttonalliance.espi.authserver.service.EspiTokenCustomizer; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; +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.OAuth2TokenFormat; +import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; +import org.springframework.security.web.SecurityFilterChain; + +import java.time.Duration; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; /** - * Integration tests for OpenESPI Authorization Server Application - * - * Tests basic Spring Boot application startup and configuration - * with Spring Authorization Server 1.3+. - * - * @author Green Button Alliance - * @version 1.0.0 - * @since Spring Boot 3.5 + * Application context test for the OpenESPI Authorization Server, booted with the real configuration + * against the shared MySQL Testcontainer (see {@link AbstractAuthserverIntegrationTest}). + * + *

Replaces the former H2 {@code local}-profile {@code contextLoads} and the mock-{@code HttpSecurity} + * {@code AuthorizationServerConfigTest} (#129): instead of calling {@code @Bean} methods against mocks, + * it asserts what the started context actually contains.

*/ -@SpringBootTest -@ActiveProfiles("local") -class AuthorizationServerApplicationTests { - - /** - * Test that the Spring Boot application context loads successfully - * with all OAuth2 Authorization Server configurations. - */ +@DisplayName("Authorization Server application context") +class AuthorizationServerApplicationTests extends AbstractAuthserverIntegrationTest { + + @Autowired + private ApplicationContext context; + + @Autowired + private AuthorizationServerSettings authorizationServerSettings; + + @Autowired + private RegisteredClientRepository registeredClientRepository; + + @Autowired + private OAuth2AuthorizationService authorizationService; + + @Autowired + private OAuth2TokenCustomizer espiTokenCustomizer; + + @Test + @DisplayName("registers exactly the five application security filter chains") + void registersTheApplicationSecurityFilterChains() { + // Pinned on purpose: a new chain changes which requests each existing chain sees. + assertThat(context.getBeansOfType(SecurityFilterChain.class)) + .containsOnlyKeys( + "actuatorSecurityFilterChain", // #240: health public, the rest authserver.admin + "clientRegistrationSecurityFilterChain", + "authorizationServerSecurityFilterChain", + "adminApiBearerSecurityFilterChain", + "defaultSecurityFilterChain"); + } + + @Test + @DisplayName("is opaque-token only: no JwtDecoder or JwtEncoder bean (#134)") + void hasNoJwtInfrastructure() { + assertThat(context.getBeanNamesForType(JwtDecoder.class)).isEmpty(); + assertThat(context.getBeanNamesForType(JwtEncoder.class)).isEmpty(); + } + + @Test + @DisplayName("exposes the configured issuer and OAuth2 protocol endpoints") + void authorizationServerSettings() { + assertThat(authorizationServerSettings) + .extracting( + AuthorizationServerSettings::getIssuer, + AuthorizationServerSettings::getAuthorizationEndpoint, + AuthorizationServerSettings::getTokenEndpoint, + AuthorizationServerSettings::getTokenRevocationEndpoint, + AuthorizationServerSettings::getTokenIntrospectionEndpoint) + .containsExactly( + "http://localhost:9999", + "/oauth2/authorize", + "/oauth2/token", + "/oauth2/revoke", + "/oauth2/introspect"); + } + + @Test + @DisplayName("wires the JDBC client repository, the grant-context authorization service and the ESPI customizer") + void persistenceAndCustomizerBeans() { + assertThat(registeredClientRepository).isInstanceOf(JdbcRegisteredClientRepository.class); + assertThat(authorizationService).isInstanceOf(GrantContextEnrichingAuthorizationService.class); + assertThat(espiTokenCustomizer).isInstanceOf(EspiTokenCustomizer.class); + } + + @Test + @DisplayName("seeds data_custodian_admin: client_credentials, opaque, 60 min, no consent") + void seedsDataCustodianAdminClient() { + assertThat(registeredClientRepository.findByClientId("data_custodian_admin")) + .isNotNull() + .satisfies(client -> assertThat(client) + .extracting( + RegisteredClient::getAuthorizationGrantTypes, + RegisteredClient::getScopes, + c -> c.getTokenSettings().getAccessTokenFormat(), + c -> c.getTokenSettings().getAccessTokenTimeToLive(), + c -> c.getClientSettings().isRequireAuthorizationConsent()) + .containsExactly( + Set.of(AuthorizationGrantType.CLIENT_CREDENTIALS), + Set.of("DataCustodian_Admin_Access"), + OAuth2TokenFormat.REFERENCE, + Duration.ofMinutes(60), + false)); + } + + @Test + @DisplayName("seeds third_party: authorization_code + refresh_token, ESPI scopes, opaque, 6 h, consent, no PKCE") + void seedsThirdPartyClient() { + assertThat(registeredClientRepository.findByClientId("third_party")) + .isNotNull() + .satisfies(client -> assertThat(client) + .extracting( + RegisteredClient::getAuthorizationGrantTypes, + c -> c.getTokenSettings().getAccessTokenFormat(), + c -> c.getTokenSettings().getAccessTokenTimeToLive(), + c -> c.getClientSettings().isRequireAuthorizationConsent(), + c -> c.getClientSettings().isRequireProofKey()) + .containsExactly( + Set.of(AuthorizationGrantType.AUTHORIZATION_CODE, + AuthorizationGrantType.REFRESH_TOKEN), + OAuth2TokenFormat.REFERENCE, + Duration.ofHours(6), + true, + false)) + .extracting(RegisteredClient::getScopes) + .satisfies(scopes -> assertThat(scopes).contains( + "openid", "profile", + "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13", + "FB=4_5_15;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13")); + } + @Test - void contextLoads() { - // This test verifies that: - // - Spring Boot application starts successfully - // - All OAuth2 Authorization Server beans are configured - // - Database schema is created (H2 in-memory for tests) - // - JWT key pair is generated - // - Registered clients are loaded - // - Security configurations are applied + @DisplayName("seeds third_party_admin and the datacustodian introspection client with opaque tokens") + void seedsRemainingDefaultClients() { + assertThat(registeredClientRepository.findByClientId("third_party_admin")) + .isNotNull() + .extracting( + RegisteredClient::getAuthorizationGrantTypes, + RegisteredClient::getScopes, + c -> c.getTokenSettings().getAccessTokenFormat()) + .containsExactly( + Set.of(AuthorizationGrantType.CLIENT_CREDENTIALS), + Set.of("ThirdParty_Admin_Access"), + OAuth2TokenFormat.REFERENCE); + assertThat(registeredClientRepository.findByClientId("datacustodian")) + .isNotNull() + .extracting(RegisteredClient::getAuthorizationGrantTypes) + .isEqualTo(Set.of(AuthorizationGrantType.CLIENT_CREDENTIALS)); } -} \ No newline at end of file +} diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfigTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfigTest.java deleted file mode 100644 index 06cc2bed..00000000 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfigTest.java +++ /dev/null @@ -1,463 +0,0 @@ -/* - * - * Copyright (c) 2018-2026 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.authserver.config; - -import com.nimbusds.jose.jwk.JWK; -import com.nimbusds.jose.jwk.JWKSet; -import com.nimbusds.jose.jwk.RSAKey; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.proc.SecurityContext; -import org.greenbuttonalliance.espi.authserver.service.EspiTokenCustomizer; -import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.jwt.JwtDecoder; -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.token.JwtEncodingContext; -import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.DefaultSecurityFilterChain; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -/** - * Comprehensive unit tests for AuthorizationServerConfig - * - * Tests OAuth2 Authorization Server configuration including security filter chains, - * client repository setup, JWK configuration, and ESPI-specific customizations. - * - * @author Green Button Alliance - * @version 1.0.0 - * @since Spring Boot 3.5 - */ -@ExtendWith(MockitoExtension.class) -@DisplayName("AuthorizationServerConfig Tests") -class AuthorizationServerConfigTest { - - @Mock - private JdbcTemplate jdbcTemplate; - - @Mock - private HttpSecurity httpSecurity; - - @Mock - PasswordEncoder passwordEncoder; - - private AuthorizationServerConfig config; - - @BeforeEach - void setUp() { - config = new AuthorizationServerConfig(); - } - - @Nested - @DisplayName("Security Filter Chain Tests") - class SecurityFilterChainTests { - - @Test - @DisplayName("Should create authorization server security filter chain") - void shouldCreateAuthorizationServerSecurityFilterChain() throws Exception { - // Given - HttpSecurity mockHttpSecurity = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - SecurityFilterChain mockFilterChain = mock(SecurityFilterChain.class); - - when(mockHttpSecurity.build()).thenReturn((DefaultSecurityFilterChain) mockFilterChain); - - // When - SecurityFilterChain filterChain = config.authorizationServerSecurityFilterChain(mockHttpSecurity); - - // Then - assertThat(filterChain).isNotNull(); - verify(mockHttpSecurity).build(); - } - - @Test - @DisplayName("Should create default security filter chain") - void shouldCreateDefaultSecurityFilterChain() throws Exception { - // Given - HttpSecurity mockHttpSecurity = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - SecurityFilterChain mockFilterChain = mock(SecurityFilterChain.class); - - when(mockHttpSecurity.build()).thenReturn((DefaultSecurityFilterChain) mockFilterChain); - - // When - SecurityFilterChain filterChain = config.defaultSecurityFilterChain(mockHttpSecurity, null, null); - - // Then - assertThat(filterChain).isNotNull(); - verify(mockHttpSecurity).build(); - } - } - - @Nested - @DisplayName("Client Repository Tests") - class ClientRepositoryTests { - - @Test - @DisplayName("Should create registered client repository with default ESPI clients") - void shouldCreateRegisteredClientRepositoryWithDefaultEspiClients() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - // Then - assertThat(repository).isNotNull(); - assertThat(repository).isInstanceOf(JdbcRegisteredClientRepository.class); - - // Verify default clients are attempted to be saved - verify(jdbcTemplate, atLeast(3)).queryForObject(anyString(), any(Class.class), anyString()); - } - - @Test - @DisplayName("Should not overwrite existing clients") - void shouldNotOverwriteExistingClients() { - // Given - RegisteredClient existingClient = mock(RegisteredClient.class); - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(existingClient); - - // When - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - // Then - assertThat(repository).isNotNull(); - - // Verify existing clients are found but not overwritten - verify(jdbcTemplate, atLeast(3)).queryForObject(anyString(), any(Class.class), anyString()); - verify(jdbcTemplate, never()).update(anyString(), any(Object[].class)); - } - - @Test - @DisplayName("Should configure DataCustodian admin client correctly") - void shouldConfigureDataCustodianAdminClientCorrectly() { - // Given - JdbcRegisteredClientRepository mockRepo = mock(JdbcRegisteredClientRepository.class); - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - // Then - Verify DataCustodian admin client configuration through method calls - verify(jdbcTemplate, atLeastOnce()).queryForObject(anyString(), any(Class.class), eq("data_custodian_admin")); - } - - @Test - @DisplayName("Should configure ThirdParty client correctly") - void shouldConfigureThirdPartyClientCorrectly() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - // Then - Verify ThirdParty client configuration through method calls - verify(jdbcTemplate, atLeastOnce()).queryForObject(anyString(), any(Class.class), eq("third_party")); - } - - @Test - @DisplayName("Should configure ThirdParty admin client correctly") - void shouldConfigureThirdPartyAdminClientCorrectly() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - // Then - Verify ThirdParty admin client configuration through method calls - verify(jdbcTemplate, atLeastOnce()).queryForObject(anyString(), any(Class.class), eq("third_party_admin")); - } - } - - // JWK Configuration Tests removed in #134 — the auth-server no longer exposes - // jwkSource()/jwtDecoder() (ESPI is opaque-only; no JWK/JWT). - - @Nested - @DisplayName("Authorization Server Settings Tests") - class AuthorizationServerSettingsTests { - - @Test - @DisplayName("Should create authorization server settings with ESPI endpoints") - void shouldCreateAuthorizationServerSettingsWithEspiEndpoints() { - // When - AuthorizationServerSettings settings = config.authorizationServerSettings(); - - // Then - assertThat(settings).isNotNull(); - assertThat(settings.getIssuer()).isEqualTo("http://localhost:9999"); - assertThat(settings.getAuthorizationEndpoint()).isEqualTo("/oauth2/authorize"); - assertThat(settings.getTokenEndpoint()).isEqualTo("/oauth2/token"); - assertThat(settings.getJwkSetEndpoint()).isEqualTo("/oauth2/jwks"); - assertThat(settings.getTokenRevocationEndpoint()).isEqualTo("/oauth2/revoke"); - assertThat(settings.getTokenIntrospectionEndpoint()).isEqualTo("/oauth2/introspect"); - assertThat(settings.getOidcClientRegistrationEndpoint()).isEqualTo("/connect/register"); - assertThat(settings.getOidcUserInfoEndpoint()).isEqualTo("/userinfo"); - } - - @Test - @DisplayName("Should use correct issuer URL") - void shouldUseCorrectIssuerUrl() { - // When - AuthorizationServerSettings settings = config.authorizationServerSettings(); - - // Then - assertThat(settings.getIssuer()).isEqualTo("http://localhost:9999"); - } - } - - @Nested - @DisplayName("Token Customizer Tests") - class TokenCustomizerTests { - - @Test - @DisplayName("Should create ESPI token customizer") - void shouldCreateEspiTokenCustomizer() { - // When - OAuth2TokenCustomizer customizer = config.espiTokenCustomizer(); - - // Then - assertThat(customizer).isNotNull(); - assertThat(customizer).isInstanceOf(EspiTokenCustomizer.class); - } - - @Test - @DisplayName("Should return new instance on each call") - void shouldReturnNewInstanceOnEachCall() { - // When - OAuth2TokenCustomizer customizer1 = config.espiTokenCustomizer(); - OAuth2TokenCustomizer customizer2 = config.espiTokenCustomizer(); - - // Then - assertThat(customizer1).isNotNull(); - assertThat(customizer2).isNotNull(); - assertThat(customizer1).isNotSameAs(customizer2); - } - } - - @Nested - @DisplayName("ESPI Compliance Tests") - class EspiComplianceTests { - - @Test - @DisplayName("Should configure clients with opaque token format (ESPI standard)") - void shouldConfigureClientsWithOpaqueTokenFormat() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - // Then - // The verification is indirect through repository calls since we can't directly - // access the created clients, but the configuration should use OAuth2TokenFormat.REFERENCE - verify(jdbcTemplate, atLeast(3)).queryForObject(anyString(), any(Class.class), anyString()); - } - - @Test - @DisplayName("Should support ESPI scopes for ThirdParty client") - void shouldSupportEspiScopesForThirdPartyClient() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - // Then - // Verify that ThirdParty client is queried (which means it was configured) - verify(jdbcTemplate, atLeastOnce()).queryForObject(anyString(), any(Class.class), eq("third_party")); - } - - @Test - @DisplayName("Should configure proper grant types for ESPI clients") - void shouldConfigureProperGrantTypesForEspiClients() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - // Then - // Verify all default ESPI clients are configured - verify(jdbcTemplate, atLeastOnce()).queryForObject(anyString(), any(Class.class), eq("data_custodian_admin")); - verify(jdbcTemplate, atLeastOnce()).queryForObject(anyString(), any(Class.class), eq("third_party")); - verify(jdbcTemplate, atLeastOnce()).queryForObject(anyString(), any(Class.class), eq("third_party_admin")); - } - - @Test - @DisplayName("Should configure appropriate token lifetimes for ESPI") - void shouldConfigureAppropriateTokenLifetimesForEspi() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - // Then - // The token lifetimes are configured internally in the registered clients - // We verify through the repository initialization - assertThat(repository).isNotNull(); - } - - @Test - @DisplayName("Should configure consent requirements correctly") - void shouldConfigureConsentRequirementsCorrectly() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - // Then - // Admin clients should not require consent, customer clients should - // This is configured in the client settings within the method - assertThat(repository).isNotNull(); - } - } - - @Nested - @DisplayName("Error Handling Tests") - class ErrorHandlingTests { - - @Test - @DisplayName("Should handle JdbcTemplate exceptions gracefully") - void shouldHandleJdbcTemplateExceptionsGracefully() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())) - .thenThrow(new RuntimeException("Database error")); - - // When & Then - Should not throw exception - RegisteredClientRepository repository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - - assertThat(repository).isNotNull(); - } - - @Test - @DisplayName("Should handle null JdbcTemplate gracefully") - void shouldHandleNullJdbcTemplateGracefully() { - // When & Then - Should throw appropriate exception - try { - config.registeredClientRepository(null, passwordEncoder); - } catch (Exception e) { - assertThat(e).isInstanceOf(NullPointerException.class); - } - } - } - - @Nested - @DisplayName("Bean Configuration Tests") - class BeanConfigurationTests { - - @Test - @DisplayName("Should create all required beans") - void shouldCreateAllRequiredBeans() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository clientRepository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - AuthorizationServerSettings serverSettings = config.authorizationServerSettings(); - OAuth2TokenCustomizer tokenCustomizer = config.espiTokenCustomizer(); - - // Then - assertThat(clientRepository).isNotNull(); - assertThat(serverSettings).isNotNull(); - assertThat(tokenCustomizer).isNotNull(); - } - - @Test - @DisplayName("Should create beans with correct types") - void shouldCreateBeansWithCorrectTypes() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When & Then - assertThat(config.registeredClientRepository(jdbcTemplate, passwordEncoder)) - .isInstanceOf(JdbcRegisteredClientRepository.class); - - assertThat(config.authorizationServerSettings()) - .isInstanceOf(AuthorizationServerSettings.class); - - assertThat(config.espiTokenCustomizer()) - .isInstanceOf(EspiTokenCustomizer.class); - } - } - - @Nested - @DisplayName("Integration Tests") - class IntegrationTests { - - @Test - @DisplayName("Should work with complete configuration") - void shouldWorkWithCompleteConfiguration() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository clientRepository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - AuthorizationServerSettings serverSettings = config.authorizationServerSettings(); - OAuth2TokenCustomizer tokenCustomizer = config.espiTokenCustomizer(); - - // Then - All components should work together - assertThat(clientRepository).isNotNull(); - assertThat(serverSettings).isNotNull(); - assertThat(tokenCustomizer).isNotNull(); - - // Test server settings configuration - assertThat(serverSettings.getIssuer()).isNotNull(); - assertThat(serverSettings.getTokenEndpoint()).isNotNull(); - } - - @Test - @DisplayName("Should maintain ESPI compliance across all components") - void shouldMaintainEspiComplianceAcrossAllComponents() { - // Given - when(jdbcTemplate.queryForObject(anyString(), any(Class.class), anyString())).thenReturn(null); - - // When - RegisteredClientRepository clientRepository = config.registeredClientRepository(jdbcTemplate, passwordEncoder); - AuthorizationServerSettings serverSettings = config.authorizationServerSettings(); - OAuth2TokenCustomizer tokenCustomizer = config.espiTokenCustomizer(); - - // Then - assertThat(clientRepository).isInstanceOf(JdbcRegisteredClientRepository.class); - assertThat(tokenCustomizer).isInstanceOf(EspiTokenCustomizer.class); - - // ESPI endpoints should be properly configured - assertThat(serverSettings.getOidcClientRegistrationEndpoint()).isEqualTo("/connect/register"); - assertThat(serverSettings.getTokenRevocationEndpoint()).isEqualTo("/oauth2/revoke"); - assertThat(serverSettings.getTokenIntrospectionEndpoint()).isEqualTo("/oauth2/introspect"); - } - } -} \ No newline at end of file diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/controller/OAuthAdminControllerTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/controller/OAuthAdminControllerTest.java index c62d1b95..5a87bb81 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/controller/OAuthAdminControllerTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/controller/OAuthAdminControllerTest.java @@ -52,11 +52,16 @@ import static org.hamcrest.CoreMatchers.containsString; /** - * Comprehensive unit tests for OAuthAdminController - * - * Tests administrative OAuth2 operations including token management, - * client management, and authorization tracking with proper security. - * + * Unit tests for OAuthAdminController request handling (token management, + * client management, and authorization tracking). + * + *

These tests use {@code MockMvcBuilders.standaloneSetup}, which installs NO Spring Security + * filter chain and no method-security proxy, so {@code @WithMockUser} has no effect here and access + * control cannot be asserted. Authorization of the admin APIs (anonymous, customer session, ADMIN / + * DC_ADMIN with both MFA factors, {@code @PreAuthorize} enforcement) is covered against the real + * security configuration by {@code integration.AdminEndpointAuthorizationIntegrationTest} and + * {@code integration.AdminBearerTokenIntegrationTest} (#129).

+ * * @author Green Button Alliance * @version 1.0.0 * @since Spring Boot 3.5 @@ -117,15 +122,6 @@ void shouldListTokensWithDcAdminRole() throws Exception { .andExpect(jsonPath("$").isArray()); } - @Test - @WithMockUser(roles = "USER") - @DisplayName("Should deny access to tokens with user role") - void shouldDenyAccessToTokensWithUserRole() throws Exception { - // When & Then - mockMvc.perform(get("/admin/oauth2/tokens")) - .andExpect(status().isForbidden()); - } - @Test @WithMockUser(roles = "ADMIN") @DisplayName("Should filter tokens by client ID") @@ -418,70 +414,6 @@ void shouldAllowDcAdminToViewAuthorizations() throws Exception { } } - @Nested - @DisplayName("Security Tests") - class SecurityTests { - - @Test - @DisplayName("Should deny unauthenticated access to admin endpoints") - void shouldDenyUnauthenticatedAccessToAdminEndpoints() throws Exception { - // When & Then - mockMvc.perform(get("/admin/oauth2/tokens")) - .andExpect(status().isUnauthorized()); - - mockMvc.perform(get("/admin/oauth2/clients")) - .andExpect(status().isUnauthorized()); - - mockMvc.perform(get("/admin/oauth2/authorizations")) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser(roles = "USER") - @DisplayName("Should deny user role access to admin endpoints") - void shouldDenyUserRoleAccessToAdminEndpoints() throws Exception { - // When & Then - mockMvc.perform(get("/admin/oauth2/tokens")) - .andExpect(status().isForbidden()); - - mockMvc.perform(get("/admin/oauth2/clients")) - .andExpect(status().isForbidden()); - - mockMvc.perform(get("/admin/oauth2/authorizations")) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser(roles = "CUSTOMER") - @DisplayName("Should deny customer role access to admin endpoints") - void shouldDenyCustomerRoleAccessToAdminEndpoints() throws Exception { - // When & Then - mockMvc.perform(delete("/admin/oauth2/tokens/test-token")) - .andExpect(status().isForbidden()); - - mockMvc.perform(delete("/admin/oauth2/clients/test-client")) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser(authorities = "ROLE_ADMIN") - @DisplayName("Should allow ROLE_ADMIN authority access") - void shouldAllowRoleAdminAuthorityAccess() throws Exception { - // When & Then - mockMvc.perform(get("/admin/oauth2/tokens")) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser(authorities = "ROLE_DC_ADMIN") - @DisplayName("Should allow ROLE_DC_ADMIN authority access") - void shouldAllowRoleDcAdminAuthorityAccess() throws Exception { - // When & Then - mockMvc.perform(get("/admin/oauth2/clients")) - .andExpect(status().isOk()); - } - } - @Nested @DisplayName("ESPI Compliance Tests") class EspiComplianceTests { diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AbstractAuthserverIntegrationTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AbstractAuthserverIntegrationTest.java index 081ac72a..5005a566 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AbstractAuthserverIntegrationTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AbstractAuthserverIntegrationTest.java @@ -46,8 +46,8 @@ * and Flyway wiring are inherited. The {@code testcontainers} Spring profile supplies the rest of the * AS test configuration (see {@code application-testcontainers.yml}).

*/ -// Tagged so CI can run ONLY the Docker-backed integration tests (-Dgroups=testcontainers-it), -// excluding the AS module's pre-existing broken H2-profile unit tests. Inherited by subclasses. +// Tagged (and inherited by subclasses) so the Docker-backed integration tests can be selected on their +// own with -Dgroups=testcontainers-it. CI runs the full module suite (#129), so the tag is optional there. @Tag("testcontainers-it") @SpringBootTest(classes = AuthorizationServerApplication.class) @AutoConfigureMockMvc diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AuthorizationPurgeVendorPortabilityTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AuthorizationPurgeVendorPortabilityTest.java index 7f3ab812..a7ddf8e6 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AuthorizationPurgeVendorPortabilityTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AuthorizationPurgeVendorPortabilityTest.java @@ -61,8 +61,8 @@ * (#152). Replace with a full migrate once #152 lands. * * - *

Tagged {@code testcontainers-it} so CI runs it with the other Docker-backed integration tests - * (the PostgreSQL half needs Docker; the H2 half is in-memory).

+ *

Tagged {@code testcontainers-it} with the other Docker-backed integration tests (the PostgreSQL + * half needs Docker; the H2 half is in-memory).

*/ @Tag("testcontainers-it") @DisplayName("Authorization purge (#192) vendor portability") diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/MySqlTestcontainersIntegrationTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/MySqlTestcontainersIntegrationTest.java index 3949cd44..fc1a50af 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/MySqlTestcontainersIntegrationTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/MySqlTestcontainersIntegrationTest.java @@ -61,7 +61,7 @@ * @version 1.0.0 * @since Spring Boot 3.5 */ -@Tag("testcontainers-it") // CI runs the Docker-backed AS integration tests via -Dgroups=testcontainers-it +@Tag("testcontainers-it") // Docker-backed; selectable alone via -Dgroups=testcontainers-it @SpringBootTest @Testcontainers @ActiveProfiles("testcontainers") diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java index 3fe1e48f..165cc80a 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/SecurityIntegrationTest.java @@ -25,148 +25,190 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureWebMvc; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletResponse; +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.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.time.Instant; import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.greenbuttonalliance.espi.authserver.integration.AdminMfaTestSupport.adminWithBothFactors; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** - * Security Integration Tests - * - * Comprehensive security testing for OAuth2 Authorization Server including - * authentication, authorization, access control, and ESPI-specific security requirements. - * - * @author Green Button Alliance - * @version 1.0.0 - * @since Spring Boot 3.5 + * Security integration tests for the OAuth2 protocol endpoints: client authentication, grant/scope/ + * redirect-URI validation, CSRF exemptions, opaque-token properties and response headers. + * + *

Runs against the real security configuration and the shared MySQL Testcontainer (#129). Admin-API + * access control is covered by {@link AdminEndpointAuthorizationIntegrationTest} and + * {@link AdminBearerTokenIntegrationTest}; RFC 7591 registration by + * {@link OAuth2DynamicClientRegistrationIntegrationTest}.

*/ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@AutoConfigureWebMvc -@AutoConfigureMockMvc -@ActiveProfiles("test") @DisplayName("Security Integration Tests") -@Transactional -class SecurityIntegrationTest { +class SecurityIntegrationTest extends AbstractAuthserverIntegrationTest { + + /** Seeded client_credentials client (AuthorizationServerConfig#registeredClientRepository). */ + private static final String ADMIN_CLIENT_ID = "data_custodian_admin"; + private static final String ADMIN_CLIENT_SECRET = "dc-secret"; + private static final String ADMIN_SCOPE = "DataCustodian_Admin_Access"; + + /** Seeded client_credentials client with a different scope. */ + private static final String TP_ADMIN_CLIENT_ID = "third_party_admin"; + private static final String TP_ADMIN_CLIENT_SECRET = "tpadmin-secret"; + + /** Seeded DC resource-server introspection client. */ + private static final String INTROSPECTION_CLIENT_ID = "datacustodian"; + private static final String INTROSPECTION_CLIENT_SECRET = "datacustodian-secret"; + + /** Seeded authorization_code client (no client_credentials grant). */ + private static final String TP_CLIENT_ID = "third_party"; + private static final String TP_CLIENT_SECRET = "tp-secret"; + + private static final String ESPI_SCOPE_HOURLY = "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13"; + private static final String ESPI_SCOPE_15_MIN = "FB=4_5_15;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13"; + private static final String REDIRECT_URI = "https://tp.example/espi/callback"; @Autowired private MockMvc mockMvc; @Autowired - private ObjectMapper objectMapper; + private RegisteredClientRepository registeredClientRepository; - private static final String CLIENT_ID = "third_party"; - private static final String CLIENT_SECRET = "{bcrypt}secret"; - private static final String ADMIN_CLIENT_ID = "data_custodian_admin"; - private static final String ADMIN_CLIENT_SECRET = "{bcrypt}secret"; + @Autowired + private JdbcTemplate jdbcTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** Per-test ESPI authorization_code client with an https redirect URI (Spring AS rejects "localhost"). */ + private String authCodeClientId; + + @BeforeEach + void seedAuthorizationCodeClient() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + authCodeClientId = "sec-it-tp-" + suffix; + registeredClientRepository.save(RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId(authCodeClientId) + .clientName("Security IT ThirdParty") + .clientSecret("{noop}sec-it-secret-" + suffix) // JdbcRegisteredClientRepository requires unique secrets + .clientIdIssuedAt(Instant.now()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(REDIRECT_URI) + .scope(ESPI_SCOPE_HOURLY) + .scope(ESPI_SCOPE_15_MIN) + .clientSettings(ClientSettings.builder() + .requireAuthorizationConsent(true) + .requireProofKey(false) + .build()) + .tokenSettings(TokenSettings.builder() + .accessTokenFormat(OAuth2TokenFormat.REFERENCE) + .accessTokenTimeToLive(Duration.ofHours(6)) + .build()) + .build()); + } @Nested @DisplayName("Authentication Tests") class AuthenticationTests { + /* + * Accept: application/json is sent explicitly. The protocol chain's login entry point matches + * text/html, and a request with no Accept header (or a wildcard Accept) is treated as compatible + * with text/html, so such a request is redirected to /authorize/delegate instead of getting a 401. + */ @Test - @DisplayName("Should require authentication for protected endpoints") + @DisplayName("Should require client authentication on token, introspection and revocation endpoints") void shouldRequireAuthenticationForProtectedEndpoints() throws Exception { - // OAuth2 endpoints - mockMvc.perform(post("/oauth2/token")) - .andExpect(status().isUnauthorized()); - - mockMvc.perform(post("/oauth2/introspect")) - .andExpect(status().isUnauthorized()); - - mockMvc.perform(post("/oauth2/revoke")) + mockMvc.perform(post("/oauth2/token") + .accept(MediaType.APPLICATION_JSON) + .param("grant_type", "client_credentials")) .andExpect(status().isUnauthorized()); - // Admin endpoints - mockMvc.perform(get("/admin/oauth2/tokens")) + mockMvc.perform(post("/oauth2/introspect") + .accept(MediaType.APPLICATION_JSON) + .param("token", "anything")) .andExpect(status().isUnauthorized()); - mockMvc.perform(get("/admin/oauth2/clients")) + mockMvc.perform(post("/oauth2/revoke") + .accept(MediaType.APPLICATION_JSON) + .param("token", "anything")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("Should accept valid client credentials") void shouldAcceptValidClientCredentials() throws Exception { - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isOk()); + mockMvc.perform(clientCredentials(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET, ADMIN_SCOPE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token_type").value("Bearer")) + .andExpect(jsonPath("$.scope").value(ADMIN_SCOPE)); } @Test @DisplayName("Should reject invalid client credentials") void shouldRejectInvalidClientCredentials() throws Exception { - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, "wrong-password")) - .with(csrf())) - .andExpect(status().isUnauthorized()); + mockMvc.perform(clientCredentials(ADMIN_CLIENT_ID, "wrong-password", ADMIN_SCOPE)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("invalid_client")); } @Test @DisplayName("Should reject non-existent client") void shouldRejectNonExistentClient() throws Exception { - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic("non-existent-client", "password")) - .with(csrf())) - .andExpect(status().isUnauthorized()); + mockMvc.perform(clientCredentials("non-existent-client", "password", ADMIN_SCOPE)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("invalid_client")); } @Test - @DisplayName("Should handle malformed basic auth header") + @DisplayName("Should reject a malformed Basic authorization header as invalid_request") void shouldHandleMalformedBasicAuthHeader() throws Exception { String malformedAuth = Base64.getEncoder().encodeToString("malformed".getBytes()); - + mockMvc.perform(post("/oauth2/token") - .header("Authorization", "Basic " + malformedAuth) - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(csrf())) - .andExpect(status().isUnauthorized()); + .header("Authorization", "Basic " + malformedAuth) + .param("grant_type", "client_credentials") + .param("scope", ADMIN_SCOPE)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("invalid_request")); } @Test - @DisplayName("Should validate client authentication method") + @DisplayName("Should only accept the client's registered authentication method (client_secret_basic)") void shouldValidateClientAuthenticationMethod() throws Exception { - // Test client_secret_basic (valid) - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) + mockMvc.perform(clientCredentials(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET, ADMIN_SCOPE)) .andExpect(status().isOk()); - // Test client_secret_post (should work if supported) + // data_custodian_admin is registered for client_secret_basic only mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("client_id", ADMIN_CLIENT_ID) - .param("client_secret", ADMIN_CLIENT_SECRET) - .param("scope", "DataCustodian_Admin_Access") - .with(csrf())) - .andExpect(status().isOk()); + .param("grant_type", "client_credentials") + .param("client_id", ADMIN_CLIENT_ID) + .param("client_secret", ADMIN_CLIENT_SECRET) + .param("scope", ADMIN_SCOPE)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("invalid_client")); } } @@ -177,153 +219,64 @@ class AuthorizationTests { @Test @DisplayName("Should enforce scope-based authorization") void shouldEnforceScopeBasedAuthorization() throws Exception { - // Admin client should be able to access admin scopes - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) + mockMvc.perform(clientCredentials(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET, ADMIN_SCOPE)) .andExpect(status().isOk()); - // Regular client should not be able to access admin scopes - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(CLIENT_ID, CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isBadRequest()); + // A client_credentials client cannot obtain a scope it is not registered for + mockMvc.perform(clientCredentials(TP_ADMIN_CLIENT_ID, TP_ADMIN_CLIENT_SECRET, ADMIN_SCOPE)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("invalid_scope")); } @Test - @DisplayName("Should validate grant type authorization") + @DisplayName("Should reject grant types the client is not registered for") void shouldValidateGrantTypeAuthorization() throws Exception { - // Admin client authorized for client_credentials + // data_custodian_admin is client_credentials only; Spring AS resolves the code first, and no + // code can exist for a client without the authorization_code grant, so this is invalid_grant mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isOk()); + .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) + .param("grant_type", "authorization_code") + .param("code", "invalid-code") + .param("redirect_uri", REDIRECT_URI)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("invalid_grant")); - // Admin client not authorized for authorization_code without code - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "authorization_code") - .param("code", "invalid-code") - .param("redirect_uri", "http://localhost:8080/callback") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isBadRequest()); + // third_party is authorization_code/refresh_token only + mockMvc.perform(clientCredentials(TP_CLIENT_ID, TP_CLIENT_SECRET, ESPI_SCOPE_HOURLY)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("unauthorized_client")); } @Test @DisplayName("Should enforce redirect URI validation") void shouldEnforceRedirectUriValidation() throws Exception { - // Valid redirect URI - mockMvc.perform(get("/oauth2/authorize") - .param("response_type", "code") - .param("client_id", CLIENT_ID) - .param("redirect_uri", "http://localhost:8080/DataCustodian/oauth/callback") - .param("scope", "openid") - .param("state", "test-state")) - .andExpect(status().is3xxRedirection()); - - // Invalid redirect URI - mockMvc.perform(get("/oauth2/authorize") - .param("response_type", "code") - .param("client_id", CLIENT_ID) - .param("redirect_uri", "https://malicious.com/callback") - .param("scope", "openid") - .param("state", "test-state")) - .andExpect(status().isBadRequest()); + // Registered redirect URI: the unauthenticated user is sent to the delegated login/consent + mockMvc.perform(authorize(authCodeClientId, REDIRECT_URI, ESPI_SCOPE_HOURLY)) + .andExpect(status().isFound()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .contains("/authorize/delegate")); + + // Unregistered redirect URI: 400, and never a redirect to the attacker's URI + mockMvc.perform(authorize(authCodeClientId, "https://malicious.example/callback", ESPI_SCOPE_HOURLY)) + .andExpect(status().isBadRequest()) + .andExpect(header().doesNotExist("Location")); } @Test @DisplayName("Should validate requested scopes") void shouldValidateRequestedScopes() throws Exception { - // Valid ESPI scope - mockMvc.perform(get("/oauth2/authorize") - .param("response_type", "code") - .param("client_id", CLIENT_ID) - .param("redirect_uri", "http://localhost:8080/DataCustodian/oauth/callback") - .param("scope", "openid FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13") - .param("state", "test-state")) - .andExpect(status().is3xxRedirection()); - - // Invalid scope for client - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "invalid_scope") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isBadRequest()); - } - } - - @Nested - @DisplayName("Admin Access Control Tests") - class AdminAccessControlTests { - - @Test - @WithMockUser(roles = "ADMIN") - @DisplayName("Should allow admin access to admin endpoints") - void shouldAllowAdminAccessToAdminEndpoints() throws Exception { - mockMvc.perform(get("/admin/oauth2/tokens")) - .andExpect(status().isOk()); - - mockMvc.perform(get("/admin/oauth2/clients")) - .andExpect(status().isOk()); - - mockMvc.perform(get("/admin/oauth2/authorizations")) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser(roles = "DC_ADMIN") - @DisplayName("Should allow DC_ADMIN access to admin endpoints") - void shouldAllowDcAdminAccessToAdminEndpoints() throws Exception { - mockMvc.perform(get("/admin/oauth2/tokens")) - .andExpect(status().isOk()); - - mockMvc.perform(get("/admin/oauth2/clients")) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser(roles = "USER") - @DisplayName("Should deny user access to admin endpoints") - void shouldDenyUserAccessToAdminEndpoints() throws Exception { - mockMvc.perform(get("/admin/oauth2/tokens")) - .andExpect(status().isForbidden()); - - mockMvc.perform(get("/admin/oauth2/clients")) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser(roles = "CUSTOMER") - @DisplayName("Should deny customer access to admin endpoints") - void shouldDenyCustomerAccessToAdminEndpoints() throws Exception { - mockMvc.perform(delete("/admin/oauth2/tokens/test-token")) - .andExpect(status().isForbidden()); - - mockMvc.perform(delete("/admin/oauth2/clients/test-client")) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") - @DisplayName("Should allow DataCustodian admin scope access") - void shouldAllowDataCustodianAdminScopeAccess() throws Exception { - mockMvc.perform(get("/admin/oauth2/clients")) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser(authorities = "SCOPE_openid") - @DisplayName("Should deny regular scope access to admin endpoints") - void shouldDenyRegularScopeAccessToAdminEndpoints() throws Exception { - mockMvc.perform(get("/admin/oauth2/clients")) - .andExpect(status().isForbidden()); + // Scope the client is not registered for: invalid_scope is returned to the client's redirect URI + mockMvc.perform(authorize(authCodeClientId, REDIRECT_URI, + "FB=4_5_16;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13")) + .andExpect(status().isFound()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .startsWith(REDIRECT_URI) + .contains("error=invalid_scope")); + + // Unknown scope at the token endpoint + mockMvc.perform(clientCredentials(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET, "invalid_scope")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("invalid_scope")); } } @@ -332,46 +285,35 @@ void shouldDenyRegularScopeAccessToAdminEndpoints() throws Exception { class CsrfProtectionTests { @Test - @DisplayName("Should require CSRF token for state-changing operations") - void shouldRequireCsrfTokenForStateChangingOperations() throws Exception { - // POST without CSRF token should fail - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET))) - .andExpect(status().isForbidden()); - - // POST with CSRF token should succeed - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) + @DisplayName("OAuth2 protocol endpoints are CSRF-exempt (client-authenticated, not session-based)") + void protocolEndpointsAreCsrfExempt() throws Exception { + // No csrf() post-processor: the token endpoint must still issue a token + mockMvc.perform(clientCredentials(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET, ADMIN_SCOPE)) .andExpect(status().isOk()); } @Test - @DisplayName("Should not require CSRF for GET operations") + @DisplayName("Should not require authentication or CSRF for the discovery document") void shouldNotRequireCsrfForGetOperations() throws Exception { - // GET operations should work without CSRF - mockMvc.perform(get("/oauth2/jwks")) - .andExpect(status().isOk()); - mockMvc.perform(get("/.well-known/oauth-authorization-server")) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.issuer").value("http://localhost:9999")) + .andExpect(jsonPath("$.token_endpoint").value("http://localhost:9999/oauth2/token")) + .andExpect(jsonPath("$.introspection_endpoint").value("http://localhost:9999/oauth2/introspect")) + .andExpect(jsonPath("$.registration_endpoint").value("http://localhost:9999/oauth2/register")); } @Test - @WithMockUser(roles = "ADMIN") - @DisplayName("Should require CSRF for admin operations") + @DisplayName("Should require CSRF for state-changing admin operations on the session chain") void shouldRequireCsrfForAdminOperations() throws Exception { - // DELETE without CSRF should fail - mockMvc.perform(delete("/admin/oauth2/tokens/test-token")) + mockMvc.perform(delete("/admin/oauth2/tokens/test-token") + .with(adminWithBothFactors("sec-it-admin", "ROLE_ADMIN"))) .andExpect(status().isForbidden()); - // DELETE with CSRF should succeed (may return 404 for non-existent token) + // With a CSRF token the request reaches the controller (unknown token -> 404) mockMvc.perform(delete("/admin/oauth2/tokens/test-token") - .with(csrf())) + .with(adminWithBothFactors("sec-it-admin", "ROLE_ADMIN")) + .with(csrf())) .andExpect(status().isNotFound()); } } @@ -381,112 +323,56 @@ void shouldRequireCsrfForAdminOperations() throws Exception { class TokenSecurityTests { @Test - @DisplayName("Should generate secure opaque tokens") + @DisplayName("Should generate secure opaque (non-JWT) access tokens (ESPI)") void shouldGenerateSecureOpaqueTokens() throws Exception { - // Get an access token - MvcResult result = mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); + String accessToken = obtainAccessToken(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET, ADMIN_SCOPE); - String responseBody = result.getResponse().getContentAsString(); - JsonNode response = objectMapper.readTree(responseBody); - String accessToken = response.get("access_token").asText(); - - // Verify token properties (ESPI standard: opaque tokens) - assertThat(accessToken).isNotNull(); - assertThat(accessToken.length()).isGreaterThan(20); // Should be reasonably long - assertThat(accessToken).doesNotContain("."); // Should not be JWT format - assertThat(accessToken).matches("[A-Za-z0-9_-]+"); // Should be URL-safe + assertThat(accessToken) + .hasSizeGreaterThan(20) + .doesNotContain(".") + .doesNotStartWith("eyJ") + .matches("[A-Za-z0-9_-]+"); } @Test - @DisplayName("Should protect against token replay attacks") + @DisplayName("A revoked access token introspects as inactive") void shouldProtectAgainstTokenReplayAttacks() throws Exception { - // Get an access token - MvcResult tokenResult = mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); + String accessToken = obtainAccessToken(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET, ADMIN_SCOPE); - String tokenResponseBody = tokenResult.getResponse().getContentAsString(); - JsonNode tokenResponse = objectMapper.readTree(tokenResponseBody); - String accessToken = tokenResponse.get("access_token").asText(); - - // Use token for introspection (should work) - mockMvc.perform(post("/oauth2/introspect") - .param("token", accessToken) - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) + mockMvc.perform(introspect(accessToken, ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) .andExpect(status().isOk()) .andExpect(jsonPath("$.active").value(true)); - // Revoke the token mockMvc.perform(post("/oauth2/revoke") - .param("token", accessToken) - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) + .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) + .param("token", accessToken)) .andExpect(status().isOk()); - // Try to use revoked token (should fail) - mockMvc.perform(post("/oauth2/introspect") - .param("token", accessToken) - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) + mockMvc.perform(introspect(accessToken, ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) .andExpect(status().isOk()) .andExpect(jsonPath("$.active").value(false)); } @Test - @DisplayName("Should enforce token expiration") + @DisplayName("Should issue admin tokens that expire within one hour") void shouldEnforceTokenExpiration() throws Exception { - // Get an access token - MvcResult result = mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); - - String responseBody = result.getResponse().getContentAsString(); - JsonNode response = objectMapper.readTree(responseBody); - - // Verify token has expiration - assertThat(response.has("expires_in")).isTrue(); - int expiresIn = response.get("expires_in").asInt(); - assertThat(expiresIn).isGreaterThan(0); - assertThat(expiresIn).isLessThanOrEqualTo(3600); // Should not exceed 1 hour for admin tokens + JsonNode response = tokenResponse(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET, ADMIN_SCOPE); + + assertThat(response.path("expires_in").asInt()) + .isPositive() + .isLessThanOrEqualTo(3600); } @Test - @DisplayName("Should validate token ownership") + @DisplayName("The DataCustodian introspection client can introspect a token issued to another client") void shouldValidateTokenOwnership() throws Exception { - // Get token with one client - MvcResult tokenResult = mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); - - String tokenResponseBody = tokenResult.getResponse().getContentAsString(); - JsonNode tokenResponse = objectMapper.readTree(tokenResponseBody); - String accessToken = tokenResponse.get("access_token").asText(); + String accessToken = obtainAccessToken(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET, ADMIN_SCOPE); - // Try to introspect with different client (should still work for admin) - mockMvc.perform(post("/oauth2/introspect") - .param("token", accessToken) - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isOk()); + mockMvc.perform(introspect(accessToken, INTROSPECTION_CLIENT_ID, INTROSPECTION_CLIENT_SECRET)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.active").value(true)) + .andExpect(jsonPath("$.client_id").value(ADMIN_CLIENT_ID)) + .andExpect(jsonPath("$.scope").value(ADMIN_SCOPE)); } } @@ -495,85 +381,33 @@ void shouldValidateTokenOwnership() throws Exception { class EspiSecurityComplianceTests { @Test - @DisplayName("Should enforce ESPI-required opaque tokens") - void shouldEnforceEspiRequiredOpaqueTokens() throws Exception { - // Get token for ESPI client - MvcResult result = mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "DataCustodian_Admin_Access") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); - - String responseBody = result.getResponse().getContentAsString(); - JsonNode response = objectMapper.readTree(responseBody); - String accessToken = response.get("access_token").asText(); - - // Verify token is opaque (not JWT) - assertThat(accessToken.split("\\.").length).isLessThan(3); - assertThat(accessToken).doesNotContain("eyJ"); // Should not start like JWT - } - - @Test - @DisplayName("Should require authorization consent for customer data") + @DisplayName("Should route an unauthenticated customer to the delegated login/consent") void shouldRequireAuthorizationConsentForCustomerData() throws Exception { - // Start authorization flow that should require consent - mockMvc.perform(get("/oauth2/authorize") - .param("response_type", "code") - .param("client_id", CLIENT_ID) - .param("redirect_uri", "http://localhost:8080/DataCustodian/oauth/callback") - .param("scope", "openid profile FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13") - .param("state", "test-state")) - .andExpect(status().is3xxRedirection()); - // Should redirect to login/consent page + mockMvc.perform(authorize(authCodeClientId, REDIRECT_URI, ESPI_SCOPE_HOURLY)) + .andExpect(status().isFound()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .contains("/authorize/delegate") + .doesNotContain("code=")); } @Test - @DisplayName("Should validate ESPI scope format") + @DisplayName("Should accept multiple registered ESPI Function Block scopes") void shouldValidateEspiScopeFormat() throws Exception { - // Valid ESPI scope format - mockMvc.perform(get("/oauth2/authorize") - .param("response_type", "code") - .param("client_id", CLIENT_ID) - .param("redirect_uri", "http://localhost:8080/DataCustodian/oauth/callback") - .param("scope", "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13") - .param("state", "test-state")) - .andExpect(status().is3xxRedirection()); - - // Should handle multiple ESPI scopes - mockMvc.perform(get("/oauth2/authorize") - .param("response_type", "code") - .param("client_id", CLIENT_ID) - .param("redirect_uri", "http://localhost:8080/DataCustodian/oauth/callback") - .param("scope", "openid FB=4_5_15;IntervalDuration=3600 FB=4_5_16;IntervalDuration=900") - .param("state", "test-state")) - .andExpect(status().is3xxRedirection()); + mockMvc.perform(authorize(authCodeClientId, REDIRECT_URI, ESPI_SCOPE_HOURLY + " " + ESPI_SCOPE_15_MIN)) + .andExpect(status().isFound()) + .andExpect(result -> assertThat(result.getResponse().getRedirectedUrl()) + .contains("/authorize/delegate") + .doesNotContain("error=")); } @Test - @DisplayName("Should enforce proper token lifetimes for ESPI") - void shouldEnforceProperTokenLifetimesForEspi() throws Exception { - // Get customer access token - MvcResult result = mockMvc.perform(post("/oauth2/token") - .param("grant_type", "authorization_code") - .param("code", "test-code") - .param("redirect_uri", "http://localhost:8080/DataCustodian/oauth/callback") - .with(httpBasic(CLIENT_ID, CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); - - if (result.getResponse().getStatus() == 200) { - String responseBody = result.getResponse().getContentAsString(); - JsonNode response = objectMapper.readTree(responseBody); - - if (response.has("expires_in")) { - int expiresIn = response.get("expires_in").asInt(); - // ESPI recommends 6-hour access tokens for customer data - assertThat(expiresIn).isLessThanOrEqualTo(21600); // 6 hours - } - } + @DisplayName("The seeded third_party client issues opaque 6-hour access tokens (ESPI)") + void shouldEnforceProperTokenLifetimesForEspi() { + assertThat(registeredClientRepository.findByClientId(TP_CLIENT_ID)) + .isNotNull() + .extracting(RegisteredClient::getTokenSettings) + .extracting(TokenSettings::getAccessTokenFormat, TokenSettings::getAccessTokenTimeToLive) + .containsExactly(OAuth2TokenFormat.REFERENCE, Duration.ofHours(6)); } } @@ -582,33 +416,19 @@ void shouldEnforceProperTokenLifetimesForEspi() throws Exception { class SecurityHeadersTests { @Test - @DisplayName("Should include security headers in responses") + @DisplayName("Should include security headers in protocol responses") void shouldIncludeSecurityHeadersInResponses() throws Exception { mockMvc.perform(get("/.well-known/oauth-authorization-server")) .andExpect(status().isOk()) - .andExpect(header().exists("X-Content-Type-Options")) - .andExpect(header().exists("X-Frame-Options")) - .andExpect(header().exists("X-XSS-Protection")); + .andExpect(header().string("X-Content-Type-Options", "nosniff")) + .andExpect(header().string("X-Frame-Options", "DENY")) + .andExpect(header().string("Cache-Control", containsString("no-store"))); } @Test - @DisplayName("Should set appropriate CORS headers") - void shouldSetAppropriateCorsHeaders() throws Exception { - mockMvc.perform(get("/oauth2/jwks") - .header("Origin", "https://trusted-client.example.com")) - .andExpect(status().isOk()); - // CORS headers should be configured appropriately - } - - @Test - @DisplayName("Should prevent clickjacking attacks") + @DisplayName("Should prevent clickjacking of the authorization endpoint") void shouldPreventClickjackingAttacks() throws Exception { - mockMvc.perform(get("/oauth2/authorize") - .param("response_type", "code") - .param("client_id", CLIENT_ID) - .param("redirect_uri", "http://localhost:8080/DataCustodian/oauth/callback") - .param("scope", "openid") - .param("state", "test-state")) + mockMvc.perform(authorize(authCodeClientId, REDIRECT_URI, ESPI_SCOPE_HOURLY)) .andExpect(header().string("X-Frame-Options", "DENY")); } } @@ -618,42 +438,84 @@ void shouldPreventClickjackingAttacks() throws Exception { class InputValidationTests { @Test - @DisplayName("Should sanitize and validate all inputs") + @DisplayName("Should reject script-injection attempts without redirecting to the injected URI") void shouldSanitizeAndValidateAllInputs() throws Exception { - // Test with potentially malicious inputs mockMvc.perform(get("/oauth2/authorize") - .param("response_type", "") - .param("client_id", CLIENT_ID) - .param("redirect_uri", "javascript:alert('xss')") - .param("scope", "openid") - .param("state", "")) - .andExpect(status().isBadRequest()); + .accept(MediaType.TEXT_HTML) + .queryParam("response_type", "") + .queryParam("client_id", authCodeClientId) + .queryParam("redirect_uri", "javascript:alert('xss')") + .queryParam("scope", ESPI_SCOPE_HOURLY) + .queryParam("state", "")) + .andExpect(status().isBadRequest()) + .andExpect(header().doesNotExist("Location")); } @Test - @DisplayName("Should handle SQL injection attempts") + @DisplayName("Should treat SQL in the scope parameter as an invalid scope") void shouldHandleSqlInjectionAttempts() throws Exception { - mockMvc.perform(post("/oauth2/token") - .param("grant_type", "client_credentials") - .param("scope", "'; DROP TABLE oauth2_registered_client; --") - .with(httpBasic(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET)) - .with(csrf())) - .andExpect(status().isBadRequest()); + mockMvc.perform(clientCredentials(ADMIN_CLIENT_ID, ADMIN_CLIENT_SECRET, + "'; DROP TABLE oauth2_registered_client; --")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("invalid_scope")); + + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM oauth2_registered_client WHERE client_id = ?", + Integer.class, ADMIN_CLIENT_ID)) + .isOne(); } @Test - @DisplayName("Should validate parameter lengths") + @DisplayName("Should reject an oversized scope parameter as invalid_scope") void shouldValidateParameterLengths() throws Exception { - String veryLongString = "a".repeat(10000); - - mockMvc.perform(get("/oauth2/authorize") - .param("response_type", "code") - .param("client_id", CLIENT_ID) - .param("redirect_uri", "http://localhost:8080/DataCustodian/oauth/callback") - .param("scope", veryLongString) - .param("state", "test-state")) - .andExpect(status().isBadRequest()); + mockMvc.perform(authorize(authCodeClientId, REDIRECT_URI, "a".repeat(10000))) + .andExpect(status().isFound()) + .andExpect(result -> assertThat(result.getResponse()) + .extracting(MockHttpServletResponse::getRedirectedUrl) + .asString() + .startsWith(REDIRECT_URI) + .contains("error=invalid_scope")); } } -} \ No newline at end of file + // Helpers + + private static MockHttpServletRequestBuilder clientCredentials(String clientId, String secret, String scope) { + return post("/oauth2/token") + .with(httpBasic(clientId, secret)) + .param("grant_type", "client_credentials") + .param("scope", scope); + } + + private static MockHttpServletRequestBuilder introspect(String token, String clientId, String secret) { + return post("/oauth2/introspect") + .with(httpBasic(clientId, secret)) + .param("token", token); + } + + /** + * Browser-style authorization request (Accept: text/html, like a user agent). Uses {@code queryParam}: + * Spring AS reads the authorization request from the query string, so {@code param(...)} on a GET + * (which sets no query string in MockMvc) is reported as a missing {@code response_type}. + */ + private static MockHttpServletRequestBuilder authorize(String clientId, String redirectUri, String scope) { + return get("/oauth2/authorize") + .accept(MediaType.TEXT_HTML) + .queryParam("response_type", "code") + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .queryParam("scope", scope) + .queryParam("state", "test-state"); + } + + private JsonNode tokenResponse(String clientId, String secret, String scope) throws Exception { + String body = mockMvc.perform(clientCredentials(clientId, secret, scope)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(body); + } + + private String obtainAccessToken(String clientId, String secret, String scope) throws Exception { + return tokenResponse(clientId, secret, scope).get("access_token").asText(); + } +} diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/service/EspiTokenCustomizerTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/service/EspiTokenCustomizerTest.java index 87feefc9..73152ec1 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/service/EspiTokenCustomizerTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/service/EspiTokenCustomizerTest.java @@ -24,47 +24,46 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.core.AuthorizationGrantType; -import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; import org.springframework.test.util.ReflectionTestUtils; -import java.util.Collection; -import java.util.List; +import java.util.HashMap; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; -import static org.mockito.ArgumentMatchers.*; /** - * Comprehensive unit tests for EspiTokenCustomizer - * - * Tests ESPI-specific JWT token customization including Green Button Alliance - * standard claims, client type determination, and resource URI generation. - * - * Note: ESPI standard uses opaque tokens, JWT customization is experimental. - * - * @author Green Button Alliance - * @version 1.0.0 - * @since Spring Boot 3.5 + * Unit tests for {@link EspiTokenCustomizer}. + * + *

The customizer is EXPERIMENTAL: it only fires when {@code espi.token.format=jwt}; the ESPI + * standard (and this AS's default) is opaque tokens. The tests drive it with a real + * {@link JwtEncodingContext} and inspect the resulting claims, rather than mocking the context + * and verifying builder calls (#129).

*/ -@ExtendWith(MockitoExtension.class) @DisplayName("EspiTokenCustomizer Tests") class EspiTokenCustomizerTest { + private static final String DC_ENDPOINT = "http://localhost:8080"; + private static final String RESOURCE_BASE = DC_ENDPOINT + "/espi/1_1/resource"; + private EspiTokenCustomizer customizer; @BeforeEach void setUp() { customizer = new EspiTokenCustomizer(); - // Set default property values using reflection - ReflectionTestUtils.setField(customizer, "dataCustodianEndpoint", "http://localhost:8080"); + ReflectionTestUtils.setField(customizer, "dataCustodianEndpoint", DC_ENDPOINT); ReflectionTestUtils.setField(customizer, "tokenFormat", "jwt"); } @@ -72,71 +71,32 @@ void setUp() { @DisplayName("Token Format Tests") class TokenFormatTests { - @Test - @DisplayName("Should skip customization when token format is opaque (ESPI standard)") - void shouldSkipCustomizationWhenTokenFormatIsOpaque() { - // Given - ReflectionTestUtils.setField(customizer, "tokenFormat", "opaque"); - - JwtEncodingContext context = createMockJwtEncodingContext(); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder, never()).claim(anyString(), any()); - } - - @Test - @DisplayName("Should skip customization when token format is reference") - void shouldSkipCustomizationWhenTokenFormatIsReference() { - // Given - ReflectionTestUtils.setField(customizer, "tokenFormat", "reference"); - - JwtEncodingContext context = createMockJwtEncodingContext(); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); + @ParameterizedTest(name = "espi.token.format={0} -> no ESPI claims") + @ValueSource(strings = {"opaque", "reference"}) + @DisplayName("Should skip customization when the token format is not jwt (ESPI standard)") + void shouldSkipCustomizationWhenTokenFormatIsNotJwt(String format) { + ReflectionTestUtils.setField(customizer, "tokenFormat", format); - // Then - verify(claimsBuilder, never()).claim(anyString(), any()); + assertThat(customize(clientCredentials("data_custodian_admin", "ROLE_DC_ADMIN"))) + .isEmpty(); } @Test - @DisplayName("Should customize tokens when format is jwt (experimental)") + @DisplayName("Should add the base ESPI claims when the format is jwt (experimental)") void shouldCustomizeTokensWhenFormatIsJwt() { - // Given - JwtEncodingContext context = createMockJwtEncodingContext(); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("espi_version", "1.1"); - verify(claimsBuilder).claim("data_custodian_endpoint", "http://localhost:8080"); + assertThat(customize(clientCredentials("data_custodian_admin", "ROLE_DC_ADMIN"))) + .containsEntry("espi_version", "1.1") + .containsEntry("data_custodian_endpoint", DC_ENDPOINT); } @Test @DisplayName("Should only customize access tokens") void shouldOnlyCustomizeAccessTokens() { - // Given - JwtEncodingContext context = createMockJwtEncodingContext(); - when(context.getTokenType()).thenReturn(OAuth2TokenType.REFRESH_TOKEN); - - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); + JwtEncodingContext.Builder refreshToken = context("third_party", + AuthorizationGrantType.AUTHORIZATION_CODE, customer()) + .tokenType(OAuth2TokenType.REFRESH_TOKEN); - // Then - verify(claimsBuilder, never()).claim(anyString(), any()); + assertThat(customize(refreshToken)).isEmpty(); } } @@ -147,415 +107,103 @@ class ClientCredentialsGrantTests { @Test @DisplayName("Should customize token for DataCustodian admin client") void shouldCustomizeTokenForDataCustodianAdminClient() { - // Given - JwtEncodingContext context = createClientCredentialsContext("data_custodian_admin", "ROLE_DC_ADMIN"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("espi_version", "1.1"); - verify(claimsBuilder).claim("data_custodian_endpoint", "http://localhost:8080"); - verify(claimsBuilder).claim("espi_client_type", "admin"); - verify(claimsBuilder).claim("espi_grant_type", "client_credentials"); - verify(claimsBuilder).claim("resource_uri", "http://localhost:8080/espi/1_1/resource/"); - verify(claimsBuilder).claim(eq("authorization_uri"), argThat(uri -> - uri.toString().startsWith("http://localhost:8080/espi/1_1/resource/Authorization/"))); - } - - @Test - @DisplayName("Should customize token for ThirdParty admin client") - void shouldCustomizeTokenForThirdPartyAdminClient() { - // Given - JwtEncodingContext context = createClientCredentialsContext("third_party_admin", "ROLE_TP_ADMIN"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("espi_version", "1.1"); - verify(claimsBuilder).claim("espi_client_type", "admin"); - verify(claimsBuilder).claim("espi_grant_type", "client_credentials"); - verify(claimsBuilder).claim("resource_uri", "http://localhost:8080/espi/1_1/resource/Batch/Bulk/**"); - } - - @Test - @DisplayName("Should customize token for Upload admin client") - void shouldCustomizeTokenForUploadAdminClient() { - // Given - JwtEncodingContext context = createClientCredentialsContext("upload_admin", "ROLE_UL_ADMIN"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("resource_uri", "http://localhost:8080/espi/1_1/resource/Batch/Upload/**"); - } - - @Test - @DisplayName("Should customize token for Registration admin client") - void shouldCustomizeTokenForRegistrationAdminClient() { - // Given - JwtEncodingContext context = createClientCredentialsContext("registration_admin", "ROLE_TP_REGISTRATION"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("resource_uri", "http://localhost:8080/espi/1_1/resource/ApplicationInformation/**"); - } - - @Test - @DisplayName("Should use default resource URI for unknown roles") - void shouldUseDefaultResourceUriForUnknownRoles() { - // Given - JwtEncodingContext context = createClientCredentialsContext("unknown_client", "ROLE_UNKNOWN"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("resource_uri", "http://localhost:8080/espi/1_1/resource/"); - } - - @Test - @DisplayName("Should handle multiple authorities correctly") + assertThat(customize(clientCredentials("data_custodian_admin", "ROLE_DC_ADMIN"))) + .containsEntry("espi_version", "1.1") + .containsEntry("data_custodian_endpoint", DC_ENDPOINT) + .containsEntry("espi_client_type", "admin") + .containsEntry("espi_grant_type", "client_credentials") + .containsEntry("resource_uri", RESOURCE_BASE + "/") + .hasEntrySatisfying("authorization_uri", uri -> assertThat(uri.toString()) + .matches(RESOURCE_BASE.replace(".", "\\.") + "/Authorization/\\d+")); + } + + @ParameterizedTest(name = "{0} -> {1}") + @CsvSource({ + "ROLE_DC_ADMIN, /", + "ROLE_TP_ADMIN, /Batch/Bulk/**", + "ROLE_UL_ADMIN, /Batch/Upload/**", + "ROLE_TP_REGISTRATION, /ApplicationInformation/**", + "ROLE_UNKNOWN, /" + }) + @DisplayName("Should derive the resource URI from the client's role") + void shouldDeriveResourceUriFromRole(String authority, String resourcePath) { + assertThat(customize(clientCredentials("admin_client", authority))) + .containsEntry("resource_uri", RESOURCE_BASE + resourcePath); + } + + @Test + @DisplayName("Should prioritize DC_ADMIN when a client holds several admin roles") void shouldHandleMultipleAuthoritiesCorrectly() { - // Given - Authentication principal = mock(Authentication.class); - when(principal.getAuthorities()).thenReturn((Collection) List.of( - new SimpleGrantedAuthority("ROLE_USER"), - new SimpleGrantedAuthority("ROLE_DC_ADMIN"), - new SimpleGrantedAuthority("ROLE_TP_ADMIN") - )); + Authentication principal = new TestingAuthenticationToken("multi_role_client", null, + "ROLE_USER", "ROLE_DC_ADMIN", "ROLE_TP_ADMIN"); - JwtEncodingContext context = createClientCredentialsContextWithPrincipal("multi_role_client", principal); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - Should prioritize DC_ADMIN - verify(claimsBuilder).claim("resource_uri", "http://localhost:8080/espi/1_1/resource/"); + assertThat(customize(context("multi_role_client", AuthorizationGrantType.CLIENT_CREDENTIALS, principal))) + .containsEntry("resource_uri", RESOURCE_BASE + "/"); } - } - - @Nested - @DisplayName("Authorization Code Grant Tests") - class AuthorizationCodeGrantTests { @Test - @DisplayName("Should customize token for authorization code grant") - void shouldCustomizeTokenForAuthorizationCodeGrant() { - // Given - JwtEncodingContext context = createAuthorizationCodeContext("third_party"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("espi_version", "1.1"); - verify(claimsBuilder).claim("data_custodian_endpoint", "http://localhost:8080"); - verify(claimsBuilder).claim("espi_client_type", "customer"); - verify(claimsBuilder).claim("espi_grant_type", "authorization_code"); - verify(claimsBuilder).claim("resource_uri", "http://localhost:8080/espi/1_1/resource/"); - verify(claimsBuilder).claim(eq("authorization_uri"), argThat(uri -> - uri.toString().startsWith("http://localhost:8080/espi/1_1/resource/Authorization/"))); - } - - @Test - @DisplayName("Should customize token for refresh token grant") - void shouldCustomizeTokenForRefreshTokenGrant() { - // Given - JwtEncodingContext context = createRefreshTokenContext("third_party"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("espi_version", "1.1"); - verify(claimsBuilder).claim("espi_client_type", "customer"); - verify(claimsBuilder).claim("espi_grant_type", "authorization_code"); - verify(claimsBuilder).claim("resource_uri", "http://localhost:8080/espi/1_1/resource/"); - } - } - - @Nested - @DisplayName("Configuration Tests") - class ConfigurationTests { - - @Test - @DisplayName("Should use custom data custodian endpoint") + @DisplayName("Should use the configured data custodian endpoint") void shouldUseCustomDataCustodianEndpoint() { - // Given ReflectionTestUtils.setField(customizer, "dataCustodianEndpoint", "https://datacustodian.example.com"); - - JwtEncodingContext context = createClientCredentialsContext("test_client", "ROLE_DC_ADMIN"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("data_custodian_endpoint", "https://datacustodian.example.com"); - verify(claimsBuilder).claim("resource_uri", "https://datacustodian.example.com/espi/1_1/resource/"); - verify(claimsBuilder).claim(eq("authorization_uri"), argThat(uri -> - uri.toString().startsWith("https://datacustodian.example.com/espi/1_1/resource/Authorization/"))); - } - - @Test - @DisplayName("Should handle null data custodian endpoint gracefully") - void shouldHandleNullDataCustodianEndpointGracefully() { - // Given - ReflectionTestUtils.setField(customizer, "dataCustodianEndpoint", null); - - JwtEncodingContext context = createClientCredentialsContext("test_client", "ROLE_DC_ADMIN"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("data_custodian_endpoint", null); - verify(claimsBuilder).claim("resource_uri", "null/espi/1_1/resource/"); - } - } - - @Nested - @DisplayName("Authorization ID Generation Tests") - class AuthorizationIdGenerationTests { - - @Test - @DisplayName("Should generate unique authorization IDs") - void shouldGenerateUniqueAuthorizationIds() { - // Given - JwtEncodingContext context1 = createClientCredentialsContext("client1", "ROLE_DC_ADMIN"); - JwtEncodingContext context2 = createClientCredentialsContext("client2", "ROLE_DC_ADMIN"); - - JwtClaimsSet.Builder claimsBuilder1 = spy(JwtClaimsSet.builder()); - JwtClaimsSet.Builder claimsBuilder2 = spy(JwtClaimsSet.builder()); - - when(context1.getClaims()).thenReturn(claimsBuilder1); - when(context2.getClaims()).thenReturn(claimsBuilder2); - - // When - customizer.customize(context1); - try { - Thread.sleep(1); // Ensure different timestamps - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - customizer.customize(context2); - - // Then - verify(claimsBuilder1).claim(eq("authorization_uri"), argThat(uri -> - uri.toString().matches("http://localhost:8080/espi/1_1/resource/Authorization/\\d+"))); - verify(claimsBuilder2).claim(eq("authorization_uri"), argThat(uri -> - uri.toString().matches("http://localhost:8080/espi/1_1/resource/Authorization/\\d+"))); - } - } - - @Nested - @DisplayName("ESPI Compliance Tests") - class EspiComplianceTests { - - @Test - @DisplayName("Should add ESPI version 1.1 claim") - void shouldAddEspiVersion11Claim() { - // Given - JwtEncodingContext context = createClientCredentialsContext("test_client", "ROLE_DC_ADMIN"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("espi_version", "1.1"); - } - - @Test - @DisplayName("Should use ESPI-compliant resource URI patterns") - void shouldUseEspiCompliantResourceUriPatterns() { - // Given - JwtEncodingContext context = createClientCredentialsContext("test_client", "ROLE_TP_ADMIN"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim("resource_uri", "http://localhost:8080/espi/1_1/resource/Batch/Bulk/**"); - } - - @Test - @DisplayName("Should generate ESPI-compliant authorization URIs") - void shouldGenerateEspiCompliantAuthorizationUris() { - // Given - JwtEncodingContext context = createAuthorizationCodeContext("third_party"); - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); - - // When - customizer.customize(context); - - // Then - verify(claimsBuilder).claim(eq("authorization_uri"), argThat(uri -> - uri.toString().startsWith("http://localhost:8080/espi/1_1/resource/Authorization/"))); - } - - @Test - @DisplayName("Should distinguish between admin and customer client types") - void shouldDistinguishBetweenAdminAndCustomerClientTypes() { - // Given - JwtEncodingContext adminContext = createClientCredentialsContext("admin_client", "ROLE_DC_ADMIN"); - JwtEncodingContext customerContext = createAuthorizationCodeContext("customer_client"); - - JwtClaimsSet.Builder adminClaimsBuilder = spy(JwtClaimsSet.builder()); - JwtClaimsSet.Builder customerClaimsBuilder = spy(JwtClaimsSet.builder()); - - when(adminContext.getClaims()).thenReturn(adminClaimsBuilder); - when(customerContext.getClaims()).thenReturn(customerClaimsBuilder); - - // When - customizer.customize(adminContext); - customizer.customize(customerContext); - // Then - verify(adminClaimsBuilder).claim("espi_client_type", "admin"); - verify(customerClaimsBuilder).claim("espi_client_type", "customer"); + assertThat(customize(clientCredentials("test_client", "ROLE_DC_ADMIN"))) + .containsEntry("data_custodian_endpoint", "https://datacustodian.example.com") + .containsEntry("resource_uri", "https://datacustodian.example.com/espi/1_1/resource/") + .hasEntrySatisfying("authorization_uri", uri -> assertThat(uri.toString()) + .startsWith("https://datacustodian.example.com/espi/1_1/resource/Authorization/")); } } @Nested - @DisplayName("Error Handling Tests") - class ErrorHandlingTests { - - @Test - @DisplayName("Should handle null context gracefully") - void shouldHandleNullContextGracefully() { - // When & Then - Should not throw exception - customizer.customize(null); - } - - @Test - @DisplayName("Should handle context with null claims gracefully") - void shouldHandleContextWithNullClaimsGracefully() { - // Given - JwtEncodingContext context = createMockJwtEncodingContext(); - when(context.getClaims()).thenReturn(null); - - // When & Then - Should not throw exception - customizer.customize(context); - } - - @Test - @DisplayName("Should handle context with null principal gracefully") - void shouldHandleContextWithNullPrincipalGracefully() { - // Given - JwtEncodingContext context = createMockJwtEncodingContext(); - when(context.getPrincipal()).thenReturn(null); - - JwtClaimsSet.Builder claimsBuilder = spy(JwtClaimsSet.builder()); - when(context.getClaims()).thenReturn(claimsBuilder); + @DisplayName("Authorization Code Grant Tests") + class AuthorizationCodeGrantTests { - // When & Then - Should not throw exception - customizer.customize(context); - - // Should still add basic claims - verify(claimsBuilder).claim("espi_version", "1.1"); + @ParameterizedTest(name = "grant_type={0} -> customer claims") + @ValueSource(strings = {"authorization_code", "refresh_token"}) + @DisplayName("Should add customer claims for authorization_code and refresh_token grants") + void shouldCustomizeTokenForCustomerGrants(String grantType) { + assertThat(customize(context("third_party", new AuthorizationGrantType(grantType), customer()))) + .containsEntry("espi_version", "1.1") + .containsEntry("data_custodian_endpoint", DC_ENDPOINT) + .containsEntry("espi_client_type", "customer") + .containsEntry("espi_grant_type", "authorization_code") + .containsEntry("resource_uri", RESOURCE_BASE + "/") + .hasEntrySatisfying("authorization_uri", uri -> assertThat(uri.toString()) + .matches(RESOURCE_BASE.replace(".", "\\.") + "/Authorization/\\d+")); } } - // Helper methods - - private JwtEncodingContext createMockJwtEncodingContext() { - JwtEncodingContext context = mock(JwtEncodingContext.class); - when(context.getTokenType()).thenReturn(OAuth2TokenType.ACCESS_TOKEN); - - RegisteredClient client = mock(RegisteredClient.class); - when(client.getClientId()).thenReturn("test-client"); - when(context.getRegisteredClient()).thenReturn(client); - - when(context.getAuthorizationGrantType()).thenReturn(AuthorizationGrantType.AUTHORIZATION_CODE); - - Authentication principal = mock(Authentication.class); - when(principal.getAuthorities()).thenReturn((Collection) List.of(new SimpleGrantedAuthority("ROLE_USER"))); - when(context.getPrincipal()).thenReturn(principal); - - return context; - } + // Helpers - private JwtEncodingContext createClientCredentialsContext(String clientId, String authority) { - Authentication principal = mock(Authentication.class); - when(principal.getAuthorities()).thenReturn((Collection) List.of(new SimpleGrantedAuthority(authority))); - - return createClientCredentialsContextWithPrincipal(clientId, principal); + private Map customize(JwtEncodingContext.Builder contextBuilder) { + JwtEncodingContext context = contextBuilder.build(); + customizer.customize(context); + Map claims = new HashMap<>(); + context.getClaims().claims(claims::putAll); + return claims; } - private JwtEncodingContext createClientCredentialsContextWithPrincipal(String clientId, Authentication principal) { - JwtEncodingContext context = mock(JwtEncodingContext.class); - when(context.getTokenType()).thenReturn(OAuth2TokenType.ACCESS_TOKEN); - - RegisteredClient client = mock(RegisteredClient.class); - when(client.getClientId()).thenReturn(clientId); - when(context.getRegisteredClient()).thenReturn(client); - - when(context.getAuthorizationGrantType()).thenReturn(AuthorizationGrantType.CLIENT_CREDENTIALS); - when(context.getPrincipal()).thenReturn(principal); - - return context; + private static JwtEncodingContext.Builder clientCredentials(String clientId, String authority) { + return context(clientId, AuthorizationGrantType.CLIENT_CREDENTIALS, + new TestingAuthenticationToken(clientId, null, authority)); } - private JwtEncodingContext createAuthorizationCodeContext(String clientId) { - JwtEncodingContext context = mock(JwtEncodingContext.class); - when(context.getTokenType()).thenReturn(OAuth2TokenType.ACCESS_TOKEN); - - RegisteredClient client = mock(RegisteredClient.class); - when(client.getClientId()).thenReturn(clientId); - when(context.getRegisteredClient()).thenReturn(client); - - when(context.getAuthorizationGrantType()).thenReturn(AuthorizationGrantType.AUTHORIZATION_CODE); - - Authentication principal = mock(Authentication.class); - when(principal.getAuthorities()).thenReturn((Collection) List.of(new SimpleGrantedAuthority("ROLE_USER"))); - when(context.getPrincipal()).thenReturn(principal); - - return context; + private static Authentication customer() { + return new TestingAuthenticationToken("42", null, "ROLE_CUSTOMER"); } - private JwtEncodingContext createRefreshTokenContext(String clientId) { - JwtEncodingContext context = mock(JwtEncodingContext.class); - when(context.getTokenType()).thenReturn(OAuth2TokenType.ACCESS_TOKEN); - - RegisteredClient client = mock(RegisteredClient.class); - when(client.getClientId()).thenReturn(clientId); - when(context.getRegisteredClient()).thenReturn(client); - - when(context.getAuthorizationGrantType()).thenReturn(AuthorizationGrantType.REFRESH_TOKEN); - - Authentication principal = mock(Authentication.class); - when(principal.getAuthorities()).thenReturn((Collection) List.of(new SimpleGrantedAuthority("ROLE_USER"))); - when(context.getPrincipal()).thenReturn(principal); - - return context; + private static JwtEncodingContext.Builder context(String clientId, AuthorizationGrantType grantType, + Authentication principal) { + RegisteredClient client = RegisteredClient.withId(clientId + "-id") + .clientId(clientId) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(grantType) + .redirectUri("https://tp.example/cb") + .build(); + return JwtEncodingContext.with(JwsHeader.with(SignatureAlgorithm.RS256), JwtClaimsSet.builder()) + .registeredClient(client) + .principal(principal) + .authorizationGrantType(grantType) + .tokenType(OAuth2TokenType.ACCESS_TOKEN); } -} \ No newline at end of file +} diff --git a/openespi-authserver/src/test/resources/application-test.yml b/openespi-authserver/src/test/resources/application-test.yml deleted file mode 100644 index 8e1a3d97..00000000 --- a/openespi-authserver/src/test/resources/application-test.yml +++ /dev/null @@ -1,66 +0,0 @@ -# OpenESPI Authorization Server Test Configuration -# Test profile for unit and integration testing - -server: - port: 0 # Random port for tests - -spring: - application: - name: openespi-authorization-server-test - - # H2 In-Memory Database for Tests - datasource: - url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE - driver-class-name: org.h2.Driver - username: sa - password: "" - hikari: - maximum-pool-size: 10 - minimum-idle: 2 - idle-timeout: 300000 - max-lifetime: 1200000 - - # JPA Configuration for Tests - jpa: - hibernate: - ddl-auto: validate - show-sql: true - - properties: - hibernate: - dialect: org.hibernate.dialect.H2Dialect - format_sql: true - use_sql_comments: true - show_sql: true - jdbc: - batch_size: 20 - open-in-view: false - - # enabled: false - - # Security Configuration for Tests - security: - oauth2: - authorizationserver: - issuer: http://localhost:9999 - -# ESPI Configuration for Tests -espi: - token: - format: opaque # Test with ESPI-compliant opaque tokens - datacustodian: - endpoint: http://localhost:8080 - -# Logging Configuration for Tests -logging: - level: - org.springframework.security.oauth2: DEBUG - org.greenbuttonalliance.espi: DEBUG - org.springframework.web: INFO - org.springframework.security: INFO - org.hibernate.SQL: WARN - pattern: - console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" - -# Management endpoints: inherited from application.yml, so the tests exercise the real -# exposure and access rules.