diff --git a/docs/security/etl-service-jwt.md b/docs/security/etl-service-jwt.md new file mode 100644 index 00000000..12354408 --- /dev/null +++ b/docs/security/etl-service-jwt.md @@ -0,0 +1,109 @@ +# ETL service JWT authentication + +**Capability status:** `active_pr` on PR #287; not `implemented_on_develop` until protected integration. +**Protected baseline assessed:** `develop@d6c6665163eabe1b5eca80556c6963bafd6b2625` +**Security boundary:** direct and gateway-routed access to the independently runnable `etl-service` + +## Decision + +The historical HTTP Basic mechanism is removed from the ETL service security chain. The repository default is a credential-free, fail-closed `deny` posture: actuator health and information probes remain available, but `/api/**` returns `401 Unauthorized` until a deployment explicitly selects JWT mode and supplies issuer and audience authority. + +JWT mode uses Spring Security's maintained OAuth 2.0 Resource Server support. The service validates bearer tokens independently rather than trusting a gateway header as an authenticated downstream principal. Direct access on the published ETL port and access routed through the gateway therefore cross the same service-owned authentication boundary. + +## Configuration contract + +The mode is selected through the following precedence: + +```text +mightyetl.security.mode +→ xtrmetl.security.mode +→ ETL_SECURITY_MODE +→ deny +``` + +Supported values are: + +| Value | Behavior | +|---|---| +| `deny` | Secure default. `/api/**` is unavailable and historical Basic credentials are rejected. | +| `jwt` | OAuth 2.0 Resource Server bearer-token validation is enabled. | + +Unknown values fail during security-chain construction. JWT mode additionally requires nonblank deployment-owned values for: + +```text +spring.security.oauth2.resourceserver.jwt.issuer-uri +spring.security.oauth2.resourceserver.jwt.audiences +``` + +Spring Boot environment-variable forms can be used by deployment systems: + +```text +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_AUDIENCES +``` + +No issuer, JWK endpoint, audience, client secret, certificate authority, token, or compatibility password is invented or committed by mightyETL. Deployment configuration and secret-management systems own those values. + +## Validation and principal contract + +Spring Security obtains issuer metadata and signing-key authority from the configured issuer according to its Resource Server JWT support. It validates the token signature and standard time and issuer claims; Spring Boot's configured audience authority prevents a token issued for another resource from being accepted by the ETL service. + +The authenticated principal name is the validated JWT subject (`sub`) under Spring Security's default JWT authentication mapping. Existing idempotency and durable-job owner scoping therefore consume a validated service principal rather than an untrusted forwarded username or a Basic-auth account. Changing subject semantics requires a separately versioned identity-migration decision because it can change access to previously scoped records. + +The gateway may forward the original bearer token to the ETL service, but gateway validation never substitutes for ETL validation. A future distinct gateway-to-service credential, mTLS profile, or service mesh identity must be introduced through a separate reviewed contract rather than by trusting arbitrary caller headers. + +## Failure and degraded behavior + +- Missing or blank mode selects `deny`. +- Unknown mode fails closed during startup. +- JWT mode with missing issuer or audience authority fails during startup. +- Missing, malformed, expired, wrong-issuer, wrong-audience, or unverifiable bearer tokens cannot reach ETL controllers. +- HTTP Basic, form login, logout, server-side sessions, and request caching are disabled. +- Health and information probes remain available for orchestration. +- Issuer or JWK unavailability follows Spring Security's maintained decoder and key-cache behavior; deployments must monitor readiness and authentication failures rather than silently falling back to Basic. + +Authentication failures must retain bounded classifications without logging bearer tokens, Authorization headers, JWK material, full provider exceptions, credentials, or raw request payloads. This is purpose-bound diagnostic minimization, not blanket masking of business data needed by authorized ETL work. + +## Rollout and rollback + +1. Provision an approved issuer and ETL-specific audience. +2. Configure and validate JWT mode in a non-production environment. +3. Verify direct-port and gateway-routed calls with valid, invalid, expired, wrong-issuer, and wrong-audience tokens. +4. Verify that the resulting `sub` preserves intended idempotency and durable-job ownership. +5. Remove Basic credentials from clients and secret stores. +6. Enable production JWT mode and monitor bounded authentication outcomes. + +Rollback to HTTP Basic is prohibited because it restores the vulnerability that this change removes. If issuer authority is unavailable, the safe rollback is `deny` mode while identity infrastructure is restored or a separately reviewed authentication mechanism is deployed. The service must not silently downgrade authentication to maintain availability. + +## Machine-readable API handoff + +PR #278 currently describes the protected runtime truth with an OpenAPI HTTP Basic scheme, and Semgrep correctly rejects that weak mechanism. Do not suppress or falsify the contract on the authentication branch. After this runtime change integrates, refresh the machine-readable contract from protected `develop`, replace the Basic scheme with bearer JWT semantics, regenerate SAST evidence, and preserve exact source/review lineage. The SAST finding is resolved only when runtime and contract move together. + +## Testing and evidence + +The current branch preserves hosted RED evidence showing that valid historical Basic credentials reached a protected endpoint. The GREEN contract exercises: + +- default-deny behavior through the real Spring Security filter chain; +- rejection of valid historical Basic credentials; +- rejection of missing and invalid bearer tokens; +- successful JWT subject authentication through the real bearer-token filter; +- fail-closed mode parsing; +- required issuer and audience authority; +- unchanged health/information probe intent; +- complete current-head CI, dependency, SBOM, SAST, security, and review evidence before integration. + +## References (APA 7th) + +Internet Engineering Task Force. (2012). *The OAuth 2.0 authorization framework: Bearer token usage* (RFC 6750). https://www.rfc-editor.org/rfc/rfc6750 + +Internet Engineering Task Force. (2015). *JSON Web Token (JWT)* (RFC 7519). https://www.rfc-editor.org/rfc/rfc7519 + +Internet Engineering Task Force. (2018). *OAuth 2.0 authorization server metadata* (RFC 8414). https://www.rfc-editor.org/rfc/rfc8414 + +Internet Engineering Task Force. (2021). *JSON Web Token (JWT) profile for OAuth 2.0 access tokens* (RFC 9068). https://www.rfc-editor.org/rfc/rfc9068 + +Internet Engineering Task Force. (2025). *Best current practice for OAuth 2.0 security* (RFC 9700, BCP 240). https://www.rfc-editor.org/rfc/rfc9700 + +Spring Security. (2026). *OAuth 2.0 resource server JWT*. https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html + +Spring Boot. (2026). *OAuth2 resource server*. https://docs.spring.io/spring-boot/reference/web/spring-security.html#web.security.oauth2.server diff --git a/etl-service/pom.xml b/etl-service/pom.xml index 3d840a28..a954d152 100644 --- a/etl-service/pom.xml +++ b/etl-service/pom.xml @@ -50,6 +50,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + com.fasterxml.jackson.core jackson-databind diff --git a/etl-service/src/main/java/com/xtrmetl/etl/security/EtlSecurityMode.java b/etl-service/src/main/java/com/xtrmetl/etl/security/EtlSecurityMode.java new file mode 100644 index 00000000..edfabf2a --- /dev/null +++ b/etl-service/src/main/java/com/xtrmetl/etl/security/EtlSecurityMode.java @@ -0,0 +1,34 @@ +package com.xtrmetl.etl.security; + +import java.util.Locale; + +/** + * Supported ETL service authentication postures. + * + *

{@link #DENY} is the credential-free secure default. {@link #JWT} enables deployment-owned + * OAuth 2.0 Resource Server validation.

+ */ +enum EtlSecurityMode { + DENY, + JWT; + + /** + * Converts external configuration into a fail-closed security mode. + * + * @param rawMode configured mode; null and blank values select {@link #DENY} + * @return supported security mode + * @throws IllegalArgumentException when the configured value is not supported + */ + static EtlSecurityMode parse(String rawMode) { + if (rawMode == null || rawMode.isBlank()) { + return DENY; + } + return switch (rawMode.trim().toLowerCase(Locale.ROOT)) { + case "deny" -> DENY; + case "jwt" -> JWT; + default -> throw new IllegalArgumentException( + "Unsupported mightyETL security mode: " + rawMode + ); + }; + } +} diff --git a/etl-service/src/main/java/com/xtrmetl/etl/security/SecurityConfig.java b/etl-service/src/main/java/com/xtrmetl/etl/security/SecurityConfig.java index a93dd907..c5aa7c30 100644 --- a/etl-service/src/main/java/com/xtrmetl/etl/security/SecurityConfig.java +++ b/etl-service/src/main/java/com/xtrmetl/etl/security/SecurityConfig.java @@ -1,34 +1,112 @@ package com.xtrmetl.etl.security; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.HttpStatusAccessDeniedHandler; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +/** + * Configures the independently enforced HTTP authentication boundary for the ETL service. + * + *

The secure repository default is {@code deny}: health and information probes remain + * available, while direct workload APIs are unavailable until the deployment explicitly selects + * JWT mode and supplies issuer and audience authority. Historical HTTP Basic credentials are never + * accepted by this configuration.

+ */ @Configuration -@EnableWebSecurity public class SecurityConfig { + private static final String JWT_ISSUER_PROPERTY = + "spring.security.oauth2.resourceserver.jwt.issuer-uri"; + private static final String JWT_AUDIENCES_PROPERTY = + "spring.security.oauth2.resourceserver.jwt.audiences"; + private static final String[] PUBLIC_ENDPOINTS = { + "/actuator/health", + "/actuator/health/**", + "/actuator/info" + }; + /** - * 애플리케이션의 HTTP 보안 필터 체인을 구성한다. + * Builds the service security filter chain from deployment-owned authentication settings. * - * 구성 내용: CSRF 비활성화, "/api/**" 경로에 대해 인증 요구, 다른 모든 요청 허용, HTTP Basic 인증 활성화. + *

In {@code jwt} mode Spring Security validates bearer tokens through its maintained OAuth + * 2.0 Resource Server support. The configured issuer supplies key and issuer authority, while + * the configured audience prevents a token minted for another service from being accepted by + * mightyETL. In the default {@code deny} mode no credential can reach {@code /api/**}.

* - * @param http 구성에 사용되는 HttpSecurity 인스턴스 - * @return 구성된 SecurityFilterChain 인스턴스 - * @throws Exception 보안 구성을 적용하는 동안 오류가 발생한 경우 + * @param http Spring Security HTTP configuration to build + * @param configuredMode explicit mightyETL security mode, defaulting to {@code deny} + * @param environment deployment properties used to verify JWT trust authority + * @return configured stateless security filter chain + * @throws Exception when Spring Security cannot build the filter chain + * @throws IllegalArgumentException when the mode is unknown or JWT trust settings are missing */ @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + @Value("${mightyetl.security.mode:${xtrmetl.security.mode:${ETL_SECURITY_MODE:deny}}}") + String configuredMode, + Environment environment + ) throws Exception { + EtlSecurityMode securityMode = EtlSecurityMode.parse(configuredMode); + http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/**").authenticated() - .anyRequest().permitAll() - ) - .httpBasic(Customizer.withDefaults()); + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .requestCache(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + if (securityMode == EtlSecurityMode.JWT) { + requireJwtTrustConfiguration(environment); + http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .requestMatchers("/api/**").authenticated() + .anyRequest().permitAll()) + .oauth2ResourceServer(resourceServer -> + resourceServer.jwt(Customizer.withDefaults())); + } else { + http + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .accessDeniedHandler(new HttpStatusAccessDeniedHandler(HttpStatus.UNAUTHORIZED))) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .requestMatchers("/api/**").denyAll() + .anyRequest().permitAll()); + } + return http.build(); } + + /** + * Requires the deployment authority needed for issuer and audience validation in JWT mode. + * + * @param environment deployment property source + * @throws IllegalArgumentException when issuer or audience authority is missing + */ + static void requireJwtTrustConfiguration(Environment environment) { + requireNonBlank(environment, JWT_ISSUER_PROPERTY); + requireNonBlank(environment, JWT_AUDIENCES_PROPERTY); + } + + private static void requireNonBlank(Environment environment, String propertyName) { + String value = environment.getProperty(propertyName); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException( + "JWT security mode requires non-blank property: " + propertyName + ); + } + } } diff --git a/etl-service/src/test/java/com/xtrmetl/etl/security/EtlSecurityModeTest.java b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlSecurityModeTest.java new file mode 100644 index 00000000..61cc6dd0 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlSecurityModeTest.java @@ -0,0 +1,75 @@ +package com.xtrmetl.etl.security; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies fail-closed parsing and deployment-authority requirements for ETL security modes. + */ +class EtlSecurityModeTest { + + @Test + void absentAndExplicitDenyConfigurationSelectTheSecureDefault() { + assertEquals(EtlSecurityMode.DENY, EtlSecurityMode.parse(null)); + assertEquals(EtlSecurityMode.DENY, EtlSecurityMode.parse("")); + assertEquals(EtlSecurityMode.DENY, EtlSecurityMode.parse(" ")); + assertEquals(EtlSecurityMode.DENY, EtlSecurityMode.parse("deny")); + assertEquals(EtlSecurityMode.DENY, EtlSecurityMode.parse(" DENY ")); + } + + @Test + void jwtConfigurationIsCaseInsensitiveButUnknownModesFailClosed() { + assertEquals(EtlSecurityMode.JWT, EtlSecurityMode.parse("jwt")); + assertEquals(EtlSecurityMode.JWT, EtlSecurityMode.parse(" JWT ")); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> EtlSecurityMode.parse("basic") + ); + assertTrue(failure.getMessage().contains("Unsupported mightyETL security mode")); + } + + @Test + void jwtModeRequiresDeploymentOwnedIssuerAuthority() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.security.oauth2.resourceserver.jwt.audiences", "mightyetl-etl"); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> SecurityConfig.requireJwtTrustConfiguration(environment) + ); + assertTrue(failure.getMessage().contains("issuer-uri")); + } + + @Test + void jwtModeRequiresDeploymentOwnedAudienceAuthority() { + MockEnvironment environment = new MockEnvironment() + .withProperty( + "spring.security.oauth2.resourceserver.jwt.issuer-uri", + "https://issuer.example.invalid" + ); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> SecurityConfig.requireJwtTrustConfiguration(environment) + ); + assertTrue(failure.getMessage().contains("audiences")); + } + + @Test + void completeJwtTrustAuthorityPassesThePreflight() { + MockEnvironment environment = new MockEnvironment() + .withProperty( + "spring.security.oauth2.resourceserver.jwt.issuer-uri", + "https://issuer.example.invalid" + ) + .withProperty("spring.security.oauth2.resourceserver.jwt.audiences", "mightyetl-etl"); + + assertDoesNotThrow(() -> SecurityConfig.requireJwtTrustConfiguration(environment)); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceAuthenticationModeTest.java b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceAuthenticationModeTest.java new file mode 100644 index 00000000..97683bf8 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceAuthenticationModeTest.java @@ -0,0 +1,92 @@ +package com.xtrmetl.etl.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.security.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.security.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * Exercises the registered ETL {@link SecurityConfig} default-deny posture. + * + *

A valid user known only to the historical HTTP Basic mechanism must not regain access to + * {@code /api/**}. Deployments must explicitly select JWT mode and provide issuer and audience + * authority before workload endpoints can be reached.

+ */ +@SpringJUnitConfig(classes = { + SecurityConfig.class, + EtlServiceAuthenticationModeTest.TestApplication.class +}) +@WebAppConfiguration +class EtlServiceAuthenticationModeTest { + + private static final String TEST_USERNAME = "contract-user"; + private static final String TEST_PASSWORD = "contract-secret"; + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + void protectedApiRejectsHistoricalBasicCredentials() throws Exception { + mockMvc.perform(get("/api/security-contract")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(get("/api/security-contract") + .with(httpBasic(TEST_USERNAME, TEST_PASSWORD))) + .andExpect(status().isUnauthorized()); + } + + @Configuration + @EnableWebMvc + static class TestApplication { + + @Bean + UserDetailsService testUsers() { + return new InMemoryUserDetailsManager( + User.withUsername(TEST_USERNAME) + .password("{noop}" + TEST_PASSWORD) + .roles("TEST") + .build() + ); + } + + @Bean + SecurityContractController securityContractController() { + return new SecurityContractController(); + } + } + + @RestController + static class SecurityContractController { + + @GetMapping("/api/security-contract") + String protectedEndpoint() { + return "ok"; + } + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceJwtAuthenticationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceJwtAuthenticationTest.java new file mode 100644 index 00000000..9a6504fb --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/security/EtlServiceJwtAuthenticationTest.java @@ -0,0 +1,123 @@ +package com.xtrmetl.etl.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import java.time.Instant; +import java.util.List; + +import static org.hamcrest.Matchers.startsWith; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.security.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.security.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.security.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.security.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * Exercises mightyETL JWT mode through the real Spring Security bearer-token filter chain. + */ +@SpringJUnitConfig(classes = { + SecurityConfig.class, + EtlServiceJwtAuthenticationTest.TestApplication.class +}) +@TestPropertySource(properties = { + "mightyetl.security.mode=jwt", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=https://issuer.example.invalid", + "spring.security.oauth2.resourceserver.jwt.audiences=mightyetl-etl" +}) +@WebAppConfiguration +class EtlServiceJwtAuthenticationTest { + + private static final String ACCEPTED_TOKEN = "accepted-token"; + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + void jwtModeRejectsMissingInvalidAndHistoricalBasicCredentials() throws Exception { + mockMvc.perform(get("/api/security-contract")) + .andExpect(status().isUnauthorized()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, startsWith("Bearer"))); + + mockMvc.perform(get("/api/security-contract") + .header(HttpHeaders.AUTHORIZATION, "Bearer rejected-token")) + .andExpect(status().isUnauthorized()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, startsWith("Bearer"))); + + mockMvc.perform(get("/api/security-contract") + .with(httpBasic("historical-user", "historical-secret"))) + .andExpect(status().isUnauthorized()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, startsWith("Bearer"))); + } + + @Test + void jwtModeAuthenticatesTheValidatedSubject() throws Exception { + mockMvc.perform(get("/api/security-contract") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + ACCEPTED_TOKEN)) + .andExpect(status().isOk()) + .andExpect(content().string("contract-user")); + } + + @Configuration + @EnableWebMvc + static class TestApplication { + + @Bean + JwtDecoder jwtDecoder() { + return token -> { + if (!ACCEPTED_TOKEN.equals(token)) { + throw new BadJwtException("Rejected test token"); + } + Instant issuedAt = Instant.parse("2026-08-15T00:00:00Z"); + return Jwt.withTokenValue(token) + .header("alg", "RS256") + .subject("contract-user") + .audience(List.of("mightyetl-etl")) + .issuedAt(issuedAt) + .expiresAt(issuedAt.plusSeconds(3600)) + .build(); + }; + } + + @Bean + SecurityContractController securityContractController() { + return new SecurityContractController(); + } + } + + @RestController + static class SecurityContractController { + + @GetMapping("/api/security-contract") + String protectedEndpoint(Authentication authentication) { + return authentication.getName(); + } + } +}