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 |