dockerNetwork();
+ }
+
interface LambdaServiceConfig {
@WithDefault("true")
boolean enabled();
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/AwsException.java b/src/main/java/io/github/hectorvent/floci/core/common/AwsException.java
index 58616dbf..f792d3ac 100644
--- a/src/main/java/io/github/hectorvent/floci/core/common/AwsException.java
+++ b/src/main/java/io/github/hectorvent/floci/core/common/AwsException.java
@@ -1,11 +1,29 @@
package io.github.hectorvent.floci.core.common;
+import java.util.Map;
+
/**
* Base exception for AWS emulator errors.
* Maps to AWS-style error responses with code, message, and HTTP status.
+ *
+ * Some services use different error code formats for Query (XML) and JSON protocols.
+ * {@link #jsonType()} returns the JSON-protocol {@code __type} value that the AWS SDK v2
+ * uses to instantiate a specific typed exception rather than falling back to a generic one.
*/
public class AwsException extends RuntimeException {
+ /**
+ * Maps Query-protocol error codes to their JSON-protocol {@code __type} equivalents.
+ * Codes absent from this map are used as-is for both protocols.
+ */
+ private static final Map JSON_TYPE_BY_QUERY_CODE = Map.of(
+ "AWS.SimpleQueueService.NonExistentQueue", "QueueDoesNotExist",
+ "QueueAlreadyExists", "QueueNameExists",
+ "ReceiptHandleIsInvalid", "ReceiptHandleIsInvalid",
+ "TooManyEntriesInBatchRequest", "TooManyEntriesInBatchRequest",
+ "BatchEntryIdNotUnique", "BatchEntryIdNotDistinct"
+ );
+
private final String errorCode;
private final int httpStatus;
@@ -22,4 +40,12 @@ public String getErrorCode() {
public int getHttpStatus() {
return httpStatus;
}
+
+ /**
+ * Returns the JSON-protocol {@code __type} value for this error.
+ * The AWS SDK v2 uses this to map responses to typed exception classes.
+ */
+ public String jsonType() {
+ return JSON_TYPE_BY_QUERY_CODE.getOrDefault(errorCode, errorCode);
+ }
}
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/AwsExceptionMapper.java b/src/main/java/io/github/hectorvent/floci/core/common/AwsExceptionMapper.java
index 161a65aa..7ddb33f8 100644
--- a/src/main/java/io/github/hectorvent/floci/core/common/AwsExceptionMapper.java
+++ b/src/main/java/io/github/hectorvent/floci/core/common/AwsExceptionMapper.java
@@ -18,8 +18,8 @@ public class AwsExceptionMapper implements ExceptionMapper {
public Response toResponse(AwsException exception) {
LOG.debugv("Mapping exception: {0} - {1}", exception.getErrorCode(), exception.getMessage());
return Response.status(exception.getHttpStatus())
- .entity(new AwsErrorResponse(exception.getErrorCode(), exception.getMessage()))
.type(MediaType.APPLICATION_JSON)
+ .entity(new AwsErrorResponse(exception.jsonType(), exception.getMessage()))
.build();
}
}
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/AwsJsonController.java b/src/main/java/io/github/hectorvent/floci/core/common/AwsJsonController.java
index cf34f05d..88a11f8b 100644
--- a/src/main/java/io/github/hectorvent/floci/core/common/AwsJsonController.java
+++ b/src/main/java/io/github/hectorvent/floci/core/common/AwsJsonController.java
@@ -135,7 +135,7 @@ public Response handleJsonRequest(
} catch (AwsException e) {
return Response.status(e.getHttpStatus())
.type(MediaType.APPLICATION_JSON)
- .entity(new AwsErrorResponse(e.getErrorCode(), e.getMessage()))
+ .entity(new AwsErrorResponse(e.jsonType(), e.getMessage()))
.build();
} catch (Exception e) {
LOG.error("Error processing " + serviceName + " JSON request", e);
@@ -329,7 +329,7 @@ private Response dispatchCbor(String serviceId, String operation, JsonNode reque
private Response cborErrorResponse(AwsException e, String protocolHeader) {
try {
byte[] errBytes = CBOR_MAPPER.writeValueAsBytes(
- new AwsErrorResponse(e.getErrorCode(), e.getMessage()));
+ new AwsErrorResponse(e.jsonType(), e.getMessage()));
return Response.status(e.getHttpStatus())
.header(protocolHeader, "rpc-v2-cbor")
.type("application/cbor")
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/AwsRequestIdFilter.java b/src/main/java/io/github/hectorvent/floci/core/common/AwsRequestIdFilter.java
new file mode 100644
index 00000000..bce8ad6c
--- /dev/null
+++ b/src/main/java/io/github/hectorvent/floci/core/common/AwsRequestIdFilter.java
@@ -0,0 +1,49 @@
+package io.github.hectorvent.floci.core.common;
+
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerResponseContext;
+import jakarta.ws.rs.container.ContainerResponseFilter;
+import jakarta.ws.rs.ext.Provider;
+
+import java.util.UUID;
+
+/**
+ * Adds AWS request-id response headers to every HTTP response.
+ *
+ * Real AWS services always return a request identifier so that SDKs can
+ * populate {@code $metadata.requestId}. The header name varies by protocol:
+ *
+ * - {@code x-amz-request-id} — REST XML (S3), REST JSON (Lambda), Query protocol
+ * - {@code x-amzn-RequestId} — JSON 1.0 / 1.1 services (DynamoDB, SSM, …)
+ * - {@code x-amz-id-2} — S3 extended request ID
+ *
+ *
+ * This filter emits all three so that every AWS SDK variant can find the
+ * header it expects. If a controller already set {@code x-amz-request-id}
+ * (e.g. Lambda invoke), the existing value is preserved.
+ */
+@Provider
+public class AwsRequestIdFilter implements ContainerResponseFilter {
+
+ private static final String AMZ_REQUEST_ID = "x-amz-request-id";
+ private static final String AMZN_REQUEST_ID = "x-amzn-RequestId";
+ private static final String AMZ_ID_2 = "x-amz-id-2";
+
+ @Override
+ public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) {
+ var headers = responseContext.getHeaders();
+
+ // Reuse the same ID across all header variants for this response
+ String requestId = UUID.randomUUID().toString();
+
+ if (!headers.containsKey(AMZ_REQUEST_ID)) {
+ headers.putSingle(AMZ_REQUEST_ID, requestId);
+ }
+ if (!headers.containsKey(AMZN_REQUEST_ID)) {
+ headers.putSingle(AMZN_REQUEST_ID, requestId);
+ }
+ if (!headers.containsKey(AMZ_ID_2)) {
+ headers.putSingle(AMZ_ID_2, requestId);
+ }
+ }
+}
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/ServiceEnabledFilter.java b/src/main/java/io/github/hectorvent/floci/core/common/ServiceEnabledFilter.java
index f6c054f8..1e63af3d 100644
--- a/src/main/java/io/github/hectorvent/floci/core/common/ServiceEnabledFilter.java
+++ b/src/main/java/io/github/hectorvent/floci/core/common/ServiceEnabledFilter.java
@@ -1,8 +1,12 @@
package io.github.hectorvent.floci.core.common;
+import io.github.hectorvent.floci.services.cognito.CognitoOAuthController;
+import io.github.hectorvent.floci.services.cognito.CognitoWellKnownController;
import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.container.ResourceInfo;
+import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
@@ -16,6 +20,9 @@ public class ServiceEnabledFilter implements ContainerRequestFilter {
private static final Pattern AUTH_SERVICE_PATTERN =
Pattern.compile("Credential=\\S+/\\d{8}/[^/]+/([^/]+)/");
+ @Context
+ ResourceInfo resourceInfo;
+
private final ServiceRegistry serviceRegistry;
@Inject
@@ -48,7 +55,7 @@ private String resolveServiceKey(ContainerRequestContext ctx) {
}
}
- return null;
+ return serviceKeyFromMatchedResource();
}
private String serviceKeyFromTarget(String target) {
@@ -75,12 +82,25 @@ private String mapCredentialScope(String scope) {
};
}
+ private String serviceKeyFromMatchedResource() {
+ Class> resourceClass = resourceInfo != null ? resourceInfo.getResourceClass() : null;
+ if (resourceClass == null) {
+ return null;
+ }
+ if (CognitoOAuthController.class.equals(resourceClass)
+ || CognitoWellKnownController.class.equals(resourceClass)) {
+ return "cognito-idp";
+ }
+ return null;
+ }
+
private Response disabledResponse(ContainerRequestContext ctx, String serviceKey) {
String message = "Service " + serviceKey + " is not enabled.";
String target = ctx.getHeaderString("X-Amz-Target");
String contentType = ctx.getMediaType() != null ? ctx.getMediaType().toString() : "";
+ boolean jsonEndpoint = serviceKeyFromMatchedResource() != null;
- if (target != null || contentType.contains("json")) {
+ if (target != null || contentType.contains("json") || jsonEndpoint) {
return Response.status(400)
.type(MediaType.APPLICATION_JSON)
.entity(new AwsErrorResponse("ServiceNotAvailableException", message))
@@ -99,4 +119,4 @@ private Response disabledResponse(ContainerRequestContext ctx, String serviceKey
.build();
return Response.status(400).entity(xml).type(MediaType.APPLICATION_XML).build();
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/ServiceRegistry.java b/src/main/java/io/github/hectorvent/floci/core/common/ServiceRegistry.java
index 9fd075f9..5da4e28f 100644
--- a/src/main/java/io/github/hectorvent/floci/core/common/ServiceRegistry.java
+++ b/src/main/java/io/github/hectorvent/floci/core/common/ServiceRegistry.java
@@ -47,6 +47,7 @@ public boolean isServiceEnabled(String serviceName) {
case "cloudformation" -> config.services().cloudformation().enabled();
case "acm" -> config.services().acm().enabled();
case "email" -> config.services().ses().enabled();
+ case "es" -> config.services().opensearch().enabled();
default -> true;
};
}
@@ -75,6 +76,7 @@ public List getEnabledServices() {
if (config.services().cloudformation().enabled()) enabled.add("cloudformation");
if (config.services().acm().enabled()) enabled.add("acm");
if (config.services().ses().enabled()) enabled.add("email");
+ if (config.services().opensearch().enabled()) enabled.add("es");
return enabled;
}
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/SqsQueueUrlRouterFilter.java b/src/main/java/io/github/hectorvent/floci/core/common/SqsQueueUrlRouterFilter.java
new file mode 100644
index 00000000..d093e308
--- /dev/null
+++ b/src/main/java/io/github/hectorvent/floci/core/common/SqsQueueUrlRouterFilter.java
@@ -0,0 +1,65 @@
+package io.github.hectorvent.floci.core.common;
+
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.container.PreMatching;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.ext.Provider;
+
+import java.net.URI;
+import java.util.regex.Pattern;
+
+/**
+ * Pre-matching filter that rewrites SQS JSON 1.0 requests sent to the queue URL path
+ * (/{accountId}/{queueName}) to POST / so they are handled by AwsJsonController.
+ *
+ * Newer AWS SDKs (e.g. aws-sdk-sqs Ruby gem >= 1.71) route operations to the queue URL
+ * rather than POST /. Without this filter, those requests match S3Controller's
+ * /{bucket}/{key:.+} handler and return NoSuchBucket errors.
+ */
+@Provider
+@PreMatching
+public class SqsQueueUrlRouterFilter implements ContainerRequestFilter {
+
+ private static final Pattern QUEUE_PATH = Pattern.compile("^/([^/]+)/([^/]+)$");
+
+ @Override
+ public void filter(ContainerRequestContext ctx) {
+
+ if (!"POST".equals(ctx.getMethod())) {
+ return;
+ }
+
+ String path = ctx.getUriInfo().getPath();
+ if (!QUEUE_PATH.matcher(path).matches()) {
+ return;
+ }
+
+ MediaType mt = ctx.getMediaType();
+ if (mt == null) {
+ return;
+ }
+
+ boolean isSqsJson = "application".equals(mt.getType())
+ && "x-amz-json-1.0".equals(mt.getSubtype())
+ && isSqsTarget(ctx.getHeaderString("X-Amz-Target"));
+
+ // S3 never receives form-encoded POSTs to /{bucket}/{key} paths —
+ // S3 presigned POST always goes to /{bucket}, not /{bucket}/{key}.
+ boolean isSqsQuery = "application".equals(mt.getType())
+ && "x-www-form-urlencoded".equals(mt.getSubtype());
+
+ if (!isSqsJson && !isSqsQuery) {
+ return;
+ }
+
+ URI rewritten = ctx.getUriInfo().getRequestUriBuilder()
+ .replacePath("/")
+ .build();
+ ctx.setRequestUri(rewritten);
+ }
+
+ private boolean isSqsTarget(String target) {
+ return target != null && target.startsWith("AmazonSQS.");
+ }
+}
diff --git a/src/main/java/io/github/hectorvent/floci/core/storage/StorageFactory.java b/src/main/java/io/github/hectorvent/floci/core/storage/StorageFactory.java
index 1c217f4b..151f6b51 100644
--- a/src/main/java/io/github/hectorvent/floci/core/storage/StorageFactory.java
+++ b/src/main/java/io/github/hectorvent/floci/core/storage/StorageFactory.java
@@ -98,17 +98,19 @@ public void shutdownAll() {
}
private String resolveMode(String serviceName) {
+ String globalMode = config.storage().mode();
return switch (serviceName) {
- case "ssm" -> config.storage().services().ssm().mode();
- case "sqs" -> config.storage().services().sqs().mode();
- case "s3" -> config.storage().services().s3().mode();
- case "dynamodb" -> config.storage().services().dynamodb().mode();
- case "sns" -> config.storage().services().sns().mode();
- case "lambda" -> config.storage().services().lambda().mode();
- case "cloudwatchlogs" -> config.storage().services().cloudwatchlogs().mode();
- case "cloudwatchmetrics" -> config.storage().services().cloudwatchmetrics().mode();
- case "secretsmanager" -> config.storage().services().secretsmanager().mode();
- default -> config.storage().mode();
+ case "ssm" -> config.storage().services().ssm().mode().orElse(globalMode);
+ case "sqs" -> config.storage().services().sqs().mode().orElse(globalMode);
+ case "s3" -> config.storage().services().s3().mode().orElse(globalMode);
+ case "dynamodb" -> config.storage().services().dynamodb().mode().orElse(globalMode);
+ case "sns" -> config.storage().services().sns().mode().orElse(globalMode);
+ case "lambda" -> config.storage().services().lambda().mode().orElse(globalMode);
+ case "cloudwatchlogs" -> config.storage().services().cloudwatchlogs().mode().orElse(globalMode);
+ case "cloudwatchmetrics" -> config.storage().services().cloudwatchmetrics().mode().orElse(globalMode);
+ case "secretsmanager" -> config.storage().services().secretsmanager().mode().orElse(globalMode);
+ case "opensearch" -> config.storage().services().opensearch().mode().orElse(globalMode);
+ default -> globalMode;
};
}
@@ -121,6 +123,7 @@ private long resolveFlushInterval(String serviceName) {
case "cloudwatchlogs" -> config.storage().services().cloudwatchlogs().flushIntervalMs();
case "cloudwatchmetrics" -> config.storage().services().cloudwatchmetrics().flushIntervalMs();
case "secretsmanager" -> config.storage().services().secretsmanager().flushIntervalMs();
+ case "opensearch" -> config.storage().services().opensearch().flushIntervalMs();
default -> 5000L;
};
}
diff --git a/src/main/java/io/github/hectorvent/floci/lifecycle/EmulatorLifecycle.java b/src/main/java/io/github/hectorvent/floci/lifecycle/EmulatorLifecycle.java
index 13fb7fbd..9e1aa5bc 100644
--- a/src/main/java/io/github/hectorvent/floci/lifecycle/EmulatorLifecycle.java
+++ b/src/main/java/io/github/hectorvent/floci/lifecycle/EmulatorLifecycle.java
@@ -7,6 +7,7 @@
import io.github.hectorvent.floci.lifecycle.inithook.InitializationHooksRunner;
import io.github.hectorvent.floci.services.elasticache.proxy.ElastiCacheProxyManager;
import io.github.hectorvent.floci.services.rds.proxy.RdsProxyManager;
+import io.quarkus.runtime.Quarkus;
import io.quarkus.runtime.ShutdownEvent;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
@@ -15,11 +16,17 @@
import org.jboss.logging.Logger;
import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
@ApplicationScoped
public class EmulatorLifecycle {
private static final Logger LOG = Logger.getLogger(EmulatorLifecycle.class);
+ private static final int HTTP_PORT = 4566;
+ private static final int PORT_POLL_TIMEOUT_MS = 100;
+ private static final int PORT_POLL_INTERVAL_MS = 50;
+ private static final int PORT_POLL_MAX_RETRIES = 100;
private final StorageFactory storageFactory;
private final ServiceRegistry serviceRegistry;
@@ -40,16 +47,44 @@ public EmulatorLifecycle(StorageFactory storageFactory, ServiceRegistry serviceR
this.initializationHooksRunner = initializationHooksRunner;
}
- void onStart(@Observes StartupEvent ignored) throws IOException, InterruptedException {
+ void onStart(@Observes StartupEvent ignored) throws IOException {
LOG.info("=== AWS Local Emulator Starting ===");
LOG.infov("Storage mode: {0}", config.storage().mode());
LOG.infov("Persistent path: {0}", config.storage().persistentPath());
serviceRegistry.logEnabledServices();
storageFactory.loadAll();
- initializationHooksRunner.run(InitializationHook.START);
- LOG.info("=== AWS Local Emulator Ready ===");
+ if (initializationHooksRunner.hasHooks(InitializationHook.START)) {
+ LOG.info("Startup hooks detected — deferring execution until HTTP server is ready");
+ Thread.ofVirtual().name("init-hooks-runner").start(this::runStartupHooksAfterReady);
+ } else {
+ LOG.info("=== AWS Local Emulator Ready ===");
+ }
+ }
+
+ private void runStartupHooksAfterReady() {
+ try {
+ waitForHttpPort();
+ initializationHooksRunner.run(InitializationHook.START);
+ LOG.info("=== AWS Local Emulator Ready ===");
+ } catch (Exception e) {
+ LOG.error("Startup hook execution failed — shutting down", e);
+ Quarkus.asyncExit();
+ }
+ }
+
+ private static void waitForHttpPort() throws InterruptedException {
+ for (int attempt = 1; attempt <= PORT_POLL_MAX_RETRIES; attempt++) {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress("localhost", HTTP_PORT), PORT_POLL_TIMEOUT_MS);
+ LOG.debugv("HTTP port {0} is ready (attempt {1})", HTTP_PORT, attempt);
+ return;
+ } catch (IOException ignored) {
+ Thread.sleep(PORT_POLL_INTERVAL_MS);
+ }
+ }
+ throw new IllegalStateException("HTTP port " + HTTP_PORT + " did not become ready in time");
}
void onStop(@Observes ShutdownEvent ignored) throws IOException, InterruptedException {
diff --git a/src/main/java/io/github/hectorvent/floci/lifecycle/inithook/InitializationHooksRunner.java b/src/main/java/io/github/hectorvent/floci/lifecycle/inithook/InitializationHooksRunner.java
index df8d1a2b..d6af7f70 100644
--- a/src/main/java/io/github/hectorvent/floci/lifecycle/inithook/InitializationHooksRunner.java
+++ b/src/main/java/io/github/hectorvent/floci/lifecycle/inithook/InitializationHooksRunner.java
@@ -44,6 +44,10 @@ private static String[] findScriptFileNames(final String hookName, final File ho
return scriptFileNames;
}
+ public boolean hasHooks(final InitializationHook hook) {
+ return findScriptFileNames(hook.getName(), hook.getPath()).length > 0;
+ }
+
public void run(final InitializationHook hook) throws IOException, InterruptedException {
final String hookName = hook.getName();
final File hookDirectory = hook.getPath();
diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java
index 66719c85..5776d493 100644
--- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java
+++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java
@@ -3,7 +3,9 @@
import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
import io.github.hectorvent.floci.services.dynamodb.DynamoDbService;
import io.github.hectorvent.floci.services.dynamodb.model.AttributeDefinition;
+import io.github.hectorvent.floci.services.dynamodb.model.GlobalSecondaryIndex;
import io.github.hectorvent.floci.services.dynamodb.model.KeySchemaElement;
+import io.github.hectorvent.floci.services.dynamodb.model.LocalSecondaryIndex;
import io.github.hectorvent.floci.services.iam.IamService;
import io.github.hectorvent.floci.services.kms.KmsService;
import io.github.hectorvent.floci.services.lambda.LambdaService;
@@ -59,34 +61,35 @@ public CloudFormationResourceProvisioner(S3Service s3Service, SqsService sqsServ
* Returns null and logs a warning for unsupported types.
*/
public StackResource provision(String logicalId, String resourceType, JsonNode properties,
- CloudFormationTemplateEngine engine, String region, String accountId) {
+ CloudFormationTemplateEngine engine, String region, String accountId,
+ String stackName) {
StackResource resource = new StackResource();
resource.setLogicalId(logicalId);
resource.setResourceType(resourceType);
try {
switch (resourceType) {
- case "AWS::S3::Bucket" -> provisionS3Bucket(resource, properties, engine, region, accountId);
- case "AWS::SQS::Queue" -> provisionSqsQueue(resource, properties, engine, region, accountId);
- case "AWS::SNS::Topic" -> provisionSnsTopic(resource, properties, engine, region, accountId);
+ case "AWS::S3::Bucket" -> provisionS3Bucket(resource, properties, engine, region, accountId, stackName);
+ case "AWS::SQS::Queue" -> provisionSqsQueue(resource, properties, engine, region, accountId, stackName);
+ case "AWS::SNS::Topic" -> provisionSnsTopic(resource, properties, engine, region, accountId, stackName);
case "AWS::DynamoDB::Table", "AWS::DynamoDB::GlobalTable" ->
- provisionDynamoTable(resource, properties, engine, region, accountId);
- case "AWS::Lambda::Function" -> provisionLambda(resource, properties, engine, region, accountId);
- case "AWS::IAM::Role" -> provisionIamRole(resource, properties, engine, accountId);
- case "AWS::IAM::User" -> provisionIamUser(resource, properties, engine);
+ provisionDynamoTable(resource, properties, engine, region, accountId, stackName);
+ case "AWS::Lambda::Function" -> provisionLambda(resource, properties, engine, region, accountId, stackName);
+ case "AWS::IAM::Role" -> provisionIamRole(resource, properties, engine, accountId, stackName);
+ case "AWS::IAM::User" -> provisionIamUser(resource, properties, engine, stackName);
case "AWS::IAM::AccessKey" -> provisionIamAccessKey(resource, properties, engine);
case "AWS::IAM::Policy", "AWS::IAM::ManagedPolicy" ->
- provisionIamPolicy(resource, properties, engine, accountId);
- case "AWS::IAM::InstanceProfile" -> provisionInstanceProfile(resource, properties, engine, accountId);
- case "AWS::SSM::Parameter" -> provisionSsmParameter(resource, properties, engine, region);
+ provisionIamPolicy(resource, properties, engine, accountId, stackName);
+ case "AWS::IAM::InstanceProfile" -> provisionInstanceProfile(resource, properties, engine, accountId, stackName);
+ case "AWS::SSM::Parameter" -> provisionSsmParameter(resource, properties, engine, region, stackName);
case "AWS::KMS::Key" -> provisionKmsKey(resource, properties, engine, region, accountId);
case "AWS::KMS::Alias" -> provisionKmsAlias(resource, properties, engine, region);
- case "AWS::SecretsManager::Secret" -> provisionSecret(resource, properties, engine, region, accountId);
+ case "AWS::SecretsManager::Secret" -> provisionSecret(resource, properties, engine, region, accountId, stackName);
case "AWS::CloudFormation::Stack" -> provisionNestedStack(resource, properties, engine, region);
case "AWS::CDK::Metadata" -> provisionCdkMetadata(resource);
case "AWS::S3::BucketPolicy" -> provisionS3BucketPolicy(resource, properties, engine);
case "AWS::SQS::QueuePolicy" -> provisionSqsQueuePolicy(resource, properties, engine);
- case "AWS::ECR::Repository" -> provisionEcrRepository(resource, properties, engine);
+ case "AWS::ECR::Repository" -> provisionEcrRepository(resource, properties, engine, stackName);
case "AWS::Route53::HostedZone" -> provisionRoute53HostedZone(resource, properties, engine);
case "AWS::Route53::RecordSet" -> provisionRoute53RecordSet(resource, properties, engine);
default -> {
@@ -131,10 +134,10 @@ public void delete(String resourceType, String physicalId, String region) {
// ── S3 ────────────────────────────────────────────────────────────────────
private void provisionS3Bucket(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String region, String accountId) {
+ String region, String accountId, String stackName) {
String bucketName = resolveOptional(props, "BucketName", engine);
if (bucketName == null || bucketName.isBlank()) {
- bucketName = "cfn-" + UUID.randomUUID().toString().substring(0, 12).toLowerCase();
+ bucketName = generatePhysicalName(stackName, r.getLogicalId(), 63, true);
}
s3Service.createBucket(bucketName, region);
r.setPhysicalId(bucketName);
@@ -148,10 +151,10 @@ private void provisionS3Bucket(StackResource r, JsonNode props, CloudFormationTe
// ── SQS ───────────────────────────────────────────────────────────────────
private void provisionSqsQueue(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String region, String accountId) {
+ String region, String accountId, String stackName) {
String queueName = resolveOptional(props, "QueueName", engine);
if (queueName == null || queueName.isBlank()) {
- queueName = "cfn-" + UUID.randomUUID().toString().substring(0, 12);
+ queueName = generatePhysicalName(stackName, r.getLogicalId(), 80, false);
}
Map attrs = new HashMap<>();
if (props != null && props.has("VisibilityTimeout")) {
@@ -168,10 +171,10 @@ private void provisionSqsQueue(StackResource r, JsonNode props, CloudFormationTe
// ── SNS ───────────────────────────────────────────────────────────────────
private void provisionSnsTopic(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String region, String accountId) {
+ String region, String accountId, String stackName) {
String topicName = resolveOptional(props, "TopicName", engine);
if (topicName == null || topicName.isBlank()) {
- topicName = "cfn-" + UUID.randomUUID().toString().substring(0, 12);
+ topicName = generatePhysicalName(stackName, r.getLogicalId(), 256, false);
}
var topic = snsService.createTopic(topicName, Map.of(), Map.of(), region);
r.setPhysicalId(topic.getTopicArn());
@@ -182,14 +185,16 @@ private void provisionSnsTopic(StackResource r, JsonNode props, CloudFormationTe
// ── DynamoDB ──────────────────────────────────────────────────────────────
private void provisionDynamoTable(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String region, String accountId) {
+ String region, String accountId, String stackName) {
String tableName = resolveOptional(props, "TableName", engine);
if (tableName == null || tableName.isBlank()) {
- tableName = "cfn-" + UUID.randomUUID().toString().substring(0, 12);
+ tableName = generatePhysicalName(stackName, r.getLogicalId(), 255, false);
}
List keySchema = new ArrayList<>();
List attrDefs = new ArrayList<>();
+ List gsis = new ArrayList<>();
+ List lsis = new ArrayList<>();
if (props != null && props.has("KeySchema")) {
for (JsonNode ks : props.get("KeySchema")) {
@@ -206,12 +211,52 @@ private void provisionDynamoTable(StackResource r, JsonNode props, CloudFormatio
}
}
+ if (props != null && props.has("GlobalSecondaryIndexes")) {
+ for (JsonNode gsiNode : props.get("GlobalSecondaryIndexes")) {
+ String indexName = engine.resolve(gsiNode.get("IndexName"));
+ List gsiKeySchema = new ArrayList<>();
+ if (gsiNode.has("KeySchema")) {
+ for (JsonNode ks : gsiNode.get("KeySchema")) {
+ String attrName = engine.resolve(ks.get("AttributeName"));
+ String keyType = engine.resolve(ks.get("KeyType"));
+ gsiKeySchema.add(new KeySchemaElement(attrName, keyType));
+ }
+ }
+ String projectionType = "ALL";
+ JsonNode projection = gsiNode.get("Projection");
+ if (projection != null && projection.has("ProjectionType")) {
+ projectionType = engine.resolve(projection.get("ProjectionType"));
+ }
+ gsis.add(new GlobalSecondaryIndex(indexName, gsiKeySchema, null, projectionType));
+ }
+ }
+
+ if (props != null && props.has("LocalSecondaryIndexes")) {
+ for (JsonNode lsiNode : props.get("LocalSecondaryIndexes")) {
+ String indexName = engine.resolve(lsiNode.get("IndexName"));
+ List lsiKeySchema = new ArrayList<>();
+ if (lsiNode.has("KeySchema")) {
+ for (JsonNode ks : lsiNode.get("KeySchema")) {
+ String attrName = engine.resolve(ks.get("AttributeName"));
+ String keyType = engine.resolve(ks.get("KeyType"));
+ lsiKeySchema.add(new KeySchemaElement(attrName, keyType));
+ }
+ }
+ String projectionType = "ALL";
+ JsonNode projection = lsiNode.get("Projection");
+ if (projection != null && projection.has("ProjectionType")) {
+ projectionType = engine.resolve(projection.get("ProjectionType"));
+ }
+ lsis.add(new LocalSecondaryIndex(indexName, lsiKeySchema, null, projectionType));
+ }
+ }
+
if (keySchema.isEmpty()) {
keySchema.add(new KeySchemaElement("id", "HASH"));
attrDefs.add(new AttributeDefinition("id", "S"));
}
- var table = dynamoDbService.createTable(tableName, keySchema, attrDefs, null, null, region);
+ var table = dynamoDbService.createTable(tableName, keySchema, attrDefs, null, null, gsis, lsis, region);
r.setPhysicalId(tableName);
r.getAttributes().put("Arn", table.getTableArn());
r.getAttributes().put("StreamArn", table.getTableArn() + "/stream/2024-01-01T00:00:00.000");
@@ -220,10 +265,10 @@ private void provisionDynamoTable(StackResource r, JsonNode props, CloudFormatio
// ── Lambda ────────────────────────────────────────────────────────────────
private void provisionLambda(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String region, String accountId) {
+ String region, String accountId, String stackName) {
String funcName = resolveOptional(props, "FunctionName", engine);
if (funcName == null || funcName.isBlank()) {
- funcName = "cfn-func-" + UUID.randomUUID().toString().substring(0, 8);
+ funcName = generatePhysicalName(stackName, r.getLogicalId(), 64, false);
}
Map req = new HashMap<>();
req.put("FunctionName", funcName);
@@ -243,10 +288,10 @@ private void provisionLambda(StackResource r, JsonNode props, CloudFormationTemp
// ── IAM Role ──────────────────────────────────────────────────────────────
private void provisionIamRole(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String accountId) {
+ String accountId, String stackName) {
String roleName = resolveOptional(props, "RoleName", engine);
if (roleName == null || roleName.isBlank()) {
- roleName = "cfn-role-" + UUID.randomUUID().toString().substring(0, 8);
+ roleName = generatePhysicalName(stackName, r.getLogicalId(), 64, false);
}
String assumeDoc = props != null && props.has("AssumeRolePolicyDocument")
? props.get("AssumeRolePolicyDocument").toString()
@@ -284,10 +329,10 @@ private void provisionIamRole(StackResource r, JsonNode props, CloudFormationTem
// ── IAM Policy ────────────────────────────────────────────────────────────
private void provisionIamPolicy(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String accountId) {
+ String accountId, String stackName) {
String policyName = resolveOptional(props, "PolicyName", engine);
if (policyName == null || policyName.isBlank()) {
- policyName = "cfn-policy-" + UUID.randomUUID().toString().substring(0, 8);
+ policyName = generatePhysicalName(stackName, r.getLogicalId(), 128, false);
}
String document = props != null && props.has("PolicyDocument")
? props.get("PolicyDocument").toString()
@@ -309,17 +354,17 @@ private void provisionIamPolicy(StackResource r, JsonNode props, CloudFormationT
}
private void provisionIamManagedPolicy(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String accountId) {
- provisionIamPolicy(r, props, engine, accountId);
+ String accountId, String stackName) {
+ provisionIamPolicy(r, props, engine, accountId, stackName);
}
// ── IAM Instance Profile ──────────────────────────────────────────────────
private void provisionInstanceProfile(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String accountId) {
+ String accountId, String stackName) {
String name = resolveOptional(props, "InstanceProfileName", engine);
if (name == null || name.isBlank()) {
- name = "cfn-profile-" + UUID.randomUUID().toString().substring(0, 8);
+ name = generatePhysicalName(stackName, r.getLogicalId(), 128, false);
}
try {
var profile = iamService.createInstanceProfile(name, "/");
@@ -334,10 +379,10 @@ private void provisionInstanceProfile(StackResource r, JsonNode props, CloudForm
// ── SSM Parameter ─────────────────────────────────────────────────────────
private void provisionSsmParameter(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String region) {
+ String region, String stackName) {
String name = resolveOptional(props, "Name", engine);
if (name == null || name.isBlank()) {
- name = "/cfn/" + UUID.randomUUID().toString().substring(0, 12);
+ name = generatePhysicalName(stackName, r.getLogicalId(), 2048, false);
}
String value = resolveOptional(props, "Value", engine);
if (value == null) {
@@ -375,10 +420,10 @@ private void provisionKmsAlias(StackResource r, JsonNode props, CloudFormationTe
// ── Secrets Manager ───────────────────────────────────────────────────────
private void provisionSecret(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
- String region, String accountId) {
+ String region, String accountId, String stackName) {
String name = resolveOptional(props, "Name", engine);
if (name == null || name.isBlank()) {
- name = "cfn-secret-" + UUID.randomUUID().toString().substring(0, 8);
+ name = generatePhysicalName(stackName, r.getLogicalId(), 512, false);
}
String value = resolveOptional(props, "SecretString", engine);
if (value == null) {
@@ -416,10 +461,11 @@ private void provisionSqsQueuePolicy(StackResource r, JsonNode props, CloudForma
r.setPhysicalId("queue-policy-" + UUID.randomUUID().toString().substring(0, 8));
}
- private void provisionIamUser(StackResource r, JsonNode props, CloudFormationTemplateEngine engine) {
+ private void provisionIamUser(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
+ String stackName) {
String userName = resolveOptional(props, "UserName", engine);
if (userName == null || userName.isBlank()) {
- userName = "cfn-user-" + UUID.randomUUID().toString().substring(0, 8);
+ userName = generatePhysicalName(stackName, r.getLogicalId(), 64, false);
}
var user = iamService.createUser(userName, "/");
r.setPhysicalId(userName);
@@ -435,10 +481,11 @@ private void provisionIamAccessKey(StackResource r, JsonNode props, CloudFormati
}
}
- private void provisionEcrRepository(StackResource r, JsonNode props, CloudFormationTemplateEngine engine) {
+ private void provisionEcrRepository(StackResource r, JsonNode props, CloudFormationTemplateEngine engine,
+ String stackName) {
String repoName = resolveOptional(props, "RepositoryName", engine);
if (repoName == null || repoName.isBlank()) {
- repoName = "cfn-repo-" + UUID.randomUUID().toString().substring(0, 8);
+ repoName = generatePhysicalName(stackName, r.getLogicalId(), 256, true);
}
r.setPhysicalId(repoName);
r.getAttributes().put("Arn", "arn:aws:ecr:us-east-1:000000000000:repository/" + repoName);
@@ -483,4 +530,20 @@ private void deletePolicySafe(String policyArn) {
LOG.debugv("Could not delete policy {0}: {1}", policyArn, e.getMessage());
}
}
+
+ /**
+ * Generate an AWS-like physical name: {stackName}-{logicalId}-{randomSuffix}.
+ * Mirrors the naming pattern AWS CloudFormation uses when no explicit name is provided.
+ */
+ private String generatePhysicalName(String stackName, String logicalId, int maxLength, boolean lowercase) {
+ String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
+ String name = stackName + "-" + logicalId + "-" + suffix;
+ if (lowercase) {
+ name = name.toLowerCase();
+ }
+ if (maxLength > 0 && name.length() > maxLength) {
+ name = name.substring(0, maxLength);
+ }
+ return name;
+ }
}
diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationService.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationService.java
index 9093a628..e72f93be 100644
--- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationService.java
+++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationService.java
@@ -200,6 +200,10 @@ private void executeTemplate(Stack stack, String templateBody, Map conditions = resolveConditions(template, resolvedParams, stack, region);
+ // Mappings
+ Map mappings = new HashMap<>();
+ template.path("Mappings").fields().forEachRemaining(e -> mappings.put(e.getKey(), e.getValue()));
+
// Process resources in order
JsonNode resources = template.path("Resources");
Map physicalIds = new LinkedHashMap<>();
@@ -224,7 +228,7 @@ private void executeTemplate(Stack stack, String templateBody, Map physicalIds;
private final Map> resourceAttributes;
private final Map conditions;
+ private final Map mappings;
private final ObjectMapper objectMapper;
CloudFormationTemplateEngine(String accountId, String region, String stackName, String stackId,
@@ -34,6 +35,7 @@ public class CloudFormationTemplateEngine {
Map physicalIds,
Map> resourceAttributes,
Map conditions,
+ Map mappings,
ObjectMapper objectMapper) {
this.accountId = accountId;
this.region = region;
@@ -43,6 +45,7 @@ public class CloudFormationTemplateEngine {
this.physicalIds = physicalIds;
this.resourceAttributes = resourceAttributes;
this.conditions = conditions;
+ this.mappings = mappings;
this.objectMapper = objectMapper;
}
@@ -87,6 +90,9 @@ public String resolve(JsonNode node) {
if (node.has("Fn::ImportValue")) {
return resolve(node.get("Fn::ImportValue"));
}
+ if (node.has("Fn::FindInMap")) {
+ return resolveFindInMap(node.get("Fn::FindInMap"));
+ }
}
return node.asText();
}
@@ -245,4 +251,24 @@ private String resolveGetAttParts(String logicalId, String attrName) {
LOG.debugv("Unresolved GetAtt: {0}.{1}", logicalId, attrName);
return logicalId + "." + attrName;
}
+
+ private String resolveFindInMap(JsonNode node) {
+ if (node.isArray()) {
+ String mapName = resolve(node.get(0));
+ String topLvlName = resolve(node.get(1));
+ String secondLvlName = resolve(node.get(2));
+
+ JsonNode map = mappings.get(mapName);
+ if (map != null && map.isObject()) {
+ JsonNode topLvl = map.get(topLvlName);
+ if (topLvl != null && topLvl.isObject()) {
+ JsonNode secondLvl = topLvl.get(secondLvlName);
+ if (secondLvl != null) {
+ return resolve(secondLvl);
+ }
+ }
+ }
+ }
+ return "";
+ }
}
diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoJsonHandler.java
index 6e7acd74..0af2ca23 100644
--- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoJsonHandler.java
+++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoJsonHandler.java
@@ -5,7 +5,10 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.github.hectorvent.floci.services.cognito.model.CognitoGroup;
import io.github.hectorvent.floci.services.cognito.model.CognitoUser;
+import io.github.hectorvent.floci.services.cognito.model.ResourceServer;
+import io.github.hectorvent.floci.services.cognito.model.ResourceServerScope;
import io.github.hectorvent.floci.services.cognito.model.UserPool;
import io.github.hectorvent.floci.services.cognito.model.UserPoolClient;
import jakarta.enterprise.context.ApplicationScoped;
@@ -38,6 +41,11 @@ public Response handle(String action, JsonNode request, String region) {
case "DescribeUserPoolClient" -> handleDescribeUserPoolClient(request);
case "ListUserPoolClients" -> handleListUserPoolClients(request);
case "DeleteUserPoolClient" -> handleDeleteUserPoolClient(request);
+ case "CreateResourceServer" -> handleCreateResourceServer(request);
+ case "DescribeResourceServer" -> handleDescribeResourceServer(request);
+ case "ListResourceServers" -> handleListResourceServers(request);
+ case "UpdateResourceServer" -> handleUpdateResourceServer(request);
+ case "DeleteResourceServer" -> handleDeleteResourceServer(request);
case "AdminCreateUser" -> handleAdminCreateUser(request);
case "AdminGetUser" -> handleAdminGetUser(request);
case "AdminDeleteUser" -> handleAdminDeleteUser(request);
@@ -54,6 +62,13 @@ public Response handle(String action, JsonNode request, String region) {
case "ConfirmForgotPassword" -> handleConfirmForgotPassword(request);
case "GetUser" -> handleGetUser(request);
case "UpdateUserAttributes" -> handleUpdateUserAttributes(request);
+ case "CreateGroup" -> handleCreateGroup(request);
+ case "GetGroup" -> handleGetGroup(request);
+ case "ListGroups" -> handleListGroups(request);
+ case "DeleteGroup" -> handleDeleteGroup(request);
+ case "AdminAddUserToGroup" -> handleAdminAddUserToGroup(request);
+ case "AdminRemoveUserFromGroup" -> handleAdminRemoveUserFromGroup(request);
+ case "AdminListGroupsForUser" -> handleAdminListGroupsForUser(request);
default -> Response.status(400)
.entity(new AwsErrorResponse("UnsupportedOperation", "Operation " + action + " is not supported."))
.build();
@@ -91,7 +106,11 @@ private Response handleDeleteUserPool(JsonNode request) {
private Response handleCreateUserPoolClient(JsonNode request) {
UserPoolClient client = service.createUserPoolClient(
request.path("UserPoolId").asText(),
- request.path("ClientName").asText()
+ request.path("ClientName").asText(),
+ request.path("GenerateSecret").asBoolean(false),
+ request.path("AllowedOAuthFlowsUserPoolClient").asBoolean(false),
+ readStringList(request.path("AllowedOAuthFlows")),
+ readStringList(request.path("AllowedOAuthScopes"))
);
ObjectNode response = objectMapper.createObjectNode();
response.set("UserPoolClient", clientToNode(client));
@@ -124,6 +143,56 @@ private Response handleDeleteUserPoolClient(JsonNode request) {
return Response.ok(objectMapper.createObjectNode()).build();
}
+ private Response handleCreateResourceServer(JsonNode request) {
+ ResourceServer server = service.createResourceServer(
+ request.path("UserPoolId").asText(),
+ request.path("Identifier").asText(),
+ request.path("Name").asText(),
+ parseScopes(request.path("Scopes"))
+ );
+ ObjectNode response = objectMapper.createObjectNode();
+ response.set("ResourceServer", resourceServerToNode(server));
+ return Response.ok(response).build();
+ }
+
+ private Response handleDescribeResourceServer(JsonNode request) {
+ ResourceServer server = service.describeResourceServer(
+ request.path("UserPoolId").asText(),
+ request.path("Identifier").asText()
+ );
+ ObjectNode response = objectMapper.createObjectNode();
+ response.set("ResourceServer", resourceServerToNode(server));
+ return Response.ok(response).build();
+ }
+
+ private Response handleListResourceServers(JsonNode request) {
+ List servers = service.listResourceServers(request.path("UserPoolId").asText());
+ ObjectNode response = objectMapper.createObjectNode();
+ ArrayNode items = response.putArray("ResourceServers");
+ servers.forEach(server -> items.add(resourceServerToNode(server)));
+ return Response.ok(response).build();
+ }
+
+ private Response handleUpdateResourceServer(JsonNode request) {
+ ResourceServer server = service.updateResourceServer(
+ request.path("UserPoolId").asText(),
+ request.path("Identifier").asText(),
+ request.path("Name").asText(),
+ parseScopes(request.path("Scopes"))
+ );
+ ObjectNode response = objectMapper.createObjectNode();
+ response.set("ResourceServer", resourceServerToNode(server));
+ return Response.ok(response).build();
+ }
+
+ private Response handleDeleteResourceServer(JsonNode request) {
+ service.deleteResourceServer(
+ request.path("UserPoolId").asText(),
+ request.path("Identifier").asText()
+ );
+ return Response.ok(objectMapper.createObjectNode()).build();
+ }
+
private Response handleAdminCreateUser(JsonNode request) {
Map attrs = new HashMap<>();
request.path("UserAttributes").forEach(a -> attrs.put(a.path("Name").asText(), a.path("Value").asText()));
@@ -321,11 +390,63 @@ private ObjectNode clientToNode(UserPoolClient c) {
node.put("ClientId", c.getClientId());
node.put("UserPoolId", c.getUserPoolId());
node.put("ClientName", c.getClientName());
+ if (c.getClientSecret() != null) {
+ node.put("ClientSecret", c.getClientSecret());
+ }
+ node.put("GenerateSecret", c.isGenerateSecret());
+ node.put("AllowedOAuthFlowsUserPoolClient", c.isAllowedOAuthFlowsUserPoolClient());
+ ArrayNode flows = node.putArray("AllowedOAuthFlows");
+ c.getAllowedOAuthFlows().forEach(flows::add);
+ ArrayNode scopes = node.putArray("AllowedOAuthScopes");
+ c.getAllowedOAuthScopes().forEach(scopes::add);
node.put("CreationDate", c.getCreationDate());
node.put("LastModifiedDate", c.getLastModifiedDate());
return node;
}
+ private ObjectNode resourceServerToNode(ResourceServer server) {
+ ObjectNode node = objectMapper.createObjectNode();
+ node.put("UserPoolId", server.getUserPoolId());
+ node.put("Identifier", server.getIdentifier());
+ node.put("Name", server.getName());
+ node.put("CreationDate", server.getCreationDate());
+ node.put("LastModifiedDate", server.getLastModifiedDate());
+ ArrayNode scopes = node.putArray("Scopes");
+ for (ResourceServerScope scope : server.getScopes()) {
+ ObjectNode item = scopes.addObject();
+ item.put("ScopeName", scope.getScopeName());
+ if (scope.getScopeDescription() != null) {
+ item.put("ScopeDescription", scope.getScopeDescription());
+ }
+ }
+ return node;
+ }
+
+ private List parseScopes(JsonNode scopesNode) {
+ if (scopesNode == null || !scopesNode.isArray()) {
+ return List.of();
+ }
+
+ List scopes = new java.util.ArrayList<>();
+ scopesNode.forEach(item -> {
+ ResourceServerScope scope = new ResourceServerScope();
+ scope.setScopeName(item.path("ScopeName").asText());
+ scope.setScopeDescription(item.path("ScopeDescription").asText(null));
+ scopes.add(scope);
+ });
+ return scopes;
+ }
+
+ private List readStringList(JsonNode node) {
+ if (node == null || !node.isArray()) {
+ return List.of();
+ }
+
+ List values = new java.util.ArrayList<>();
+ node.forEach(item -> values.add(item.asText()));
+ return values;
+ }
+
private ObjectNode userToNode(CognitoUser u) {
ObjectNode node = objectMapper.createObjectNode();
node.put("Username", u.getUsername());
@@ -342,4 +463,79 @@ private ObjectNode userToNode(CognitoUser u) {
return node;
}
+ private Response handleCreateGroup(JsonNode request) {
+ String userPoolId = request.path("UserPoolId").asText();
+ String groupName = request.path("GroupName").asText();
+ String description = request.path("Description").asText(null);
+ JsonNode precNode = request.path("Precedence");
+ Integer precedence = precNode.isMissingNode() || precNode.isNull() ? null : precNode.asInt();
+ String roleArn = request.path("RoleArn").asText(null);
+ CognitoGroup group = service.createGroup(userPoolId, groupName, description, precedence, roleArn);
+ ObjectNode response = objectMapper.createObjectNode();
+ response.set("Group", groupToNode(group));
+ return Response.ok(response).build();
+ }
+
+ private Response handleGetGroup(JsonNode request) {
+ CognitoGroup group = service.getGroup(
+ request.path("UserPoolId").asText(),
+ request.path("GroupName").asText());
+ ObjectNode response = objectMapper.createObjectNode();
+ response.set("Group", groupToNode(group));
+ return Response.ok(response).build();
+ }
+
+ private Response handleListGroups(JsonNode request) {
+ List groups = service.listGroups(request.path("UserPoolId").asText());
+ ObjectNode response = objectMapper.createObjectNode();
+ ArrayNode items = response.putArray("Groups");
+ groups.forEach(g -> items.add(groupToNode(g)));
+ return Response.ok(response).build();
+ }
+
+ private Response handleDeleteGroup(JsonNode request) {
+ service.deleteGroup(
+ request.path("UserPoolId").asText(),
+ request.path("GroupName").asText());
+ return Response.ok(objectMapper.createObjectNode()).build();
+ }
+
+ private Response handleAdminAddUserToGroup(JsonNode request) {
+ service.adminAddUserToGroup(
+ request.path("UserPoolId").asText(),
+ request.path("GroupName").asText(),
+ request.path("Username").asText());
+ return Response.ok(objectMapper.createObjectNode()).build();
+ }
+
+ private Response handleAdminRemoveUserFromGroup(JsonNode request) {
+ service.adminRemoveUserFromGroup(
+ request.path("UserPoolId").asText(),
+ request.path("GroupName").asText(),
+ request.path("Username").asText());
+ return Response.ok(objectMapper.createObjectNode()).build();
+ }
+
+ private Response handleAdminListGroupsForUser(JsonNode request) {
+ List groups = service.adminListGroupsForUser(
+ request.path("UserPoolId").asText(),
+ request.path("Username").asText());
+ ObjectNode response = objectMapper.createObjectNode();
+ ArrayNode items = response.putArray("Groups");
+ groups.forEach(g -> items.add(groupToNode(g)));
+ return Response.ok(response).build();
+ }
+
+ private ObjectNode groupToNode(CognitoGroup g) {
+ ObjectNode node = objectMapper.createObjectNode();
+ node.put("GroupName", g.getGroupName());
+ node.put("UserPoolId", g.getUserPoolId());
+ if (g.getDescription() != null) node.put("Description", g.getDescription());
+ if (g.getPrecedence() != null) node.put("Precedence", g.getPrecedence());
+ if (g.getRoleArn() != null) node.put("RoleArn", g.getRoleArn());
+ node.put("CreationDate", g.getCreationDate());
+ node.put("LastModifiedDate", g.getLastModifiedDate());
+ return node;
+ }
+
}
diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthController.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthController.java
new file mode 100644
index 00000000..f131f71b
--- /dev/null
+++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoOAuthController.java
@@ -0,0 +1,158 @@
+package io.github.hectorvent.floci.services.cognito;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.github.hectorvent.floci.core.common.AwsException;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.HeaderParam;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+import org.jboss.logging.Logger;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+
+@ApplicationScoped
+@Path("/")
+@Produces(MediaType.APPLICATION_JSON)
+public class CognitoOAuthController {
+
+ private static final Logger LOG = Logger.getLogger(CognitoOAuthController.class);
+
+ private final CognitoService cognitoService;
+ private final ObjectMapper objectMapper;
+
+ @Inject
+ public CognitoOAuthController(CognitoService cognitoService, ObjectMapper objectMapper) {
+ this.cognitoService = cognitoService;
+ this.objectMapper = objectMapper;
+ }
+
+ @POST
+ @Path("/cognito-idp/oauth2/token")
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
+ public Response token(@HeaderParam("Authorization") String authorization,
+ MultivaluedMap formParams) {
+ return issueToken(authorization, formParams);
+ }
+
+ private Response issueToken(String authorization, MultivaluedMap formParams) {
+ String grantType = trimToNull(formParams.getFirst("grant_type"));
+ if (grantType == null) {
+ return oauthError("invalid_request", "grant_type is required");
+ }
+ if (!"client_credentials".equals(grantType)) {
+ return oauthError("unsupported_grant_type", "Only client_credentials is supported");
+ }
+
+ BasicCredentials basicCredentials;
+ try {
+ basicCredentials = parseBasicCredentials(authorization);
+ } catch (IllegalArgumentException e) {
+ return oauthError("invalid_request", e.getMessage());
+ }
+
+ String bodyClientId = trimToNull(formParams.getFirst("client_id"));
+ String bodyClientSecret = trimToNull(formParams.getFirst("client_secret"));
+ String basicClientId = basicCredentials != null ? basicCredentials.clientId() : null;
+ String basicClientSecret = basicCredentials != null ? basicCredentials.clientSecret() : null;
+
+ if (bodyClientSecret != null && basicClientSecret != null && !bodyClientSecret.equals(basicClientSecret)) {
+ return oauthError("invalid_request", "client_secret does not match Authorization header");
+ }
+
+ if (bodyClientId != null && basicClientId != null && !bodyClientId.equals(basicClientId)) {
+ return oauthError("invalid_request", "client_id does not match Authorization header");
+ }
+
+ String clientId = bodyClientId != null ? bodyClientId : basicClientId;
+ if (clientId == null) {
+ return oauthError("invalid_request", "client_id is required");
+ }
+
+ String clientSecret = bodyClientSecret != null ? bodyClientSecret : basicClientSecret;
+ String scope = trimToNull(formParams.getFirst("scope"));
+
+ try {
+ Map result = cognitoService.issueClientCredentialsToken(clientId, clientSecret, scope);
+ return Response.ok(objectMapper.valueToTree(result))
+ .type(MediaType.APPLICATION_JSON)
+ .header("Cache-Control", "no-store")
+ .header("Pragma", "no-cache")
+ .build();
+ } catch (AwsException e) {
+ if ("ResourceNotFoundException".equals(e.getErrorCode())) {
+ return oauthError("invalid_client", "Client not found");
+ }
+ if ("InvalidClientException".equals(e.getErrorCode())) {
+ return oauthError("invalid_client", e.getMessage());
+ }
+ if ("UnauthorizedClientException".equals(e.getErrorCode())) {
+ return oauthError("unauthorized_client", e.getMessage());
+ }
+ if ("InvalidScopeException".equals(e.getErrorCode())) {
+ return oauthError("invalid_scope", e.getMessage());
+ }
+ LOG.error("Failed to issue Cognito OAuth token", e);
+ return oauthError("invalid_request", e.getMessage());
+ }
+ }
+
+ private Response oauthError(String error, String description) {
+ ObjectNode body = objectMapper.createObjectNode();
+ body.put("error", error);
+ body.put("error_description", description);
+ return Response.status(400)
+ .type(MediaType.APPLICATION_JSON)
+ .header("Cache-Control", "no-store")
+ .header("Pragma", "no-cache")
+ .entity(body)
+ .build();
+ }
+
+ private BasicCredentials parseBasicCredentials(String authorization) {
+ if (authorization == null || authorization.isBlank()) {
+ return null;
+ }
+ if (!authorization.regionMatches(true, 0, "Basic ", 0, 6)) {
+ return null;
+ }
+
+ String encoded = authorization.substring(6).trim();
+ if (encoded.isEmpty()) {
+ throw new IllegalArgumentException("Basic Authorization header is malformed");
+ }
+
+ try {
+ String decoded = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8);
+ int separator = decoded.indexOf(':');
+ if (separator < 0) {
+ throw new IllegalArgumentException("Basic Authorization header is malformed");
+ }
+ return new BasicCredentials(
+ trimToNull(decoded.substring(0, separator)),
+ trimToNull(decoded.substring(separator + 1))
+ );
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Basic Authorization header is malformed");
+ }
+ }
+
+ private String trimToNull(String value) {
+ if (value == null) {
+ return null;
+ }
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
+ private record BasicCredentials(String clientId, String clientSecret) {
+ }
+}
diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java
index 95e8f4cd..f7364cf1 100644
--- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java
+++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java
@@ -1,21 +1,33 @@
package io.github.hectorvent.floci.services.cognito;
import io.github.hectorvent.floci.core.common.AwsException;
+import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.core.storage.StorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory;
import com.fasterxml.jackson.core.type.TypeReference;
+import io.github.hectorvent.floci.services.cognito.model.CognitoGroup;
import io.github.hectorvent.floci.services.cognito.model.CognitoUser;
+import io.github.hectorvent.floci.services.cognito.model.ResourceServer;
+import io.github.hectorvent.floci.services.cognito.model.ResourceServerScope;
import io.github.hectorvent.floci.services.cognito.model.UserPool;
import io.github.hectorvent.floci.services.cognito.model.UserPoolClient;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
-import javax.crypto.Mac;
-import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
import java.util.*;
+import java.util.stream.Collectors;
@ApplicationScoped
public class CognitoService {
@@ -24,16 +36,38 @@ public class CognitoService {
private final StorageBackend poolStore;
private final StorageBackend clientStore;
+ private final StorageBackend resourceServerStore;
private final StorageBackend userStore;
+ private final StorageBackend groupStore;
+ private final String baseUrl;
@Inject
- public CognitoService(StorageFactory storageFactory) {
+ public CognitoService(StorageFactory storageFactory, EmulatorConfig emulatorConfig) {
this.poolStore = storageFactory.create("cognito", "cognito-pools.json",
new TypeReference