diff --git a/.github/instructions/springboot.instructions.md b/.github/instructions/springboot.instructions.md index 975d16ad..c737929e 100644 --- a/.github/instructions/springboot.instructions.md +++ b/.github/instructions/springboot.instructions.md @@ -56,6 +56,7 @@ applyTo: '**/*.java' - Use parameterized queries. Always use Spring Data JPA or `NamedParameterJdbcTemplate` to prevent SQL injection. - Validate request bodies and parameters using JSR-380 (`@NotNull`, `@Size`, etc.) annotations and `BindingResult` +- When adding or modifying a `SecurityFilterChain`, inject `HttpMessageSignatureConfiguration.ChainConfigurer` and call its `configure(http)` method before `http.build()`. ### Profiles diff --git a/.github/workflows/basic_code_checks.yml b/.github/workflows/basic_code_checks.yml index 6e7d1214..ec855bf0 100644 --- a/.github/workflows/basic_code_checks.yml +++ b/.github/workflows/basic_code_checks.yml @@ -206,6 +206,7 @@ jobs: - name: Check if static swagger file is up to date env: SPEC_URL_PATH: '/v3/api-docs/internal' + HTTP_MESSAGE_SIGNATURE_VERIFICATION_ENABLED: 'false' SERVER_PORT: 8080 OAUTH_JWK_URI: 'http://localhost:9999/mock/jwks' OAUTH_TOKEN_URI: 'http://localhost:9999/mock/token' diff --git a/docs/src/concepts/authentication.md b/docs/src/concepts/authentication.md index 23d50ff2..971ca0b8 100644 --- a/docs/src/concepts/authentication.md +++ b/docs/src/concepts/authentication.md @@ -8,13 +8,93 @@ identity into a catalog record. **Authorization** decides what that principal ca ```mermaid flowchart LR - A[HTTP request] --> B[Authentication mode
JWT / OAuth2 login / Mock] + A[HTTP request] --> G[HTTP message signature] + G --> B[Authentication mode
JWT / OAuth2 login / Mock] B --> C[Principal extraction
PrincipalInfo] C --> D[Provisioning
JIT or LINK_ONLY] D --> E[Global authorization
policy] E --> F[Controller] ``` +## HTTP message signatures + +HTTP message signature verification proves possession of a shared secret after CORS processing and before authentication or webhook ingestion. It protects every HTTP security chain, including public routes that you do not explicitly exclude. It does not replace JWT validation, principal authorization, or webhook connector authentication. + +Any client with the shared secret can sign requests directly; an API gateway is optional. + +The feature defaults to enabled and requires a shared secret. The `local` and `test` profiles disable it explicitly. Configuration changes, including secret rotation, require an application restart. See the [configuration and deployment reference](../deployment/configuration.md#http-message-signature-configuration). + +### Signature contract + +Send two distinct headers. The configurable signed identifier header defaults to `x-request-id` and contains an identifier generated by the signing client. The fixed `Signature` header carries legacy HTTP Signature attributes. Configure verification under `app.security.http-message-signature-verification` and use `identifier-header-name` to choose the signed identifier header. + +```http +x-request-id: f47ac10b-58cc-4372-a567-0e02b2c3d479 +Signature: keyId="client",algorithm="hmac-sha256",headers="x-request-id (created) (expires)",created="1793789280",expires="1793789310",signature="hDLDYJ+Y16dQPFUj3X6LXzvnp/T+wwwt0D9BCnpgUqA=" +``` + +This known-answer example uses the **dummy secret** `http-message-test-secret` and historical timestamps. It is not a current request. Sign these UTF-8 bytes with LF separators and **no final newline**: + +```text +x-request-id: f47ac10b-58cc-4372-a567-0e02b2c3d479 +(created): 1793789280 +(expires): 1793789310 +``` + +Encode the HMAC digest with standard Base64. Sign the configured identifier header and both timestamp components, each exactly once, in the order declared by `headers`. Component names are lowercase in the signing string. `keyId` is optional metadata; it does not select a different secret. + +Use Unix epoch seconds, not milliseconds. `created` accepts nonnegative integers; `expires` also accepts fractional seconds with up to nine decimal places. Quoted and unquoted numeric attributes produce the same signing bytes; retain the numeric text, including fractional digits, when computing the HMAC. IDP-Core rejects malformed or duplicate attributes, repeated headers, missing signed components, and unsupported components. + +`expires` must be later than `created`, and their difference must not exceed `validity-duration-seconds` (default 30). Clock skew applies to expiration and future-creation checks, not to that maximum lifetime. At zero skew, a signature expires when the current time reaches `expires`. + +### Algorithm selection + +Configure one algorithm on both the signing client and IDP-Core. The incoming `algorithm` attribute must match that choice: + +| Configuration value | Wire value | Digest bytes | +| ------------------- | ---------- | ------------ | +| `HMAC_SHA256` (default) | `hmac-sha256` | 32 | +| `HMAC_SHA1` | `hmac-sha1` | 20 | +| `HMAC_SHA384` | `hmac-sha384` | 48 | +| `HMAC_SHA512` | `hmac-sha512` | 64 | + +Prefer SHA256 or stronger. SHA1 requires explicit configuration and exists only for legacy compatibility. + +> [!WARNING] +> This feature supports the `Signature` header, not `Authorization: Signature` or RFC 9421 `Signature-Input`. +> Keep `Authorization` available for Bearer JWT authentication. A valid HTTP message signature does not grant catalog access. + +### Exclusions and errors + +Default exclusions cover `/actuator/health`, `/actuator/health/**`, `/swagger-ui.html`, `/swagger-ui/**`, and `/v3/api-docs/**`. Overrides replace the defaults and an empty list protects every path. Exclusion matching uses application-relative Ant paths, without the deployment context path. + +Public authentication paths and JIT exemptions do not automatically bypass signature verification. Root, login, webhooks, and ordinary `OPTIONS` requests require signatures unless you explicitly exclude them. Exclusion only skips signature verification; downstream security still applies. + +In chains with CORS enabled, `CorsFilter` handles browser preflight requests before signature verification. An allowed preflight (`OPTIONS` with `Origin` and `Access-Control-Request-Method`) needs no signature or authentication. It carries requested header names, not signed header values. CORS rejects disallowed preflight requests. The actual request still requires a signature before authentication; `OPTIONS` without the preflight headers does too. You do not need a path exclusion for preflight requests. + +Rejections return HTTP 401 with `Content-Type: application/json`, UTF-8 encoding, `Cache-Control: no-store`, and `WWW-Authenticate: Signature realm="idp-core"`. The body contains exactly two fields: + +```json +{ + "error": "UNAUTHORIZED", + "error_description": "The HTTP message signature is invalid" +} +``` + +| Failure | Description | +| ------- | ----------- | +| Missing either required header | `The HTTP message signature is missing` | +| Expired signature | `The HTTP message signature is expired` | +| Invalid HMAC, algorithm, signed components, or lifetime | `The HTTP message signature is invalid` | +| Malformed attributes, Base64, or timestamps | `Error parsing signature: malformed entry` | + +Warnings include a bounded, sanitized signed identifier as **unverified correlation data**, never secrets or signature values. + +> [!WARNING] +> This feature does not bind method, path, query, or body content. +> It proves secret possession, not a unique client identity or use of a gateway. Retain TLS, network restrictions, and existing +> webhook authentication. + ## Authentication modes Each mode has its own `SecurityFilterChain` that you enable with a property. Public paths (health, Swagger, API docs) diff --git a/docs/src/deployment/configuration.md b/docs/src/deployment/configuration.md index bd769394..dd68b782 100644 --- a/docs/src/deployment/configuration.md +++ b/docs/src/deployment/configuration.md @@ -153,6 +153,43 @@ Installing extensions requires superuser privileges or a role with `CREATE` exte --- +## HTTP message signature configuration + +Configure HTTP message signature verification under `app.security.http-message-signature-verification`. Any client with the shared secret can sign requests directly. + +```yaml title="application.yml" +app: + security: + http-message-signature-verification: + enabled: true + secret: ${HTTP_MESSAGE_SIGNATURE_VERIFICATION_SECRET} + identifier-header-name: x-request-id + algorithm: HMAC_SHA256 + validity-duration-seconds: 30 + clock-skew-seconds: 0 + excluded-paths: + - /actuator/health + - /actuator/health/** + - /swagger-ui.html + - /swagger-ui/** + - /v3/api-docs/** +``` + +| Environment variable | Purpose | Default | +| -------------------- | ------- | ------- | +| `HTTP_MESSAGE_SIGNATURE_VERIFICATION_ENABLED` | Activate signature verification in all HTTP security chains | `true`; local/test opt out | +| `HTTP_MESSAGE_SIGNATURE_VERIFICATION_SECRET` | Shared UTF-8 secret | Required when enabled | +| `HTTP_MESSAGE_SIGNATURE_VERIFICATION_IDENTIFIER_HEADER_NAME` | Header containing the client-generated signed identifier, not signature attributes | `x-request-id` | +| `HTTP_MESSAGE_SIGNATURE_VERIFICATION_ALGORITHM` | Pinned HMAC algorithm | `HMAC_SHA256` | +| `HTTP_MESSAGE_SIGNATURE_VERIFICATION_VALIDITY_DURATION_SECONDS` | Positive maximum signed lifetime in seconds | `30` | +| `HTTP_MESSAGE_SIGNATURE_VERIFICATION_CLOCK_SKEW_SECONDS` | Non-negative tolerance for signing client clock differences | `0` | +| `HTTP_MESSAGE_SIGNATURE_VERIFICATION_EXCLUDED_PATHS` | Comma-separated Ant paths, replacing all defaults | Health and Swagger/OpenAPI paths above | + +The signed identifier header must differ from `Signature` and `Authorization`. The fixed `Signature` header contains the signature attributes. Set `excluded-paths: []` or an empty exclusions environment value to protect every path. Supported algorithms are `HMAC_SHA256`, `HMAC_SHA1`, `HMAC_SHA384`, and `HMAC_SHA512`; but prefer SHA256 or stronger. +The signing client chooses its lifetime, but it must not exceed the backend maximum. See the [signing contract](../concepts/authentication.md#signature-contract) for canonical bytes and timestamp rules. + +--- + ## Application Settings ```yaml diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureFilter.java new file mode 100644 index 00000000..25628294 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureFilter.java @@ -0,0 +1,76 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.jspecify.annotations.NonNull; +import org.springframework.http.HttpStatus; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureProperties; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiErrorResponseWriter; + +import lombok.extern.slf4j.Slf4j; + +/// Enforces HTTP message signatures before HTTP authentication and webhook ingestion. +@Slf4j +public final class HttpMessageSignatureFilter extends OncePerRequestFilter { + + private final HttpMessageSignatureProperties properties; + private final HttpMessageSignatureVerifier verifier; + private final ApiErrorResponseWriter errorWriter; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + /// Creates a filter sharing the standard two-field JSON error writer. + public HttpMessageSignatureFilter(HttpMessageSignatureProperties properties, + HttpMessageSignatureVerifier verifier, ApiErrorResponseWriter errorWriter) { + this.properties = properties; + this.verifier = verifier; + this.errorWriter = errorWriter; + } + + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + String path = request.getRequestURI().substring(request.getContextPath().length()); + return properties.excludedPaths().stream() + .anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, @NonNull FilterChain chain) + throws ServletException, IOException { + List signatures = Collections.list(request.getHeaders("Signature")); + List requestIds = Collections + .list(request.getHeaders(properties.identifierHeaderName())); + HttpMessageSignatureResult result; + if (signatures.isEmpty() || requestIds.isEmpty() || signatures.getFirst().isBlank() + || requestIds.getFirst().isBlank()) { + result = HttpMessageSignatureResult.MISSING; + } else if (signatures.size() != 1 || requestIds.size() != 1) { + result = HttpMessageSignatureResult.MALFORMED; + } else { + result = verifier.verify(signatures.getFirst(), requestIds.getFirst()); + } + if (result == HttpMessageSignatureResult.VALID) { + chain.doFilter(request, response); + return; + } + String correlation = requestIds.isEmpty() + ? "[missing]" + : requestIds.getFirst().substring(0, Math.min(requestIds.getFirst().length(), 128)) + .replaceAll("[^A-Za-z0-9._-]", "_"); + log.warn("HTTP message signature rejected: result={}, unverifiedRequestId={}", result, + correlation); + response.setHeader("WWW-Authenticate", "Signature realm=\"idp-core\""); + response.setHeader("Cache-Control", "no-store"); + errorWriter.write(response, HttpStatus.UNAUTHORIZED, result.description()); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureParser.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureParser.java new file mode 100644 index 00000000..4b9f48c7 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureParser.java @@ -0,0 +1,88 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/// Parses the restricted legacy HTTP Signature profile, without accepting ambiguous entries. +public final class HttpMessageSignatureParser { + + private static final int MAX_HEADER_LENGTH = 8192; + private static final String ALGORITHM = "algorithm"; + private static final String HEADERS = "headers"; + private static final String CREATED = "created"; + private static final String EXPIRES = "expires"; + private static final String SIGNATURE = "signature"; + private static final Pattern ATTRIBUTE = Pattern.compile( + "[ \\t]*([A-Za-z][A-Za-z\\d]*)[ \\t]*=[ \\t]*(?:\"([^\"\\\\\\r\\n]*)\"|([^\\s,\"\\\\]+))[ \\t]*(,|$)"); + private static final Set REQUIRED = Set.of(ALGORITHM, HEADERS, CREATED, EXPIRES, + SIGNATURE); + private static final Set ALLOWED = Set.of("keyid", ALGORITHM, HEADERS, CREATED, EXPIRES, + SIGNATURE); + private static final BigDecimal MAX_TIMESTAMP = BigDecimal.valueOf(Instant.MAX.getEpochSecond()); + + /// Returns parsed attributes, or empty for malformed or ambiguous input. + public Optional parse(String header) { + if (header == null || header.isBlank() || header.length() > MAX_HEADER_LENGTH) { + return Optional.empty(); + } + Map attributes = new HashMap<>(); + var matcher = ATTRIBUTE.matcher(header); + int position = 0; + while (position < header.length()) { + matcher.region(position, header.length()); + if (!matcher.lookingAt()) { + return Optional.empty(); + } + String name = matcher.group(1).toLowerCase(Locale.ROOT); + String value = matcher.group(matcher.group(2) != null ? 2 : 3); + if (!ALLOWED.contains(name) || value.isBlank() + || attributes.putIfAbsent(name, value) != null) { + return Optional.empty(); + } + position = matcher.end(); + if (position == header.length() && ",".equals(matcher.group(4))) { + return Optional.empty(); + } + } + return parseSignature(attributes); + } + + private Optional parseSignature(Map attributes) { + if (!attributes.keySet().containsAll(REQUIRED) || !attributes.get(CREATED).matches("\\d+") + || !attributes.get(EXPIRES).matches("\\d+(?:\\.\\d{1,9})?")) { + return Optional.empty(); + } + var created = new BigDecimal(attributes.get(CREATED)); + var expires = new BigDecimal(attributes.get(EXPIRES)); + if (created.compareTo(MAX_TIMESTAMP) > 0 || expires.compareTo(MAX_TIMESTAMP) > 0) { + return Optional.empty(); + } + List components = List + .of(attributes.get(HEADERS).toLowerCase(Locale.ROOT).split(" ", -1)); + return Optional + .of(new ParsedSignature(attributes.get(ALGORITHM), components, attributes.get(CREATED), + attributes.get(EXPIRES), created, expires, attributes.get(SIGNATURE))); + } + + /// Parsed values retain timestamp text for byte-exact canonicalization. + public record ParsedSignature(String algorithm, List components, String createdText, + String expiresText, BigDecimal created, BigDecimal expires, String signature) { + /// Copies signed component names so canonicalization cannot change after + /// parsing. + public ParsedSignature { + components = List.copyOf(components); + } + + @Override + public String toString() { + return "ParsedSignature[REDACTED]"; + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureResult.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureResult.java new file mode 100644 index 00000000..d6c834b1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureResult.java @@ -0,0 +1,20 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature; + +/// Verification outcomes translated to the HTTP message signature authentication error contract. +public enum HttpMessageSignatureResult { + VALID(""), MISSING("The HTTP message signature is missing"), EXPIRED( + "The HTTP message signature is expired"), INVALID( + "The HTTP message signature is invalid"), MALFORMED( + "Error parsing signature: malformed entry"); + + private final String description; + + HttpMessageSignatureResult(String description) { + this.description = description; + } + + /// Returns the public rejection description without cryptographic details. + public String description() { + return description; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureVerifier.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureVerifier.java new file mode 100644 index 00000000..7205300a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureVerifier.java @@ -0,0 +1,92 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.Clock; +import java.util.Base64; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureProperties; + +/// Verifies HTTP message signatures without consuming request content or changing authentication. +public final class HttpMessageSignatureVerifier { + + private final HttpMessageSignatureProperties properties; + private final Clock clock; + private final HttpMessageSignatureParser parser = new HttpMessageSignatureParser(); + + /// Creates a verifier with restart-bound policy and an injectable time source. + public HttpMessageSignatureVerifier(HttpMessageSignatureProperties properties, Clock clock) { + this.properties = properties; + this.clock = clock; + } + + /// Verifies an unambiguous signature header against the client-generated ID. + public HttpMessageSignatureResult verify(String header, String requestId) { + var parsed = parser.parse(header); + if (parsed.isEmpty()) { + return HttpMessageSignatureResult.MALFORMED; + } + var signature = parsed.orElseThrow(); + String idHeader = properties.identifierHeaderName().toLowerCase(Locale.ROOT); + if (!properties.algorithm().wireName().equals(signature.algorithm()) + || signature.components().size() != 3 + || !Set.copyOf(signature.components()).equals(Set.of(idHeader, "(created)", "(expires)")) + || requestId == null || requestId.isBlank() || requestId.contains("\r") + || requestId.contains("\n")) { + return HttpMessageSignatureResult.INVALID; + } + byte[] supplied; + try { + supplied = Base64.getDecoder().decode(signature.signature()); + } catch (IllegalArgumentException _) { + return HttpMessageSignatureResult.MALFORMED; + } + if (supplied.length != properties.algorithm().digestLength()) { + return HttpMessageSignatureResult.INVALID; + } + String canonical = signature.components().stream().map(component -> switch (component) { + case "(created)" -> "(created): " + signature.createdText(); + case "(expires)" -> "(expires): " + signature.expiresText(); + default -> idHeader + ": " + requestId; + }).collect(Collectors.joining("\n")); + if (!MessageDigest.isEqual(compute(canonical), supplied)) { + return HttpMessageSignatureResult.INVALID; + } + var lifetime = signature.expires().subtract(signature.created()); + if (lifetime.signum() <= 0 + || lifetime.compareTo(BigDecimal.valueOf(properties.validityDurationSeconds())) > 0) { + return HttpMessageSignatureResult.INVALID; + } + var now = clock.instant(); + var nowSeconds = BigDecimal.valueOf(now.getEpochSecond()) + .add(BigDecimal.valueOf(now.getNano(), 9)); + var skew = BigDecimal.valueOf(properties.clockSkewSeconds()); + if (signature.created().compareTo(nowSeconds.add(skew)) > 0) { + return HttpMessageSignatureResult.INVALID; + } + if (signature.expires().compareTo(nowSeconds.subtract(skew)) <= 0) { + return HttpMessageSignatureResult.EXPIRED; + } + return HttpMessageSignatureResult.VALID; + } + + private byte[] compute(String canonical) { + try { + var mac = Mac.getInstance(properties.algorithm().jcaName()); + mac.init(new SecretKeySpec(properties.secret().getBytes(StandardCharsets.UTF_8), + properties.algorithm().jcaName())); + return mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("HTTP message signature cryptography is unavailable", + exception); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/HttpMessageSignatureAlgorithm.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/HttpMessageSignatureAlgorithm.java new file mode 100644 index 00000000..e98e1264 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/HttpMessageSignatureAlgorithm.java @@ -0,0 +1,32 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +/// Supported HTTP message HMAC algorithms, pinned by server configuration. +public enum HttpMessageSignatureAlgorithm { + HMAC_SHA256("hmac-sha256", "HmacSHA256", 32), HMAC_SHA1("hmac-sha1", "HmacSHA1", 20), HMAC_SHA384( + "hmac-sha384", "HmacSHA384", 48), HMAC_SHA512("hmac-sha512", "HmacSHA512", 64); + + private final String wireName; + private final String jcaName; + private final int digestLength; + + HttpMessageSignatureAlgorithm(String wireName, String jcaName, int digestLength) { + this.wireName = wireName; + this.jcaName = jcaName; + this.digestLength = digestLength; + } + + /// Returns the required HTTP Signature algorithm attribute. + public String wireName() { + return wireName; + } + + /// Returns the JCA MAC algorithm name. + public String jcaName() { + return jcaName; + } + + /// Returns the expected digest length in bytes. + public int digestLength() { + return digestLength; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/HttpMessageSignatureConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/HttpMessageSignatureConfiguration.java new file mode 100644 index 00000000..574792e2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/HttpMessageSignatureConfiguration.java @@ -0,0 +1,60 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +import java.time.Clock; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.web.filter.CorsFilter; + +import com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature.HttpMessageSignatureFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature.HttpMessageSignatureVerifier; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiErrorResponseWriter; + +/// Shared HTTP message signature enforcement wiring for every HTTP security chain. +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(HttpMessageSignatureProperties.class) +public class HttpMessageSignatureConfiguration { + + /// Adds HTTP message signature verification only when enabled, without + /// registering a servlet + /// filter. + @Bean + public ChainConfigurer httpMessageSignatureChainConfigurer( + Optional filter) { + return new ChainConfigurer(filter); + } + + /// Creates the enabled HTTP message signature verification filter with the + /// system UTC clock. + @Bean + @ConditionalOnProperty(prefix = "app.security.http-message-signature-verification", name = "enabled", havingValue = "true", matchIfMissing = true) + public HttpMessageSignatureFilter httpMessageSignatureFilter( + HttpMessageSignatureProperties properties, ApiErrorResponseWriter errorWriter) { + return new HttpMessageSignatureFilter(properties, + new HttpMessageSignatureVerifier(properties, Clock.systemUTC()), errorWriter); + } + + /// Prevents Spring Boot from also running the filter outside Spring Security. + @Bean + @ConditionalOnProperty(prefix = "app.security.http-message-signature-verification", name = "enabled", havingValue = "true", matchIfMissing = true) + public FilterRegistrationBean httpMessageSignatureRegistration( + HttpMessageSignatureFilter filter) { + var registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } + + /// Applies the same optional filter to each independently matched security + /// chain. + public record ChainConfigurer(Optional filter) { + /// Lets CORS handle preflights before verification, CSRF, and authentication. + public void configure(HttpSecurity http) { + filter.ifPresent(value -> http.addFilterAfter(value, CorsFilter.class)); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/HttpMessageSignatureProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/HttpMessageSignatureProperties.java new file mode 100644 index 00000000..303a6f0c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/HttpMessageSignatureProperties.java @@ -0,0 +1,49 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +import java.util.List; +import java.util.Objects; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/// HTTP message signature verification configuration. +@ConfigurationProperties("app.security.http-message-signature-verification") +public record HttpMessageSignatureProperties(@DefaultValue("true") boolean enabled, String secret, + @DefaultValue("x-request-id") String identifierHeaderName, + @DefaultValue("HMAC_SHA256") HttpMessageSignatureAlgorithm algorithm, + @DefaultValue("30") long validityDurationSeconds, @DefaultValue("0") long clockSkewSeconds, + @DefaultValue( { + "/actuator/health", "/actuator/health/**", "/swagger-ui.html", "/swagger-ui/**", + "/v3/api-docs/**"}) List excludedPaths){ + + /// Validates startup policy and takes an immutable copy of the exclusion paths. + public HttpMessageSignatureProperties { + if (enabled && (secret == null || secret.isBlank())) { + throw new IllegalArgumentException("HTTP message signature secret is required when enabled"); + } + if (identifierHeaderName == null || !identifierHeaderName.matches("[!#$%&'*+.^_`|~0-9A-Za-z-]+") + || identifierHeaderName.equalsIgnoreCase("Authorization") + || identifierHeaderName.equalsIgnoreCase("Signature")) { + throw new IllegalArgumentException("Invalid HTTP message signature ID header name"); + } + Objects.requireNonNull(algorithm, "HTTP message signature algorithm is required"); + if (validityDurationSeconds <= 0 || clockSkewSeconds < 0) { + throw new IllegalArgumentException( + "HTTP message signature validity must be positive and clock skew nonnegative"); + } + excludedPaths = List.copyOf(excludedPaths); + if (excludedPaths.stream() + .anyMatch(path -> !path.startsWith("/") || path.contains("\r") || path.contains("\n"))) { + throw new IllegalArgumentException( + "HTTP message signature exclusions must be absolute Ant paths"); + } + } + + @Override + public String toString() { + return "HttpMessageSignatureProperties[enabled=" + enabled + ", secret=[REDACTED]" + + ", identifierHeaderName=" + identifierHeaderName + ", algorithm=" + algorithm + + ", validityDurationSeconds=" + validityDurationSeconds + ", clockSkewSeconds=" + + clockSkewSeconds + ", excludedPaths=" + excludedPaths + "]"; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/ApiKeyFilterChainConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/ApiKeyFilterChainConfig.java index 372593e5..87a00707 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/ApiKeyFilterChainConfig.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/ApiKeyFilterChainConfig.java @@ -9,6 +9,8 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; + /// Security filter chain for API key authentication. /// /// **Configuration:** @@ -23,9 +25,11 @@ public class ApiKeyFilterChainConfig { @Bean @Order(3) - public SecurityFilterChain apiKeySecurityFilterChain(HttpSecurity http) { + public SecurityFilterChain apiKeySecurityFilterChain(HttpSecurity http, + ChainConfigurer httpMessageSignature) { http.authorizeHttpRequests(auth -> auth.requestMatchers("/api/v1/**").fullyAuthenticated() .anyRequest().authenticated()).cors(withDefaults()); + httpMessageSignature.configure(http); return http.build(); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfig.java index 9488ca17..66a49a7b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfig.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfig.java @@ -13,6 +13,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; @Configuration @ConditionalOnProperty(prefix = "app.security.authentication.jwt", name = "enabled", havingValue = "true", matchIfMissing = false) @@ -32,7 +33,8 @@ public JwtFilterChainConfig(JitProvisioningFilter jitProvisioningFilter, @Bean @Order(2) - public SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) { + public SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http, + ChainConfigurer httpMessageSignature) { http.authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/**").fullyAuthenticated().anyRequest().authenticated()) .cors(withDefaults()) @@ -41,6 +43,7 @@ public SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) { .addFilterAfter(jitProvisioningFilter, BearerTokenAuthenticationFilter.class) .addFilterAfter(globalAuthorizationFilter, JitProvisioningFilter.class); + httpMessageSignature.configure(http); return http.build(); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java index d797498d..f89be7c0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java @@ -34,6 +34,7 @@ import com.decathlon.idp_core.domain.exception.mock.MockSecurityConfigurationException; import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; /// Local mock security configuration that mirrors OAuth2/JWT behavior for local development. /// @@ -76,7 +77,8 @@ public MockFilterChainConfig(JitProvisioningFilter jitProvisioningFilter, /// @return Configured security filter chain @Bean @Order(4) - public SecurityFilterChain mockSecurityFilterChain(HttpSecurity http) { + public SecurityFilterChain mockSecurityFilterChain(HttpSecurity http, + ChainConfigurer httpMessageSignature) { try { http.sessionManagement( session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) @@ -96,6 +98,7 @@ public SecurityFilterChain mockSecurityFilterChain(HttpSecurity http) { throw new MockSecurityConfigurationException("Failed to configure mock security filter chain", e); } + httpMessageSignature.configure(http); return http.build(); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfig.java index 3769ab78..4a8971f5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfig.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfig.java @@ -15,6 +15,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; /// Security filter chain for browser-based OAuth2 login. /// @@ -37,7 +38,8 @@ public OAuth2LoginFilterChainConfig(JitProvisioningFilter jitProvisioningFilter, @Bean @Order(2) - public SecurityFilterChain oauth2LoginSecurityFilterChain(HttpSecurity http) { + public SecurityFilterChain oauth2LoginSecurityFilterChain(HttpSecurity http, + ChainConfigurer httpMessageSignature) { http.sessionManagement( session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .cors(withDefaults()).csrf(withDefaults()) @@ -47,6 +49,7 @@ public SecurityFilterChain oauth2LoginSecurityFilterChain(HttpSecurity http) { .addFilterAfter(jitProvisioningFilter, OAuth2LoginAuthenticationFilter.class) .addFilterAfter(globalAuthorizationFilter, JitProvisioningFilter.class); + httpMessageSignature.configure(http); return http.build(); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfig.java index c8ddf9a7..cb0d86ab 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfig.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfig.java @@ -9,6 +9,7 @@ import org.springframework.security.web.SecurityFilterChain; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; @Configuration public class PublicFilterChainConfig { @@ -21,13 +22,15 @@ public PublicFilterChainConfig(AuthenticationProperties authProperties) { @Bean @Order(1) // Evaluated first - public SecurityFilterChain publicFilterChain(HttpSecurity http) { + public SecurityFilterChain publicFilterChain(HttpSecurity http, + ChainConfigurer httpMessageSignature) { // Uses the DRY properties from YAML String[] publicPaths = authProperties.jitProvisioningExcludedPaths().toArray(new String[0]); http.securityMatcher(publicPaths).authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .cors(withDefaults()); + httpMessageSignature.configure(http); return http.build(); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionSecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionSecurityConfiguration.java index f5247748..ef972576 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionSecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/ingestion/configuration/IngestionSecurityConfiguration.java @@ -7,6 +7,8 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; + // Dedicated security filter chain for inbound webhook ingestion routes. // Authentication model: // External callers do not authenticate through Spring Security for this entrypoint. @@ -34,13 +36,15 @@ public IngestionSecurityConfiguration(IngestionProperties ingestionProperties) { // Security level, and ignores CSRF for these M2M endpoints. @Bean @Order(1) - public SecurityFilterChain ingestionSecurityFilterChain(HttpSecurity http) { + public SecurityFilterChain ingestionSecurityFilterChain(HttpSecurity http, + ChainConfigurer httpMessageSignature) { String webhookMatcher = ingestionProperties.basePath() + "/**"; http.securityMatcher(webhookMatcher) .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()) .csrf(csrf -> csrf.ignoringRequestMatchers(webhookMatcher)); + httpMessageSignature.configure(http); return http.build(); } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 50e9b598..8312ea11 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -41,6 +41,8 @@ app: # ${app.idp-core-prefix-url}/login/oauth2/code/github in the OAuth app settings. idp-core-prefix-url: http://localhost:8084 security: + http-message-signature-verification: + enabled: ${HTTP_MESSAGE_SIGNATURE_VERIFICATION_ENABLED:false} principal-provisioning: mode: ${IDP_PRINCIPAL_PROVISIONING_MODE:LINK_ONLY} authorization: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index de15a8de..c6272e39 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -123,6 +123,14 @@ app: # Base URL of this service, used to build self-referencing links. idp-core-prefix-url: ${IDP_CORE_PREFIX_URL} security: + http-message-signature-verification: + enabled: ${HTTP_MESSAGE_SIGNATURE_VERIFICATION_ENABLED:true} + secret: ${HTTP_MESSAGE_SIGNATURE_VERIFICATION_SECRET} + identifier-header-name: ${HTTP_MESSAGE_SIGNATURE_VERIFICATION_IDENTIFIER_HEADER_NAME:x-request-id} + algorithm: ${HTTP_MESSAGE_SIGNATURE_VERIFICATION_ALGORITHM:HMAC_SHA256} + validity-duration-seconds: ${HTTP_MESSAGE_SIGNATURE_VERIFICATION_VALIDITY_DURATION_SECONDS:30} + clock-skew-seconds: ${HTTP_MESSAGE_SIGNATURE_VERIFICATION_CLOCK_SKEW_SECONDS:0} + excluded-paths: ${HTTP_MESSAGE_SIGNATURE_VERIFICATION_EXCLUDED_PATHS:/actuator/health,/actuator/health/**,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**} principal-provisioning: mode: ${IDP_PRINCIPAL_PROVISIONING_MODE:LINK_ONLY} authorization: diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureConfigurationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureConfigurationTest.java new file mode 100644 index 00000000..bb4c9e9d --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureConfigurationTest.java @@ -0,0 +1,223 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureAlgorithm; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureProperties; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.security.chains.ApiKeyFilterChainConfig; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.security.chains.JwtFilterChainConfig; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.security.chains.MockFilterChainConfig; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.security.chains.OAuth2LoginFilterChainConfig; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.security.chains.PublicFilterChainConfig; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiErrorResponseWriter; +import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionProperties; +import com.decathlon.idp_core.infrastructure.adapters.ingestion.configuration.IngestionSecurityConfiguration; +import com.fasterxml.jackson.databind.ObjectMapper; + +class HttpMessageSignatureConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(HttpMessageSignatureConfiguration.class) + .withBean(ApiErrorResponseWriter.class, () -> new ApiErrorResponseWriter(new ObjectMapper())); + + @Test + void defaultEnablementRequiresSecretAndFailsWithoutExposingIt() { + contextRunner.run(context -> assertThat(context).hasFailed()); + contextRunner.withPropertyValues("app.security.http-message-signature-verification.secret= ") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void defaultsBindAndServletAutoRegistrationIsDisabled() { + contextRunner + .withPropertyValues("app.security.http-message-signature-verification.secret=dummy-secret") + .run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(HttpMessageSignatureFilter.class); + var properties = context.getBean(HttpMessageSignatureProperties.class); + assertThat(properties.enabled()).isTrue(); + assertThat(properties.identifierHeaderName()).isEqualTo("x-request-id"); + assertThat(properties.algorithm()).isEqualTo(HttpMessageSignatureAlgorithm.HMAC_SHA256); + assertThat(properties.validityDurationSeconds()).isEqualTo(30); + assertThat(properties.clockSkewSeconds()).isZero(); + assertThat(properties.excludedPaths()).contains("/actuator/health", "/swagger-ui/**"); + assertThat(properties.toString()).contains("[REDACTED]").doesNotContain("dummy-secret"); + assertThat(context.getBean(FilterRegistrationBean.class).isEnabled()).isFalse(); + }); + } + + @Test + void disabledFeatureNeedsNoSecretAndRegistersNoFilter() { + contextRunner + .withPropertyValues("app.security.http-message-signature-verification.enabled=false") + .run(context -> { + assertThat(context).hasNotFailed().doesNotHaveBean(HttpMessageSignatureFilter.class) + .doesNotHaveBean(FilterRegistrationBean.class); + assertThat( + context.getBean(HttpMessageSignatureConfiguration.ChainConfigurer.class).filter()) + .isEmpty(); + }); + } + + @Test + void verificationConfigurationBindsCurrentPolicy() { + contextRunner + .withPropertyValues("app.security.http-message-signature-verification.secret=dummy-secret", + "app.security.http-message-signature-verification.identifier-header-name=x-client-id") + .run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(HttpMessageSignatureFilter.class); + var properties = context.getBean(HttpMessageSignatureProperties.class); + assertThat(properties.enabled()).isTrue(); + assertThat(properties.identifierHeaderName()).isEqualTo("x-client-id"); + assertThat(properties.algorithm()).isEqualTo(HttpMessageSignatureAlgorithm.HMAC_SHA256); + assertThat(properties.validityDurationSeconds()).isEqualTo(30); + assertThat(properties.secret()).isEqualTo("dummy-secret"); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"identifier-header-name=Authorization", + "identifier-header-name=Signature", "identifier-header-name=bad header", + "clock-skew-seconds=-1", "validity-duration-seconds=0", "validity-duration-seconds=-1", + "algorithm=HMAC_MD5", "excluded-paths=relative"}) + void invalidPolicyFailsStartup(String setting) { + contextRunner + .withPropertyValues("app.security.http-message-signature-verification.secret=dummy-secret", + "app.security.http-message-signature-verification." + setting) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void exclusionsAreReplacedAndCanBeEmpty() { + contextRunner + .withPropertyValues("app.security.http-message-signature-verification.enabled=false", + "app.security.http-message-signature-verification.excluded-paths=/custom/**") + .run(context -> { + assertThat(context.getBean(HttpMessageSignatureProperties.class).excludedPaths()) + .containsExactly("/custom/**"); + }); + contextRunner + .withPropertyValues("app.security.http-message-signature-verification.enabled=false", + "app.security.http-message-signature-verification.excluded-paths=") + .run(context -> { + assertThat(context.getBean(HttpMessageSignatureProperties.class).excludedPaths()) + .isEmpty(); + }); + var paths = new ArrayList<>(List.of("/custom/**")); + var properties = new HttpMessageSignatureProperties(false, null, "request-id", + HttpMessageSignatureAlgorithm.HMAC_SHA256, 30, 0, paths); + paths.clear(); + assertThat(properties.excludedPaths()).containsExactly("/custom/**"); + var excludedPaths = properties.excludedPaths(); + assertThatThrownBy(excludedPaths::clear).isInstanceOf(UnsupportedOperationException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"jwt", "api-key", "mock", "oauth2-login"}) + void everyRealChainContainsExactlyOneSignatureFilterAfterCorsAndBeforeAuthentication( + String mode) { + chainRunner(mode).run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(SecurityFilterChain.class)).hasSize(3); + context.getBeansOfType(SecurityFilterChain.class).values().forEach(chain -> { + var filters = chain.getFilters(); + assertThat(filters.stream().filter(HttpMessageSignatureFilter.class::isInstance)) + .hasSize(1); + int signatureIndex = filters.indexOf(context.getBean(HttpMessageSignatureFilter.class)); + for (int i = 0; i < filters.size(); i++) { + if (filters.get(i) instanceof CorsFilter) { + assertThat(signatureIndex).isEqualTo(i + 1); + } + if (filters.get(i) instanceof CsrfFilter + || filters.get(i) instanceof BearerTokenAuthenticationFilter + || filters.get(i) instanceof AbstractAuthenticationProcessingFilter + || filters.get(i) instanceof OAuth2AuthorizationRequestRedirectFilter + || filters.get(i) instanceof JitProvisioningFilter + || filters.get(i) instanceof GlobalAuthorizationFilter + || filters.get(i) instanceof AnonymousAuthenticationFilter) { + assertThat(signatureIndex).isLessThan(i); + } + } + }); + }); + } + + @Test + void disabledFeatureIsAbsentFromAllRealChains() { + chainRunner("jwt") + .withPropertyValues("app.security.http-message-signature-verification.enabled=false") + .run(context -> { + assertThat(context).hasNotFailed(); + context.getBeansOfType(SecurityFilterChain.class).values() + .forEach(chain -> assertThat(chain.getFilters()) + .noneMatch(HttpMessageSignatureFilter.class::isInstance)); + }); + } + + private WebApplicationContextRunner chainRunner(String mode) { + var client = ClientRegistration.withRegistrationId("test").clientId("client") + .clientSecret("dummy").authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://example.com/authorize").tokenUri("https://example.com/token") + .userInfoUri("https://example.com/user").userNameAttributeName("id").build(); + return new WebApplicationContextRunner().withUserConfiguration(Chains.class) + .withBean("corsConfigurationSource", CorsConfigurationSource.class, + UrlBasedCorsConfigurationSource::new) + .withPropertyValues("app.security.http-message-signature-verification.secret=dummy-secret", + "app.security.authentication." + mode + ".enabled=true") + .withBean(ApiErrorResponseWriter.class, + () -> new ApiErrorResponseWriter(new ObjectMapper())) + .withBean(AuthenticationProperties.class, + () -> new AuthenticationProperties(Map.of(), + List.of("/", "/actuator/**", "/swagger-ui/**", "/v3/api-docs/**"))) + .withBean(IngestionProperties.class, () -> new IngestionProperties("/webhooks", "/{id}")) + .withBean(JitProvisioningFilter.class, () -> mock(JitProvisioningFilter.class)) + .withBean(GlobalAuthorizationFilter.class, () -> mock(GlobalAuthorizationFilter.class)) + .withBean(JwtAuthenticationConverter.class, JwtAuthenticationConverter::new) + .withBean(JwtDecoder.class, () -> mock(JwtDecoder.class)) + .withBean(ClientRegistrationRepository.class, + () -> new InMemoryClientRegistrationRepository(client)); + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @Import({HttpMessageSignatureConfiguration.class, PublicFilterChainConfig.class, + JwtFilterChainConfig.class, ApiKeyFilterChainConfig.class, MockFilterChainConfig.class, + OAuth2LoginFilterChainConfig.class, IngestionSecurityConfiguration.class}) + static class Chains { + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureFilterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureFilterTest.java new file mode 100644 index 00000000..799abca8 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureFilterTest.java @@ -0,0 +1,183 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature; + +import static com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature.HttpMessageSignatureVerifierTest.CREATED; +import static com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature.HttpMessageSignatureVerifierTest.EXPIRES; +import static com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature.HttpMessageSignatureVerifierTest.ID; +import static com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature.HttpMessageSignatureVerifierTest.SECRET; +import static com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature.HttpMessageSignatureVerifierTest.signed; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.servlet.ServletException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureAlgorithm; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureProperties; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiErrorResponseWriter; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(OutputCaptureExtension.class) +class HttpMessageSignatureFilterTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void validRequestPreservesBodyAndBearerAuthentication() throws Exception { + var request = request(); + request.addHeader("Signature", + signed(HttpMessageSignatureAlgorithm.HMAC_SHA256, "x-request-id", ID, CREATED, EXPIRES)); + request.addHeader("Authorization", "Bearer unchanged"); + request.setContent(new byte[]{0, 1, 2, -1}); + var response = new MockHttpServletResponse(); + var calls = new AtomicInteger(); + filter(List.of()).doFilter(request, response, (req, res) -> { + calls.incrementAndGet(); + assertThat(request.getInputStream().readAllBytes()).containsExactly(0, 1, 2, -1); + assertThat(request.getHeader("Authorization")).isEqualTo("Bearer unchanged"); + }); + assertThat(calls.get()).isEqualTo(1); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + void rejectionUsesExactJsonAndHeadersAndStopsProcessing() throws Exception { + var request = request(); + request.addHeader("Authorization", "Signature ignored"); + var response = reject(request); + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentType()).isEqualTo("application/json;charset=UTF-8"); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Signature realm=\"idp-core\""); + assertThat(response.getHeader("Cache-Control")).isEqualTo("no-store"); + assertThat(mapper.readTree(response.getContentAsString())).isEqualTo(mapper.readTree(""" + {"error":"UNAUTHORIZED","error_description":"The HTTP message signature is missing"} + """)); + } + + @Test + void bothHeadersAreRequiredAndDuplicatesRejected() throws Exception { + var missingId = new MockHttpServletRequest("GET", "/api/v1/entities"); + missingId.addHeader("Signature", "anything"); + assertDescription(reject(missingId), HttpMessageSignatureResult.MISSING); + var duplicate = request(); + duplicate.addHeader("Signature", "one"); + duplicate.addHeader("Signature", "two"); + assertDescription(reject(duplicate), HttpMessageSignatureResult.MALFORMED); + var duplicateId = request(); + duplicateId.addHeader("x-request-id", "another"); + duplicateId.addHeader("Signature", "anything"); + assertDescription(reject(duplicateId), HttpMessageSignatureResult.MALFORMED); + } + + @Test + void errorsAreClassifiedAndLogsDoNotExposeSignatures(CapturedOutput output) throws Exception { + var malformed = request(); + malformed.addHeader("Signature", "malformed-private-header"); + assertDescription(reject(malformed), HttpMessageSignatureResult.MALFORMED); + var expired = request(); + expired.addHeader("Signature", signed(HttpMessageSignatureAlgorithm.HMAC_SHA256, "x-request-id", + ID, "1793789250", CREATED)); + assertDescription(reject(expired), HttpMessageSignatureResult.EXPIRED); + var invalid = request(); + String signature = signed(HttpMessageSignatureAlgorithm.HMAC_SHA256, "x-request-id", + "different", CREATED, EXPIRES); + invalid.addHeader("Signature", signature); + assertDescription(reject(invalid), HttpMessageSignatureResult.INVALID); + assertThat(output).contains("unverifiedRequestId=" + ID).doesNotContain(SECRET, signature, + "malformed-private-header"); + } + + @Test + void failureCorrelationIsBoundedAndSanitized(CapturedOutput output) throws Exception { + var request = new MockHttpServletRequest("GET", "/protected"); + request.addHeader("x-request-id", "id\r\ninjection " + "x".repeat(200)); + reject(request); + assertThat(output).contains("unverifiedRequestId=id__injection_") + .doesNotContain("id\r\ninjection", "x".repeat(129)); + } + + @ParameterizedTest + @ValueSource(strings = {"/actuator/health", "/actuator/health/readiness", "/swagger-ui.html", + "/swagger-ui/index.html", "/v3/api-docs", "/v3/api-docs/internal"}) + void excludesOnlyConfiguredPathsIncludingContextPath(String path) throws Exception { + var request = new MockHttpServletRequest("GET", "/context" + path); + request.setContextPath("/context"); + var calls = new AtomicInteger(); + filter(List.of("/actuator/health/**", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**")) + .doFilter(request, new MockHttpServletResponse(), (req, res) -> calls.incrementAndGet()); + assertThat(calls.get()).isEqualTo(1); + } + + @ParameterizedTest + @ValueSource(strings = {"/", "/login", "/oauth2/authorization/github", "/api/v1/entities", + "/actuator/env", "/webhooks/public-connector"}) + void nonPreflightOptionsOnPublicOrWebhookRoutesAreNotImplicitlyExcluded(String path) + throws Exception { + var request = new MockHttpServletRequest("OPTIONS", path); + assertThat(reject(request).getStatus()).isEqualTo(401); + } + + @Test + void exclusionsCanBeEmptyOrReplaced() throws Exception { + var health = new MockHttpServletRequest("GET", "/actuator/health"); + assertThat(reject(health).getStatus()).isEqualTo(401); + var calls = new AtomicInteger(); + var custom = new MockHttpServletRequest("GET", "/custom/public"); + filter(List.of("/custom/p?blic")).doFilter(custom, new MockHttpServletResponse(), + (req, res) -> calls.incrementAndGet()); + assertThat(calls.get()).isEqualTo(1); + } + + @Test + void downstreamErrorsRemainObservable() throws Exception { + var request = request(); + request.addHeader("Signature", + signed(HttpMessageSignatureAlgorithm.HMAC_SHA256, "x-request-id", ID, CREATED, EXPIRES)); + assertThatThrownBy( + () -> filter(List.of()).doFilter(request, new MockHttpServletResponse(), (req, res) -> { + throw new ServletException("downstream failure"); + })).isInstanceOf(ServletException.class).hasMessage("downstream failure"); + } + + private MockHttpServletRequest request() { + var request = new MockHttpServletRequest("POST", "/api/v1/entities"); + request.addHeader("x-request-id", ID); + return request; + } + + private HttpMessageSignatureFilter filter(List exclusions) { + var properties = new HttpMessageSignatureProperties(true, SECRET, "x-request-id", + HttpMessageSignatureAlgorithm.HMAC_SHA256, 30, 0, exclusions); + return new HttpMessageSignatureFilter(properties, + new HttpMessageSignatureVerifier(properties, + Clock.fixed(Instant.ofEpochSecond(1793789290), ZoneOffset.UTC)), + new ApiErrorResponseWriter(mapper)); + } + + private MockHttpServletResponse reject(MockHttpServletRequest request) throws Exception { + var response = new MockHttpServletResponse(); + filter(List.of()).doFilter(request, response, (req, res) -> { + throw new AssertionError("Rejected request reached downstream"); + }); + return response; + } + + private void assertDescription(MockHttpServletResponse response, + HttpMessageSignatureResult result) throws Exception { + assertThat(mapper.readTree(response.getContentAsString()).get("error_description").asText()) + .isEqualTo(result.description()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureIntegrationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureIntegrationTest.java new file mode 100644 index 00000000..8a5fc0c8 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureIntegrationTest.java @@ -0,0 +1,226 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature; + +import static com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature.HttpMessageSignatureVerifierTest.ID; +import static com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature.HttpMessageSignatureVerifierTest.signed; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.client.RestTestClient; + +import com.decathlon.idp_core.AbstractIntegrationTest; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureAlgorithm; +import com.fasterxml.jackson.annotation.JsonProperty; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "app.security.http-message-signature-verification.enabled=true", + "app.security.http-message-signature-verification.secret=http-message-test-secret", + "spring.web.cors.allowed-origins=https://example.com", + "app.ingestion.webhook.base-path=/signature-webhooks", "spring.threads.virtual.enabled=true"}) +@Sql(statements = {""" + INSERT INTO webhook_connector (id, identifier, name, description, enabled, security) + VALUES ('80e8400e-e29b-41d4-a716-446655440001', 'signature-test-hmac', 'HTTP message test HMAC', + 'HTTP message signature HTTP fixture', true, + '{"type":"HMAC_SHA256","config":{"header_name":"X-Hub-Signature-256", + "secret_alias":"HTTP_MESSAGE_TEST_WEBHOOK_SECRET","prefix":"sha256="}}'::jsonb); + """, + """ + INSERT INTO webhook_connector (id, identifier, name, description, enabled, security) + VALUES ('80e8400e-e29b-41d4-a716-446655440002', 'signature-test-token', 'HTTP message test token', + 'HTTP message signature HTTP fixture', true, + '{"type":"STATIC_TOKEN","config":{"header_name":"X-Auth-Token", + "secret_alias":"HTTP_MESSAGE_TEST_WEBHOOK_SECRET"}}'::jsonb); + """}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +@Sql(statements = """ + DELETE FROM webhook_connector WHERE identifier IN ('signature-test-hmac', 'signature-test-token') + """, executionPhase = Sql.ExecutionPhase.AFTER_TEST_CLASS) +class HttpMessageSignatureIntegrationTest extends AbstractIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private JwtDecoder jwtDecoder; + + @Autowired + @Qualifier("springSecurityFilterChain") + private FilterChainProxy securityFilterChain; + + @Autowired + private HttpMessageSignatureFilter signatureFilter; + + private RestTestClient client; + + @Test + void everyActiveSecurityFilterChainContainsExactlyOneSignatureFilter() { + var chains = securityFilterChain.getFilterChains(); + assertThat(chains).isNotEmpty(); + chains.forEach(chain -> assertThat(chain.getFilters()) + .as("HTTP message signature verification in %s", chain) + .filteredOn(HttpMessageSignatureFilter.class::isInstance).containsExactly(signatureFilter)); + } + + @BeforeEach + void setUpClient() { + client = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build(); + System.setProperty("HTTP_MESSAGE_TEST_WEBHOOK_SECRET", "http-message-webhook-test-secret"); + org.mockito.Mockito.clearInvocations(jwtDecoder); + } + + @AfterEach + void clearWebhookSecret() { + System.clearProperty("HTTP_MESSAGE_TEST_WEBHOOK_SECRET"); + } + + @ParameterizedTest + @ValueSource(strings = {"/api/v1/entities", "/", "/login", + "/signature-webhooks/public-connector"}) + void unsignedRequestsStopBeforeDownstreamAuthentication(String path) { + client.post().uri(path).header("Authorization", "Bearer invalid") + .contentType(MediaType.APPLICATION_JSON).body("{}").exchange().expectStatus() + .isUnauthorized().expectHeader() + .valueEquals("WWW-Authenticate", "Signature realm=\"idp-core\"").expectHeader() + .valueEquals("Cache-Control", "no-store").expectBody(SignatureError.class).value(error -> { + assertThat(error.error()).isEqualTo("UNAUTHORIZED"); + assertThat(error.errorDescription()).isEqualTo("The HTTP message signature is missing"); + }); + verifyNoInteractions(jwtDecoder); + } + + @ParameterizedTest + @ValueSource(strings = {"/api/v1/entities", "/"}) + void allowedPreflightDoesNotRequireHttpMessageSignatureOrAuthentication(String path) { + client.options().uri(path).header("Origin", "https://example.com") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", + "Signature,x-request-id,Authorization,Content-Type") + .exchange().expectStatus().isOk().expectHeader() + .valueEquals("Access-Control-Allow-Origin", "https://example.com").expectHeader() + .value("Access-Control-Allow-Methods", value -> assertThat(value).contains("POST")) + .expectHeader() + .value("Access-Control-Allow-Headers", + value -> assertThat(value).containsIgnoringCase("Signature") + .containsIgnoringCase("x-request-id").containsIgnoringCase("Authorization") + .containsIgnoringCase("Content-Type")) + .expectHeader().doesNotExist("WWW-Authenticate").expectBody().isEmpty(); + verifyNoInteractions(jwtDecoder); + } + + @Test + void disallowedPreflightIsRejectedByCorsBeforeSignatureVerification() { + client.options().uri("/api/v1/entities").header("Origin", "https://untrusted.example.com") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "Signature,x-request-id").exchange() + .expectStatus().isForbidden().expectHeader().doesNotExist("Access-Control-Allow-Origin") + .expectHeader().doesNotExist("WWW-Authenticate"); + verifyNoInteractions(jwtDecoder); + } + + @ParameterizedTest + @ValueSource(strings = {"", "https://example.com"}) + void nonPreflightOptionsStillRequiresHttpMessageSignature(String origin) { + client.options().uri("/api/v1/entities").headers(headers -> { + if (!origin.isEmpty()) { + headers.setOrigin(origin); + } + }).exchange().expectStatus().isUnauthorized().expectBody(SignatureError.class) + .value(error -> assertThat(error.errorDescription()) + .isEqualTo("The HTTP message signature is missing")); + verifyNoInteractions(jwtDecoder); + } + + @Test + void actualCorsRequestStillRequiresHttpMessageSignatureBeforeAuthentication() { + client.post().uri("/api/v1/entities").header("Origin", "https://example.com") + .header("Authorization", "******").contentType(MediaType.APPLICATION_JSON).body("{}") + .exchange().expectStatus().isUnauthorized().expectHeader() + .valueEquals("Access-Control-Allow-Origin", "https://example.com").expectHeader() + .valueEquals("WWW-Authenticate", "Signature realm=\"idp-core\"") + .expectBody(SignatureError.class).value(error -> assertThat(error.errorDescription()) + .isEqualTo("The HTTP message signature is missing")); + verifyNoInteractions(jwtDecoder); + } + + @Test + void validSignatureStillRequiresJwtAuthentication() throws Exception { + client.get().uri("/api/v1/entities").header("x-request-id", ID) + .header("Origin", "https://example.com").header("Signature", currentSignature()).exchange() + .expectStatus().isUnauthorized().expectHeader() + .valueEquals("Access-Control-Allow-Origin", "https://example.com").expectHeader() + .value("WWW-Authenticate", value -> assertThat(value).startsWith("Bearer")); + } + + @Test + void validSignatureDoesNotBypassConnectorAuthentication() throws Exception { + client.post().uri("/signature-webhooks/signature-test-token").header("x-request-id", ID) + .header("Signature", currentSignature()).contentType(MediaType.APPLICATION_JSON) + .body("{\"event\":\"ping\"}").exchange().expectStatus().isUnauthorized() + .expectBody(SignatureError.class) + .value(error -> assertThat(error.error()).isEqualTo("webhook_authentication_failed")); + } + + @Test + void directClientAndWebhookSignaturesPreserveExactPayloadBytes() throws Exception { + String payload = "{ \"event\" : \"ping\", \"unicode\" : \"\\u00e9\" }\n"; + var mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec("http-message-webhook-test-secret".getBytes(StandardCharsets.UTF_8), + "HmacSHA256")); + String webhookSignature = "sha256=" + + HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + client.post().uri("/signature-webhooks/signature-test-hmac").header("x-request-id", ID) + .header("Signature", currentSignature()).header("X-Hub-Signature-256", webhookSignature) + .contentType(MediaType.APPLICATION_JSON).body(payload).exchange().expectStatus() + .isCreated(); + } + + @Test + void healthExclusionPreservesPublicAccess() { + client.get().uri("/actuator/health").exchange().expectStatus().isOk(); + } + + @Test + void expiredAndTamperedSignaturesRejectBeforeWebhookProcessing() throws Exception { + long now = Instant.now().getEpochSecond(); + String expired = signed(HttpMessageSignatureAlgorithm.HMAC_SHA256, "x-request-id", ID, + Long.toString(now - 60), Long.toString(now - 30)); + client.post().uri("/signature-webhooks/public-connector").header("x-request-id", ID) + .header("Signature", expired).contentType(MediaType.APPLICATION_JSON).body("{}").exchange() + .expectStatus().isUnauthorized().expectBody(SignatureError.class) + .value(error -> assertThat(error.errorDescription()) + .isEqualTo("The HTTP message signature is expired")); + client.post().uri("/signature-webhooks/public-connector").header("x-request-id", "tampered") + .header("Signature", currentSignature()).contentType(MediaType.APPLICATION_JSON).body("{}") + .exchange().expectStatus().isUnauthorized().expectBody(SignatureError.class) + .value(error -> assertThat(error.errorDescription()) + .isEqualTo("The HTTP message signature is invalid")); + } + + private String currentSignature() throws Exception { + long now = Instant.now().getEpochSecond(); + return signed(HttpMessageSignatureAlgorithm.HMAC_SHA256, "x-request-id", ID, Long.toString(now), + Long.toString(now + 30)); + } + + private record SignatureError(String error, + @JsonProperty("error_description") String errorDescription) { + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureVerifierTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureVerifierTest.java new file mode 100644 index 00000000..046d09ca --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/http_message_signature/HttpMessageSignatureVerifierTest.java @@ -0,0 +1,216 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth.http_message_signature; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.List; +import java.util.stream.Stream; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureAlgorithm; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureProperties; + +class HttpMessageSignatureVerifierTest { + + static final String SECRET = "http-message-test-secret"; + static final String ID = "f47ac10b-58cc-4372-a567-0e02b2c3d479"; + static final String CREATED = "1793789280"; + static final String EXPIRES = "1793789310"; + static final String COMPONENTS = "x-request-id (created) (expires)"; + + // Generated independently with Python hmac/hashlib and the UTF-8 dummy secret. + static Stream knownAnswers() { + return Stream.of( + Arguments.of(HttpMessageSignatureAlgorithm.HMAC_SHA256, + "hDLDYJ+Y16dQPFUj3X6LXzvnp/T+wwwt0D9BCnpgUqA="), + Arguments.of(HttpMessageSignatureAlgorithm.HMAC_SHA1, "/v/oIsZSMmwvzB6GHYuBKRy6mo0="), + Arguments.of(HttpMessageSignatureAlgorithm.HMAC_SHA384, + "79q26OAzlDSfqxC6R72xdv0h8w5fD/aEJnXfPbDzHkrWQHlBDq0Wt7uVk5FWVaDA"), + Arguments.of(HttpMessageSignatureAlgorithm.HMAC_SHA512, + "vim88k2M2jR/4tmqv8iBzDHw3Zf8dVvwNKkllZqq1AwsrFgCLsmFOAjDSwlPGK7B7OKVYbPvQsO7wtNeSgHU6Q==")); + } + + @ParameterizedTest + @MethodSource("knownAnswers") + void verifiesIndependentKnownAnswers(HttpMessageSignatureAlgorithm algorithm, String digest) { + String header = attributes(algorithm, COMPONENTS, CREATED, EXPIRES, digest); + assertThat(verifier(algorithm, 30, 0, "1793789290", "x-request-id").verify(header, ID)) + .isEqualTo(HttpMessageSignatureResult.VALID); + } + + @Test + void parsesQuotedTimestampExampleWithoutClaimingItsHmacIsVerified() { + String mock = attributes(HttpMessageSignatureAlgorithm.HMAC_SHA256, COMPONENTS, CREATED, + EXPIRES, "hOb3Mz++JLh3BaBmXJkNT2Rf455fU+03ZjmF1MzPMms="); + var parsed = new HttpMessageSignatureParser().parse(mock).orElseThrow(); + assertThat(parsed.createdText()).isEqualTo(CREATED); + assertThat(parsed.expiresText()).isEqualTo(EXPIRES); + assertThat(parsed.components()).containsExactly("x-request-id", "(created)", "(expires)"); + assertThat(parsed.toString()).doesNotContain("hOb3"); + } + + @ParameterizedTest + @CsvSource({"1793789280,1793789310,1793789280,30,0,VALID", + "1793789280,1793789310,1793789310,30,0,EXPIRED", + "1793789280,1793789310,1793789310,30,1,VALID", + "1793789280,1793789310,1793789311,30,1,EXPIRED", + "1793789280,1793789310,1793789279,30,0,INVALID", + "1793789280,1793789310,1793789279,30,1,VALID", + "1793789280,1793789280,1793789280,30,0,INVALID", + "1793789280,1793789279,1793789280,30,0,INVALID", + "1793789280,1793789311,1793789290,30,5,INVALID", + "1793789280,1793789311,1793789290,31,0,VALID", + "1793789280,1793789280.500000001,1793789280.5,30,0,VALID", + "1793789280,1793789310.1,1793789290,30,0,INVALID"}) + void enforcesTimeAndLifetime(String created, String expires, String now, long maximum, long skew, + HttpMessageSignatureResult expected) throws Exception { + String header = signed(HttpMessageSignatureAlgorithm.HMAC_SHA256, "x-request-id", ID, created, + expires); + assertThat( + verifier(HttpMessageSignatureAlgorithm.HMAC_SHA256, maximum, skew, now, "x-request-id") + .verify(header, ID)).isEqualTo(expected); + } + + @Test + void largeConfiguredDurationsDoNotOverflow() throws Exception { + String header = signed(HttpMessageSignatureAlgorithm.HMAC_SHA256, "x-request-id", ID, CREATED, + EXPIRES); + assertThat(verifier(HttpMessageSignatureAlgorithm.HMAC_SHA256, Long.MAX_VALUE, Long.MAX_VALUE, + "1793789290", "x-request-id").verify(header, ID)) + .isEqualTo(HttpMessageSignatureResult.VALID); + } + + @ParameterizedTest + @EnumSource(HttpMessageSignatureAlgorithm.class) + void rejectsTamperingAndAlgorithmMismatch(HttpMessageSignatureAlgorithm algorithm) + throws Exception { + String header = signed(algorithm, "x-request-id", ID, CREATED, EXPIRES); + var verifier = verifier(algorithm, 30, 0, "1793789290", "x-request-id"); + assertThat(verifier.verify(header, ID + "-changed")) + .isEqualTo(HttpMessageSignatureResult.INVALID); + assertThat(verifier.verify(header.replace(CREATED, "1793789281"), ID)) + .isEqualTo(HttpMessageSignatureResult.INVALID); + assertThat(verifier.verify(header.replace(algorithm.wireName(), "hmac-md5"), ID)) + .isEqualTo(HttpMessageSignatureResult.INVALID); + var other = algorithm == HttpMessageSignatureAlgorithm.HMAC_SHA256 + ? HttpMessageSignatureAlgorithm.HMAC_SHA1 + : HttpMessageSignatureAlgorithm.HMAC_SHA256; + assertThat(verifier(other, 30, 0, "1793789290", "x-request-id").verify(header, ID)) + .isEqualTo(HttpMessageSignatureResult.INVALID); + } + + @Test + void acceptsCustomHeaderAndDeclaredComponentOrder() throws Exception { + String components = "(expires) x-client-request-id (created)"; + String canonical = "(expires): " + EXPIRES + "\nx-client-request-id: " + ID + "\n(created): " + + CREATED; + String header = attributes(HttpMessageSignatureAlgorithm.HMAC_SHA256, components, CREATED, + EXPIRES, digest(HttpMessageSignatureAlgorithm.HMAC_SHA256, canonical)); + assertThat(verifier(HttpMessageSignatureAlgorithm.HMAC_SHA256, 30, 0, "1793789290", + "X-Client-Request-Id").verify(header, ID)).isEqualTo(HttpMessageSignatureResult.VALID); + } + + @Test + void acceptsUnquotedTimestampsAndOptionalWhitespace() throws Exception { + String header = signed(HttpMessageSignatureAlgorithm.HMAC_SHA256, "x-request-id", ID, CREATED, + EXPIRES).replace("\"" + CREATED + "\"", CREATED).replace("\"" + EXPIRES + "\"", EXPIRES) + .replace(",", ", \t"); + assertThat( + verifier(HttpMessageSignatureAlgorithm.HMAC_SHA256, 30, 0, "1793789290", "x-request-id") + .verify(header, ID)).isEqualTo(HttpMessageSignatureResult.VALID); + } + + @ParameterizedTest + @ValueSource(strings = {"", "Signature ", "signature=\"x\",", "signature=\"unterminated", + "created=1,created=2", "garbage", "signature=\"a\\\"b\""}) + void rejectsMalformedAttributes(String header) { + assertThat( + verifier(HttpMessageSignatureAlgorithm.HMAC_SHA256, 30, 0, "1793789290", "x-request-id") + .verify(header, ID)).isEqualTo(HttpMessageSignatureResult.MALFORMED); + } + + @ParameterizedTest + @ValueSource(strings = {"-1", "1e9", "NaN", "1.1234567890", "999999999999999999999999", "\u0661", + "\uFF11"}) + void rejectsInvalidTimestampRepresentations(String timestamp) { + String header = attributes(HttpMessageSignatureAlgorithm.HMAC_SHA256, COMPONENTS, CREATED, + timestamp, "hDLDYJ+Y16dQPFUj3X6LXzvnp/T+wwwt0D9BCnpgUqA="); + assertThat(new HttpMessageSignatureParser().parse(header)).isEmpty(); + String invalidCreated = attributes(HttpMessageSignatureAlgorithm.HMAC_SHA256, COMPONENTS, + timestamp, EXPIRES, "hDLDYJ+Y16dQPFUj3X6LXzvnp/T+wwwt0D9BCnpgUqA="); + assertThat(new HttpMessageSignatureParser().parse(invalidCreated)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"x-request-id", "x-request-id (created) (created)", + "x-request-id (created) (expires) host"}) + void rejectsMissingDuplicateOrExtraComponents(String components) { + String header = attributes(HttpMessageSignatureAlgorithm.HMAC_SHA256, components, CREATED, + EXPIRES, "hDLDYJ+Y16dQPFUj3X6LXzvnp/T+wwwt0D9BCnpgUqA="); + assertThat( + verifier(HttpMessageSignatureAlgorithm.HMAC_SHA256, 30, 0, "1793789290", "x-request-id") + .verify(header, ID)).isEqualTo(HttpMessageSignatureResult.INVALID); + } + + @Test + void rejectsAmbiguousAttributesAndInvalidDigest() throws Exception { + String header = signed(HttpMessageSignatureAlgorithm.HMAC_SHA256, "x-request-id", ID, CREATED, + EXPIRES); + var verifier = verifier(HttpMessageSignatureAlgorithm.HMAC_SHA256, 30, 0, "1793789290", + "x-request-id"); + assertThat(verifier.verify(header + ",created=1", ID)) + .isEqualTo(HttpMessageSignatureResult.MALFORMED); + assertThat( + verifier.verify(header.replace("hDLDYJ+Y16dQPFUj3X6LXzvnp/T+wwwt0D9BCnpgUqA=", "!"), ID)) + .isEqualTo(HttpMessageSignatureResult.MALFORMED); + assertThat( + verifier.verify(header.replace("hDLDYJ+Y16dQPFUj3X6LXzvnp/T+wwwt0D9BCnpgUqA=", "YQ=="), ID)) + .isEqualTo(HttpMessageSignatureResult.INVALID); + assertThat(new HttpMessageSignatureParser().parse("x".repeat(8193))).isEmpty(); + } + + static HttpMessageSignatureVerifier verifier(HttpMessageSignatureAlgorithm algorithm, + long lifetime, long skew, String now, String idHeader) { + var seconds = new java.math.BigDecimal(now); + var instant = Instant.ofEpochSecond(seconds.longValue(), + seconds.remainder(java.math.BigDecimal.ONE).movePointRight(9).longValueExact()); + return new HttpMessageSignatureVerifier(new HttpMessageSignatureProperties(true, SECRET, + idHeader, algorithm, lifetime, skew, List.of()), Clock.fixed(instant, ZoneOffset.UTC)); + } + + static String signed(HttpMessageSignatureAlgorithm algorithm, String idHeader, String id, + String created, String expires) throws Exception { + String canonical = idHeader.toLowerCase(java.util.Locale.ROOT) + ": " + id + "\n(created): " + + created + "\n(expires): " + expires; + return attributes(algorithm, + idHeader.toLowerCase(java.util.Locale.ROOT) + " (created) (expires)", created, expires, + digest(algorithm, canonical)); + } + + static String digest(HttpMessageSignatureAlgorithm algorithm, String canonical) throws Exception { + var mac = Mac.getInstance(algorithm.jcaName()); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), algorithm.jcaName())); + return Base64.getEncoder() + .encodeToString(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); + } + + static String attributes(HttpMessageSignatureAlgorithm algorithm, String components, + String created, String expires, String digest) { + return "keyId=\"client\",algorithm=\"" + algorithm.wireName() + "\",headers=\"" + components + + "\",created=\"" + created + "\",expires=\"" + expires + "\",signature=\"" + digest + "\""; + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/ApiKeyFilterChainConfigTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/ApiKeyFilterChainConfigTest.java index 212b874f..6837fabd 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/ApiKeyFilterChainConfigTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/ApiKeyFilterChainConfigTest.java @@ -15,6 +15,8 @@ import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.SecurityFilterChain; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; + @DisplayName("ApiKeyFilterChainConfigTest") @ExtendWith(MockitoExtension.class) class ApiKeyFilterChainConfigTest { @@ -24,6 +26,7 @@ class ApiKeyFilterChainConfigTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(ApiKeyFilterChainConfig.class)) + .withBean(ChainConfigurer.class, () -> mock(ChainConfigurer.class)) .withBean(HttpSecurity.class, () -> httpSecurity()); @Test diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfigTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfigTest.java index 53b321d7..6638968b 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfigTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfigTest.java @@ -19,6 +19,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; @DisplayName("JwtFilterChainConfigTest") @ExtendWith(MockitoExtension.class) @@ -29,6 +30,7 @@ class JwtFilterChainConfigTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(JwtFilterChainConfig.class)) + .withBean(ChainConfigurer.class, () -> mock(ChainConfigurer.class)) .withBean(JitProvisioningFilter.class, () -> mock(JitProvisioningFilter.class)) .withBean(GlobalAuthorizationFilter.class, () -> mock(GlobalAuthorizationFilter.class)) .withBean(JwtAuthenticationConverter.class, JwtAuthenticationConverter::new) @@ -56,7 +58,7 @@ void shouldRegisterGlobalAuthorizationAfterJitProvisioning() { new JwtAuthenticationConverter()); HttpSecurity http = httpSecurity(); - configuration.jwtSecurityFilterChain(http); + configuration.jwtSecurityFilterChain(http, mock(ChainConfigurer.class)); verify(http).addFilterAfter(authorizationFilter, JitProvisioningFilter.class); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java index a877727e..b61d04b2 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java @@ -35,6 +35,7 @@ import com.decathlon.idp_core.domain.exception.mock.MockSecurityConfigurationException; import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; /// Unit tests for MockFilterChainConfig and MockJwtAuthenticationFilter. /// Covers mock security setup and JWT token generation for local development. @@ -79,6 +80,7 @@ void shouldInjectMockJwtAuthenticationAndClearAfterwards() throws ServletExcepti private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(MockFilterChainConfig.class)) + .withBean(ChainConfigurer.class, () -> mock(ChainConfigurer.class)) .withBean(JitProvisioningFilter.class, () -> mock(JitProvisioningFilter.class)) .withBean(GlobalAuthorizationFilter.class, () -> mock(GlobalAuthorizationFilter.class)) .withBean(HttpSecurity.class, this::httpSecurity); @@ -128,9 +130,10 @@ void shouldThrowMockFilterChainConfigExceptionWhenHttpConfigFails() { when(httpSecurityMock.sessionManagement(any())).thenThrow(simulatedError); // When & Then - assertThatThrownBy(() -> configuration.mockSecurityFilterChain(httpSecurityMock)) - .isInstanceOf(MockSecurityConfigurationException.class) - .hasMessage("Failed to configure mock security filter chain").hasCause(simulatedError); + assertThatThrownBy( + () -> configuration.mockSecurityFilterChain(httpSecurityMock, mock(ChainConfigurer.class))) + .isInstanceOf(MockSecurityConfigurationException.class) + .hasMessage("Failed to configure mock security filter chain").hasCause(simulatedError); } @Nested @@ -144,7 +147,7 @@ void shouldRegisterGlobalAuthorizationAfterJitProvisioning() { var configuration = new MockFilterChainConfig(jitFilter, authorizationFilter); HttpSecurity http = httpSecurity(); - configuration.mockSecurityFilterChain(http); + configuration.mockSecurityFilterChain(http, mock(ChainConfigurer.class)); verify(http).addFilterAfter(authorizationFilter, JitProvisioningFilter.class); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfigTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfigTest.java index 38239a53..23146272 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfigTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfigTest.java @@ -20,6 +20,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; @DisplayName("OAuth2LoginFilterChainConfigTest") @ExtendWith(MockitoExtension.class) @@ -30,6 +31,7 @@ class OAuth2LoginFilterChainConfigTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(OAuth2LoginFilterChainConfig.class)) + .withBean(ChainConfigurer.class, () -> mock(ChainConfigurer.class)) .withBean(JitProvisioningFilter.class, () -> mock(JitProvisioningFilter.class)) .withBean(GlobalAuthorizationFilter.class, () -> mock(GlobalAuthorizationFilter.class)) .withBean(ClientRegistrationRepository.class, () -> mock(ClientRegistrationRepository.class)) @@ -68,7 +70,7 @@ void shouldApplyGlobalAuthorizationAfterJitProvisioning() { var configuration = new OAuth2LoginFilterChainConfig(jitFilter, authorizationFilter); HttpSecurity http = httpSecurity(); - configuration.oauth2LoginSecurityFilterChain(http); + configuration.oauth2LoginSecurityFilterChain(http, mock(ChainConfigurer.class)); verify(http).addFilterAfter(authorizationFilter, JitProvisioningFilter.class); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfigTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfigTest.java index d3a4ffd6..770ca590 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfigTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfigTest.java @@ -19,6 +19,7 @@ import org.springframework.security.web.SecurityFilterChain; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.HttpMessageSignatureConfiguration.ChainConfigurer; @DisplayName("PublicFilterChainConfigTest") @ExtendWith(MockitoExtension.class) @@ -29,6 +30,7 @@ class PublicFilterChainConfigTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(PublicFilterChainConfig.class)) + .withBean(ChainConfigurer.class, () -> mock(ChainConfigurer.class)) .withBean(AuthenticationProperties.class, () -> authenticationProperties(List.of("/public/**"))) .withBean(HttpSecurity.class, () -> httpSecurity()); diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 9cab8771..e738483b 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -29,6 +29,8 @@ spring: # Added App Security configuration app: security: + http-message-signature-verification: + enabled: false principal-provisioning: mode: JIT authentication: