Skip to content
Open
1 change: 1 addition & 0 deletions .github/instructions/springboot.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/basic_code_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
82 changes: 81 additions & 1 deletion docs/src/concepts/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,93 @@ identity into a catalog record. **Authorization** decides what that principal ca

```mermaid
flowchart LR
A[HTTP request] --> B[Authentication mode<br/>JWT / OAuth2 login / Mock]
A[HTTP request] --> G[HTTP message signature]
G --> B[Authentication mode<br/>JWT / OAuth2 login / Mock]
B --> C[Principal extraction<br/>PrincipalInfo]
C --> D[Provisioning<br/>JIT or LINK_ONLY]
D --> E[Global authorization<br/>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)
Expand Down
37 changes: 37 additions & 0 deletions docs/src/deployment/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> signatures = Collections.list(request.getHeaders("Signature"));
List<String> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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<String> REQUIRED = Set.of(ALGORITHM, HEADERS, CREATED, EXPIRES,
SIGNATURE);
private static final Set<String> 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<ParsedSignature> parse(String header) {
if (header == null || header.isBlank() || header.length() > MAX_HEADER_LENGTH) {
return Optional.empty();
}
Map<String, String> 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<ParsedSignature> parseSignature(Map<String, String> 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<String> 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<String> 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]";
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading