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- 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. 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).
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", "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