Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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}.
Expand All @@ -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.*}.
*
* <p>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.
*
* <ul>
* <li>{@code basic} → {@code Authorization: Basic base64(username:password)}
* <li>{@code header} → {@code name: value}
* <li>{@code bearer} → {@code Authorization: Bearer token}
* <li>{@code null} / no type → empty map (no authentication)
* </ul>
*/
static Map<String, String> resolveWebhookAuthHeaders(N8nProperties.WebhookAuth auth) {
if (auth == null || auth.getType() == null) return Collections.emptyMap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Blank webhook auth values are accepted

The resolver only treats null as missing, so common optional placeholders like ${N8N_WEBHOOK_TOKEN:} bind to empty strings and produce invalid auth headers instead of failing at startup. Consider normalizing type and rejecting blank required fields (username, password, name, value, token) with isBlank() before constructing headers.

Suggested change
if (auth == null || auth.getType() == null) return Collections.emptyMap();
if (auth == null || auth.getType() == null || auth.getType().isBlank()) return Collections.emptyMap();

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

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());
};
}

/**
Expand All @@ -148,9 +263,10 @@ public static class DestinationConfiguration {
* <ul>
* <li>The destination URI plus {@code /webhook} (or {@code /webhook-test}) becomes the base
* URL.
* <li>All destination headers except {@code X-N8N-API-KEY} are forwarded as {@code
* authHeaders}.
* <li>{@code n8n.api-key} overrides any {@code X-N8N-API-KEY} header from the destination.
* <li>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).
* <li>{@code n8n.webhook-auth} config headers are merged on top (override destination headers
* for the same header name).
* </ul>
*/
@Bean
Expand Down Expand Up @@ -178,23 +294,17 @@ public N8nWebhookService n8nWebhookServiceFromDestination(
String baseUrl = rawUrl + (props.isUseTestWebhook() ? "/webhook-test" : "/webhook");

Map<String, String> 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);
}
}

Expand Down Expand Up @@ -245,8 +355,9 @@ public ConsoleN8NWebhookService consoleN8nWebhookService() {
* destination-based bean was already registered by {@link DestinationConfiguration}.
*
* <ul>
* <li>{@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}
* <li>{@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}
* <li>{@code n8n.base-url} missing + {@code development} profile → warns and falls back to
* {@code http://localhost:5678}
* <li>{@code n8n.base-url} missing + non-dev profile → throws at startup
Expand All @@ -258,10 +369,11 @@ public ConsoleN8NWebhookService consoleN8nWebhookService() {
public N8nWebhookService n8nWebhookService(
N8nProperties props, RestClient n8nRestClient, Environment environment) {

Map<String, String> 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")) {
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,26 @@
* HTTP layer for calling n8n webhooks.
*
* <p>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 {

private static final Logger log = LoggerFactory.getLogger(N8nWebhookService.class);

private final String baseUrl;
private final String apiKey;
private final Map<String, String> 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<String, String> authHeaders, RestClient restClient) {
public N8nWebhookService(String baseUrl, Map<String, String> 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;
}
Expand All @@ -65,11 +61,7 @@ public void notify(String path, Map<String, Object> 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 {
Expand Down
Loading
Loading