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 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 { * */ @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}. * *