secretFor(CustomerId customerId);
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java
new file mode 100644
index 0000000..d3c7f4e
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java
@@ -0,0 +1,10 @@
+package dev.amg.payguard.identity.verification.application.port;
+
+@FunctionalInterface
+public interface OtpDelivery {
+ /**
+ * Submits to the configured adapter; completion is not proof of receipt by the customer. The
+ * local mock deliberately discards the payload. Failures propagate to the use case.
+ */
+ void submit(OtpDeliveryRequest request);
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java
new file mode 100644
index 0000000..7ed1958
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java
@@ -0,0 +1,20 @@
+package dev.amg.payguard.identity.verification.application.port;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.customer.domain.value_object.OtpId;
+import dev.amg.payguard.identity.verification.domain.enums.OtpChannel;
+import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose;
+
+/** destinationRef is the caller's delivery reference; provider resolution is not implemented. */
+public record OtpDeliveryRequest(
+ OtpId challengeId,
+ CustomerId customerId,
+ OtpPurpose purpose,
+ OtpChannel channel,
+ String destinationRef,
+ String code) {
+ @Override
+ public String toString() {
+ return "OtpDeliveryRequest[challengeId=" + challengeId + ", code=[REDACTED]]";
+ }
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java
new file mode 100644
index 0000000..23c0a6d
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java
@@ -0,0 +1,13 @@
+package dev.amg.payguard.identity.verification.application.port;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import java.time.Instant;
+
+/**
+ * Server-side TOTP verification using a trusted secret source. Retained for the existing challenge
+ * flow; ownership of LOGIN MFA remains unresolved pending provider integration.
+ */
+@FunctionalInterface
+public interface TotpVerification {
+ boolean verify(CustomerId customerId, String code, Instant at);
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java
new file mode 100644
index 0000000..cff3fac
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java
@@ -0,0 +1,7 @@
+package dev.amg.payguard.identity.verification.application.port;
+
+@FunctionalInterface
+public interface VerificationAudit {
+ /** Persists synchronously; a persistence failure propagates to the caller. */
+ void record(VerificationAuditEntry entry);
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java
new file mode 100644
index 0000000..78f0aed
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java
@@ -0,0 +1,22 @@
+package dev.amg.payguard.identity.verification.application.port;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.customer.domain.value_object.OtpId;
+import java.time.Instant;
+
+/**
+ * customerId is the challenge subject, not an authenticated actor. No trusted actor or correlation
+ * context is currently supplied by the inbound contract.
+ */
+public record VerificationAuditEntry(
+ OtpId challengeId,
+ CustomerId customerId,
+ Action action,
+ String reasonCode,
+ String sourceIp,
+ Instant occurredAt) {
+ public enum Action {
+ ISSUED,
+ VERIFY
+ }
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java
new file mode 100644
index 0000000..abab62e
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java
@@ -0,0 +1,20 @@
+package dev.amg.payguard.identity.verification.application.port;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose;
+
+@FunctionalInterface
+public interface VerificationQuota {
+ /**
+ * Consumes one request in the fixed window, including a denied request. Returns false on
+ * exhausted quota; storage failures propagate.
+ */
+ boolean tryAcquire(Request request);
+
+ record Request(CustomerId customerId, OtpPurpose purpose, String sourceIp, Operation operation) {}
+
+ enum Operation {
+ ISSUE,
+ VERIFY
+ }
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java
index 9998b96..2fe39e0 100644
--- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java
@@ -12,7 +12,6 @@
import domain.DomainEvent;
import java.util.Objects;
import java.util.Optional;
-import org.jspecify.annotations.Nullable;
/**
* An OTP challenge associated with a PayGuard customer.
@@ -20,6 +19,8 @@
* Owns the challenge's verification and invalidation behavior. State changes replace the current
* {@link OtpSnapshot} without modifying previously created snapshots.
*
+ *
Equality and hashing use the immutable challenge identity inherited from the aggregate base.
+ *
*
Issuance and restoration
*
* Issuance requires an ACTIVE snapshot with zero recorded attempts and no consumption time.
@@ -134,21 +135,4 @@ public CustomerId getCustomerId() {
public OtpSnapshot getSnapshot() {
return snapshot;
}
-
- @Override
- public boolean equals(@Nullable Object o) {
- if (!(o instanceof OTP otp)) return false;
- if (!super.equals(o)) return false;
-
- return Objects.equals(getCustomerId(), otp.getCustomerId())
- && Objects.equals(getSnapshot(), otp.getSnapshot());
- }
-
- @Override
- public int hashCode() {
- int result = super.hashCode();
- result = 31 * result + Objects.hashCode(getCustomerId());
- result = 31 * result + Objects.hashCode(getSnapshot());
- return result;
- }
}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java
index cf1ef52..5d44d06 100644
--- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java
@@ -26,6 +26,9 @@ public static Optional findRejection(
private static Optional evaluateActiveStateGuards(
VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) {
+ if (command.now().isBefore(snapshot.createdAt())) {
+ throw new IllegalArgumentException("verification time must not precede challenge creation");
+ }
if (!command.now().isBefore(snapshot.expiresAt())) {
return Optional.of(OtpVerificationResult.EXPIRED);
}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/EmailDeliveryAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/EmailDeliveryAdapter.java
new file mode 100644
index 0000000..1d7843b
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/EmailDeliveryAdapter.java
@@ -0,0 +1,17 @@
+package dev.amg.payguard.identity.verification.infrastructure.delivery;
+
+import dev.amg.payguard.identity.verification.application.port.OtpDelivery;
+import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+/** TLS provider integration seam for email. The provider request must never be logged. */
+@Component
+@ConditionalOnProperty(name = "identity.delivery.mode", havingValue = "email")
+public class EmailDeliveryAdapter implements OtpDelivery {
+
+ @Override
+ public void submit(OtpDeliveryRequest message) {
+ throw new UnsupportedOperationException("Delivery provider is not configured");
+ }
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/MockDeliveryAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/MockDeliveryAdapter.java
new file mode 100644
index 0000000..23ef0c8
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/MockDeliveryAdapter.java
@@ -0,0 +1,20 @@
+package dev.amg.payguard.identity.verification.infrastructure.delivery;
+
+import dev.amg.payguard.identity.verification.application.port.OtpDelivery;
+import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+/**
+ * Safe local adapter. It deliberately discards the code and never logs it. A provider adapter is
+ * selected in production without changing the domain or application layer.
+ */
+@Component
+@ConditionalOnProperty(name = "identity.delivery.mode", havingValue = "mock", matchIfMissing = true)
+public class MockDeliveryAdapter implements OtpDelivery {
+
+ @Override
+ public void submit(OtpDeliveryRequest message) {
+ // Provider calls belong here. Never log or persist message.code.
+ }
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/PushDeliveryAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/PushDeliveryAdapter.java
new file mode 100644
index 0000000..c55dad0
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/PushDeliveryAdapter.java
@@ -0,0 +1,17 @@
+package dev.amg.payguard.identity.verification.infrastructure.delivery;
+
+import dev.amg.payguard.identity.verification.application.port.OtpDelivery;
+import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+/** TLS provider integration seam for push notifications. */
+@Component
+@ConditionalOnProperty(name = "identity.delivery.mode", havingValue = "push")
+public class PushDeliveryAdapter implements OtpDelivery {
+
+ @Override
+ public void submit(OtpDeliveryRequest message) {
+ throw new UnsupportedOperationException("Delivery provider is not configured");
+ }
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/SmsDeliveryAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/SmsDeliveryAdapter.java
new file mode 100644
index 0000000..1fa7c76
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/SmsDeliveryAdapter.java
@@ -0,0 +1,17 @@
+package dev.amg.payguard.identity.verification.infrastructure.delivery;
+
+import dev.amg.payguard.identity.verification.application.port.OtpDelivery;
+import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+/** TLS provider integration seam for SMS. The provider request must never be logged. */
+@Component
+@ConditionalOnProperty(name = "identity.delivery.mode", havingValue = "sms")
+public class SmsDeliveryAdapter implements OtpDelivery {
+
+ @Override
+ public void submit(OtpDeliveryRequest message) {
+ throw new UnsupportedOperationException("Delivery provider is not configured");
+ }
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisher.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisher.java
new file mode 100644
index 0000000..298a466
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisher.java
@@ -0,0 +1,56 @@
+package dev.amg.payguard.identity.verification.infrastructure.kafka;
+
+import dev.amg.payguard.identity.verification.domain.event.OtpEvent;
+import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+import tools.jackson.databind.ObjectMapper;
+
+@Component
+public class KafkaOtpEventPublisher implements OtpEventPublisher {
+
+ private final KafkaTemplate kafka;
+ private final ObjectMapper objectMapper;
+ private final String topic;
+
+ public KafkaOtpEventPublisher(
+ KafkaTemplate kafka,
+ ObjectMapper objectMapper,
+ @Value("${identity.kafka.topic:identity.events.v1}") String topic) {
+ this.kafka = kafka;
+ this.objectMapper = objectMapper;
+ this.topic = topic;
+ }
+
+ @Override
+ public void publish(OtpEvent event) {
+ try {
+ kafka.send(
+ topic,
+ event.challengeId().toString(),
+ objectMapper.writeValueAsString(
+ new EventPayload(
+ event.type(),
+ event.challengeId().value(),
+ event.customerId().getValue(),
+ event.purpose().name(),
+ event.channel().name(),
+ event.result() == null ? null : event.result().name(),
+ event.occurredAt(),
+ event.reason())));
+ } catch (tools.jackson.core.JacksonException exception) {
+ throw new IllegalStateException("Unable to serialize OTP event", exception);
+ }
+ }
+
+ private record EventPayload(
+ String type,
+ String challengeId,
+ String userId,
+ String purpose,
+ String channel,
+ String result,
+ java.time.Instant occurredAt,
+ String reason) {}
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java
new file mode 100644
index 0000000..a182c24
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java
@@ -0,0 +1,22 @@
+package dev.amg.payguard.identity.verification.infrastructure.persistence;
+
+import dev.amg.payguard.identity.verification.application.port.VerificationAudit;
+import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@Component
+public class JpaOtpAuditLogAdapter implements VerificationAudit {
+
+ private final OtpAuditLogJpaRepository repository;
+
+ public JpaOtpAuditLogAdapter(OtpAuditLogJpaRepository repository) {
+ this.repository = repository;
+ }
+
+ @Override
+ @Transactional
+ public void record(VerificationAuditEntry entry) {
+ repository.save(new OtpAuditLogEntity(entry));
+ }
+}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogEntity.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java
similarity index 61%
rename from payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogEntity.java
rename to payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java
index 081a596..3bebd17 100644
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogEntity.java
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java
@@ -1,5 +1,6 @@
-package dev.amg.payguard.otp.infrastructure.persistence;
+package dev.amg.payguard.identity.verification.infrastructure.persistence;
+import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
@@ -10,7 +11,7 @@
import java.util.UUID;
@Entity
-@Table(name = "otp_audit_log")
+@Table(name = "identity_audit_log")
public class OtpAuditLogEntity {
@Id
@@ -18,7 +19,7 @@ public class OtpAuditLogEntity {
private UUID id;
@Column(name = "challenge_id", nullable = false)
- private UUID challengeId;
+ private String challengeId;
@Column(name = "user_id", nullable = false, length = 255)
private String userId;
@@ -37,18 +38,12 @@ public class OtpAuditLogEntity {
protected OtpAuditLogEntity() {}
- OtpAuditLogEntity(
- UUID challengeId,
- String userId,
- String action,
- String reason,
- String sourceIp,
- Instant occurredAt) {
- this.challengeId = challengeId;
- this.userId = userId;
- this.action = action;
- this.reason = reason;
- this.sourceIp = sourceIp;
- this.occurredAt = occurredAt;
+ OtpAuditLogEntity(VerificationAuditEntry entry) {
+ this.challengeId = entry.challengeId().value();
+ this.userId = entry.customerId().getValue();
+ this.action = entry.action().name();
+ this.reason = entry.reasonCode();
+ this.sourceIp = entry.sourceIp();
+ this.occurredAt = entry.occurredAt();
}
}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogJpaRepository.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java
similarity index 69%
rename from payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogJpaRepository.java
rename to payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java
index af87577..1af6d22 100644
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogJpaRepository.java
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java
@@ -1,4 +1,4 @@
-package dev.amg.payguard.otp.infrastructure.persistence;
+package dev.amg.payguard.identity.verification.infrastructure.persistence;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisRateLimiterAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java
similarity index 52%
rename from payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisRateLimiterAdapter.java
rename to payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java
index 7f61194..e3bef71 100644
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisRateLimiterAdapter.java
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java
@@ -1,14 +1,14 @@
-package dev.amg.payguard.otp.infrastructure.redis;
+package dev.amg.payguard.identity.verification.infrastructure.redis;
-import dev.amg.payguard.otp.domain.OtpPurpose;
-import dev.amg.payguard.otp.domain.RateLimiter;
+import dev.amg.payguard.identity.verification.application.port.VerificationQuota;
+import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@Component
-public class RedisRateLimiterAdapter implements RateLimiter {
+public class RedisRateLimiterAdapter implements VerificationQuota {
private final StringRedisTemplate redis;
private final int maxIssues;
@@ -17,9 +17,9 @@ public class RedisRateLimiterAdapter implements RateLimiter {
public RedisRateLimiterAdapter(
StringRedisTemplate redis,
- @Value("${otp.rate-limit.max-issues:3}") int maxIssues,
- @Value("${otp.rate-limit.max-verifications:20}") int maxVerifications,
- @Value("${otp.rate-limit.window:PT15M}") Duration window) {
+ @Value("${identity.rate-limit.max-issues:3}") int maxIssues,
+ @Value("${identity.rate-limit.max-verifications:20}") int maxVerifications,
+ @Value("${identity.rate-limit.window:PT15M}") Duration window) {
this.redis = redis;
this.maxIssues = maxIssues;
this.maxVerifications = maxVerifications;
@@ -27,19 +27,20 @@ public RedisRateLimiterAdapter(
}
@Override
- public boolean allowIssue(String userId, OtpPurpose purpose, String sourceIp) {
- return allow("issue", userId, purpose, sourceIp, maxIssues);
- }
-
- @Override
- public boolean allowVerify(String userId, OtpPurpose purpose, String sourceIp) {
- return allow("verify", userId, purpose, sourceIp, maxVerifications);
+ public boolean tryAcquire(VerificationQuota.Request request) {
+ boolean issuing = request.operation() == VerificationQuota.Operation.ISSUE;
+ return allow(
+ issuing ? "issue" : "verify",
+ request.customerId().getValue(),
+ request.purpose(),
+ request.sourceIp(),
+ issuing ? maxIssues : maxVerifications);
}
private boolean allow(
String operation, String userId, OtpPurpose purpose, String sourceIp, int maximum) {
String key =
- "otp:rate:" + operation + ":" + safe(userId) + ":" + purpose + ":" + safe(sourceIp);
+ "identity:rate:" + operation + ":" + safe(userId) + ":" + purpose + ":" + safe(sourceIp);
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1L) {
redis.expire(key, window);
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java
new file mode 100644
index 0000000..aa5879a
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java
@@ -0,0 +1,20 @@
+package dev.amg.payguard.identity.verification.infrastructure.security;
+
+import dev.amg.payguard.identity.customer.domain.value_object.OtpId;
+import java.security.SecureRandom;
+import org.springframework.stereotype.Component;
+
+@Component
+public final class OtpIdGenerator {
+ private static final String ALPHABET =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ private final SecureRandom random = new SecureRandom();
+
+ public OtpId generate() {
+ StringBuilder value = new StringBuilder("otp_");
+ for (int index = 0; index < 22; index++) {
+ value.append(ALPHABET.charAt(random.nextInt(ALPHABET.length())));
+ }
+ return OtpId.parse(value.toString());
+ }
+}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/SecureNumericOtpGenerator.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java
similarity index 69%
rename from payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/SecureNumericOtpGenerator.java
rename to payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java
index 572fbff..89124c6 100644
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/SecureNumericOtpGenerator.java
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java
@@ -1,6 +1,6 @@
-package dev.amg.payguard.otp.infrastructure.security;
+package dev.amg.payguard.identity.verification.infrastructure.security;
-import dev.amg.payguard.otp.domain.OtpCodeGenerator;
+import dev.amg.payguard.identity.verification.domain.port.OtpCodeGenerator;
import java.security.SecureRandom;
import org.springframework.stereotype.Component;
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/TotpAuthenticatorCodeVerifier.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java
similarity index 83%
rename from payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/TotpAuthenticatorCodeVerifier.java
rename to payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java
index 3393ef0..3176f61 100644
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/TotpAuthenticatorCodeVerifier.java
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java
@@ -1,6 +1,8 @@
-package dev.amg.payguard.otp.infrastructure.security;
+package dev.amg.payguard.identity.verification.infrastructure.security;
-import dev.amg.payguard.otp.domain.AuthenticatorCodeVerifier;
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.verification.application.port.AuthenticatorSecretPort;
+import dev.amg.payguard.identity.verification.application.port.TotpVerification;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
@@ -11,7 +13,7 @@
import org.springframework.stereotype.Component;
@Component
-public final class TotpAuthenticatorCodeVerifier implements AuthenticatorCodeVerifier {
+public final class TotpAuthenticatorCodeVerifier implements TotpVerification {
private static final int DIGITS = 6;
private static final long STEP_SECONDS = 30;
@@ -22,11 +24,11 @@ public TotpAuthenticatorCodeVerifier(AuthenticatorSecretPort secrets) {
}
@Override
- public boolean verify(String userId, String code, Instant at) {
+ public boolean verify(CustomerId customerId, String code, Instant at) {
if (code == null || !code.matches("\\d{" + DIGITS + "}")) {
return false;
}
- Optional secret = secrets.secretFor(userId);
+ Optional secret = secrets.secretFor(customerId);
if (secret.isEmpty()) {
return false;
}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java
new file mode 100644
index 0000000..5a31bd8
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java
@@ -0,0 +1,18 @@
+package dev.amg.payguard.identity.verification.infrastructure.security;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.verification.application.port.AuthenticatorSecretPort;
+import java.util.Optional;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+/** Resolves no secrets by default; production wiring supplies a vault-backed implementation. */
+@Component
+@ConditionalOnProperty(name = "payguard.vault.enabled", havingValue = "false")
+public final class UnavailableAuthenticatorSecretAdapter implements AuthenticatorSecretPort {
+
+ @Override
+ public Optional secretFor(CustomerId customerId) {
+ return Optional.empty();
+ }
+}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpController.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java
similarity index 58%
rename from payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpController.java
rename to payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java
index 78d96e1..48eebbd 100644
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpController.java
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java
@@ -1,19 +1,20 @@
-package dev.amg.payguard.otp.interfaces.rest;
+package dev.amg.payguard.identity.verification.interfaces.rest;
-import dev.amg.payguard.otp.application.IssueOtpCommand;
-import dev.amg.payguard.otp.application.IssuedOtp;
-import dev.amg.payguard.otp.application.OtpApplicationService;
-import dev.amg.payguard.otp.application.VerifiedOtp;
-import dev.amg.payguard.otp.application.VerifyOtpCommand;
-import dev.amg.payguard.otp.domain.OtpChannel;
-import dev.amg.payguard.otp.domain.OtpPurpose;
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.customer.domain.value_object.OtpId;
+import dev.amg.payguard.identity.verification.application.IssueOtpCommand;
+import dev.amg.payguard.identity.verification.application.IssuedOtp;
+import dev.amg.payguard.identity.verification.application.OtpApplicationService;
+import dev.amg.payguard.identity.verification.application.OtpVerificationCommand;
+import dev.amg.payguard.identity.verification.application.VerifiedOtp;
+import dev.amg.payguard.identity.verification.domain.enums.OtpChannel;
+import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.time.Instant;
-import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -22,7 +23,7 @@
import org.springframework.web.bind.annotation.RestController;
@RestController
-@RequestMapping("/otp/challenges")
+@RequestMapping("/identity/challenges")
public class OtpController {
private final OtpApplicationService service;
@@ -37,7 +38,7 @@ public ResponseEntity issue(
IssuedOtp issued =
service.issue(
new IssueOtpCommand(
- request.userId(),
+ CustomerId.parse(request.userId()),
request.purpose(),
request.channel(),
request.destinationRef(),
@@ -45,25 +46,26 @@ public ResponseEntity issue(
sourceIp(httpRequest)));
return ResponseEntity.ok(
new IssueOtpResponse(
- issued.challengeId(), issued.purpose(), issued.channel(), issued.expiresAt()));
+ issued.challengeId().value(), issued.purpose(), issued.channel(), issued.expiresAt()));
}
@PostMapping("/{challengeId}/verify")
public ResponseEntity verify(
- @PathVariable UUID challengeId,
+ @PathVariable String challengeId,
@Valid @RequestBody VerifyOtpRequest request,
HttpServletRequest httpRequest) {
VerifiedOtp verified =
service.verify(
- new VerifyOtpCommand(
- challengeId,
- request.userId(),
+ new OtpVerificationCommand(
+ OtpId.parse(challengeId),
+ CustomerId.parse(request.userId()),
request.purpose(),
request.code(),
request.deviceFingerprint(),
sourceIp(httpRequest)));
return ResponseEntity.ok(
- new VerifyOtpResponse(verified.stepUpToken(), verified.purpose(), verified.expiresAt()));
+ new VerifyOtpResponse(
+ verified.challengeId().value(), verified.purpose(), verified.verifiedAt()));
}
private static String sourceIp(HttpServletRequest request) {
@@ -81,10 +83,15 @@ public record VerifyOtpRequest(
@NotBlank String userId,
@NotNull OtpPurpose purpose,
@NotBlank @Pattern(regexp = "\\d{6}") String code,
- String deviceFingerprint) {}
+ String deviceFingerprint) {
+ @Override
+ public String toString() {
+ return "VerifyOtpRequest[code=[REDACTED]]";
+ }
+ }
public record IssueOtpResponse(
- UUID challengeId, OtpPurpose purpose, OtpChannel channel, Instant expiresAt) {}
+ String challengeId, OtpPurpose purpose, OtpChannel channel, Instant expiresAt) {}
- public record VerifyOtpResponse(String stepUpToken, OtpPurpose purpose, Instant expiresAt) {}
+ public record VerifyOtpResponse(String challengeId, OtpPurpose purpose, Instant verifiedAt) {}
}
diff --git a/payguard-identity-service/src/main/resources/banner.txt b/payguard-identity-service/src/main/resources/banner.txt
new file mode 100644
index 0000000..88535c2
--- /dev/null
+++ b/payguard-identity-service/src/main/resources/banner.txt
@@ -0,0 +1,6 @@
+ __ _______ _______ .__ __. .___________. __ .___________.____ ____
+| | | \ | ____|| \ | | | || | | |\ \ / /
+| | | .--. || |__ | \| | `---| |----`| | `---| |----` \ \/ /
+| | | | | || __| | . ` | | | | | | | \_ _/
+| | | '--' || |____ | |\ | | | | | | | | |
+|__| |_______/ |_______||__| \__| |__| |__| |__| |__|
diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java
new file mode 100644
index 0000000..eec7089
--- /dev/null
+++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java
@@ -0,0 +1,180 @@
+package dev.amg.payguard.identity.verification.application;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.customer.domain.value_object.OtpId;
+import dev.amg.payguard.identity.verification.application.port.OtpDelivery;
+import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest;
+import dev.amg.payguard.identity.verification.application.port.VerificationQuota;
+import dev.amg.payguard.identity.verification.domain.enums.OtpChannel;
+import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose;
+import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult;
+import dev.amg.payguard.identity.verification.domain.model.OTP;
+import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher;
+import dev.amg.payguard.identity.verification.domain.port.OtpRepository;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class OtpApplicationServiceTest {
+
+ private final FixedHasher hasher = new FixedHasher();
+ private final InMemoryChallenges challenges = new InMemoryChallenges();
+ private final CapturingDelivery delivery = new CapturingDelivery();
+ private OtpApplicationService service;
+
+ @BeforeEach
+ void setUp() {
+ Instant now = Instant.parse("2026-01-01T00:00:00Z");
+ service =
+ new OtpApplicationService(
+ challenges,
+ hasher,
+ () -> "123456",
+ (userId, code, at) -> false,
+ delivery,
+ new AllowAllVerificationQuota(),
+ () -> OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"),
+ event -> {},
+ entry -> {},
+ Clock.fixed(now, ZoneOffset.UTC),
+ Duration.ofMinutes(5),
+ Duration.ofSeconds(30),
+ Duration.ofDays(1097),
+ 5);
+ }
+
+ @Test
+ void issueAndVerifyReturnsCompletedChallengeAndRejectsReplay() {
+ IssuedOtp issued =
+ service.issue(
+ new IssueOtpCommand(
+ CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"),
+ OtpPurpose.LOGIN,
+ OtpChannel.SMS,
+ "identity:phone:1",
+ null,
+ "127.0.0.1"));
+
+ assertEquals("123456", delivery.code);
+ VerifiedOtp verified =
+ service.verify(
+ new OtpVerificationCommand(
+ issued.challengeId(),
+ CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"),
+ OtpPurpose.LOGIN,
+ "123456",
+ null,
+ "127.0.0.1"));
+
+ assertEquals(issued.challengeId(), verified.challengeId());
+ assertEquals(Instant.parse("2026-01-01T00:00:00Z"), verified.verifiedAt());
+ OtpVerificationException replay =
+ assertThrows(
+ OtpVerificationException.class,
+ () ->
+ service.verify(
+ new OtpVerificationCommand(
+ issued.challengeId(),
+ CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"),
+ OtpPurpose.LOGIN,
+ "123456",
+ null,
+ "127.0.0.1")));
+ assertEquals(OtpVerificationResult.ALREADY_USED, replay.result());
+ }
+
+ @Test
+ void wrongPurposeCannotVerifyChallenge() {
+ IssuedOtp issued =
+ service.issue(
+ new IssueOtpCommand(
+ CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"),
+ OtpPurpose.LOGIN,
+ OtpChannel.EMAIL,
+ "identity:email:1",
+ null,
+ "127.0.0.1"));
+
+ OtpVerificationException exception =
+ assertThrows(
+ OtpVerificationException.class,
+ () ->
+ service.verify(
+ new OtpVerificationCommand(
+ issued.challengeId(),
+ CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"),
+ OtpPurpose.LOAN_DISBURSE,
+ "123456",
+ null,
+ "127.0.0.1")));
+ assertEquals(OtpVerificationResult.PURPOSE_MISMATCH, exception.result());
+ }
+
+ private static final class FixedHasher implements OtpCodeHasher {
+ @Override
+ public String hash(String value) {
+ return "hash:" + value;
+ }
+
+ @Override
+ public boolean matches(String value, String encodedHash) {
+ return encodedHash.equals(hash(value));
+ }
+ }
+
+ private static final class CapturingDelivery implements OtpDelivery {
+ private String code;
+
+ @Override
+ public void submit(OtpDeliveryRequest message) {
+ code = message.code();
+ }
+ }
+
+ private static final class AllowAllVerificationQuota implements VerificationQuota {
+ @Override
+ public boolean tryAcquire(VerificationQuota.Request request) {
+ return true;
+ }
+ }
+
+ private static final class InMemoryChallenges implements OtpRepository {
+ @Override
+ public Optional withLockedChallenge(
+ OtpId id, java.util.function.Function mutation) {
+ return findById(id).map(mutation);
+ }
+
+ private final Map values = new HashMap<>();
+
+ @Override
+ public void replaceActive(OTP replacement) {
+ values.values().stream()
+ .filter(
+ challenge ->
+ challenge.getCustomerId().equals(replacement.getCustomerId())
+ && challenge.getSnapshot().purpose() == replacement.getSnapshot().purpose())
+ .forEach(OTP::invalidate);
+ save(replacement);
+ }
+
+ @Override
+ public void save(OTP challenge) {
+ values.put(challenge.getId(), challenge);
+ }
+
+ @Override
+ public Optional findById(OtpId challengeId) {
+ return Optional.ofNullable(values.get(challengeId));
+ }
+ }
+}
diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java
new file mode 100644
index 0000000..33b3c24
--- /dev/null
+++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java
@@ -0,0 +1,248 @@
+package dev.amg.payguard.identity.verification.domain;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.customer.domain.value_object.OtpId;
+import dev.amg.payguard.identity.verification.domain.enums.OtpChannel;
+import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose;
+import dev.amg.payguard.identity.verification.domain.enums.OtpStatus;
+import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode;
+import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult;
+import dev.amg.payguard.identity.verification.domain.model.OTP;
+import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot;
+import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher;
+import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.HashSet;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+class OTPDomainContractTest {
+ private static final Instant NOW = Instant.parse("2026-01-01T00:00:00Z");
+ private static final Instant EXPIRES = NOW.plusSeconds(300);
+ private static final Instant RETAINS = EXPIRES.atZone(ZoneOffset.UTC).plusYears(3).toInstant();
+ private static final OtpId ID = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV");
+ private static final CustomerId CUSTOMER = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV");
+ private static final OtpCodeHasher HASHER =
+ new OtpCodeHasher() {
+ @Override
+ public String hash(String value) {
+ return "hash:" + value;
+ }
+
+ @Override
+ public boolean matches(String value, String encodedHash) {
+ return hash(value).equals(encodedHash);
+ }
+ };
+
+ @Test
+ void issueRequiresActiveZeroAttemptSnapshot() {
+ assertEquals(
+ OtpStatus.ACTIVE,
+ OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)).getSnapshot().status());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 1, 3, null)));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.USED, 1, 3, NOW)));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.LOCKED, 3, 3, null)));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.EXPIRED, 1, 3, null)));
+ }
+
+ @Test
+ void issueRejectsNullArgumentsAtRuntime() {
+ OtpSnapshot initial = state(OtpStatus.ACTIVE, 0, 3, null);
+ assertThrows(NullPointerException.class, () -> OTP.issue(null, CUSTOMER, initial));
+ assertThrows(NullPointerException.class, () -> OTP.issue(ID, null, initial));
+ assertThrows(NullPointerException.class, () -> OTP.issue(ID, CUSTOMER, null));
+ }
+
+ @Test
+ void restoreAcceptsHistoricalStatesWithoutReset() {
+ for (OtpSnapshot state :
+ new OtpSnapshot[] {
+ state(OtpStatus.ACTIVE, 2, 3, null),
+ state(OtpStatus.USED, 2, 3, NOW.plusSeconds(1)),
+ state(OtpStatus.LOCKED, 3, 3, null),
+ state(OtpStatus.EXPIRED, 1, 3, null),
+ state(OtpStatus.INVALIDATED, 1, 3, null)
+ }) {
+ OTP restored = OTP.restore(ID, CUSTOMER, state);
+ assertEquals(ID, restored.getId());
+ assertEquals(CUSTOMER, restored.getCustomerId());
+ assertSame(state, restored.getSnapshot());
+ assertEquals(state.attempts(), restored.getSnapshot().attempts());
+ assertEquals(state.consumedAt(), restored.getSnapshot().consumedAt());
+ }
+ }
+
+ @Test
+ void restoreRejectsNullArgumentsAtRuntime() {
+ OtpSnapshot existing = state(OtpStatus.USED, 1, 3, NOW);
+ assertThrows(NullPointerException.class, () -> OTP.restore(null, CUSTOMER, existing));
+ assertThrows(NullPointerException.class, () -> OTP.restore(ID, null, existing));
+ assertThrows(NullPointerException.class, () -> OTP.restore(ID, CUSTOMER, null));
+ }
+
+ @Test
+ void correctCodeSucceedsOnLastRemainingAttemptAfterPreviousFailures() {
+ OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.ACTIVE, 2, 3, null));
+ assertEquals(OtpVerificationResult.VERIFIED, otp.verify(command("123456", NOW), HASHER));
+ assertEquals(OtpStatus.USED, otp.getSnapshot().status());
+ assertEquals(3, otp.getSnapshot().attempts());
+ assertEquals(NOW, otp.getSnapshot().consumedAt());
+ }
+
+ @Test
+ void failedAttemptDoesNotRequireDeviceBindingOrConsumptionTime() {
+ OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null));
+ OtpSnapshot before = otp.getSnapshot();
+ assertEquals(OtpVerificationResult.INVALID_CODE, otp.verify(command("000000", NOW), HASHER));
+ assertEquals(OtpStatus.ACTIVE, otp.getSnapshot().status());
+ assertEquals(1, otp.getSnapshot().attempts());
+ assertNull(otp.getSnapshot().consumedAt());
+ assertNull(otp.getSnapshot().deviceBindingHash());
+ assertEquals(0, before.attempts());
+ }
+
+ @Test
+ void consumedChallengeCannotBeReusedOrMutated() {
+ OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.USED, 1, 3, NOW));
+ OtpSnapshot before = otp.getSnapshot();
+ assertEquals(
+ OtpVerificationResult.ALREADY_USED,
+ otp.verify(command("123456", NOW.plusSeconds(2)), HASHER));
+ assertSame(before, otp.getSnapshot());
+ }
+
+ @Test
+ void lockedChallengeCannotBeReusedOrMutated() {
+ OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.LOCKED, 3, 3, null));
+ OtpSnapshot before = otp.getSnapshot();
+ assertEquals(OtpVerificationResult.LOCKED, otp.verify(command("123456", NOW), HASHER));
+ assertSame(before, otp.getSnapshot());
+ }
+
+ @Test
+ void verificationAtExpirationTransitionsToExpiredWithoutCountingAttempt() {
+ OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null));
+ assertEquals(OtpVerificationResult.EXPIRED, otp.verify(command("123456", EXPIRES), HASHER));
+ assertEquals(OtpStatus.EXPIRED, otp.getSnapshot().status());
+ assertEquals(0, otp.getSnapshot().attempts());
+ assertNull(otp.getSnapshot().consumedAt());
+ }
+
+ @Test
+ void verificationBeforeCreationIsInvalidEvenForWrongCode() {
+ OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null));
+ OtpSnapshot before = otp.getSnapshot();
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> otp.verify(command("000000", NOW.minusSeconds(1)), HASHER));
+ assertSame(before, otp.getSnapshot());
+ }
+
+ @Test
+ void hasherFailureInDeviceBindingGuardPreservesState() {
+ OtpSnapshot withBinding =
+ new OtpSnapshot(
+ OtpPurpose.LOGIN,
+ OtpChannel.SMS,
+ OtpVerificationMode.RANDOM_NUMERIC,
+ "hash:123456",
+ "hash:device-a",
+ 3,
+ NOW,
+ EXPIRES,
+ RETAINS,
+ 0,
+ OtpStatus.ACTIVE,
+ null);
+ OTP otp = OTP.issue(ID, CUSTOMER, withBinding);
+ OtpCodeHasher failing =
+ new OtpCodeHasher() {
+ @Override
+ public String hash(String value) {
+ throw new IllegalStateException("hash unavailable");
+ }
+
+ @Override
+ public boolean matches(String value, String encodedHash) {
+ throw new IllegalStateException("hash unavailable");
+ }
+ };
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ otp.verify(
+ new VerifyOtpCommand(OtpPurpose.LOGIN, "123456", "device-a", NOW, false), failing));
+ assertSame(withBinding, otp.getSnapshot());
+ }
+
+ @Test
+ void invalidatingActiveChallengeIsCompleteAndIdempotentInMemory() {
+ OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null));
+ otp.invalidate();
+ assertEquals(OtpStatus.INVALIDATED, otp.getSnapshot().status());
+ assertEquals(0, otp.getSnapshot().attempts());
+ OtpSnapshot invalidated = otp.getSnapshot();
+ otp.invalidate();
+ assertSame(invalidated, otp.getSnapshot());
+ }
+
+ @Test
+ void invalidatingUsedChallengeDoesNotChangeIt() {
+ OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.USED, 1, 3, NOW));
+ OtpSnapshot before = otp.getSnapshot();
+ otp.invalidate();
+ assertSame(before, otp.getSnapshot());
+ }
+
+ @Test
+ void identityHashingDoesNotChangeWhenSnapshotChanges() {
+ OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null));
+ Set known = new HashSet<>();
+ known.add(otp);
+ int beforeHash = otp.hashCode();
+ assertEquals(OtpVerificationResult.VERIFIED, otp.verify(command("123456", NOW), HASHER));
+ assertEquals(beforeHash, otp.hashCode());
+ assertTrue(known.contains(otp));
+ OTP restored = OTP.restore(ID, CUSTOMER, otp.getSnapshot());
+ assertEquals(otp, restored);
+ assertFalse(otp == restored);
+ }
+
+ private static VerifyOtpCommand command(String code, Instant now) {
+ return new VerifyOtpCommand(OtpPurpose.LOGIN, code, null, now, false);
+ }
+
+ private static OtpSnapshot state(
+ OtpStatus status, int attempts, int maxAttempts, Instant consumedAt) {
+ return new OtpSnapshot(
+ OtpPurpose.LOGIN,
+ OtpChannel.SMS,
+ OtpVerificationMode.RANDOM_NUMERIC,
+ "hash:123456",
+ null,
+ maxAttempts,
+ NOW,
+ EXPIRES,
+ RETAINS,
+ attempts,
+ status,
+ consumedAt);
+ }
+}
diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java
new file mode 100644
index 0000000..a7c077e
--- /dev/null
+++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java
@@ -0,0 +1,32 @@
+package dev.amg.payguard.identity.verification.infrastructure.delivery;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.customer.domain.value_object.OtpId;
+import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest;
+import dev.amg.payguard.identity.verification.domain.enums.OtpChannel;
+import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose;
+import org.junit.jupiter.api.Test;
+
+class DeliveryAdaptersTest {
+ @Test
+ void mockRemainsLocalAndUnconfiguredProviderModesFailExplicitly() {
+ var request =
+ new OtpDeliveryRequest(
+ OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"),
+ CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"),
+ OtpPurpose.LOGIN,
+ OtpChannel.SMS,
+ "phone-ref",
+ "123456");
+ assertDoesNotThrow(() -> new MockDeliveryAdapter().submit(request));
+ assertThrows(
+ UnsupportedOperationException.class, () -> new SmsDeliveryAdapter().submit(request));
+ assertThrows(
+ UnsupportedOperationException.class, () -> new EmailDeliveryAdapter().submit(request));
+ assertThrows(
+ UnsupportedOperationException.class, () -> new PushDeliveryAdapter().submit(request));
+ }
+}
diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java
new file mode 100644
index 0000000..e8b0380
--- /dev/null
+++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java
@@ -0,0 +1,44 @@
+package dev.amg.payguard.identity.verification.infrastructure.kafka;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.customer.domain.value_object.OtpId;
+import dev.amg.payguard.identity.verification.domain.enums.OtpChannel;
+import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose;
+import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult;
+import dev.amg.payguard.identity.verification.domain.event.OtpEvent;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.kafka.core.KafkaTemplate;
+import tools.jackson.databind.ObjectMapper;
+
+class KafkaOtpEventPublisherTest {
+ @Test
+ @SuppressWarnings("unchecked")
+ void convertsTypedIdsToExistingWireFields() {
+ KafkaTemplate kafka = mock(KafkaTemplate.class);
+ OtpId id = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV");
+ var publisher = new KafkaOtpEventPublisher(kafka, new ObjectMapper(), "identity.events.v1");
+ publisher.publish(
+ new OtpEvent(
+ "identity.verified",
+ id,
+ CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"),
+ OtpPurpose.LOGIN,
+ OtpChannel.SMS,
+ OtpVerificationResult.VERIFIED,
+ Instant.parse("2026-01-01T00:00:00Z"),
+ "verified"));
+ var json = ArgumentCaptor.forClass(String.class);
+ verify(kafka).send(eq("identity.events.v1"), eq(id.value()), json.capture());
+ assertTrue(json.getValue().contains("\"challengeId\":\"otp_ABCDEFGHIJKLMNOPQRSTUV\""));
+ assertTrue(json.getValue().contains("\"userId\":\"cus_ABCDEFGHIJKLMNOPQRSTUV\""));
+ assertFalse(json.getValue().contains("customerId"));
+ }
+}
diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java
new file mode 100644
index 0000000..3e3f2c0
--- /dev/null
+++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java
@@ -0,0 +1,32 @@
+package dev.amg.payguard.identity.verification.infrastructure.security;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import java.time.Instant;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+class TotpVerificationTest {
+ private static final CustomerId CUSTOMER = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV");
+
+ @Test
+ void verifiesKnownRfc6238Sha1VectorWithExistingSixDigitFormat() {
+ var verifier =
+ new TotpAuthenticatorCodeVerifier(
+ customer -> Optional.of("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"));
+ assertTrue(verifier.verify(CUSTOMER, "287082", Instant.ofEpochSecond(59)));
+ assertFalse(verifier.verify(CUSTOMER, "000000", Instant.ofEpochSecond(59)));
+ assertFalse(verifier.verify(CUSTOMER, "287082", Instant.ofEpochSecond(300)));
+ assertFalse(verifier.verify(CUSTOMER, "2870820", Instant.ofEpochSecond(59)));
+ }
+
+ @Test
+ void missingOrInvalidSecretsFailClosed() {
+ var missing = new TotpAuthenticatorCodeVerifier(new UnavailableAuthenticatorSecretAdapter());
+ assertFalse(missing.verify(CUSTOMER, "287082", Instant.ofEpochSecond(59)));
+ var invalid = new TotpAuthenticatorCodeVerifier(customer -> Optional.of("invalid!"));
+ assertFalse(invalid.verify(CUSTOMER, "287082", Instant.ofEpochSecond(59)));
+ }
+}
diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java
new file mode 100644
index 0000000..e1b916c
--- /dev/null
+++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java
@@ -0,0 +1,104 @@
+package dev.amg.payguard.identity.verification.interfaces.rest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import dev.amg.payguard.identity.customer.domain.value_object.CustomerId;
+import dev.amg.payguard.identity.customer.domain.value_object.OtpId;
+import dev.amg.payguard.identity.verification.application.IssueOtpCommand;
+import dev.amg.payguard.identity.verification.application.IssuedOtp;
+import dev.amg.payguard.identity.verification.application.OtpApplicationService;
+import dev.amg.payguard.identity.verification.application.OtpVerificationCommand;
+import dev.amg.payguard.identity.verification.application.VerifiedOtp;
+import dev.amg.payguard.identity.verification.domain.enums.OtpChannel;
+import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose;
+import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand;
+import jakarta.servlet.http.HttpServletRequest;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+class OtpControllerTest {
+ @Test
+ void parsesCustomerAndChallengeAtBoundaryAndReturnsStringIds() {
+ var service = mock(OtpApplicationService.class);
+ var request = mock(HttpServletRequest.class);
+ when(request.getRemoteAddr()).thenReturn("127.0.0.1");
+ OtpId id = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV");
+ CustomerId customer = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV");
+ Instant now = Instant.parse("2026-01-01T00:00:00Z");
+ when(service.issue(any())).thenReturn(new IssuedOtp(id, OtpPurpose.LOGIN, OtpChannel.SMS, now));
+ when(service.verify(any())).thenReturn(new VerifiedOtp(id, OtpPurpose.LOGIN, now));
+ var controller = new OtpController(service);
+ assertEquals(
+ id.value(),
+ controller
+ .issue(
+ new OtpController.IssueOtpRequest(
+ customer.getValue(), OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null),
+ request)
+ .getBody()
+ .challengeId());
+ assertEquals(
+ id.value(),
+ controller
+ .verify(
+ id.value(),
+ new OtpController.VerifyOtpRequest(
+ customer.getValue(), OtpPurpose.LOGIN, "123456", null),
+ request)
+ .getBody()
+ .challengeId());
+ var issue = ArgumentCaptor.forClass(IssueOtpCommand.class);
+ verify(service).issue(issue.capture());
+ assertEquals(customer, issue.getValue().customerId());
+ var verification = ArgumentCaptor.forClass(OtpVerificationCommand.class);
+ verify(service).verify(verification.capture());
+ assertEquals(id, verification.getValue().challengeId());
+ assertEquals(customer, verification.getValue().customerId());
+ }
+
+ @Test
+ void rejectsMalformedIdsBeforeCallingApplication() {
+ var service = mock(OtpApplicationService.class);
+ var controller = new OtpController(service);
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ controller.verify(
+ "invalid",
+ new OtpController.VerifyOtpRequest(
+ "cus_ABCDEFGHIJKLMNOPQRSTUV", OtpPurpose.LOGIN, "123456", null),
+ mock(HttpServletRequest.class)));
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void diagnosticOutputDoesNotExposeCodeButPayloadStillContainsIt() {
+ var request =
+ new OtpController.VerifyOtpRequest(
+ "cus_ABCDEFGHIJKLMNOPQRSTUV", OtpPurpose.LOGIN, "123456", null);
+ assertEquals("123456", request.code());
+ assertFalse(request.toString().contains("123456"));
+ assertFalse(
+ new OtpVerificationCommand(
+ OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"),
+ CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"),
+ OtpPurpose.LOGIN,
+ "123456",
+ null,
+ null)
+ .toString()
+ .contains("123456"));
+ assertFalse(
+ new VerifyOtpCommand(OtpPurpose.LOGIN, "123456", null, Instant.now(), false)
+ .toString()
+ .contains("123456"));
+ }
+}
diff --git a/payguard-otp-service/Dockerfile b/payguard-otp-service/Dockerfile
deleted file mode 100644
index 9d740dc..0000000
--- a/payguard-otp-service/Dockerfile
+++ /dev/null
@@ -1,13 +0,0 @@
-# syntax=docker/dockerfile:1
-FROM eclipse-temurin:25-jdk-alpine AS build
-WORKDIR /workspace
-COPY . .
-RUN chmod +x mvnw && ./mvnw --batch-mode --no-transfer-progress -pl :payguard-otp-service -am -DskipTests package
-
-FROM eclipse-temurin:25-jre-alpine
-WORKDIR /app
-RUN addgroup -S payguard && adduser -S payguard -G payguard
-COPY --from=build /workspace/payguard-otp-service/target/payguard-otp-service-*.jar /app/app.jar
-USER payguard
-EXPOSE 8084
-ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]
diff --git a/payguard-otp-service/README.md b/payguard-otp-service/README.md
deleted file mode 100644
index c8efd8c..0000000
--- a/payguard-otp-service/README.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# PayGuard OTP Service
-
-`payguard-otp-service` is the bounded context for login MFA and sensitive-action
-step-up authentication. It is independently deployable, persists challenge
-metadata in Oracle, and keeps rate-limit counters and one-time step-up tokens in
-Redis.
-
-## API
-
-- `POST /otp/challenges` issues a challenge for an opaque `userId`, purpose,
- channel, destination reference, and optional device fingerprint.
-- `POST /otp/challenges/{challengeId}/verify` verifies the six-digit code and
- returns a single-use, purpose-scoped step-up token.
-- `POST /otp/step-up-tokens/consume` lets a downstream gateway consumer redeem
- the token once for the exact user and purpose.
-
-SMS, email, and push channels use random six-digit codes. Authenticator-app
-challenges use an RFC 6238 TOTP verifier behind `AuthenticatorSecretPort`; the
-secret is resolved by a vault/identity adapter and is never stored in the OTP
-database. The default mock delivery adapter discards codes without logging them.
-
-## Privacy and security posture
-
-- Only opaque user IDs and destination references are stored; phone numbers and
- email addresses remain in the identity service.
-- Random codes are salted and HMAC-SHA256 hashed with a server-side pepper.
-- Challenges are single-use, expiry-bound, device-bindable, and limited to five
- failed attempts by default.
-- Redis enforces issuance/verification rate limits and stores three-minute,
- single-use step-up tokens.
-- Oracle retains challenge/audit facts for 90 days by default; the purge job is
- intentionally externalized so retention can be configured by jurisdiction.
-- Audit records contain no OTP code and are indexed by subject and time.
-
-## Local validation
-
-Unit tests run with Maven. The Oracle/Redis smoke test is enabled explicitly:
-
-```bash
-RUN_OTP_INTEGRATION_TESTS=true ./mvnw -pl payguard-otp-service -am test
-```
-
-Production requires `SPRING_DATASOURCE_*`, `REDIS_HOST`,
-`OTP_SECURITY_PEPPER`, and `KAFKA_BOOTSTRAP_SERVERS` environment variables.
diff --git a/payguard-otp-service/pom.xml b/payguard-otp-service/pom.xml
deleted file mode 100644
index 90238b7..0000000
--- a/payguard-otp-service/pom.xml
+++ /dev/null
@@ -1,92 +0,0 @@
-
-
- 4.0.0
-
-
- dev.amg.payguard
- payguard-parent
- 1.0.0-SNAPSHOT
-
-
- payguard-otp-service
- PayGuard OTP Service
-
-
-
- org.springframework.boot
- spring-boot-starter-webmvc
-
-
- org.springframework.boot
- spring-boot-starter-validation
-
-
- org.springframework.boot
- spring-boot-starter-jackson
-
-
- org.springframework.boot
- spring-boot-starter-data-jpa
-
-
- org.springframework.boot
- spring-boot-starter-data-redis
-
-
- org.springframework.kafka
- spring-kafka
-
-
- org.flywaydb
- flyway-core
-
-
- org.flywaydb
- flyway-database-oracle
-
-
- com.oracle.database.jdbc
- ojdbc17
- runtime
-
-
- org.springframework.boot
- spring-boot-starter-webmvc-test
- test
-
-
- org.testcontainers
- testcontainers-oracle-xe
- test
-
-
- com.redis
- testcontainers-redis
- test
-
-
- org.testcontainers
- testcontainers-junit-jupiter
- test
-
-
-
-
-
-
- org.springframework.boot
- spring-boot-maven-plugin
-
-
- repackage
-
- repackage
-
-
-
-
-
-
-
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssueOtpCommand.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssueOtpCommand.java
deleted file mode 100644
index 27fa7ec..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssueOtpCommand.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package dev.amg.payguard.otp.application;
-
-import dev.amg.payguard.otp.domain.OtpChannel;
-import dev.amg.payguard.otp.domain.OtpPurpose;
-
-public record IssueOtpCommand(
- String userId,
- OtpPurpose purpose,
- OtpChannel channel,
- String destinationRef,
- String deviceFingerprint,
- String sourceIp) {}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssuedOtp.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssuedOtp.java
deleted file mode 100644
index 3f57a66..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssuedOtp.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package dev.amg.payguard.otp.application;
-
-import dev.amg.payguard.otp.domain.OtpChannel;
-import dev.amg.payguard.otp.domain.OtpPurpose;
-import java.time.Instant;
-import java.util.UUID;
-
-public record IssuedOtp(
- UUID challengeId, OtpPurpose purpose, OtpChannel channel, Instant expiresAt) {}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpApplicationService.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpApplicationService.java
deleted file mode 100644
index 15f0c74..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpApplicationService.java
+++ /dev/null
@@ -1,182 +0,0 @@
-package dev.amg.payguard.otp.application;
-
-import dev.amg.payguard.otp.domain.AuthenticatorCodeVerifier;
-import dev.amg.payguard.otp.domain.DeliveryPort;
-import dev.amg.payguard.otp.domain.OtpAuditLogPort;
-import dev.amg.payguard.otp.domain.OtpChallenge;
-import dev.amg.payguard.otp.domain.OtpChallengeRepository;
-import dev.amg.payguard.otp.domain.OtpChannel;
-import dev.amg.payguard.otp.domain.OtpCodeGenerator;
-import dev.amg.payguard.otp.domain.OtpCodeHasher;
-import dev.amg.payguard.otp.domain.OtpEventPublisher;
-import dev.amg.payguard.otp.domain.OtpVerificationMode;
-import dev.amg.payguard.otp.domain.OtpVerificationResult;
-import dev.amg.payguard.otp.domain.RateLimiter;
-import dev.amg.payguard.otp.domain.StepUpTokenPort;
-import java.time.Clock;
-import java.time.Duration;
-import java.time.Instant;
-import java.util.UUID;
-
-public final class OtpApplicationService {
-
- private final OtpChallengeRepository challenges;
- private final OtpCodeHasher hasher;
- private final OtpCodeGenerator codeGenerator;
- private final AuthenticatorCodeVerifier authenticatorCodeVerifier;
- private final DeliveryPort delivery;
- private final RateLimiter rateLimiter;
- private final StepUpTokenPort stepUpTokens;
- private final OtpEventPublisher events;
- private final OtpAuditLogPort audit;
- private final Clock clock;
- private final Duration randomTtl;
- private final Duration totpTtl;
- private final Duration auditRetention;
- private final int maxAttempts;
-
- public OtpApplicationService(
- OtpChallengeRepository challenges,
- OtpCodeHasher hasher,
- OtpCodeGenerator codeGenerator,
- AuthenticatorCodeVerifier authenticatorCodeVerifier,
- DeliveryPort delivery,
- RateLimiter rateLimiter,
- StepUpTokenPort stepUpTokens,
- OtpEventPublisher events,
- OtpAuditLogPort audit,
- Clock clock,
- Duration randomTtl,
- Duration totpTtl,
- Duration auditRetention,
- int maxAttempts) {
- this.challenges = challenges;
- this.hasher = hasher;
- this.codeGenerator = codeGenerator;
- this.authenticatorCodeVerifier = authenticatorCodeVerifier;
- this.delivery = delivery;
- this.rateLimiter = rateLimiter;
- this.stepUpTokens = stepUpTokens;
- this.events = events;
- this.audit = audit;
- this.clock = clock;
- this.randomTtl = randomTtl;
- this.totpTtl = totpTtl;
- this.auditRetention = auditRetention;
- this.maxAttempts = maxAttempts;
- }
-
- public IssuedOtp issue(IssueOtpCommand command) {
- requireText(command.userId(), "userId");
- if (!rateLimiter.allowIssue(command.userId(), command.purpose(), command.sourceIp())) {
- throw new OtpRateLimitExceededException();
- }
- Instant now = clock.instant();
- boolean totp = command.channel() == OtpChannel.AUTHENTICATOR_APP;
- Duration ttl = totp ? totpTtl : randomTtl;
- UUID challengeId = UUID.randomUUID();
- String code = totp ? null : codeGenerator.generate();
- OtpChallenge challenge =
- OtpChallenge.issue(
- challengeId,
- command.userId(),
- command.purpose(),
- command.channel(),
- totp ? OtpVerificationMode.TOTP : OtpVerificationMode.RANDOM_NUMERIC,
- code == null ? null : hasher.hash(code),
- command.deviceFingerprint() == null ? null : hasher.hash(command.deviceFingerprint()),
- maxAttempts,
- now,
- now.plus(ttl),
- now.plus(auditRetention));
- challenges.invalidateActive(command.userId(), command.purpose());
- challenges.save(challenge);
- if (!totp) {
- delivery.deliver(
- new DeliveryPort.DeliveryMessage(
- challengeId,
- command.userId(),
- command.purpose(),
- command.channel(),
- command.destinationRef(),
- code));
- }
- publish(
- new OtpEventPublisher.OtpEvent(
- "otp.challenge-issued",
- challengeId,
- command.userId(),
- command.purpose(),
- command.channel(),
- null,
- now,
- "challenge-issued"));
- audit.append(
- challengeId, command.userId(), "ISSUED", "challenge-issued", command.sourceIp(), now);
- return new IssuedOtp(challengeId, command.purpose(), command.channel(), challenge.expiresAt());
- }
-
- public VerifiedOtp verify(VerifyOtpCommand command) {
- OtpChallenge challenge =
- challenges
- .findById(command.challengeId())
- .orElseThrow(() -> new OtpChallengeNotFoundException(command.challengeId()));
- if (!challenge.userId().equals(command.userId())) {
- throw new OtpChallengeNotFoundException(command.challengeId());
- }
- if (!rateLimiter.allowVerify(command.userId(), command.purpose(), command.sourceIp())) {
- throw new OtpRateLimitExceededException();
- }
- Instant now = clock.instant();
- boolean authenticatorValid =
- challenge.verificationMode() == OtpVerificationMode.TOTP
- && authenticatorCodeVerifier.verify(command.userId(), command.code(), now);
- OtpVerificationResult result =
- challenge.verify(
- command.purpose(),
- command.code(),
- command.deviceFingerprint(),
- hasher,
- authenticatorValid,
- now);
- challenges.save(challenge);
- String reason = result.name().toLowerCase(java.util.Locale.ROOT);
- audit.append(challenge.id(), command.userId(), "VERIFY", reason, command.sourceIp(), now);
- if (result != OtpVerificationResult.VERIFIED) {
- publish(
- new OtpEventPublisher.OtpEvent(
- result == OtpVerificationResult.LOCKED ? "otp.locked-out" : "otp.failed",
- challenge.id(),
- command.userId(),
- command.purpose(),
- challenge.channel(),
- result,
- now,
- reason));
- throw new OtpVerificationException(result);
- }
- StepUpTokenPort.IssuedStepUpToken token =
- stepUpTokens.issue(command.userId(), command.purpose(), now);
- publish(
- new OtpEventPublisher.OtpEvent(
- "otp.verified",
- challenge.id(),
- command.userId(),
- command.purpose(),
- challenge.channel(),
- result,
- now,
- "verified"));
- return new VerifiedOtp(token.token(), command.purpose(), token.expiresAt());
- }
-
- private void publish(OtpEventPublisher.OtpEvent event) {
- events.publish(event);
- }
-
- private static void requireText(String value, String field) {
- if (value == null || value.isBlank()) {
- throw new IllegalArgumentException(field + " must not be blank");
- }
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpChallengeNotFoundException.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpChallengeNotFoundException.java
deleted file mode 100644
index df19fd4..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpChallengeNotFoundException.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package dev.amg.payguard.otp.application;
-
-import java.util.UUID;
-
-public class OtpChallengeNotFoundException extends RuntimeException {
-
- private static final long serialVersionUID = 1L;
-
- public OtpChallengeNotFoundException(UUID challengeId) {
- super("OTP challenge not found: " + challengeId);
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifiedOtp.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifiedOtp.java
deleted file mode 100644
index b9f10d7..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifiedOtp.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package dev.amg.payguard.otp.application;
-
-import dev.amg.payguard.otp.domain.OtpPurpose;
-import java.time.Instant;
-
-public record VerifiedOtp(String stepUpToken, OtpPurpose purpose, Instant expiresAt) {}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifyOtpCommand.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifyOtpCommand.java
deleted file mode 100644
index 90066fd..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifyOtpCommand.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package dev.amg.payguard.otp.application;
-
-import dev.amg.payguard.otp.domain.OtpPurpose;
-import java.util.UUID;
-
-public record VerifyOtpCommand(
- UUID challengeId,
- String userId,
- OtpPurpose purpose,
- String code,
- String deviceFingerprint,
- String sourceIp) {}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/AuthenticatorCodeVerifier.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/AuthenticatorCodeVerifier.java
deleted file mode 100644
index b2d0f23..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/AuthenticatorCodeVerifier.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-import java.time.Instant;
-
-@FunctionalInterface
-public interface AuthenticatorCodeVerifier {
-
- boolean verify(String userId, String code, Instant at);
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/DeliveryPort.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/DeliveryPort.java
deleted file mode 100644
index fcadf54..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/DeliveryPort.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-@FunctionalInterface
-public interface DeliveryPort {
-
- void deliver(DeliveryMessage message);
-
- record DeliveryMessage(
- java.util.UUID challengeId,
- String userId,
- OtpPurpose purpose,
- OtpChannel channel,
- String destinationRef,
- String code) {}
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpAuditLogPort.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpAuditLogPort.java
deleted file mode 100644
index d31c2c3..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpAuditLogPort.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-import java.time.Instant;
-import java.util.UUID;
-
-@FunctionalInterface
-public interface OtpAuditLogPort {
-
- void append(
- UUID challengeId, String userId, String action, String reason, String sourceIp, Instant at);
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallenge.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallenge.java
deleted file mode 100644
index f6f1452..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallenge.java
+++ /dev/null
@@ -1,236 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-import java.time.Instant;
-import java.util.Objects;
-import java.util.UUID;
-
-@SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName")
-public final class OtpChallenge {
-
- private static final int MIN_ATTEMPTS = 1;
-
- private final UUID id;
- private final String userId;
- private final OtpPurpose purpose;
- private final OtpChannel channel;
- private final OtpVerificationMode verificationMode;
- private final String codeHash;
- private final String deviceBindingHash;
- private final int maxAttempts;
- private final Instant createdAt;
- private final Instant expiresAt;
- private final Instant retentionUntil;
- private int attempts;
- private OtpChallengeStatus status;
- private Instant consumedAt;
-
- private OtpChallenge(
- UUID id,
- String userId,
- OtpPurpose purpose,
- OtpChannel channel,
- OtpVerificationMode verificationMode,
- String codeHash,
- String deviceBindingHash,
- int maxAttempts,
- Instant createdAt,
- Instant expiresAt,
- Instant retentionUntil,
- int attempts,
- OtpChallengeStatus status,
- Instant consumedAt) {
- this.id = Objects.requireNonNull(id);
- this.userId = requireText(userId, "userId");
- this.purpose = Objects.requireNonNull(purpose);
- this.channel = Objects.requireNonNull(channel);
- this.verificationMode = Objects.requireNonNull(verificationMode);
- this.codeHash = codeHash;
- this.deviceBindingHash = deviceBindingHash;
- if (maxAttempts < MIN_ATTEMPTS) {
- throw new IllegalArgumentException("maxAttempts must be positive");
- }
- this.maxAttempts = maxAttempts;
- this.createdAt = Objects.requireNonNull(createdAt);
- this.expiresAt = Objects.requireNonNull(expiresAt);
- this.retentionUntil = Objects.requireNonNull(retentionUntil);
- this.attempts = attempts;
- this.status = Objects.requireNonNull(status);
- this.consumedAt = consumedAt;
- }
-
- public static OtpChallenge issue(
- UUID id,
- String userId,
- OtpPurpose purpose,
- OtpChannel channel,
- OtpVerificationMode verificationMode,
- String codeHash,
- String deviceBindingHash,
- int maxAttempts,
- Instant createdAt,
- Instant expiresAt,
- Instant retentionUntil) {
- return new OtpChallenge(
- id,
- userId,
- purpose,
- channel,
- verificationMode,
- codeHash,
- deviceBindingHash,
- maxAttempts,
- createdAt,
- expiresAt,
- retentionUntil,
- 0,
- OtpChallengeStatus.ACTIVE,
- null);
- }
-
- public static OtpChallenge restore(
- UUID id,
- String userId,
- OtpPurpose purpose,
- OtpChannel channel,
- OtpVerificationMode verificationMode,
- String codeHash,
- String deviceBindingHash,
- int maxAttempts,
- Instant createdAt,
- Instant expiresAt,
- Instant retentionUntil,
- int attempts,
- OtpChallengeStatus status,
- Instant consumedAt) {
- return new OtpChallenge(
- id,
- userId,
- purpose,
- channel,
- verificationMode,
- codeHash,
- deviceBindingHash,
- maxAttempts,
- createdAt,
- expiresAt,
- retentionUntil,
- attempts,
- status,
- consumedAt);
- }
-
- public OtpVerificationResult verify(
- OtpPurpose requestedPurpose,
- String code,
- String deviceFingerprint,
- OtpCodeHasher hasher,
- boolean authenticatorCodeValid,
- Instant now) {
- if (requestedPurpose != purpose) {
- return OtpVerificationResult.PURPOSE_MISMATCH;
- }
- if (status == OtpChallengeStatus.USED) {
- return OtpVerificationResult.ALREADY_USED;
- }
- if (status == OtpChallengeStatus.LOCKED) {
- return OtpVerificationResult.LOCKED;
- }
- if (status == OtpChallengeStatus.INVALIDATED) {
- return OtpVerificationResult.INVALIDATED;
- }
- if (status == OtpChallengeStatus.EXPIRED || !now.isBefore(expiresAt)) {
- status = OtpChallengeStatus.EXPIRED;
- return OtpVerificationResult.EXPIRED;
- }
- if (deviceBindingHash != null
- && (deviceFingerprint == null || !hasher.matches(deviceFingerprint, deviceBindingHash))) {
- return OtpVerificationResult.DEVICE_MISMATCH;
- }
-
- attempts++;
- boolean valid =
- verificationMode == OtpVerificationMode.TOTP
- ? authenticatorCodeValid
- : code != null && codeHash != null && hasher.matches(code, codeHash);
- if (!valid) {
- if (attempts >= maxAttempts) {
- status = OtpChallengeStatus.LOCKED;
- }
- return status == OtpChallengeStatus.LOCKED
- ? OtpVerificationResult.LOCKED
- : OtpVerificationResult.INVALID_CODE;
- }
- status = OtpChallengeStatus.USED;
- consumedAt = now;
- return OtpVerificationResult.VERIFIED;
- }
-
- public void invalidate() {
- if (status == OtpChallengeStatus.ACTIVE) {
- status = OtpChallengeStatus.INVALIDATED;
- }
- }
-
- private static String requireText(String value, String field) {
- if (value == null || value.isBlank()) {
- throw new IllegalArgumentException(field + " must not be blank");
- }
- return value;
- }
-
- public UUID id() {
- return id;
- }
-
- public String userId() {
- return userId;
- }
-
- public OtpPurpose purpose() {
- return purpose;
- }
-
- public OtpChannel channel() {
- return channel;
- }
-
- public OtpVerificationMode verificationMode() {
- return verificationMode;
- }
-
- public String codeHash() {
- return codeHash;
- }
-
- public String deviceBindingHash() {
- return deviceBindingHash;
- }
-
- public int maxAttempts() {
- return maxAttempts;
- }
-
- public int attempts() {
- return attempts;
- }
-
- public OtpChallengeStatus status() {
- return status;
- }
-
- public Instant createdAt() {
- return createdAt;
- }
-
- public Instant expiresAt() {
- return expiresAt;
- }
-
- public Instant retentionUntil() {
- return retentionUntil;
- }
-
- public Instant consumedAt() {
- return consumedAt;
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeRepository.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeRepository.java
deleted file mode 100644
index ebfdad4..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeRepository.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-import java.util.Optional;
-import java.util.UUID;
-
-public interface OtpChallengeRepository {
-
- void invalidateActive(String userId, OtpPurpose purpose);
-
- OtpChallenge save(OtpChallenge challenge);
-
- Optional findById(UUID challengeId);
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeStatus.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeStatus.java
deleted file mode 100644
index 492a7f6..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeStatus.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-public enum OtpChallengeStatus {
- ACTIVE,
- USED,
- EXPIRED,
- LOCKED,
- INVALIDATED
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChannel.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChannel.java
deleted file mode 100644
index 048564e..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChannel.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-public enum OtpChannel {
- SMS,
- EMAIL,
- PUSH,
- AUTHENTICATOR_APP
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeGenerator.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeGenerator.java
deleted file mode 100644
index 5a45023..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeGenerator.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-@FunctionalInterface
-public interface OtpCodeGenerator {
-
- String generate();
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeHasher.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeHasher.java
deleted file mode 100644
index b0b9d9d..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeHasher.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-public interface OtpCodeHasher {
-
- String hash(String value);
-
- boolean matches(String value, String encodedHash);
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpEventPublisher.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpEventPublisher.java
deleted file mode 100644
index 40e6aa0..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpEventPublisher.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-import java.time.Instant;
-import java.util.UUID;
-
-@FunctionalInterface
-public interface OtpEventPublisher {
-
- void publish(OtpEvent event);
-
- record OtpEvent(
- String type,
- UUID challengeId,
- String userId,
- OtpPurpose purpose,
- OtpChannel channel,
- OtpVerificationResult result,
- Instant occurredAt,
- String reason) {}
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpPurpose.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpPurpose.java
deleted file mode 100644
index 3f7b398..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpPurpose.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-public enum OtpPurpose {
- LOGIN,
- TRANSFER_CONFIRM,
- LOAN_DISBURSE,
- COLLATERAL_RELEASE,
- CARD_ISSUANCE,
- PROFILE_CHANGE
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationMode.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationMode.java
deleted file mode 100644
index 00aa23e..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationMode.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-public enum OtpVerificationMode {
- RANDOM_NUMERIC,
- TOTP
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationResult.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationResult.java
deleted file mode 100644
index 2b1b8b9..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationResult.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-public enum OtpVerificationResult {
- VERIFIED,
- INVALID_CODE,
- EXPIRED,
- ALREADY_USED,
- LOCKED,
- INVALIDATED,
- PURPOSE_MISMATCH,
- DEVICE_MISMATCH
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/RateLimiter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/RateLimiter.java
deleted file mode 100644
index d2d47db..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/RateLimiter.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-public interface RateLimiter {
-
- boolean allowIssue(String userId, OtpPurpose purpose, String sourceIp);
-
- boolean allowVerify(String userId, OtpPurpose purpose, String sourceIp);
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/StepUpTokenPort.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/StepUpTokenPort.java
deleted file mode 100644
index f9e27c2..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/StepUpTokenPort.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-import java.time.Instant;
-
-public interface StepUpTokenPort {
-
- IssuedStepUpToken issue(String userId, OtpPurpose purpose, Instant now);
-
- boolean consume(String token, String userId, OtpPurpose purpose, Instant now);
-
- record IssuedStepUpToken(String token, Instant expiresAt) {}
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/config/OtpConfiguration.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/config/OtpConfiguration.java
deleted file mode 100644
index b301dd9..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/config/OtpConfiguration.java
+++ /dev/null
@@ -1,58 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.config;
-
-import dev.amg.payguard.otp.application.OtpApplicationService;
-import dev.amg.payguard.otp.domain.AuthenticatorCodeVerifier;
-import dev.amg.payguard.otp.domain.OtpAuditLogPort;
-import dev.amg.payguard.otp.domain.OtpChallengeRepository;
-import dev.amg.payguard.otp.domain.OtpCodeGenerator;
-import dev.amg.payguard.otp.domain.OtpCodeHasher;
-import dev.amg.payguard.otp.domain.OtpEventPublisher;
-import dev.amg.payguard.otp.domain.RateLimiter;
-import dev.amg.payguard.otp.domain.StepUpTokenPort;
-import java.time.Clock;
-import java.time.Duration;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-
-@Configuration
-public class OtpConfiguration {
-
- @Bean
- Clock otpClock() {
- return Clock.systemUTC();
- }
-
- @Bean
- OtpApplicationService otpApplicationService(
- OtpChallengeRepository challenges,
- OtpCodeHasher hasher,
- OtpCodeGenerator generator,
- AuthenticatorCodeVerifier authenticatorCodeVerifier,
- dev.amg.payguard.otp.domain.DeliveryPort delivery,
- RateLimiter rateLimiter,
- StepUpTokenPort stepUpTokens,
- OtpEventPublisher events,
- OtpAuditLogPort audit,
- Clock otpClock,
- @Value("${otp.challenge.random-ttl:PT5M}") Duration randomTtl,
- @Value("${otp.challenge.totp-ttl:PT30S}") Duration totpTtl,
- @Value("${otp.audit.retention:PT2160H}") Duration auditRetention,
- @Value("${otp.challenge.max-attempts:5}") int maxAttempts) {
- return new OtpApplicationService(
- challenges,
- hasher,
- generator,
- authenticatorCodeVerifier,
- delivery,
- rateLimiter,
- stepUpTokens,
- events,
- audit,
- otpClock,
- randomTtl,
- totpTtl,
- auditRetention,
- maxAttempts);
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/EmailDeliveryAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/EmailDeliveryAdapter.java
deleted file mode 100644
index 9cf1b97..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/EmailDeliveryAdapter.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.delivery;
-
-import dev.amg.payguard.otp.domain.DeliveryPort;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
-
-/** TLS provider integration seam for email. The provider request must never be logged. */
-@Component
-@ConditionalOnProperty(name = "otp.delivery.mode", havingValue = "email")
-public class EmailDeliveryAdapter implements DeliveryPort {
-
- @Override
- public void deliver(DeliveryMessage message) {
- // Integrate with the email provider over TLS; do not log message.code.
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/MockDeliveryAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/MockDeliveryAdapter.java
deleted file mode 100644
index 112c177..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/MockDeliveryAdapter.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.delivery;
-
-import dev.amg.payguard.otp.domain.DeliveryPort;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
-
-/**
- * Safe local adapter. It deliberately discards the code and never logs it. A provider adapter is
- * selected in production without changing the domain or application layer.
- */
-@Component
-@ConditionalOnProperty(name = "otp.delivery.mode", havingValue = "mock", matchIfMissing = true)
-public class MockDeliveryAdapter implements DeliveryPort {
-
- @Override
- public void deliver(DeliveryMessage message) {
- // Provider calls belong here. Never log or persist message.code.
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/PushDeliveryAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/PushDeliveryAdapter.java
deleted file mode 100644
index fbd0038..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/PushDeliveryAdapter.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.delivery;
-
-import dev.amg.payguard.otp.domain.DeliveryPort;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
-
-/** TLS provider integration seam for push notifications. */
-@Component
-@ConditionalOnProperty(name = "otp.delivery.mode", havingValue = "push")
-public class PushDeliveryAdapter implements DeliveryPort {
-
- @Override
- public void deliver(DeliveryMessage message) {
- // Integrate with the push provider over TLS; do not log message.code.
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/SmsDeliveryAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/SmsDeliveryAdapter.java
deleted file mode 100644
index 6e0d081..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/SmsDeliveryAdapter.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.delivery;
-
-import dev.amg.payguard.otp.domain.DeliveryPort;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
-
-/** TLS provider integration seam for SMS. The provider request must never be logged. */
-@Component
-@ConditionalOnProperty(name = "otp.delivery.mode", havingValue = "sms")
-public class SmsDeliveryAdapter implements DeliveryPort {
-
- @Override
- public void deliver(DeliveryMessage message) {
- // Integrate with the SMS provider over TLS; do not log message.code.
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/kafka/KafkaOtpEventPublisher.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/kafka/KafkaOtpEventPublisher.java
deleted file mode 100644
index 4aeff54..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/kafka/KafkaOtpEventPublisher.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.kafka;
-
-import dev.amg.payguard.otp.domain.OtpEventPublisher;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.kafka.core.KafkaTemplate;
-import org.springframework.stereotype.Component;
-import tools.jackson.databind.ObjectMapper;
-
-@Component
-public class KafkaOtpEventPublisher implements OtpEventPublisher {
-
- private final KafkaTemplate kafka;
- private final ObjectMapper objectMapper;
- private final String topic;
-
- public KafkaOtpEventPublisher(
- KafkaTemplate kafka,
- ObjectMapper objectMapper,
- @Value("${otp.kafka.topic:otp.events.v1}") String topic) {
- this.kafka = kafka;
- this.objectMapper = objectMapper;
- this.topic = topic;
- }
-
- @Override
- public void publish(OtpEvent event) {
- try {
- kafka.send(topic, event.challengeId().toString(), objectMapper.writeValueAsString(event));
- } catch (tools.jackson.core.JacksonException exception) {
- throw new IllegalStateException("Unable to serialize OTP event", exception);
- }
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpAuditLogAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpAuditLogAdapter.java
deleted file mode 100644
index 0ee2220..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpAuditLogAdapter.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.persistence;
-
-import dev.amg.payguard.otp.domain.OtpAuditLogPort;
-import java.time.Instant;
-import java.util.UUID;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-
-@Component
-public class JpaOtpAuditLogAdapter implements OtpAuditLogPort {
-
- private final OtpAuditLogJpaRepository repository;
-
- public JpaOtpAuditLogAdapter(OtpAuditLogJpaRepository repository) {
- this.repository = repository;
- }
-
- @Override
- @Transactional
- public void append(
- UUID challengeId, String userId, String action, String reason, String sourceIp, Instant at) {
- repository.save(new OtpAuditLogEntity(challengeId, userId, action, reason, sourceIp, at));
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpChallengeRepositoryAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpChallengeRepositoryAdapter.java
deleted file mode 100644
index a377d36..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpChallengeRepositoryAdapter.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.persistence;
-
-import dev.amg.payguard.otp.domain.OtpChallenge;
-import dev.amg.payguard.otp.domain.OtpChallengeRepository;
-import dev.amg.payguard.otp.domain.OtpChallengeStatus;
-import dev.amg.payguard.otp.domain.OtpPurpose;
-import java.util.Optional;
-import java.util.UUID;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-
-@Component
-public class JpaOtpChallengeRepositoryAdapter implements OtpChallengeRepository {
-
- private final OtpChallengeJpaRepository repository;
-
- public JpaOtpChallengeRepositoryAdapter(OtpChallengeJpaRepository repository) {
- this.repository = repository;
- }
-
- @Override
- @Transactional
- public void invalidateActive(String userId, OtpPurpose purpose) {
- repository
- .findByUserIdAndPurposeAndStatus(userId, purpose, OtpChallengeStatus.ACTIVE)
- .forEach(
- entity -> {
- OtpChallenge challenge = entity.toDomain();
- challenge.invalidate();
- entity.copyFrom(challenge);
- repository.save(entity);
- });
- }
-
- @Override
- @Transactional
- public OtpChallenge save(OtpChallenge challenge) {
- OtpChallengeEntity entity =
- repository
- .findById(challenge.id())
- .map(
- existing -> {
- existing.copyFrom(challenge);
- return existing;
- })
- .orElseGet(() -> OtpChallengeEntity.from(challenge));
- return repository.save(entity).toDomain();
- }
-
- @Override
- @Transactional(readOnly = true)
- public Optional findById(UUID challengeId) {
- return repository.findById(challengeId).map(OtpChallengeEntity::toDomain);
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeEntity.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeEntity.java
deleted file mode 100644
index e3a2da5..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeEntity.java
+++ /dev/null
@@ -1,126 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.persistence;
-
-import dev.amg.payguard.otp.domain.OtpChallenge;
-import dev.amg.payguard.otp.domain.OtpChallengeStatus;
-import dev.amg.payguard.otp.domain.OtpChannel;
-import dev.amg.payguard.otp.domain.OtpPurpose;
-import dev.amg.payguard.otp.domain.OtpVerificationMode;
-import jakarta.persistence.Column;
-import jakarta.persistence.Entity;
-import jakarta.persistence.EnumType;
-import jakarta.persistence.Enumerated;
-import jakarta.persistence.Id;
-import jakarta.persistence.Table;
-import java.time.Instant;
-import java.util.UUID;
-
-@Entity
-@Table(name = "otp_challenge")
-public class OtpChallengeEntity {
-
- @Id private UUID id;
-
- @Column(name = "user_id", nullable = false, length = 255)
- private String userId;
-
- @Enumerated(EnumType.STRING)
- @Column(nullable = false, length = 32)
- private OtpPurpose purpose;
-
- @Enumerated(EnumType.STRING)
- @Column(nullable = false, length = 32)
- private OtpChannel channel;
-
- @Enumerated(EnumType.STRING)
- @Column(name = "verification_mode", nullable = false, length = 32)
- private OtpVerificationMode verificationMode;
-
- @Column(name = "code_hash", length = 512)
- private String codeHash;
-
- @Column(name = "device_binding_hash", length = 512)
- private String deviceBindingHash;
-
- @Column(name = "attempts", nullable = false)
- private int attempts;
-
- @Column(name = "max_attempts", nullable = false)
- private int maxAttempts;
-
- @Enumerated(EnumType.STRING)
- @Column(nullable = false, length = 20)
- private OtpChallengeStatus status;
-
- @Column(name = "created_at", nullable = false)
- private Instant createdAt;
-
- @Column(name = "expires_at", nullable = false)
- private Instant expiresAt;
-
- @Column(name = "consumed_at")
- private Instant consumedAt;
-
- @Column(name = "retention_until", nullable = false)
- private Instant retentionUntil;
-
- protected OtpChallengeEntity() {}
-
- private OtpChallengeEntity(OtpChallenge challenge) {
- copyFrom(challenge);
- }
-
- static OtpChallengeEntity from(OtpChallenge challenge) {
- return new OtpChallengeEntity(challenge);
- }
-
- final void copyFrom(OtpChallenge challenge) {
- id = challenge.id();
- userId = challenge.userId();
- purpose = challenge.purpose();
- channel = challenge.channel();
- verificationMode = challenge.verificationMode();
- codeHash = challenge.codeHash();
- deviceBindingHash = challenge.deviceBindingHash();
- attempts = challenge.attempts();
- maxAttempts = challenge.maxAttempts();
- status = challenge.status();
- createdAt = challenge.createdAt();
- expiresAt = challenge.expiresAt();
- consumedAt = challenge.consumedAt();
- retentionUntil = challenge.retentionUntil();
- }
-
- OtpChallenge toDomain() {
- return OtpChallenge.restore(
- id,
- userId,
- purpose,
- channel,
- verificationMode,
- codeHash,
- deviceBindingHash,
- maxAttempts,
- createdAt,
- expiresAt,
- retentionUntil,
- attempts,
- status,
- consumedAt);
- }
-
- public UUID getId() {
- return id;
- }
-
- public String getUserId() {
- return userId;
- }
-
- public OtpPurpose getPurpose() {
- return purpose;
- }
-
- public OtpChallengeStatus getStatus() {
- return status;
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeJpaRepository.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeJpaRepository.java
deleted file mode 100644
index a97a54f..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeJpaRepository.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.persistence;
-
-import dev.amg.payguard.otp.domain.OtpChallengeStatus;
-import dev.amg.payguard.otp.domain.OtpPurpose;
-import java.util.List;
-import java.util.UUID;
-import org.springframework.data.jpa.repository.JpaRepository;
-
-interface OtpChallengeJpaRepository extends JpaRepository {
-
- List findByUserIdAndPurposeAndStatus(
- String userId, OtpPurpose purpose, OtpChallengeStatus status);
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisStepUpTokenAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisStepUpTokenAdapter.java
deleted file mode 100644
index a5386e8..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisStepUpTokenAdapter.java
+++ /dev/null
@@ -1,69 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.redis;
-
-import dev.amg.payguard.otp.domain.OtpPurpose;
-import dev.amg.payguard.otp.domain.StepUpTokenPort;
-import java.nio.charset.StandardCharsets;
-import java.security.SecureRandom;
-import java.time.Duration;
-import java.time.Instant;
-import java.util.Base64;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.stereotype.Component;
-
-@Component
-public class RedisStepUpTokenAdapter implements StepUpTokenPort {
-
- private static final int TOKEN_PARTS = 3;
- private final StringRedisTemplate redis;
- private final Duration ttl;
- private final SecureRandom random = new SecureRandom();
-
- public RedisStepUpTokenAdapter(
- StringRedisTemplate redis, @Value("${otp.step-up-token.ttl:PT3M}") Duration ttl) {
- this.redis = redis;
- this.ttl = ttl;
- }
-
- @Override
- public IssuedStepUpToken issue(String userId, OtpPurpose purpose, Instant now) {
- byte[] bytes = new byte[32];
- random.nextBytes(bytes);
- String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
- Instant expiresAt = now.plus(ttl);
- String value =
- Base64.getUrlEncoder()
- .withoutPadding()
- .encodeToString(userId.getBytes(StandardCharsets.UTF_8))
- + "|"
- + purpose
- + "|"
- + expiresAt.toEpochMilli();
- redis.opsForValue().set("otp:step-up:" + token, value, ttl);
- return new IssuedStepUpToken(token, expiresAt);
- }
-
- @Override
- public boolean consume(String token, String userId, OtpPurpose purpose, Instant now) {
- if (token == null || token.isBlank()) {
- return false;
- }
- String value = redis.opsForValue().getAndDelete("otp:step-up:" + token);
- if (value == null) {
- return false;
- }
- String[] parts = value.split("\\|", -1);
- if (parts.length != TOKEN_PARTS) {
- return false;
- }
- try {
- String storedUser =
- new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8);
- return storedUser.equals(userId)
- && purpose.name().equals(parts[1])
- && now.isBefore(Instant.ofEpochMilli(Long.parseLong(parts[2])));
- } catch (IllegalArgumentException exception) {
- return false;
- }
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/AuthenticatorSecretPort.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/AuthenticatorSecretPort.java
deleted file mode 100644
index 24945d1..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/AuthenticatorSecretPort.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.security;
-
-import java.util.Optional;
-
-@FunctionalInterface
-public interface AuthenticatorSecretPort {
-
- Optional secretFor(String userId);
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasher.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasher.java
deleted file mode 100644
index e092043..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasher.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.security;
-
-import dev.amg.payguard.otp.domain.OtpCodeHasher;
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.security.SecureRandom;
-import java.util.HexFormat;
-import javax.crypto.Mac;
-import javax.crypto.spec.SecretKeySpec;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-
-@Component
-public final class HmacOtpCodeHasher implements OtpCodeHasher {
-
- private static final String ALGORITHM = "HmacSHA256";
- private static final int HASH_PARTS = 2;
- private final SecureRandom secureRandom = new SecureRandom();
- private final byte[] pepper;
-
- public HmacOtpCodeHasher(@Value("${otp.security.pepper}") String pepper) {
- if (pepper == null || pepper.isBlank() || pepper.length() < 32) {
- throw new IllegalArgumentException("otp.security.pepper must contain at least 32 characters");
- }
- this.pepper = pepper.getBytes(StandardCharsets.UTF_8);
- }
-
- @Override
- public String hash(String value) {
- byte[] salt = new byte[16];
- secureRandom.nextBytes(salt);
- return HexFormat.of().formatHex(salt) + ":" + HexFormat.of().formatHex(digest(value, salt));
- }
-
- @Override
- public boolean matches(String value, String encodedHash) {
- if (value == null || encodedHash == null) {
- return false;
- }
- try {
- String[] parts = encodedHash.split(":", -1);
- if (parts.length != HASH_PARTS) {
- return false;
- }
- byte[] salt = HexFormat.of().parseHex(parts[0]);
- byte[] expected = HexFormat.of().parseHex(parts[1]);
- return MessageDigest.isEqual(expected, digest(value, salt));
- } catch (IllegalArgumentException exception) {
- return false;
- }
- }
-
- private byte[] digest(String value, byte[] salt) {
- try {
- Mac mac = Mac.getInstance(ALGORITHM);
- mac.init(new SecretKeySpec(pepper, ALGORITHM));
- mac.update(salt);
- return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
- } catch (java.security.GeneralSecurityException exception) {
- throw new IllegalStateException("HMAC-SHA256 is unavailable", exception);
- }
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java
deleted file mode 100644
index defb71b..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.security;
-
-import java.util.Optional;
-import org.springframework.stereotype.Component;
-
-/** Resolves no secrets by default; production wiring supplies a vault-backed implementation. */
-@Component
-public final class UnavailableAuthenticatorSecretAdapter implements AuthenticatorSecretPort {
-
- @Override
- public Optional secretFor(String userId) {
- return Optional.empty();
- }
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpExceptionHandler.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpExceptionHandler.java
deleted file mode 100644
index 6c01fd3..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpExceptionHandler.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package dev.amg.payguard.otp.interfaces.rest;
-
-import dev.amg.payguard.otp.application.OtpChallengeNotFoundException;
-import dev.amg.payguard.otp.application.OtpRateLimitExceededException;
-import dev.amg.payguard.otp.application.OtpVerificationException;
-import java.time.Instant;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.ExceptionHandler;
-import org.springframework.web.bind.annotation.RestControllerAdvice;
-
-@RestControllerAdvice
-public class OtpExceptionHandler {
-
- @ExceptionHandler(OtpChallengeNotFoundException.class)
- ResponseEntity notFound(OtpChallengeNotFoundException exception) {
- return response(HttpStatus.NOT_FOUND, "challenge_not_found");
- }
-
- @ExceptionHandler(OtpRateLimitExceededException.class)
- ResponseEntity rateLimited(OtpRateLimitExceededException exception) {
- return response(HttpStatus.TOO_MANY_REQUESTS, "rate_limit_exceeded");
- }
-
- @ExceptionHandler(OtpVerificationException.class)
- ResponseEntity verificationFailed(OtpVerificationException exception) {
- HttpStatus status =
- switch (exception.result()) {
- case LOCKED -> HttpStatus.TOO_MANY_REQUESTS;
- case ALREADY_USED, INVALIDATED -> HttpStatus.CONFLICT;
- case EXPIRED -> HttpStatus.GONE;
- default -> HttpStatus.UNAUTHORIZED;
- };
- return response(status, exception.result().name().toLowerCase(java.util.Locale.ROOT));
- }
-
- private static ResponseEntity response(HttpStatus status, String code) {
- return ResponseEntity.status(status).body(new ErrorResponse(code, Instant.now()));
- }
-
- record ErrorResponse(String code, Instant timestamp) {}
-}
diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/StepUpTokenController.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/StepUpTokenController.java
deleted file mode 100644
index 4466703..0000000
--- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/StepUpTokenController.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package dev.amg.payguard.otp.interfaces.rest;
-
-import dev.amg.payguard.otp.domain.OtpPurpose;
-import dev.amg.payguard.otp.domain.StepUpTokenPort;
-import jakarta.validation.Valid;
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
-import java.time.Clock;
-import org.springframework.http.HttpStatus;
-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.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-
-@RestController
-@RequestMapping("/otp/step-up-tokens")
-public class StepUpTokenController {
-
- private final StepUpTokenPort tokens;
- private final Clock clock;
-
- public StepUpTokenController(StepUpTokenPort tokens, Clock clock) {
- this.tokens = tokens;
- this.clock = clock;
- }
-
- @PostMapping("/consume")
- public ResponseEntity consume(@Valid @RequestBody ConsumeTokenRequest request) {
- boolean accepted =
- tokens.consume(request.token(), request.userId(), request.purpose(), clock.instant());
- return accepted
- ? ResponseEntity.noContent().build()
- : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
- }
-
- public record ConsumeTokenRequest(
- @NotBlank String token, @NotBlank String userId, @NotNull OtpPurpose purpose) {}
-}
diff --git a/payguard-otp-service/src/main/resources/application.yaml b/payguard-otp-service/src/main/resources/application.yaml
deleted file mode 100644
index dc0c2d2..0000000
--- a/payguard-otp-service/src/main/resources/application.yaml
+++ /dev/null
@@ -1,46 +0,0 @@
-spring:
- application:
- name: payguard-otp-service
- datasource:
- url: ${SPRING_DATASOURCE_URL}
- username: ${SPRING_DATASOURCE_USERNAME}
- password: ${SPRING_DATASOURCE_PASSWORD}
- driver-class-name: oracle.jdbc.OracleDriver
- jpa:
- open-in-view: false
- hibernate:
- ddl-auto: none
- flyway:
- enabled: true
- data:
- redis:
- host: ${REDIS_HOST:localhost}
- port: ${REDIS_PORT:6379}
- kafka:
- bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
- producer:
- key-serializer: org.apache.kafka.common.serialization.StringSerializer
- value-serializer: org.apache.kafka.common.serialization.StringSerializer
-
-server:
- port: ${OTP_SERVICE_PORT:8084}
-
-otp:
- security:
- pepper: ${OTP_SECURITY_PEPPER:change-this-otp-pepper-in-production-32-chars}
- delivery:
- mode: ${OTP_DELIVERY_MODE:mock}
- challenge:
- random-ttl: ${OTP_RANDOM_TTL:PT5M}
- totp-ttl: ${OTP_TOTP_TTL:PT30S}
- max-attempts: ${OTP_MAX_ATTEMPTS:5}
- rate-limit:
- max-issues: ${OTP_MAX_ISSUES:3}
- max-verifications: ${OTP_MAX_VERIFICATIONS:20}
- window: ${OTP_RATE_LIMIT_WINDOW:PT15M}
- step-up-token:
- ttl: ${OTP_STEP_UP_TOKEN_TTL:PT3M}
- audit:
- retention: ${OTP_AUDIT_RETENTION:PT2160H}
- kafka:
- topic: ${OTP_KAFKA_TOPIC:otp.events.v1}
diff --git a/payguard-otp-service/src/main/resources/banner.txt b/payguard-otp-service/src/main/resources/banner.txt
deleted file mode 100644
index 83938d4..0000000
--- a/payguard-otp-service/src/main/resources/banner.txt
+++ /dev/null
@@ -1,6 +0,0 @@
- ____ ____ _
- | _ \ __ _ _ _/ ___|_ _ __ _ _ __ __| |
- | |_) / _` | | | \___ \ | | |/ _` | '__/ _` |
- | __/ (_| | |_| |___) || |_| (_| | | | (_| |
- |_| \__,_|\__, |____/ \__,\__,_|_| \__,_|
- |___/
diff --git a/payguard-otp-service/src/main/resources/db/migration/V1__otp_schema.sql b/payguard-otp-service/src/main/resources/db/migration/V1__otp_schema.sql
deleted file mode 100644
index 5f6084a..0000000
--- a/payguard-otp-service/src/main/resources/db/migration/V1__otp_schema.sql
+++ /dev/null
@@ -1,41 +0,0 @@
-CREATE TABLE otp_challenge (
- id RAW(16) NOT NULL,
- user_id VARCHAR2(255 CHAR) NOT NULL,
- purpose VARCHAR2(32 CHAR) NOT NULL,
- channel VARCHAR2(32 CHAR) NOT NULL,
- verification_mode VARCHAR2(32 CHAR) NOT NULL,
- code_hash VARCHAR2(512 CHAR),
- device_binding_hash VARCHAR2(512 CHAR),
- attempts NUMBER(3) DEFAULT 0 NOT NULL,
- max_attempts NUMBER(3) NOT NULL,
- status VARCHAR2(20 CHAR) NOT NULL,
- created_at TIMESTAMP WITH TIME ZONE NOT NULL,
- expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
- consumed_at TIMESTAMP WITH TIME ZONE,
- retention_until TIMESTAMP WITH TIME ZONE NOT NULL,
- CONSTRAINT pk_otp_challenge PRIMARY KEY (id),
- CONSTRAINT ck_otp_challenge_attempts CHECK (attempts >= 0 AND attempts <= max_attempts)
-);
-
-CREATE INDEX ix_otp_challenge_active
- ON otp_challenge (user_id, purpose, status);
-CREATE INDEX ix_otp_challenge_retention
- ON otp_challenge (retention_until);
-
-CREATE TABLE otp_audit_log (
- id RAW(16) DEFAULT SYS_GUID() NOT NULL,
- challenge_id RAW(16) NOT NULL,
- user_id VARCHAR2(255 CHAR) NOT NULL,
- action VARCHAR2(32 CHAR) NOT NULL,
- reason VARCHAR2(128 CHAR) NOT NULL,
- source_ip VARCHAR2(64 CHAR),
- occurred_at TIMESTAMP WITH TIME ZONE NOT NULL,
- CONSTRAINT pk_otp_audit_log PRIMARY KEY (id),
- CONSTRAINT fk_otp_audit_challenge FOREIGN KEY (challenge_id)
- REFERENCES otp_challenge (id)
-);
-
-CREATE INDEX ix_otp_audit_subject_time
- ON otp_audit_log (user_id, occurred_at);
-CREATE INDEX ix_otp_audit_challenge
- ON otp_audit_log (challenge_id, occurred_at);
diff --git a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/application/OtpApplicationServiceTest.java b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/application/OtpApplicationServiceTest.java
deleted file mode 100644
index 3c12e36..0000000
--- a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/application/OtpApplicationServiceTest.java
+++ /dev/null
@@ -1,176 +0,0 @@
-package dev.amg.payguard.otp.application;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
-import dev.amg.payguard.otp.domain.DeliveryPort;
-import dev.amg.payguard.otp.domain.OtpChallenge;
-import dev.amg.payguard.otp.domain.OtpChallengeRepository;
-import dev.amg.payguard.otp.domain.OtpChannel;
-import dev.amg.payguard.otp.domain.OtpCodeHasher;
-import dev.amg.payguard.otp.domain.OtpPurpose;
-import dev.amg.payguard.otp.domain.OtpVerificationResult;
-import dev.amg.payguard.otp.domain.RateLimiter;
-import dev.amg.payguard.otp.domain.StepUpTokenPort;
-import java.time.Clock;
-import java.time.Duration;
-import java.time.Instant;
-import java.time.ZoneOffset;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
-import java.util.UUID;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-class OtpApplicationServiceTest {
-
- private final FixedHasher hasher = new FixedHasher();
- private final InMemoryChallenges challenges = new InMemoryChallenges();
- private final CapturingDelivery delivery = new CapturingDelivery();
- private final StepUpTokenPort tokens =
- new StepUpTokenPort() {
- @Override
- public IssuedStepUpToken issue(String userId, OtpPurpose purpose, Instant now) {
- return new IssuedStepUpToken("step-up-token", now.plusSeconds(180));
- }
-
- @Override
- public boolean consume(String token, String userId, OtpPurpose purpose, Instant now) {
- return "step-up-token".equals(token);
- }
- };
- private OtpApplicationService service;
-
- @BeforeEach
- void setUp() {
- Instant now = Instant.parse("2026-01-01T00:00:00Z");
- service =
- new OtpApplicationService(
- challenges,
- hasher,
- () -> "123456",
- (userId, code, at) -> false,
- delivery,
- new AllowAllRateLimiter(),
- tokens,
- event -> {},
- (challengeId, userId, action, reason, sourceIp, at) -> {},
- Clock.fixed(now, ZoneOffset.UTC),
- Duration.ofMinutes(5),
- Duration.ofSeconds(30),
- Duration.ofDays(90),
- 5);
- }
-
- @Test
- void issueAndVerifyReturnsSingleUseStepUpToken() {
- IssuedOtp issued =
- service.issue(
- new IssueOtpCommand(
- "user-1", OtpPurpose.LOGIN, OtpChannel.SMS, "identity:phone:1", null, "127.0.0.1"));
-
- assertEquals("123456", delivery.code);
- VerifiedOtp verified =
- service.verify(
- new VerifyOtpCommand(
- issued.challengeId(), "user-1", OtpPurpose.LOGIN, "123456", null, "127.0.0.1"));
-
- assertEquals("step-up-token", verified.stepUpToken());
- OtpVerificationException replay =
- assertThrows(
- OtpVerificationException.class,
- () ->
- service.verify(
- new VerifyOtpCommand(
- issued.challengeId(),
- "user-1",
- OtpPurpose.LOGIN,
- "123456",
- null,
- "127.0.0.1")));
- assertEquals(OtpVerificationResult.ALREADY_USED, replay.result());
- }
-
- @Test
- void wrongPurposeCannotVerifyChallenge() {
- IssuedOtp issued =
- service.issue(
- new IssueOtpCommand(
- "user-1",
- OtpPurpose.LOGIN,
- OtpChannel.EMAIL,
- "identity:email:1",
- null,
- "127.0.0.1"));
-
- OtpVerificationException exception =
- assertThrows(
- OtpVerificationException.class,
- () ->
- service.verify(
- new VerifyOtpCommand(
- issued.challengeId(),
- "user-1",
- OtpPurpose.LOAN_DISBURSE,
- "123456",
- null,
- "127.0.0.1")));
- assertEquals(OtpVerificationResult.PURPOSE_MISMATCH, exception.result());
- }
-
- private static final class FixedHasher implements OtpCodeHasher {
- @Override
- public String hash(String value) {
- return "hash:" + value;
- }
-
- @Override
- public boolean matches(String value, String encodedHash) {
- return encodedHash.equals(hash(value));
- }
- }
-
- private static final class CapturingDelivery implements DeliveryPort {
- private String code;
-
- @Override
- public void deliver(DeliveryMessage message) {
- code = message.code();
- }
- }
-
- private static final class AllowAllRateLimiter implements RateLimiter {
- @Override
- public boolean allowIssue(String userId, OtpPurpose purpose, String sourceIp) {
- return true;
- }
-
- @Override
- public boolean allowVerify(String userId, OtpPurpose purpose, String sourceIp) {
- return true;
- }
- }
-
- private static final class InMemoryChallenges implements OtpChallengeRepository {
- private final Map values = new HashMap<>();
-
- @Override
- public void invalidateActive(String userId, OtpPurpose purpose) {
- values.values().stream()
- .filter(challenge -> challenge.userId().equals(userId) && challenge.purpose() == purpose)
- .forEach(OtpChallenge::invalidate);
- }
-
- @Override
- public OtpChallenge save(OtpChallenge challenge) {
- values.put(challenge.id(), challenge);
- return challenge;
- }
-
- @Override
- public Optional findById(UUID challengeId) {
- return Optional.ofNullable(values.get(challengeId));
- }
- }
-}
diff --git a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/domain/OtpChallengeTest.java b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/domain/OtpChallengeTest.java
deleted file mode 100644
index 8f80c7f..0000000
--- a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/domain/OtpChallengeTest.java
+++ /dev/null
@@ -1,102 +0,0 @@
-package dev.amg.payguard.otp.domain;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-import java.time.Instant;
-import java.util.UUID;
-import org.junit.jupiter.api.Test;
-
-class OtpChallengeTest {
-
- private final OtpCodeHasher hasher =
- new OtpCodeHasher() {
- @Override
- public String hash(String value) {
- return "hash:" + value;
- }
-
- @Override
- public boolean matches(String value, String encodedHash) {
- return encodedHash.equals(hash(value));
- }
- };
-
- @Test
- void successfulVerificationIsSingleUse() {
- Instant now = Instant.parse("2026-01-01T00:00:00Z");
- OtpChallenge challenge = challenge(now, 5);
-
- assertEquals(
- OtpVerificationResult.VERIFIED,
- challenge.verify(OtpPurpose.LOGIN, "123456", null, hasher, false, now));
- assertEquals(
- OtpVerificationResult.ALREADY_USED,
- challenge.verify(OtpPurpose.LOGIN, "123456", null, hasher, false, now.plusSeconds(1)));
- }
-
- @Test
- void fifthInvalidAttemptLocksChallenge() {
- Instant now = Instant.parse("2026-01-01T00:00:00Z");
- OtpChallenge challenge = challenge(now, 2);
-
- assertEquals(
- OtpVerificationResult.INVALID_CODE,
- challenge.verify(OtpPurpose.LOGIN, "000000", null, hasher, false, now));
- assertEquals(
- OtpVerificationResult.LOCKED,
- challenge.verify(OtpPurpose.LOGIN, "000000", null, hasher, false, now));
- assertEquals(
- OtpVerificationResult.LOCKED,
- challenge.verify(OtpPurpose.LOGIN, "123456", null, hasher, false, now));
- }
-
- @Test
- void expiredChallengeCannotBeVerified() {
- Instant now = Instant.parse("2026-01-01T00:00:00Z");
- OtpChallenge challenge = challenge(now, 5);
-
- assertEquals(
- OtpVerificationResult.EXPIRED,
- challenge.verify(OtpPurpose.LOGIN, "123456", null, hasher, false, now.plusSeconds(300)));
- }
-
- @Test
- void deviceBindingIsRequiredWhenConfigured() {
- Instant now = Instant.parse("2026-01-01T00:00:00Z");
- OtpChallenge challenge =
- OtpChallenge.issue(
- UUID.randomUUID(),
- "user-1",
- OtpPurpose.LOGIN,
- OtpChannel.SMS,
- OtpVerificationMode.RANDOM_NUMERIC,
- hasher.hash("123456"),
- hasher.hash("device-a"),
- 5,
- now,
- now.plusSeconds(300),
- now.plusSeconds(86_400));
-
- assertEquals(
- OtpVerificationResult.DEVICE_MISMATCH,
- challenge.verify(OtpPurpose.LOGIN, "123456", "device-b", hasher, false, now));
- assertEquals(
- OtpVerificationResult.VERIFIED,
- challenge.verify(OtpPurpose.LOGIN, "123456", "device-a", hasher, false, now));
- }
-
- private static OtpChallenge challenge(Instant now, int maxAttempts) {
- return OtpChallenge.issue(
- UUID.randomUUID(),
- "user-1",
- OtpPurpose.LOGIN,
- OtpChannel.SMS,
- OtpVerificationMode.RANDOM_NUMERIC,
- "hash:123456",
- null,
- maxAttempts,
- now,
- now.plusSeconds(300),
- now.plusSeconds(86_400));
- }
-}
diff --git a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/OtpInfrastructureIntegrationTest.java b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/OtpInfrastructureIntegrationTest.java
deleted file mode 100644
index 19104dd..0000000
--- a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/OtpInfrastructureIntegrationTest.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package dev.amg.payguard.otp.infrastructure;
-
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
-import org.testcontainers.containers.GenericContainer;
-import org.testcontainers.containers.OracleContainer;
-import org.testcontainers.junit.jupiter.Container;
-import org.testcontainers.junit.jupiter.Testcontainers;
-
-@Testcontainers
-@EnabledIfEnvironmentVariable(named = "RUN_OTP_INTEGRATION_TESTS", matches = "true")
-class OtpInfrastructureIntegrationTest {
-
- @Container
- static final OracleContainer ORACLE = new OracleContainer("gvenzl/oracle-xe:21-slim-faststart");
-
- @Container
- static final GenericContainer> REDIS =
- new GenericContainer<>("redis:8-alpine").withExposedPorts(6379);
-
- @Test
- void oracleAndRedisAreReachable() {
- assertTrue(ORACLE.isRunning());
- assertTrue(REDIS.isRunning());
- assertTrue(ORACLE.getJdbcUrl().startsWith("jdbc:oracle:"));
- }
-}
diff --git a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasherTest.java b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasherTest.java
deleted file mode 100644
index 779a0c4..0000000
--- a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasherTest.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package dev.amg.payguard.otp.infrastructure.security;
-
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import org.junit.jupiter.api.Test;
-
-class HmacOtpCodeHasherTest {
-
- private final HmacOtpCodeHasher hasher =
- new HmacOtpCodeHasher("a-secure-test-pepper-with-at-least-32-chars");
-
- @Test
- void storesSaltedHashAndMatchesOnlyOriginalValue() {
- String encoded = hasher.hash("123456");
-
- assertNotEquals("123456", encoded);
- assertTrue(hasher.matches("123456", encoded));
- assertFalse(hasher.matches("123457", encoded));
- assertFalse(hasher.matches("123456", "not-a-hash"));
- }
-}
diff --git a/payguard-otp-service/src/test/resources/application-test.yaml b/payguard-otp-service/src/test/resources/application-test.yaml
deleted file mode 100644
index 46c8f0f..0000000
--- a/payguard-otp-service/src/test/resources/application-test.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-spring:
- flyway:
- enabled: false
- jpa:
- open-in-view: false
-
-otp:
- security:
- pepper: test-pepper-with-at-least-32-characters
diff --git a/pom.xml b/pom.xml
index 4af7e03..72b5783 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,7 +18,7 @@
payguard-wallet-service
payguard-loan-service
payguard-collateral-service
- payguard-otp-service
+ payguard-identity-service
@@ -80,10 +80,9 @@
flyway-maven-plugin
12.4.0
- ${env.PAYGUARD_MIGRATION_JDBC_URL}
- ${env.PAYGUARD_MIGRATION_USERNAME}
- ${env.PAYGUARD_MIGRATION_PASSWORD}
- SET ROLE ${env.PAYGUARD_MIGRATION_OWNER}
+
+
+ DO 'BEGIN RAISE EXCEPTION ''FLYWAY_INIT_SQL must select the service owner''; END;'
payguard
payguard
false