From 4dafda1d3520c7da36791b43e4bb63f932e0635e Mon Sep 17 00:00:00 2001 From: Hristina Ivanova Date: Thu, 24 Sep 2026 17:09:20 +0200 Subject: [PATCH 1/3] implement change --- samples/bookshop/srv/src/main/resources/application.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/samples/bookshop/srv/src/main/resources/application.yaml b/samples/bookshop/srv/src/main/resources/application.yaml index c4f33c1..dac2b3c 100644 --- a/samples/bookshop/srv/src/main/resources/application.yaml +++ b/samples/bookshop/srv/src/main/resources/application.yaml @@ -30,6 +30,12 @@ cds: n8n: # n8n host only — no /webhook suffix. The plugin appends /webhook or /webhook-test automatically. base-url: ${N8N_BASE_URL:http://localhost:5678} + # Optional webhook authentication — configure to match your n8n Webhook node auth settings. + # Supported types: basic (username+password), header (custom header), bearer (token). + # webhook-auth: + # type: header + # name: ${N8N_WEBHOOK_AUTH_NAME} + # value: ${N8N_WEBHOOK_AUTH_VALUE} # use-test-webhook: true switches to /webhook-test — requires clicking "Listen for Test Event" # in the n8n UI first; fires only once. Use for single-trigger manual testing only. use-test-webhook: false From 25a9012f4c2ef6d77489489b50ada5b90d7c13c7 Mon Sep 17 00:00:00 2001 From: Hristina Ivanova Date: Thu, 24 Sep 2026 17:09:37 +0200 Subject: [PATCH 2/3] implement change --- .../configuration/N8nAutoConfiguration.java | 157 +++++++++++++++--- .../services/ConsoleN8NWebhookService.java | 2 +- .../n8n/services/N8nWebhookService.java | 20 +-- .../N8nAutoConfigurationTest.java | 113 ++++++++++++- .../services/N8nWebhookServiceRetryIT.java | 5 +- docs/adr-webhook-authentication.md | 61 +++++-- .../srv/src/main/resources/application.yaml | 1 - .../N8nAssociationInputIntegrationTest.java | 1 - .../N8nIfConditionIntegrationTest.java | 4 - .../integrationtest/N8nIntegrationTest.java | 5 - 10 files changed, 297 insertions(+), 72 deletions(-) diff --git a/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/configuration/N8nAutoConfiguration.java b/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/configuration/N8nAutoConfiguration.java index ce8e18c..4aa8a2b 100644 --- a/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/configuration/N8nAutoConfiguration.java +++ b/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/configuration/N8nAutoConfiguration.java @@ -14,6 +14,8 @@ import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.outbox.OutboxService; import com.sap.cds.services.persistence.PersistenceService; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -63,6 +65,7 @@ public static class N8nProperties { // Use for single-trigger manual testing only; keep false (default) for production. private boolean useTestWebhook = false; private String destination; + private WebhookAuth webhookAuth; /** * @return the n8n host URL without a {@code /webhook} suffix (e.g. {@code @@ -93,7 +96,7 @@ public void setUseTestWebhook(boolean useTestWebhook) { } /** - * @return the API key sent as {@code X-N8N-API-KEY} + * @return the n8n REST API key (used for {@code /api/v1/…} endpoints, not for webhook calls) */ public String getApiKey() { return apiKey; @@ -104,8 +107,7 @@ public void setApiKey(String apiKey) { } /** - * @return the BTP destination name; when set, takes priority over {@code baseUrl} and {@code - * apiKey} + * @return the BTP destination name; when set, takes priority over {@code baseUrl} */ public String getDestination() { return destination; @@ -115,6 +117,17 @@ public void setDestination(String destination) { this.destination = destination; } + /** + * @return the optional webhook authentication configuration + */ + public WebhookAuth getWebhookAuth() { + return webhookAuth; + } + + public void setWebhookAuth(WebhookAuth webhookAuth) { + this.webhookAuth = webhookAuth; + } + /** * Returns the effective webhook base URL with the correct prefix appended: {@code /webhook} for * production, {@code /webhook-test} when {@code useTestWebhook} is {@code true}. @@ -125,6 +138,108 @@ public String resolvedBaseUrl() { if (url.endsWith("/")) url = url.substring(0, url.length() - 1); return url + prefix; } + + /** + * Optional webhook authentication configuration, bound from {@code n8n.webhook-auth.*}. + * + *

Supported types: {@code basic} (username + password), {@code header} (name + value), + * {@code bearer} (token). When not set, webhook calls are sent without authentication. + */ + public static class WebhookAuth { + private String type; + private String username; + private String password; + private String name; + private String value; + private String token; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + } + } + + /** + * Resolves the {@code n8n.webhook-auth} configuration into a map of HTTP headers. + * + *

+ */ + static Map resolveWebhookAuthHeaders(N8nProperties.WebhookAuth auth) { + if (auth == null || auth.getType() == null) return Collections.emptyMap(); + return switch (auth.getType()) { + case "basic" -> { + if (auth.getUsername() == null || auth.getPassword() == null) + throw new IllegalStateException( + "n8n.webhook-auth.type=basic requires username and password"); + String encoded = + Base64.getEncoder() + .encodeToString( + (auth.getUsername() + ":" + auth.getPassword()) + .getBytes(StandardCharsets.UTF_8)); + yield Map.of("Authorization", "Basic " + encoded); + } + case "header" -> { + if (auth.getName() == null || auth.getValue() == null) + throw new IllegalStateException("n8n.webhook-auth.type=header requires name and value"); + yield Map.of(auth.getName(), auth.getValue()); + } + case "bearer" -> { + if (auth.getToken() == null) + throw new IllegalStateException("n8n.webhook-auth.type=bearer requires token"); + yield Map.of("Authorization", "Bearer " + auth.getToken()); + } + default -> + throw new IllegalStateException("Unsupported n8n.webhook-auth.type: " + auth.getType()); + }; } /** @@ -148,9 +263,10 @@ public static class DestinationConfiguration { *
    *
  • The destination URI plus {@code /webhook} (or {@code /webhook-test}) becomes the base * URL. - *
  • All destination headers except {@code X-N8N-API-KEY} are forwarded as {@code - * authHeaders}. - *
  • {@code n8n.api-key} overrides any {@code X-N8N-API-KEY} header from the destination. + *
  • All destination headers except {@code X-N8N-API-KEY} are forwarded as auth headers + * ({@code X-N8N-API-KEY} is a REST API credential and must not be sent to webhook nodes). + *
  • {@code n8n.webhook-auth} config headers are merged on top (override destination headers + * for the same header name). *
*/ @Bean @@ -178,23 +294,17 @@ public N8nWebhookService n8nWebhookServiceFromDestination( String baseUrl = rawUrl + (props.isUseTestWebhook() ? "/webhook-test" : "/webhook"); Map authHeaders = new LinkedHashMap<>(); - String destApiKey = null; for (com.sap.cloud.sdk.cloudplatform.connectivity.Header h : dest.getHeaders()) { - if (h.getName().equalsIgnoreCase("X-N8N-API-KEY")) { - destApiKey = h.getValue(); - } else { + // X-N8N-API-KEY is the REST API credential — do not forward it to webhook nodes + if (!h.getName().equalsIgnoreCase("X-N8N-API-KEY")) { authHeaders.put(h.getName(), h.getValue()); } } - - // Explicit n8n.api-key beats whatever the destination carries - String apiKey = - (props.getApiKey() != null && !props.getApiKey().isBlank()) - ? props.getApiKey() - : (destApiKey != null ? destApiKey : ""); + // webhook-auth config overrides destination headers for the same header name + authHeaders.putAll(resolveWebhookAuthHeaders(props.getWebhookAuth())); log.info("n8n: resolved connection via BTP destination '{}'", props.getDestination()); - return new N8nWebhookService(baseUrl, apiKey, authHeaders, n8nRestClient); + return new N8nWebhookService(baseUrl, authHeaders, n8nRestClient); } } @@ -245,8 +355,9 @@ public ConsoleN8NWebhookService consoleN8nWebhookService() { * destination-based bean was already registered by {@link DestinationConfiguration}. * *
    - *
  • {@code n8n.base-url} set → uses the configured host + optional API key; {@code /webhook} - * or {@code /webhook-test} is appended based on {@code use-test-webhook} + *
  • {@code n8n.base-url} set → uses the configured host with webhook auth from {@code + * n8n.webhook-auth}; {@code /webhook} or {@code /webhook-test} is appended based on {@code + * use-test-webhook} *
  • {@code n8n.base-url} missing + {@code development} profile → warns and falls back to * {@code http://localhost:5678} *
  • {@code n8n.base-url} missing + non-dev profile → throws at startup @@ -258,10 +369,11 @@ public ConsoleN8NWebhookService consoleN8nWebhookService() { public N8nWebhookService n8nWebhookService( N8nProperties props, RestClient n8nRestClient, Environment environment) { + Map webhookAuthHeaders = resolveWebhookAuthHeaders(props.getWebhookAuth()); + String baseUrl = props.getBaseUrl(); if (baseUrl != null && !baseUrl.isBlank()) { - return new N8nWebhookService( - props.resolvedBaseUrl(), props.getApiKey(), Collections.emptyMap(), n8nRestClient); + return new N8nWebhookService(props.resolvedBaseUrl(), webhookAuthHeaders, n8nRestClient); } // base-url is missing — behaviour depends on active profile if (environment.matchesProfiles("development")) { @@ -270,8 +382,7 @@ public N8nWebhookService n8nWebhookService( N8nProperties devProps = new N8nProperties(); devProps.setBaseUrl("http://localhost:5678"); devProps.setUseTestWebhook(props.isUseTestWebhook()); - return new N8nWebhookService( - devProps.resolvedBaseUrl(), props.getApiKey(), Collections.emptyMap(), n8nRestClient); + return new N8nWebhookService(devProps.resolvedBaseUrl(), webhookAuthHeaders, n8nRestClient); } // non-dev profile: fail fast at startup so misconfiguration is caught immediately throw new IllegalStateException( diff --git a/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/services/ConsoleN8NWebhookService.java b/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/services/ConsoleN8NWebhookService.java index c334882..0dc5063 100644 --- a/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/services/ConsoleN8NWebhookService.java +++ b/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/services/ConsoleN8NWebhookService.java @@ -22,7 +22,7 @@ public class ConsoleN8NWebhookService extends N8nWebhookService { private final AtomicInteger counter = new AtomicInteger(0); public ConsoleN8NWebhookService() { - super("http://console", "", Collections.emptyMap(), null); + super("http://console", Collections.emptyMap(), null); } /** Instead of making an HTTP call, logs the path (and payload at DEBUG level). */ diff --git a/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/services/N8nWebhookService.java b/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/services/N8nWebhookService.java index 5beecb3..7b4fae3 100644 --- a/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/services/N8nWebhookService.java +++ b/cds-feature-n8n/src/main/java/com/sap/cds/feature/n8n/services/N8nWebhookService.java @@ -16,8 +16,8 @@ * HTTP layer for calling n8n webhooks. * *

    Sends a {@code POST} to {@code baseUrl/path} with the payload as JSON. Auth headers are - * layered in order: {@code authHeaders} first (e.g. {@code Authorization: Bearer …} from a BTP - * destination), then {@code X-N8N-API-KEY}. Constructed by {@link + * injected from {@code authHeaders} (e.g. {@code Authorization: Bearer …} from a BTP destination, + * or resolved from the {@code n8n.webhook-auth} configuration). Constructed by {@link * com.sap.cds.feature.n8n.configuration.N8nAutoConfiguration} with pre-configured timeouts. */ public class N8nWebhookService { @@ -25,21 +25,17 @@ public class N8nWebhookService { private static final Logger log = LoggerFactory.getLogger(N8nWebhookService.class); private final String baseUrl; - private final String apiKey; private final Map authHeaders; private final RestClient restClient; /** * @param baseUrl n8n webhook base URL; trailing slash is stripped automatically - * @param apiKey sent as {@code X-N8N-API-KEY}; may be empty - * @param authHeaders proxy auth headers (e.g. {@code Authorization: Bearer …} from a BTP - * destination); may be empty + * @param authHeaders auth headers for the webhook node (e.g. {@code Authorization: Bearer …} from + * a BTP destination or from {@code n8n.webhook-auth} config); may be empty * @param restClient pre-configured {@link RestClient} with connect/read timeouts */ - public N8nWebhookService( - String baseUrl, String apiKey, Map authHeaders, RestClient restClient) { + public N8nWebhookService(String baseUrl, Map authHeaders, RestClient restClient) { this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - this.apiKey = apiKey != null ? apiKey : ""; this.authHeaders = authHeaders != null ? authHeaders : Collections.emptyMap(); this.restClient = restClient; } @@ -65,11 +61,7 @@ public void notify(String path, Map payload, HttpMethod httpMeth restClient .method(httpMethod) .uri(useQueryParams ? buildUriWithParams(path, payload) : baseUrl + "/" + path) - .headers( - header -> { - authHeaders.forEach(header::set); - if (!apiKey.isEmpty()) header.set("X-N8N-API-KEY", apiKey); - }); + .headers(header -> authHeaders.forEach(header::set)); if (useQueryParams) { spec.retrieve().toBodilessEntity(); } else { diff --git a/cds-feature-n8n/src/test/java/com/sap/cds/feature/n8n/configuration/N8nAutoConfigurationTest.java b/cds-feature-n8n/src/test/java/com/sap/cds/feature/n8n/configuration/N8nAutoConfigurationTest.java index 44d807d..c93f6e5 100644 --- a/cds-feature-n8n/src/test/java/com/sap/cds/feature/n8n/configuration/N8nAutoConfigurationTest.java +++ b/cds-feature-n8n/src/test/java/com/sap/cds/feature/n8n/configuration/N8nAutoConfigurationTest.java @@ -12,6 +12,7 @@ import com.sap.cds.feature.n8n.configuration.N8nAutoConfiguration.DestinationConfiguration; import com.sap.cds.feature.n8n.configuration.N8nAutoConfiguration.N8nProperties; +import com.sap.cds.feature.n8n.configuration.N8nAutoConfiguration.N8nProperties.WebhookAuth; import com.sap.cds.feature.n8n.handlers.N8nHandler; import com.sap.cds.feature.n8n.handlers.N8nServiceHandler; import com.sap.cds.feature.n8n.services.ConsoleN8NWebhookService; @@ -23,6 +24,8 @@ import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; import java.lang.reflect.Field; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -158,6 +161,104 @@ void resolvedBaseUrl_trailingSlashStripped() { assertThat(props.resolvedBaseUrl()).isEqualTo("http://n8n.example.com/webhook"); } + // --- webhookAuth header resolution --- + + @Test + void webhookAuth_null_returnsEmptyMap() { + assertThat(N8nAutoConfiguration.resolveWebhookAuthHeaders(null)).isEmpty(); + } + + @Test + void webhookAuth_noType_returnsEmptyMap() { + assertThat(N8nAutoConfiguration.resolveWebhookAuthHeaders(new WebhookAuth())).isEmpty(); + } + + @Test + void webhookAuth_basic_encodesCredentials() { + WebhookAuth auth = new WebhookAuth(); + auth.setType("basic"); + auth.setUsername("user"); + auth.setPassword("pass"); + String expected = + Base64.getEncoder().encodeToString("user:pass".getBytes(StandardCharsets.UTF_8)); + assertThat(N8nAutoConfiguration.resolveWebhookAuthHeaders(auth)) + .containsEntry("Authorization", "Basic " + expected); + } + + @Test + void webhookAuth_basic_missingPassword_throws() { + WebhookAuth auth = new WebhookAuth(); + auth.setType("basic"); + auth.setUsername("user"); + assertThatThrownBy(() -> N8nAutoConfiguration.resolveWebhookAuthHeaders(auth)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("basic"); + } + + @Test + void webhookAuth_header_setsCustomHeader() { + WebhookAuth auth = new WebhookAuth(); + auth.setType("header"); + auth.setName("X-Webhook-Secret"); + auth.setValue("my-secret"); + assertThat(N8nAutoConfiguration.resolveWebhookAuthHeaders(auth)) + .containsEntry("X-Webhook-Secret", "my-secret"); + } + + @Test + void webhookAuth_header_missingValue_throws() { + WebhookAuth auth = new WebhookAuth(); + auth.setType("header"); + auth.setName("X-My-Header"); + assertThatThrownBy(() -> N8nAutoConfiguration.resolveWebhookAuthHeaders(auth)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("header"); + } + + @Test + void webhookAuth_bearer_setsBearerToken() { + WebhookAuth auth = new WebhookAuth(); + auth.setType("bearer"); + auth.setToken("my-token"); + assertThat(N8nAutoConfiguration.resolveWebhookAuthHeaders(auth)) + .containsEntry("Authorization", "Bearer my-token"); + } + + @Test + void webhookAuth_bearer_missingToken_throws() { + WebhookAuth auth = new WebhookAuth(); + auth.setType("bearer"); + assertThatThrownBy(() -> N8nAutoConfiguration.resolveWebhookAuthHeaders(auth)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("bearer"); + } + + @Test + void webhookAuth_unsupportedType_throws() { + WebhookAuth auth = new WebhookAuth(); + auth.setType("digest"); + assertThatThrownBy(() -> N8nAutoConfiguration.resolveWebhookAuthHeaders(auth)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("digest"); + } + + @Test + void webhookAuth_basic_appearsInWebhookServiceAuthHeaders() throws Exception { + WebhookAuth auth = new WebhookAuth(); + auth.setType("basic"); + auth.setUsername("user"); + auth.setPassword("pass"); + N8nProperties props = propsWithBaseUrl("http://n8n.example.com"); + props.setWebhookAuth(auth); + + N8nWebhookService bean = config.n8nWebhookService(props, mock(RestClient.class), mockEnv()); + + String expected = + Base64.getEncoder().encodeToString("user:pass".getBytes(StandardCharsets.UTF_8)); + assertThat((Map) field(bean, "authHeaders")) + .containsEntry("Authorization", "Basic " + expected); + } + // --- BTP destination --- @Test @@ -179,14 +280,14 @@ void destination_set_resolves_baseUrlAndAuthHeadersFromDestination() throws Exce propsWithDestination("my-dest"), mock(RestClient.class)); assertThat((String) field(bean, "baseUrl")).isEqualTo("https://n8n.example.com/webhook"); - assertThat((String) field(bean, "apiKey")).isEmpty(); assertThat((Map) field(bean, "authHeaders")) .containsEntry("Authorization", "Bearer test-token"); } } @Test - void destination_set_apiKeyOverride_takesPreference() throws Exception { + void destination_xN8nApiKeyHeader_isNotForwardedToWebhooks() throws Exception { + // X-N8N-API-KEY from the destination is a REST API credential — must not reach webhook nodes HttpDestination mockDest = mock(HttpDestination.class); Destination mockDestWrapper = mock(Destination.class); when(mockDest.getUri()).thenReturn(URI.create("https://n8n.example.com")); @@ -202,14 +303,10 @@ void destination_set_apiKeyOverride_takesPreference() throws Exception { .when(() -> DestinationAccessor.getDestination("my-dest")) .thenReturn(mockDestWrapper); - N8nProperties props = propsWithDestination("my-dest"); - props.setApiKey("explicit-override"); - N8nWebhookService bean = - destConfig.n8nWebhookServiceFromDestination(props, mock(RestClient.class)); + destConfig.n8nWebhookServiceFromDestination( + propsWithDestination("my-dest"), mock(RestClient.class)); - assertThat((String) field(bean, "apiKey")).isEqualTo("explicit-override"); - // X-N8N-API-KEY from the destination must not leak into authHeaders assertThat((Map) field(bean, "authHeaders")) .doesNotContainKey("X-N8N-API-KEY") .containsEntry("Authorization", "Bearer test-token"); diff --git a/cds-feature-n8n/src/test/java/com/sap/cds/feature/n8n/services/N8nWebhookServiceRetryIT.java b/cds-feature-n8n/src/test/java/com/sap/cds/feature/n8n/services/N8nWebhookServiceRetryIT.java index 07690f3..5c280cc 100644 --- a/cds-feature-n8n/src/test/java/com/sap/cds/feature/n8n/services/N8nWebhookServiceRetryIT.java +++ b/cds-feature-n8n/src/test/java/com/sap/cds/feature/n8n/services/N8nWebhookServiceRetryIT.java @@ -50,10 +50,7 @@ N8nWebhookService n8nWebhookService() { factory.setReadTimeout(500); RestClient restClient = RestClient.builder().requestFactory(factory).build(); return new N8nWebhookService( - "http://localhost:" + wireMock.port(), - "test-api-key", - java.util.Collections.emptyMap(), - restClient); + "http://localhost:" + wireMock.port(), java.util.Collections.emptyMap(), restClient); } } diff --git a/docs/adr-webhook-authentication.md b/docs/adr-webhook-authentication.md index 647cc09..e4a3918 100644 --- a/docs/adr-webhook-authentication.md +++ b/docs/adr-webhook-authentication.md @@ -5,7 +5,7 @@ | . | . | |--------------|----------------------| | Date | 2026-08-13 | -| Version | V0.1 | +| Version | V0.2 | | Status | Draft | | Acceptance | Accepted | | Contributors | Lisa Nebel | @@ -13,13 +13,16 @@ **Version History** -| Version | Date | Changes | -|---------|------------|-----------------| -| V0.1 | 2026-08-13 | Initial version | +| Version | Date | Changes | +|---------|------------|---------------------------------------------------------------------------------| +| V0.1 | 2026-08-13 | Initial version | +| V0.2 | 2026-09-24 | Revised decision: replace fixed `X-N8N-API-KEY` with configurable webhook auth | ## Summary -The cds-feature-n8n plugin needs to authenticate outbound webhook calls from CAP to n8n. Two approaches were considered: a single shared header (`X-N8N-API-KEY`) sent on all requests, or configurable per-trigger headers. The single shared header approach was chosen for simplicity, with the architecture intentionally designed to allow per-trigger headers to be added later without breaking changes. +The cds-feature-n8n plugin needs to authenticate outbound webhook calls from CAP to n8n. Two approaches were considered: a single shared header (`X-N8N-API-KEY`) sent on all requests, or configurable per-trigger headers. The single shared header approach was chosen initially (V0.1) for simplicity. + +**V0.2 revision:** `X-N8N-API-KEY` is an n8n REST API credential — forwarding it to webhook nodes is semantically incorrect and unnecessarily couples webhook auth to the REST API key. Webhook authentication is now opt-in and configurable via `n8n.webhook-auth.*` properties, matching the three auth types that n8n's Webhook node supports natively. ## Context @@ -50,6 +53,7 @@ One API key is configured globally. The plugin sends `X-N8N-API-KEY: ` on e - ❌ All webhooks share the same secret — no per-webhook isolation - ❌ Header name is hardcoded — if a workflow author uses a different header name on their Webhook node, the plugin cannot reach it without code changes - ❌ Does not naturally extend to the BTP proxy case without additional `authHeaders` layering +- ❌ `X-N8N-API-KEY` is a REST API credential — sending it to webhook nodes is semantically wrong **Option 2: Configurable per-trigger headers** @@ -68,12 +72,47 @@ The `@n8n.process.start` annotation accepts an optional `headers` map per trigge - ❌ More complex annotation syntax and handler implementation - ❌ Secrets in CDS annotations are not ideal — would need env var interpolation support -## Decision +**Option 3: Configurable global webhook auth (chosen in V0.2)** + +Webhook authentication is configured once via `n8n.webhook-auth.*` properties, independent of `n8n.api-key`. Three types are supported, matching n8n's Webhook node auth options: + +```yaml +n8n: + webhook-auth: + type: header # basic | header | bearer + name: X-My-Header # for type=header + value: ${N8N_WEBHOOK_TOKEN} +``` + +- ✅ Decouples webhook auth from the REST API key +- ✅ Supports all three auth types the n8n Webhook node offers (Basic Auth, Header Auth, Bearer) +- ✅ No secrets in CDS annotations — credentials stay in application config / env vars +- ✅ Aligns with the Node.js plugin pattern +- ✅ Naturally extends to BTP: destination headers are forwarded first, `webhook-auth` merged on top +- ❌ Single global config — all webhook triggers share the same auth (per-trigger auth remains a future extension) + +## Decision (V0.2 — Current) + +Option 3. `X-N8N-API-KEY` is no longer forwarded to webhook nodes. Webhook authentication is opt-in and configured via `n8n.webhook-auth.*`: + +| `type` | Required fields | Header sent | +|----------|------------------------|------------------------------------------------| +| `basic` | `username`, `password` | `Authorization: Basic base64(username:password)` | +| `header` | `name`, `value` | `: ` | +| `bearer` | `token` | `Authorization: Bearer ` | + +When `n8n.webhook-auth` is not set, webhook calls are sent without any authentication header — suitable for n8n instances where the Webhook node has no auth configured. + +`n8n.api-key` is retained in the configuration model for future REST API calls (`/api/v1/…`) but is no longer forwarded to webhook nodes. + +For the BTP destination path, destination headers are forwarded as before (excluding `X-N8N-API-KEY`), with `n8n.webhook-auth` merged on top (overriding any destination header of the same name). + +## Decision (V0.1 — Superseded) -Option 1 (single shared header) was chosen as the default behaviour for now, since n8n's Webhook node does not mandate a header name, workflow authors pick their own. **Aligning on `X-N8N-API-KEY` keeps the setup story simple: one API key, one header, everywhere.** +Option 1 (single shared header) was chosen as the default behaviour, since n8n's Webhook node does not mandate a header name, workflow authors pick their own. Aligning on `X-N8N-API-KEY` keeps the setup story simple: one API key, one header, everywhere. -This matches the Node plugin at https://github.com/cap-js/n8n/. -For the BTP proxy case, the destination-based configuration already handles the two-layer auth problem: `authHeaders` from the BTP destination (e.g. `Authorization: Bearer ...`) are merged onto the request first, then `X-N8N-API-KEY` is added on top. +This matched the Node.js plugin at https://github.com/cap-js/n8n/. +For the BTP proxy case, the destination-based configuration already handled the two-layer auth problem: `authHeaders` from the BTP destination (e.g. `Authorization: Bearer ...`) were merged onto the request first, then `X-N8N-API-KEY` was added on top. ## Future Extension @@ -81,7 +120,7 @@ Per-trigger configurable headers (Option 2) are explicitly kept as a future exte ## Related -- `N8nWebhookService.java` — already accepts `Map authHeaders` layered before `X-N8N-API-KEY` -- `N8nAutoConfiguration.java` — destination resolution populates `authHeaders` from BTP destination headers +- `N8nWebhookService.java` — accepts `Map authHeaders`; no longer adds `X-N8N-API-KEY` +- `N8nAutoConfiguration.java` — `resolveWebhookAuthHeaders()` converts `n8n.webhook-auth` config to headers; destination resolution populates `authHeaders` from BTP destination headers (excluding `X-N8N-API-KEY`) - Node.js plugin `lib/api/connection.js` — `buildWebhookHeaders()` / `buildApiHeaders()` / `authHeaders` from destination - n8n REST API docs — `X-N8N-API-KEY` is the required header for `/api/v1/…` endpoints diff --git a/integration-tests/srv/src/main/resources/application.yaml b/integration-tests/srv/src/main/resources/application.yaml index d84c4f5..c55a2aa 100644 --- a/integration-tests/srv/src/main/resources/application.yaml +++ b/integration-tests/srv/src/main/resources/application.yaml @@ -20,4 +20,3 @@ cds: ordered: true n8n: base-url: http://localhost:${wiremock.port} - api-key: test-key diff --git a/integration-tests/srv/src/test/java/integrationtest/N8nAssociationInputIntegrationTest.java b/integration-tests/srv/src/test/java/integrationtest/N8nAssociationInputIntegrationTest.java index ff1a34a..8d9a2f5 100644 --- a/integration-tests/srv/src/test/java/integrationtest/N8nAssociationInputIntegrationTest.java +++ b/integration-tests/srv/src/test/java/integrationtest/N8nAssociationInputIntegrationTest.java @@ -54,7 +54,6 @@ static void stopWireMock() { @DynamicPropertySource static void n8nBaseUrl(DynamicPropertyRegistry registry) { registry.add("n8n.base-url", () -> "http://localhost:" + wireMock.port()); - registry.add("n8n.api-key", () -> "test-key"); } @BeforeEach diff --git a/integration-tests/srv/src/test/java/integrationtest/N8nIfConditionIntegrationTest.java b/integration-tests/srv/src/test/java/integrationtest/N8nIfConditionIntegrationTest.java index a6339a6..4f6a767 100644 --- a/integration-tests/srv/src/test/java/integrationtest/N8nIfConditionIntegrationTest.java +++ b/integration-tests/srv/src/test/java/integrationtest/N8nIfConditionIntegrationTest.java @@ -4,7 +4,6 @@ package integrationtest; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; @@ -53,7 +52,6 @@ static void stopWireMock() { @DynamicPropertySource static void n8nBaseUrl(DynamicPropertyRegistry registry) { registry.add("n8n.base-url", () -> "http://localhost:" + wireMock.port()); - registry.add("n8n.api-key", () -> "test-key"); } @BeforeEach @@ -92,7 +90,6 @@ void createItem_ifConditionMet_firesConditionalWebhook() { wireMock.verify( 1, postRequestedFor(urlEqualTo("/webhook/item-shipped")) - .withHeader("X-N8N-API-KEY", equalTo("test-key")) .withRequestBody( equalToJson( "{\"ID\":\"" + id + "\",\"status\":\"shipped\"}", true, false)))); @@ -247,7 +244,6 @@ void deleteItem_ifConditionMet_firesConditionalWebhook() { wireMock.verify( 1, postRequestedFor(urlEqualTo("/webhook/item-active-deleted")) - .withHeader("X-N8N-API-KEY", equalTo("test-key")) .withRequestBody( equalToJson( "{\"ID\":\"" + id + "\",\"status\":\"active\"}", true, false)))); diff --git a/integration-tests/srv/src/test/java/integrationtest/N8nIntegrationTest.java b/integration-tests/srv/src/test/java/integrationtest/N8nIntegrationTest.java index 94798ef..6c0f463 100644 --- a/integration-tests/srv/src/test/java/integrationtest/N8nIntegrationTest.java +++ b/integration-tests/srv/src/test/java/integrationtest/N8nIntegrationTest.java @@ -4,7 +4,6 @@ package integrationtest; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; @@ -54,7 +53,6 @@ static void stopWireMock() { @DynamicPropertySource static void n8nBaseUrl(DynamicPropertyRegistry registry) { registry.add("n8n.base-url", () -> "http://localhost:" + wireMock.port()); - registry.add("n8n.api-key", () -> "test-key"); } @BeforeEach @@ -88,7 +86,6 @@ void createItem_triggersN8nWebhook_withCorrectPayload() { wireMock.verify( 1, postRequestedFor(urlEqualTo("/webhook/item-created")) - .withHeader("X-N8N-API-KEY", equalTo("test-key")) .withRequestBody( equalToJson( "{\"ID\":\"" + id + "\",\"title\":\"Test Item\"}", true, false)))); @@ -118,7 +115,6 @@ void deleteItem_triggersN8nWebhook_withCorrectPayload() { wireMock.verify( 1, postRequestedFor(urlEqualTo("/webhook/item-deleted")) - .withHeader("X-N8N-API-KEY", equalTo("test-key")) .withRequestBody( equalToJson( "{\"ID\":\"" @@ -172,7 +168,6 @@ void createOrder_noInputsAnnotation_sendsAllScalarFields() { wireMock.verify( 1, postRequestedFor(urlEqualTo("/webhook/order-created")) - .withHeader("X-N8N-API-KEY", equalTo("test-key")) .withRequestBody( equalToJson("{\"ID\":\"" + id + "\",\"total\":42}", true, false)))); } From 740fed78a1bd29860025c0941c737beda33be953 Mon Sep 17 00:00:00 2001 From: Hristina Ivanova Date: Thu, 24 Sep 2026 17:57:20 +0200 Subject: [PATCH 3/3] Add CHANGELOG entry for configurable webhook auth --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 397cdbc..d855d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ - The format is based on [Keep a Changelog](https://keepachangelog.com/). - This project adheres to [Semantic Versioning](https://semver.org/). +## Version 0.0.2 + +### Changed + +- Webhook authentication is now configurable via `n8n.webhook-auth.*` — supports `basic` (username + password), `header` (custom header name and value), and `bearer` (token) auth types, matching the three auth options of the n8n Webhook node +- `X-N8N-API-KEY` is no longer forwarded to webhook nodes; it is reserved for future n8n REST API calls (`/api/v1/…`) +- BTP destination path: `X-N8N-API-KEY` is filtered from destination headers before forwarding to webhook nodes; `n8n.webhook-auth` is merged on top + ## Version 0.0.1 ### Added