Files
cameleer-saas/docs/superpowers/plans/2026-04-29-logto-webhook-auth-events.md
hsiegeln 5c9db5addf
All checks were successful
CI / build (push) Successful in 3m0s
CI / docker (push) Successful in 2m1s
docs: update CLAUDE.md with SOC 2 audit logging, webhook integration, and V006 migration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 15:58:45 +02:00

14 KiB

Logto Webhook Auth Event Logging — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Capture Logto authentication events (sign-in, registration, password reset) via webhooks and write them to the audit_log table.

Architecture: Register a Logto webhook at startup that posts auth interaction events to an internal SaaS endpoint. The endpoint validates the HMAC-SHA256 signature, extracts user/IP/event data from the payload, and writes audit_log entries via AuditService. The webhook secret is configured via CAMELEER_SAAS_IDENTITY_WEBHOOKSECRET.

Tech Stack: Java 21, Spring Boot 3, Logto Management API webhooks, HMAC-SHA256


File Map

File Action Responsibility
src/main/resources/application.yml Modify Add webhooksecret property
src/main/java/io/cameleer/saas/identity/LogtoConfig.java Modify Expose webhookSecret getter
src/main/java/io/cameleer/saas/identity/LogtoManagementClient.java Modify Add listWebhooks, createWebhook methods
src/main/java/io/cameleer/saas/config/SecurityConfig.java Modify Permit /api/internal/**
src/main/java/io/cameleer/saas/webhook/LogtoWebhookController.java Create Receive webhook, validate HMAC, dispatch to AuditService
src/main/java/io/cameleer/saas/config/LogtoStartupConfig.java Modify Auto-register webhook at startup

Task 1: Add Webhook Secret Configuration

Files:

  • Modify: src/main/resources/application.yml

  • Modify: src/main/java/io/cameleer/saas/identity/LogtoConfig.java

  • Step 1: Add webhooksecret to application.yml

In the cameleer.saas.identity section, after serverendpoint, add:

      webhooksecret: ${CAMELEER_SAAS_IDENTITY_WEBHOOKSECRET:}
  • Step 2: Add webhookSecret field to LogtoConfig

Add a new @Value field and getter to LogtoConfig.java:

@Value("${cameleer.saas.identity.webhooksecret:}")
private String webhookSecret;

Add getter:

public String getWebhookSecret() { return webhookSecret; }
  • Step 3: Build and commit

Run: ./mvnw compile -q

git add src/main/resources/application.yml \
        src/main/java/io/cameleer/saas/identity/LogtoConfig.java
git commit -m "feat(webhook): add webhooksecret identity config property"

Task 2: Add Webhook Methods to LogtoManagementClient

Files:

  • Modify: src/main/java/io/cameleer/saas/identity/LogtoManagementClient.java

  • Step 1: Add listWebhooks method

Add to LogtoManagementClient (follow existing pattern: isAvailable() guard, restClient call, getAccessToken()):

@SuppressWarnings("unchecked")
public List<Map<String, Object>> listWebhooks() {
    if (!isAvailable()) return List.of();

    var response = restClient.get()
            .uri(config.getLogtoEndpoint() + "/api/hooks")
            .header("Authorization", "Bearer " + getAccessToken())
            .retrieve()
            .body(List.class);
    return response != null ? response : List.of();
}
  • Step 2: Add createWebhook method
@SuppressWarnings("unchecked")
public Map<String, Object> createWebhook(String name, List<String> events, String url, String signingKey) {
    if (!isAvailable()) return null;

    var body = Map.of(
            "name", name,
            "events", events,
            "config", Map.of("url", url),
            "signingKey", signingKey
    );

    return restClient.post()
            .uri(config.getLogtoEndpoint() + "/api/hooks")
            .header("Authorization", "Bearer " + getAccessToken())
            .contentType(MediaType.APPLICATION_JSON)
            .body(body)
            .retrieve()
            .body(Map.class);
}
  • Step 3: Build and commit

Run: ./mvnw compile -q

git add src/main/java/io/cameleer/saas/identity/LogtoManagementClient.java
git commit -m "feat(webhook): add listWebhooks and createWebhook to LogtoManagementClient"

Task 3: Permit Internal API Path in SecurityConfig

Files:

  • Modify: src/main/java/io/cameleer/saas/config/SecurityConfig.java

  • Step 1: Add /api/internal/ to permit list**

In the authorizeHttpRequests block, after the /api/password-reset-notification line, add:

.requestMatchers("/api/internal/**").permitAll()
  • Step 2: Build and commit

Run: ./mvnw compile -q

git add src/main/java/io/cameleer/saas/config/SecurityConfig.java
git commit -m "feat(webhook): permit /api/internal/** in SecurityConfig for webhook receivers"

Task 4: Create LogtoWebhookController

Files:

  • Create: src/main/java/io/cameleer/saas/webhook/LogtoWebhookController.java

  • Step 1: Create the webhook receiver controller

package io.cameleer.saas.webhook;

import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.identity.LogtoConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/api/internal/webhooks")
public class LogtoWebhookController {

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

    private final AuditService auditService;
    private final LogtoConfig logtoConfig;

    public LogtoWebhookController(AuditService auditService, LogtoConfig logtoConfig) {
        this.auditService = auditService;
        this.logtoConfig = logtoConfig;
    }

    @PostMapping("/logto")
    public ResponseEntity<Void> handleWebhook(
            @RequestBody String rawBody,
            @RequestHeader(value = "logto-signature-sha-256", required = false) String signature) {

        String secret = logtoConfig.getWebhookSecret();
        if (secret == null || secret.isBlank()) {
            log.warn("Webhook secret not configured — rejecting request");
            return ResponseEntity.status(403).build();
        }

        if (!verifySignature(rawBody, signature, secret)) {
            log.warn("Invalid webhook signature — rejecting request");
            return ResponseEntity.status(401).build();
        }

        try {
            var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
            var payload = mapper.readTree(rawBody);

            String event = payload.path("event").asText("");
            var userNode = payload.path("user");
            String userId = userNode.path("id").asText(null);
            String email = userNode.path("primaryEmail").asText(null);
            String userIp = payload.path("userIp").asText(null);
            String userAgent = payload.path("userAgent").asText(null);
            String appName = payload.path("application").path("name").asText(null);

            UUID actorId = resolveUUID(userId);

            switch (event) {
                case "PostSignIn" -> {
                    log.info("Auth event: sign-in by user {}", userId);
                    auditService.log(actorId, email, null,
                            AuditAction.AUTH_LOGIN, userId,
                            null, userIp, "SUCCESS",
                            buildMetadata(userAgent, appName, null));
                }
                case "PostRegister" -> {
                    log.info("Auth event: registration by user {}", userId);
                    auditService.log(actorId, email, null,
                            AuditAction.AUTH_REGISTER, userId,
                            null, userIp, "SUCCESS",
                            buildMetadata(userAgent, appName, null));
                }
                case "PostResetPassword" -> {
                    log.info("Auth event: password reset by user {}", userId);
                    auditService.log(actorId, email, null,
                            AuditAction.AUTH_LOGIN, userId,
                            null, userIp, "SUCCESS",
                            buildMetadata(userAgent, appName, "password_reset"));
                }
                default -> log.debug("Ignoring unhandled Logto webhook event: {}", event);
            }

            return ResponseEntity.ok().build();
        } catch (Exception e) {
            log.error("Failed to process Logto webhook: {}", e.getMessage());
            return ResponseEntity.ok().build();
        }
    }

    private boolean verifySignature(String payload, String signatureHeader, String secret) {
        if (signatureHeader == null || signatureHeader.isBlank()) return false;
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
            String expected = HexFormat.of().formatHex(hash);
            return expected.equalsIgnoreCase(signatureHeader);
        } catch (Exception e) {
            log.error("HMAC verification failed: {}", e.getMessage());
            return false;
        }
    }

    private UUID resolveUUID(String id) {
        if (id == null) return null;
        try {
            return UUID.fromString(id);
        } catch (Exception e) {
            return UUID.nameUUIDFromBytes(id.getBytes(StandardCharsets.UTF_8));
        }
    }

    private Map<String, Object> buildMetadata(String userAgent, String appName, String via) {
        var meta = new java.util.LinkedHashMap<String, Object>();
        if (userAgent != null) meta.put("userAgent", userAgent);
        if (appName != null) meta.put("application", appName);
        if (via != null) meta.put("via", via);
        return meta.isEmpty() ? null : meta;
    }
}
  • Step 2: Build and commit

Run: ./mvnw compile -q

git add src/main/java/io/cameleer/saas/webhook/LogtoWebhookController.java
git commit -m "feat(webhook): add LogtoWebhookController for auth event audit logging"

Task 5: Auto-Register Webhook at Startup

Files:

  • Modify: src/main/java/io/cameleer/saas/config/LogtoStartupConfig.java

  • Step 1: Inject LogtoConfig and register webhook on startup

Add LogtoConfig to the constructor. After the existing MFA factor setup in onStartup(), add webhook registration:

package io.cameleer.saas.config;

import io.cameleer.saas.identity.LogtoConfig;
import io.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

@Component
public class LogtoStartupConfig {

    private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class);
    private static final String WEBHOOK_NAME = "cameleer-saas-auth-events";

    private final LogtoManagementClient logtoClient;
    private final LogtoConfig logtoConfig;

    public LogtoStartupConfig(LogtoManagementClient logtoClient, LogtoConfig logtoConfig) {
        this.logtoClient = logtoClient;
        this.logtoConfig = logtoConfig;
    }

    @EventListener(ApplicationReadyEvent.class)
    public void onStartup() {
        try {
            List<String> factors = List.of("Totp", "WebAuthn", "BackupCode");
            logtoClient.updateSignInExperience(Map.of(
                    "mfa", Map.of("factors", factors, "policy", "UserControlled")));
            log.info("Logto MFA factors set to {} (UserControlled)", factors);
        } catch (Exception e) {
            log.warn("Failed to sync MFA factors on startup: {}", e.getMessage());
        }

        registerWebhook();
    }

    private void registerWebhook() {
        String secret = logtoConfig.getWebhookSecret();
        if (secret == null || secret.isBlank()) {
            log.info("Webhook secret not configured — skipping auth event webhook registration");
            return;
        }

        try {
            var existing = logtoClient.listWebhooks();
            boolean alreadyRegistered = existing.stream()
                    .anyMatch(h -> WEBHOOK_NAME.equals(h.get("name")));

            if (alreadyRegistered) {
                log.info("Logto webhook '{}' already registered", WEBHOOK_NAME);
                return;
            }

            String url = logtoConfig.getLogtoEndpoint().replace("cameleer-logto:3001", "cameleer-saas:8080")
                    .replaceFirst("/api$", "")
                    .replaceFirst(":\\d+.*$", "");
            url = "http://cameleer-saas:8080/platform/api/internal/webhooks/logto";

            var events = List.of("PostSignIn", "PostRegister", "PostResetPassword");
            logtoClient.createWebhook(WEBHOOK_NAME, events, url, secret);
            log.info("Registered Logto webhook '{}' for events {}", WEBHOOK_NAME, events);
        } catch (Exception e) {
            log.warn("Failed to register Logto webhook: {}", e.getMessage());
        }
    }
}
  • Step 2: Build and commit

Run: ./mvnw compile -q

git add src/main/java/io/cameleer/saas/config/LogtoStartupConfig.java
git commit -m "feat(webhook): auto-register Logto auth event webhook at startup"

Coverage Summary

Spec Requirement Task
Config property cameleer.saas.identity.webhooksecret Task 1
LogtoManagementClient webhook methods Task 2
SecurityConfig permit internal path Task 3
Webhook receiver with HMAC validation Task 4
PostSignIn → AUTH_LOGIN audit entry Task 4
PostRegister → AUTH_REGISTER audit entry Task 4
PostResetPassword → AUTH_LOGIN (via: password_reset) audit entry Task 4
Idempotent startup registration Task 5
Skip registration if secret not set Task 5