diff --git a/datamodel-dynamodb/README.md b/datamodel-dynamodb/README.md
new file mode 100644
index 00000000..418d734f
--- /dev/null
+++ b/datamodel-dynamodb/README.md
@@ -0,0 +1,5 @@
+# datamodel-dynamodb
+
+This module provides an AWS [DynamoDB](https://aws.amazon.com/dynamodb/) implementation of the the data model interfaces so that the underlying implementation can be swapped out as a runtime dependency.
+
+For additional details on this module see, please visit the [Datamodel DynamoDB documentation](/docs/modules/datamodel/dynamodb.md).
diff --git a/datamodel-dynamodb/pom.xml b/datamodel-dynamodb/pom.xml
new file mode 100644
index 00000000..2eea343e
--- /dev/null
+++ b/datamodel-dynamodb/pom.xml
@@ -0,0 +1,110 @@
+
+
+ 4.0.0
+
+ com.unitvectory
+ serviceauthcentral
+ 0.0.1-SNAPSHOT
+
+
+ com.unitvectory.serviceauthcentral
+ datamodel-dynamodb
+
+
+
+
+
+
+ com.unitvectory
+ consistgen
+
+
+ com.unitvectory.serviceauthcentral
+ util
+ ${project.version}
+
+
+ com.unitvectory.serviceauthcentral
+ datamodel
+ ${project.version}
+
+
+ org.springframework
+ spring-context
+
+
+ software.amazon.awssdk
+ dynamodb-enhanced
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.mapstruct
+ mapstruct
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+ org.projectlombok
+ lombok-mapstruct-binding
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ ${project.build.directory}/generated-sources/annotations
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ true
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ ${java.version}
+ ${java.version}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
+ org.projectlombok
+ lombok-mapstruct-binding
+ ${lombok-mapstruct-binding.version}
+
+
+
+
+
+
+
diff --git a/datamodel-dynamodb/src/lombok.config b/datamodel-dynamodb/src/lombok.config
new file mode 100644
index 00000000..e5340441
--- /dev/null
+++ b/datamodel-dynamodb/src/lombok.config
@@ -0,0 +1,7 @@
+# This tells lombok this directory is the root,
+# no need to look somewhere else for java code.
+config.stopBubbling = true
+# This will add the @lombok.Generated annotation
+# to all the code generated by Lombok,
+# so it can be excluded from coverage by jacoco.
+lombok.addLombokGeneratedAnnotation = true
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/config/DatamodelDynamoDbClientConfig.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/config/DatamodelDynamoDbClientConfig.java
new file mode 100644
index 00000000..69cf53e4
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/config/DatamodelDynamoDbClientConfig.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.config;
+
+import java.net.URI;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClientBuilder;
+
+/**
+ * The data model config for AWS DynamoDB client
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Configuration
+@Profile("datamodel-dynamodb")
+public class DatamodelDynamoDbClientConfig {
+
+ @Value("${aws.region:us-east-1}")
+ private String awsRegion;
+
+ @Value("${aws.dynamodb.endpoint:}")
+ private String dynamoDbEndpoint;
+
+ @Bean
+ public DynamoDbClient dynamoDbClient() {
+ DynamoDbClientBuilder builder = DynamoDbClient.builder()
+ .region(Region.of(awsRegion));
+
+ if (dynamoDbEndpoint != null && !dynamoDbEndpoint.isEmpty()) {
+ // For local DynamoDB testing
+ builder.endpointOverride(URI.create(dynamoDbEndpoint))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create("dummy", "dummy")));
+ } else {
+ builder.credentialsProvider(DefaultCredentialsProvider.create());
+ }
+
+ return builder.build();
+ }
+
+ @Bean
+ public DynamoDbEnhancedClient dynamoDbEnhancedClient(DynamoDbClient dynamoDbClient) {
+ return DynamoDbEnhancedClient.builder()
+ .dynamoDbClient(dynamoDbClient)
+ .build();
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/config/DatamodelDynamoDbConfig.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/config/DatamodelDynamoDbConfig.java
new file mode 100644
index 00000000..eab18b3e
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/config/DatamodelDynamoDbConfig.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.config;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+
+import com.unitvectory.consistgen.epoch.EpochTimeProvider;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository.DynamoDbAuthorizationRepository;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository.DynamoDbClientRepository;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository.DynamoDbJwkCacheRepository;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository.DynamoDbLoginCodeRepository;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository.DynamoDbLoginStateRepository;
+import com.unitvectory.serviceauthcentral.datamodel.repository.AuthorizationRepository;
+import com.unitvectory.serviceauthcentral.datamodel.repository.ClientRepository;
+import com.unitvectory.serviceauthcentral.datamodel.repository.JwkCacheRepository;
+import com.unitvectory.serviceauthcentral.datamodel.repository.LoginCodeRepository;
+import com.unitvectory.serviceauthcentral.datamodel.repository.LoginStateRepository;
+
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+
+/**
+ * The data model config for AWS DynamoDB
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Configuration
+@Profile("datamodel-dynamodb")
+public class DatamodelDynamoDbConfig {
+
+ @Autowired
+ private DynamoDbEnhancedClient dynamoDbEnhancedClient;
+
+ @Autowired
+ private EpochTimeProvider epochTimeProvider;
+
+ @Value("${sac.datamodel.dynamodb.table.authorizations:sac-authorizations}")
+ private String tableAuthorizations;
+
+ @Value("${sac.datamodel.dynamodb.table.clients:sac-clients}")
+ private String tableClients;
+
+ @Value("${sac.datamodel.dynamodb.table.keys:sac-keys}")
+ private String tableKeys;
+
+ @Value("${sac.datamodel.dynamodb.table.logincodes:sac-loginCodes}")
+ private String tableLoginCodes;
+
+ @Value("${sac.datamodel.dynamodb.table.loginstates:sac-loginStates}")
+ private String tableLoginStates;
+
+ @Bean
+ public AuthorizationRepository authorizationRepository() {
+ return new DynamoDbAuthorizationRepository(this.dynamoDbEnhancedClient, this.tableAuthorizations,
+ this.epochTimeProvider);
+ }
+
+ @Bean
+ public ClientRepository clientRepository() {
+ return new DynamoDbClientRepository(this.dynamoDbEnhancedClient, this.tableClients,
+ this.epochTimeProvider);
+ }
+
+ @Bean
+ public JwkCacheRepository jwkCacheRepository() {
+ return new DynamoDbJwkCacheRepository(this.dynamoDbEnhancedClient, this.tableKeys);
+ }
+
+ @Bean
+ public LoginCodeRepository loginCodeRepository() {
+ return new DynamoDbLoginCodeRepository(this.dynamoDbEnhancedClient, this.tableLoginCodes);
+ }
+
+ @Bean
+ public LoginStateRepository loginStateRepository() {
+ return new DynamoDbLoginStateRepository(this.dynamoDbEnhancedClient, this.tableLoginStates);
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/mapper/CachedJwkRecordMapper.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/mapper/CachedJwkRecordMapper.java
new file mode 100644
index 00000000..bd4a6024
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/mapper/CachedJwkRecordMapper.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.mapper;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.factory.Mappers;
+
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.CachedJwkRecord;
+import com.unitvectory.serviceauthcentral.datamodel.model.CachedJwk;
+
+/**
+ * The mapper for the CachedJwkRecord
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Mapper
+public interface CachedJwkRecordMapper {
+
+ CachedJwkRecordMapper INSTANCE = Mappers.getMapper(CachedJwkRecordMapper.class);
+
+ @Mapping(target = "pk", ignore = true)
+ @Mapping(target = "url", source = "url")
+ @Mapping(target = "ttl", source = "ttl")
+ @Mapping(target = "valid", source = "jwk.valid")
+ @Mapping(target = "kid", source = "jwk.kid")
+ @Mapping(target = "kty", source = "jwk.kty")
+ @Mapping(target = "alg", source = "jwk.alg")
+ @Mapping(target = "use", source = "jwk.use")
+ @Mapping(target = "n", source = "jwk.n")
+ @Mapping(target = "e", source = "jwk.e")
+ CachedJwkRecord cachedJwkToCachedJwkRecord(String url, long ttl, CachedJwk jwk);
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/mapper/ClientScopeMapper.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/mapper/ClientScopeMapper.java
new file mode 100644
index 00000000..8801f946
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/mapper/ClientScopeMapper.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.mapper;
+
+import java.util.List;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.factory.Mappers;
+
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.ClientScopeRecord;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientScope;
+
+/**
+ * The mapper for ClientScopeRecord
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Mapper
+public interface ClientScopeMapper {
+
+ ClientScopeMapper INSTANCE = Mappers.getMapper(ClientScopeMapper.class);
+
+ @Mapping(target = "scope", source = "scope")
+ @Mapping(target = "description", source = "description")
+ ClientScopeRecord clientScopeToClientScopeRecord(ClientScope clientScope);
+
+ List clientScopeToClientScopeRecord(List clientScopes);
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/AuthorizationRecord.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/AuthorizationRecord.java
new file mode 100644
index 00000000..70dfb415
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/AuthorizationRecord.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.unitvectory.serviceauthcentral.datamodel.model.Authorization;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey;
+
+/**
+ * The Authorization Record for DynamoDB
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@DynamoDbBean
+public class AuthorizationRecord implements Authorization {
+
+ private String pk;
+
+ private String authorizationCreated;
+
+ private String subject;
+
+ private String audience;
+
+ private Boolean locked;
+
+ @Builder.Default
+ private List authorizedScopes = new ArrayList<>();
+
+ @DynamoDbPartitionKey
+ @DynamoDbAttribute("pk")
+ public String getPk() {
+ return pk;
+ }
+
+ @Override
+ public String getDocumentId() {
+ return pk;
+ }
+
+ @Override
+ @DynamoDbAttribute("authorizationCreated")
+ public String getAuthorizationCreated() {
+ return authorizationCreated;
+ }
+
+ @Override
+ @DynamoDbSecondaryPartitionKey(indexNames = {"subject-index"})
+ @DynamoDbAttribute("subject")
+ public String getSubject() {
+ return subject;
+ }
+
+ @Override
+ @DynamoDbSecondaryPartitionKey(indexNames = {"audience-index"})
+ @DynamoDbAttribute("audience")
+ public String getAudience() {
+ return audience;
+ }
+
+ @DynamoDbAttribute("locked")
+ public Boolean getLocked() {
+ return locked;
+ }
+
+ @Override
+ @DynamoDbAttribute("authorizedScopes")
+ public List getAuthorizedScopes() {
+ return authorizedScopes;
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/CachedJwkRecord.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/CachedJwkRecord.java
new file mode 100644
index 00000000..02cd669a
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/CachedJwkRecord.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.model;
+
+import com.unitvectory.serviceauthcentral.datamodel.model.CachedJwk;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey;
+
+/**
+ * The Cached JWK Record for DynamoDB
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@DynamoDbBean
+public class CachedJwkRecord implements CachedJwk {
+
+ private String pk;
+
+ private String url;
+
+ private Long ttl;
+
+ private boolean valid;
+
+ private String kid;
+
+ private String kty;
+
+ private String alg;
+
+ private String use;
+
+ private String n;
+
+ private String e;
+
+ @DynamoDbPartitionKey
+ @DynamoDbAttribute("pk")
+ public String getPk() {
+ return pk;
+ }
+
+ @DynamoDbSecondaryPartitionKey(indexNames = {"url-index"})
+ @DynamoDbAttribute("url")
+ public String getUrl() {
+ return url;
+ }
+
+ @DynamoDbAttribute("ttl")
+ public Long getTtl() {
+ return ttl;
+ }
+
+ @Override
+ @DynamoDbAttribute("valid")
+ public boolean isValid() {
+ return valid;
+ }
+
+ @Override
+ @DynamoDbAttribute("kid")
+ public String getKid() {
+ return kid;
+ }
+
+ @Override
+ @DynamoDbAttribute("kty")
+ public String getKty() {
+ return kty;
+ }
+
+ @Override
+ @DynamoDbAttribute("alg")
+ public String getAlg() {
+ return alg;
+ }
+
+ @Override
+ @DynamoDbAttribute("use")
+ public String getUse() {
+ return use;
+ }
+
+ @Override
+ @DynamoDbAttribute("n")
+ public String getN() {
+ return n;
+ }
+
+ @Override
+ @DynamoDbAttribute("e")
+ public String getE() {
+ return e;
+ }
+
+ @Override
+ public boolean isExpired(long now) {
+ if (ttl == null) {
+ return true;
+ }
+ return ttl < now;
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientJwtBearerRecord.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientJwtBearerRecord.java
new file mode 100644
index 00000000..86034e38
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientJwtBearerRecord.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.model;
+
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientJwtBearer;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
+
+/**
+ * The Client JWT Bearer Record for DynamoDB
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@DynamoDbBean
+public class ClientJwtBearerRecord implements ClientJwtBearer {
+
+ private String id;
+
+ private String jwksUrl;
+
+ private String iss;
+
+ private String sub;
+
+ private String aud;
+
+ @Override
+ @DynamoDbAttribute("id")
+ public String getId() {
+ return id;
+ }
+
+ @Override
+ @DynamoDbAttribute("jwksUrl")
+ public String getJwksUrl() {
+ return jwksUrl;
+ }
+
+ @Override
+ @DynamoDbAttribute("iss")
+ public String getIss() {
+ return iss;
+ }
+
+ @Override
+ @DynamoDbAttribute("sub")
+ public String getSub() {
+ return sub;
+ }
+
+ @Override
+ @DynamoDbAttribute("aud")
+ public String getAud() {
+ return aud;
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientRecord.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientRecord.java
new file mode 100644
index 00000000..c2d6da2f
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientRecord.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.model;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import com.unitvectory.serviceauthcentral.datamodel.model.Client;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientJwtBearer;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientScope;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientType;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbIgnore;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
+
+/**
+ * The Client Record for DynamoDB
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@DynamoDbBean
+public class ClientRecord implements Client {
+
+ private String pk;
+
+ private String clientCreated;
+
+ private String clientId;
+
+ private String description;
+
+ private String salt;
+
+ private ClientType clientType;
+
+ private String clientSecret1;
+
+ private String clientSecret1Updated;
+
+ private String clientSecret2;
+
+ private String clientSecret2Updated;
+
+ private List availableScopes;
+
+ private Boolean locked;
+
+ private List jwtBearer;
+
+ @DynamoDbPartitionKey
+ @DynamoDbAttribute("pk")
+ public String getPk() {
+ return pk;
+ }
+
+ @Override
+ @DynamoDbAttribute("clientCreated")
+ public String getClientCreated() {
+ return clientCreated;
+ }
+
+ @Override
+ @DynamoDbAttribute("clientId")
+ public String getClientId() {
+ return clientId;
+ }
+
+ @Override
+ @DynamoDbAttribute("description")
+ public String getDescription() {
+ return description;
+ }
+
+ @Override
+ @DynamoDbAttribute("salt")
+ public String getSalt() {
+ return salt;
+ }
+
+ @DynamoDbAttribute("clientType")
+ public ClientType getClientType() {
+ return clientType;
+ }
+
+ @Override
+ @DynamoDbAttribute("clientSecret1")
+ public String getClientSecret1() {
+ return clientSecret1;
+ }
+
+ @Override
+ @DynamoDbAttribute("clientSecret1Updated")
+ public String getClientSecret1Updated() {
+ return clientSecret1Updated;
+ }
+
+ @Override
+ @DynamoDbAttribute("clientSecret2")
+ public String getClientSecret2() {
+ return clientSecret2;
+ }
+
+ @Override
+ @DynamoDbAttribute("clientSecret2Updated")
+ public String getClientSecret2Updated() {
+ return clientSecret2Updated;
+ }
+
+ @DynamoDbAttribute("availableScopes")
+ public List getAvailableScopesRecord() {
+ return availableScopes;
+ }
+
+ public void setAvailableScopesRecord(List availableScopes) {
+ this.availableScopes = availableScopes;
+ }
+
+ @Override
+ @DynamoDbIgnore
+ public List getAvailableScopes() {
+ if (this.availableScopes == null) {
+ return Collections.emptyList();
+ }
+
+ return availableScopes.stream().map(obj -> (ClientScope) obj)
+ .collect(Collectors.toCollection(ArrayList::new));
+ }
+
+ @Override
+ @DynamoDbAttribute("locked")
+ public Boolean getLocked() {
+ return locked;
+ }
+
+ @DynamoDbAttribute("jwtBearer")
+ public List getJwtBearerRecord() {
+ return jwtBearer;
+ }
+
+ public void setJwtBearerRecord(List jwtBearer) {
+ this.jwtBearer = jwtBearer;
+ }
+
+ @Override
+ @DynamoDbIgnore
+ public List getJwtBearer() {
+ if (this.jwtBearer == null) {
+ return Collections.emptyList();
+ }
+
+ return jwtBearer.stream().map(obj -> (ClientJwtBearer) obj)
+ .collect(Collectors.toCollection(ArrayList::new));
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientScopeRecord.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientScopeRecord.java
new file mode 100644
index 00000000..2ae592ba
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientScopeRecord.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.model;
+
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientScope;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
+
+/**
+ * The Client Scope Record for DynamoDB
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@DynamoDbBean
+public class ClientScopeRecord implements ClientScope {
+
+ private String scope;
+
+ private String description;
+
+ @Override
+ @DynamoDbAttribute("scope")
+ public String getScope() {
+ return scope;
+ }
+
+ @Override
+ @DynamoDbAttribute("description")
+ public String getDescription() {
+ return description;
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientSummaryRecord.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientSummaryRecord.java
new file mode 100644
index 00000000..6e119116
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/ClientSummaryRecord.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.model;
+
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientSummary;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
+
+/**
+ * The Client Summary Record for DynamoDB
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@DynamoDbBean
+public class ClientSummaryRecord implements ClientSummary {
+
+ private String pk;
+
+ private String clientId;
+
+ private String description;
+
+ @DynamoDbPartitionKey
+ @DynamoDbAttribute("pk")
+ public String getPk() {
+ return pk;
+ }
+
+ @Override
+ @DynamoDbAttribute("clientId")
+ public String getClientId() {
+ return clientId;
+ }
+
+ @Override
+ @DynamoDbAttribute("description")
+ public String getDescription() {
+ return description;
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/LoginCodeRecord.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/LoginCodeRecord.java
new file mode 100644
index 00000000..d31d46f3
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/LoginCodeRecord.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.model;
+
+import com.unitvectory.serviceauthcentral.datamodel.model.LoginCode;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
+
+/**
+ * The Login Code Record for DynamoDB
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@DynamoDbBean
+public class LoginCodeRecord implements LoginCode {
+
+ private String pk;
+
+ private String clientId;
+
+ private String redirectUri;
+
+ private String codeChallenge;
+
+ private String userClientId;
+
+ private Long ttl;
+
+ @DynamoDbPartitionKey
+ @DynamoDbAttribute("pk")
+ public String getPk() {
+ return pk;
+ }
+
+ @Override
+ @DynamoDbAttribute("clientId")
+ public String getClientId() {
+ return clientId;
+ }
+
+ @Override
+ @DynamoDbAttribute("redirectUri")
+ public String getRedirectUri() {
+ return redirectUri;
+ }
+
+ @Override
+ @DynamoDbAttribute("codeChallenge")
+ public String getCodeChallenge() {
+ return codeChallenge;
+ }
+
+ @Override
+ @DynamoDbAttribute("userClientId")
+ public String getUserClientId() {
+ return userClientId;
+ }
+
+ @DynamoDbAttribute("ttl")
+ public Long getTtl() {
+ return ttl;
+ }
+
+ @Override
+ public long getTimeToLive() {
+ if (this.ttl != null) {
+ return this.ttl;
+ } else {
+ return 0;
+ }
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/LoginStateRecord.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/LoginStateRecord.java
new file mode 100644
index 00000000..90bf908a
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/model/LoginStateRecord.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.model;
+
+import com.unitvectory.serviceauthcentral.datamodel.model.LoginState;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
+
+/**
+ * The Login State Record for DynamoDB
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@DynamoDbBean
+public class LoginStateRecord implements LoginState {
+
+ private String pk;
+
+ private String clientId;
+
+ private String redirectUri;
+
+ private String primaryState;
+
+ private String primaryCodeChallenge;
+
+ private String secondaryState;
+
+ private Long ttl;
+
+ @DynamoDbPartitionKey
+ @DynamoDbAttribute("pk")
+ public String getPk() {
+ return pk;
+ }
+
+ @Override
+ @DynamoDbAttribute("clientId")
+ public String getClientId() {
+ return clientId;
+ }
+
+ @Override
+ @DynamoDbAttribute("redirectUri")
+ public String getRedirectUri() {
+ return redirectUri;
+ }
+
+ @Override
+ @DynamoDbAttribute("primaryState")
+ public String getPrimaryState() {
+ return primaryState;
+ }
+
+ @Override
+ @DynamoDbAttribute("primaryCodeChallenge")
+ public String getPrimaryCodeChallenge() {
+ return primaryCodeChallenge;
+ }
+
+ @Override
+ @DynamoDbAttribute("secondaryState")
+ public String getSecondaryState() {
+ return secondaryState;
+ }
+
+ @DynamoDbAttribute("ttl")
+ public Long getTtl() {
+ return ttl;
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbAuthorizationRepository.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbAuthorizationRepository.java
new file mode 100644
index 00000000..d0a3242f
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbAuthorizationRepository.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import com.unitvectory.consistgen.epoch.EpochTimeProvider;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.AuthorizationRecord;
+import com.unitvectory.serviceauthcentral.datamodel.model.Authorization;
+import com.unitvectory.serviceauthcentral.datamodel.repository.AuthorizationRepository;
+import com.unitvectory.serviceauthcentral.datamodel.time.TimeUtil;
+import com.unitvectory.serviceauthcentral.util.HashingUtil;
+import com.unitvectory.serviceauthcentral.util.exception.BadRequestException;
+import com.unitvectory.serviceauthcentral.util.exception.NotFoundException;
+
+import lombok.AllArgsConstructor;
+import lombok.NonNull;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
+import software.amazon.awssdk.enhanced.dynamodb.Key;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.model.Page;
+import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional;
+import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest;
+
+/**
+ * The DynamoDB Authorization Repository
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@AllArgsConstructor
+public class DynamoDbAuthorizationRepository implements AuthorizationRepository {
+
+ private DynamoDbEnhancedClient dynamoDbEnhancedClient;
+
+ private String tableName;
+
+ private EpochTimeProvider epochTimeProvider;
+
+ private DynamoDbTable getTable() {
+ return dynamoDbEnhancedClient.table(tableName, TableSchema.fromBean(AuthorizationRecord.class));
+ }
+
+ private String getDocumentId(@NonNull String subject, @NonNull String audience) {
+ String subjectHash = HashingUtil.sha256(subject);
+ String audienceHash = HashingUtil.sha256(audience);
+ return HashingUtil.sha256(subjectHash + audienceHash);
+ }
+
+ @Override
+ public Authorization getAuthorization(@NonNull String id) {
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(id).build();
+ return table.getItem(key);
+ }
+
+ @Override
+ public void deleteAuthorization(@NonNull String id) {
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(id).build();
+ table.deleteItem(key);
+ }
+
+ @Override
+ public Authorization getAuthorization(@NonNull String subject, @NonNull String audience) {
+ String documentId = getDocumentId(subject, audience);
+ return getAuthorization(documentId);
+ }
+
+ @Override
+ public Iterator getAuthorizationBySubject(@NonNull String subject) {
+ DynamoDbTable table = getTable();
+ DynamoDbIndex index = table.index("subject-index");
+
+ QueryConditional queryConditional = QueryConditional
+ .keyEqualTo(Key.builder().partitionValue(subject).build());
+
+ QueryEnhancedRequest request = QueryEnhancedRequest.builder()
+ .queryConditional(queryConditional)
+ .build();
+
+ ArrayList list = new ArrayList<>();
+ for (Page page : index.query(request)) {
+ for (AuthorizationRecord record : page.items()) {
+ list.add(record);
+ }
+ }
+
+ return list.iterator();
+ }
+
+ @Override
+ public Iterator getAuthorizationByAudience(@NonNull String audience) {
+ DynamoDbTable table = getTable();
+ DynamoDbIndex index = table.index("audience-index");
+
+ QueryConditional queryConditional = QueryConditional
+ .keyEqualTo(Key.builder().partitionValue(audience).build());
+
+ QueryEnhancedRequest request = QueryEnhancedRequest.builder()
+ .queryConditional(queryConditional)
+ .build();
+
+ ArrayList list = new ArrayList<>();
+ for (Page page : index.query(request)) {
+ for (AuthorizationRecord record : page.items()) {
+ list.add(record);
+ }
+ }
+
+ return list.iterator();
+ }
+
+ @Override
+ public void authorize(@NonNull String subject, @NonNull String audience,
+ @NonNull List authorizedScopes) {
+ String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds());
+ String documentId = getDocumentId(subject, audience);
+
+ AuthorizationRecord record = AuthorizationRecord.builder()
+ .pk(documentId)
+ .authorizationCreated(now)
+ .subject(subject)
+ .audience(audience)
+ .authorizedScopes(authorizedScopes)
+ .build();
+
+ DynamoDbTable table = getTable();
+ table.putItem(record);
+ }
+
+ @Override
+ public void deauthorize(@NonNull String subject, @NonNull String audience) {
+ String documentId = getDocumentId(subject, audience);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(documentId).build();
+ table.deleteItem(key);
+ }
+
+ @Override
+ public void authorizeAddScope(@NonNull String subject, @NonNull String audience,
+ @NonNull String authorizedScope) {
+ String documentId = getDocumentId(subject, audience);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(documentId).build();
+
+ AuthorizationRecord record = table.getItem(key);
+ if (record == null) {
+ throw new NotFoundException("Authorization not found");
+ }
+
+ if (record.getAuthorizedScopes().contains(authorizedScope)) {
+ throw new BadRequestException("Scope already authorized");
+ }
+
+ List authorizedScopesList = new ArrayList<>();
+ if (record.getAuthorizedScopes() != null) {
+ authorizedScopesList.addAll(record.getAuthorizedScopes());
+ }
+
+ authorizedScopesList.add(authorizedScope);
+ record.setAuthorizedScopes(authorizedScopesList);
+ table.putItem(record);
+ }
+
+ @Override
+ public void authorizeRemoveScope(@NonNull String subject, @NonNull String audience,
+ @NonNull String authorizedScope) {
+ String documentId = getDocumentId(subject, audience);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(documentId).build();
+
+ AuthorizationRecord record = table.getItem(key);
+ if (record == null) {
+ throw new NotFoundException("Authorization not found");
+ }
+
+ List authorizedScopesList = new ArrayList<>();
+ if (record.getAuthorizedScopes() != null) {
+ authorizedScopesList.addAll(record.getAuthorizedScopes());
+ }
+
+ authorizedScopesList.remove(authorizedScope);
+ record.setAuthorizedScopes(authorizedScopesList);
+ table.putItem(record);
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbClientRepository.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbClientRepository.java
new file mode 100644
index 00000000..0bcb8a1a
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbClientRepository.java
@@ -0,0 +1,421 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.unitvectory.consistgen.epoch.EpochTimeProvider;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.mapper.ClientScopeMapper;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.ClientJwtBearerRecord;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.ClientRecord;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.ClientScopeRecord;
+import com.unitvectory.serviceauthcentral.datamodel.model.Client;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientJwtBearer;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientScope;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientSummary;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientSummaryConnection;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientSummaryEdge;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientType;
+import com.unitvectory.serviceauthcentral.datamodel.model.PageInfo;
+import com.unitvectory.serviceauthcentral.datamodel.repository.ClientRepository;
+import com.unitvectory.serviceauthcentral.datamodel.time.TimeUtil;
+import com.unitvectory.serviceauthcentral.util.HashingUtil;
+import com.unitvectory.serviceauthcentral.util.exception.BadRequestException;
+import com.unitvectory.serviceauthcentral.util.exception.ConflictException;
+import com.unitvectory.serviceauthcentral.util.exception.NotFoundException;
+
+import lombok.AllArgsConstructor;
+import lombok.NonNull;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
+import software.amazon.awssdk.enhanced.dynamodb.Expression;
+import software.amazon.awssdk.enhanced.dynamodb.Key;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.model.Page;
+import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest;
+import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+/**
+ * The DynamoDB Client Repository
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@AllArgsConstructor
+public class DynamoDbClientRepository implements ClientRepository {
+
+ private DynamoDbEnhancedClient dynamoDbEnhancedClient;
+
+ private String tableName;
+
+ private EpochTimeProvider epochTimeProvider;
+
+ private DynamoDbTable getTable() {
+ return dynamoDbEnhancedClient.table(tableName, TableSchema.fromBean(ClientRecord.class));
+ }
+
+ @Override
+ public ClientSummaryConnection getClients(Integer first, String after, Integer last,
+ String before) {
+ DynamoDbTable table = getTable();
+ List edges = new ArrayList<>();
+ boolean hasPreviousPage = false;
+ boolean hasNextPage = false;
+
+ // For DynamoDB, we'll use scan with pagination
+ // Forward pagination
+ if (first != null) {
+ Map exclusiveStartKey = null;
+ if (after != null && !after.isEmpty()) {
+ String afterDecoded = new String(Base64.getDecoder().decode(after), StandardCharsets.UTF_8);
+ String pk = HashingUtil.sha256(afterDecoded);
+ exclusiveStartKey = new HashMap<>();
+ exclusiveStartKey.put("pk", AttributeValue.builder().s(pk).build());
+ hasPreviousPage = true;
+ }
+
+ ScanEnhancedRequest.Builder requestBuilder = ScanEnhancedRequest.builder()
+ .limit(first + 1);
+
+ if (exclusiveStartKey != null) {
+ requestBuilder.exclusiveStartKey(exclusiveStartKey);
+ }
+
+ for (Page page : table.scan(requestBuilder.build())) {
+ for (ClientRecord record : page.items()) {
+ if (edges.size() >= first + 1) {
+ break;
+ }
+ String cursor = Base64.getEncoder().encodeToString(
+ record.getClientId().getBytes(StandardCharsets.UTF_8));
+ ClientSummary summary = new ClientSummary() {
+ @Override
+ public String getClientId() {
+ return record.getClientId();
+ }
+
+ @Override
+ public String getDescription() {
+ return record.getDescription();
+ }
+ };
+ edges.add(ClientSummaryEdge.builder().node(summary).cursor(cursor).build());
+ }
+ if (edges.size() >= first + 1) {
+ break;
+ }
+ }
+
+ if (edges.size() > first) {
+ hasNextPage = true;
+ edges.remove(edges.size() - 1);
+ }
+ }
+
+ // Backward pagination
+ if (last != null) {
+ // DynamoDB doesn't natively support backward scanning, so we'll scan and reverse
+ List allEdges = new ArrayList<>();
+ for (Page page : table.scan()) {
+ for (ClientRecord record : page.items()) {
+ String cursor = Base64.getEncoder().encodeToString(
+ record.getClientId().getBytes(StandardCharsets.UTF_8));
+ ClientSummary summary = new ClientSummary() {
+ @Override
+ public String getClientId() {
+ return record.getClientId();
+ }
+
+ @Override
+ public String getDescription() {
+ return record.getDescription();
+ }
+ };
+ allEdges.add(ClientSummaryEdge.builder().node(summary).cursor(cursor).build());
+ }
+ }
+
+ // Sort by clientId
+ allEdges.sort((a, b) -> a.getNode().getClientId().compareTo(b.getNode().getClientId()));
+ Collections.reverse(allEdges);
+
+ if (before != null && !before.isEmpty()) {
+ String beforeDecoded = new String(Base64.getDecoder().decode(before), StandardCharsets.UTF_8);
+ int index = -1;
+ for (int i = 0; i < allEdges.size(); i++) {
+ if (allEdges.get(i).getNode().getClientId().equals(beforeDecoded)) {
+ index = i;
+ break;
+ }
+ }
+ if (index >= 0) {
+ allEdges = allEdges.subList(index + 1, allEdges.size());
+ hasNextPage = true;
+ }
+ }
+
+ if (allEdges.size() > last) {
+ hasPreviousPage = true;
+ allEdges = allEdges.subList(allEdges.size() - last, allEdges.size());
+ }
+
+ edges = allEdges;
+ Collections.reverse(edges);
+ }
+
+ PageInfo pageInfo = constructPageInfo(edges, hasPreviousPage, hasNextPage);
+ return ClientSummaryConnection.builder().edges(edges).pageInfo(pageInfo).build();
+ }
+
+ private PageInfo constructPageInfo(List edges, boolean hasPreviousPage,
+ boolean hasNextPage) {
+ String startCursor = null, endCursor = null;
+ if (!edges.isEmpty()) {
+ startCursor = edges.get(0).getCursor();
+ endCursor = edges.get(edges.size() - 1).getCursor();
+ }
+ return PageInfo.builder().hasNextPage(hasNextPage).hasPreviousPage(hasPreviousPage)
+ .startCursor(startCursor).endCursor(endCursor).build();
+ }
+
+ @Override
+ public Client getClient(@NonNull String clientId) {
+ String pk = HashingUtil.sha256(clientId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+ return table.getItem(key);
+ }
+
+ @Override
+ public void deleteClient(@NonNull String clientId) {
+ String pk = HashingUtil.sha256(clientId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+ table.deleteItem(key);
+ }
+
+ @Override
+ public void putClient(@NonNull String clientId, String description, @NonNull String salt,
+ @NonNull ClientType clientType, @NonNull List availableScopes) {
+ String pk = HashingUtil.sha256(clientId);
+ String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds());
+
+ ClientRecord record = ClientRecord.builder()
+ .pk(pk)
+ .clientCreated(now)
+ .clientId(clientId)
+ .description(description)
+ .salt(salt)
+ .clientType(clientType)
+ .availableScopes(ClientScopeMapper.INSTANCE
+ .clientScopeToClientScopeRecord(availableScopes))
+ .build();
+
+ DynamoDbTable table = getTable();
+
+ // Use a condition expression to only put if the item doesn't exist
+ Expression conditionExpression = Expression.builder()
+ .expression("attribute_not_exists(pk)")
+ .build();
+
+ PutItemEnhancedRequest request = PutItemEnhancedRequest.builder(ClientRecord.class)
+ .item(record)
+ .conditionExpression(conditionExpression)
+ .build();
+
+ try {
+ table.putItem(request);
+ } catch (software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException e) {
+ throw new ConflictException("clientId already exists");
+ }
+ }
+
+ @Override
+ public void addClientAvailableScope(@NonNull String clientId, @NonNull ClientScope availableScope) {
+ String pk = HashingUtil.sha256(clientId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+
+ ClientRecord record = table.getItem(key);
+ if (record == null) {
+ throw new NotFoundException("Client not found");
+ }
+
+ List availableScopesOriginal = record.getAvailableScopes();
+ List availableScopesList = new ArrayList<>();
+ for (ClientScope scope : availableScopesOriginal) {
+ if (scope.getScope().equals(availableScope.getScope())) {
+ throw new BadRequestException("Duplicate scope");
+ }
+ availableScopesList.add(ClientScopeMapper.INSTANCE.clientScopeToClientScopeRecord(scope));
+ }
+
+ availableScopesList.add(ClientScopeMapper.INSTANCE.clientScopeToClientScopeRecord(availableScope));
+ record.setAvailableScopes(availableScopesList);
+ table.putItem(record);
+ }
+
+ @Override
+ public void addAuthorizedJwt(@NonNull String clientId, @NonNull String id,
+ @NonNull String jwksUrl, @NonNull String iss, @NonNull String sub,
+ @NonNull String aud) {
+ String pk = HashingUtil.sha256(clientId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+
+ ClientRecord record = table.getItem(key);
+ if (record == null) {
+ throw new NotFoundException("Client not found");
+ }
+
+ List jwtBearerListOriginal = record.getJwtBearer();
+ List jwtBearerList = new ArrayList<>();
+
+ if (jwtBearerListOriginal != null) {
+ for (ClientJwtBearer cjb : jwtBearerListOriginal) {
+ jwtBearerList.add(ClientJwtBearerRecord.builder()
+ .id(cjb.getId())
+ .jwksUrl(cjb.getJwksUrl())
+ .iss(cjb.getIss())
+ .sub(cjb.getSub())
+ .aud(cjb.getAud())
+ .build());
+ }
+ }
+
+ ClientJwtBearerRecord newJwt = ClientJwtBearerRecord.builder()
+ .id(id)
+ .jwksUrl(jwksUrl)
+ .iss(iss)
+ .sub(sub)
+ .aud(aud)
+ .build();
+
+ // Check for duplicates
+ for (ClientJwtBearer cjb : jwtBearerList) {
+ if (newJwt.matches(cjb)) {
+ throw new BadRequestException("Duplicate authorization");
+ }
+ }
+
+ jwtBearerList.add(newJwt);
+ record.setJwtBearer(jwtBearerList);
+ table.putItem(record);
+ }
+
+ @Override
+ public void removeAuthorizedJwt(@NonNull String clientId, @NonNull String id) {
+ String pk = HashingUtil.sha256(clientId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+
+ ClientRecord record = table.getItem(key);
+ if (record == null) {
+ throw new NotFoundException("Client not found");
+ }
+
+ List jwtBearerListOriginal = record.getJwtBearer();
+ List jwtBearerList = new ArrayList<>();
+
+ if (jwtBearerListOriginal != null) {
+ boolean found = false;
+ for (ClientJwtBearer cjb : jwtBearerListOriginal) {
+ if (id.equals(cjb.getId())) {
+ found = true;
+ continue;
+ }
+ jwtBearerList.add(ClientJwtBearerRecord.builder()
+ .id(cjb.getId())
+ .jwksUrl(cjb.getJwksUrl())
+ .iss(cjb.getIss())
+ .sub(cjb.getSub())
+ .aud(cjb.getAud())
+ .build());
+ }
+
+ if (!found) {
+ throw new NotFoundException("JWT not found with the provided id");
+ }
+ }
+
+ record.setJwtBearer(jwtBearerList);
+ table.putItem(record);
+ }
+
+ @Override
+ public void saveClientSecret1(@NonNull String clientId, @NonNull String hashedSecret) {
+ String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds());
+ String pk = HashingUtil.sha256(clientId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+
+ ClientRecord record = table.getItem(key);
+ if (record != null) {
+ record.setClientSecret1(hashedSecret);
+ record.setClientSecret1Updated(now);
+ table.putItem(record);
+ }
+ }
+
+ @Override
+ public void saveClientSecret2(@NonNull String clientId, @NonNull String hashedSecret) {
+ String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds());
+ String pk = HashingUtil.sha256(clientId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+
+ ClientRecord record = table.getItem(key);
+ if (record != null) {
+ record.setClientSecret2(hashedSecret);
+ record.setClientSecret2Updated(now);
+ table.putItem(record);
+ }
+ }
+
+ @Override
+ public void clearClientSecret1(@NonNull String clientId) {
+ String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds());
+ String pk = HashingUtil.sha256(clientId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+
+ ClientRecord record = table.getItem(key);
+ if (record != null) {
+ record.setClientSecret1(null);
+ record.setClientSecret1Updated(now);
+ table.putItem(record);
+ }
+ }
+
+ @Override
+ public void clearClientSecret2(@NonNull String clientId) {
+ String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds());
+ String pk = HashingUtil.sha256(clientId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+
+ ClientRecord record = table.getItem(key);
+ if (record != null) {
+ record.setClientSecret2(null);
+ record.setClientSecret2Updated(now);
+ table.putItem(record);
+ }
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbJwkCacheRepository.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbJwkCacheRepository.java
new file mode 100644
index 00000000..eaff5463
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbJwkCacheRepository.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.mapper.CachedJwkRecordMapper;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.CachedJwkRecord;
+import com.unitvectory.serviceauthcentral.datamodel.model.CachedJwk;
+import com.unitvectory.serviceauthcentral.datamodel.repository.JwkCacheRepository;
+import com.unitvectory.serviceauthcentral.util.HashingUtil;
+
+import lombok.AllArgsConstructor;
+import lombok.NonNull;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbIndex;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
+import software.amazon.awssdk.enhanced.dynamodb.Key;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.model.Page;
+import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional;
+import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest;
+
+/**
+ * The DynamoDB JWK Cache Repository
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@AllArgsConstructor
+public class DynamoDbJwkCacheRepository implements JwkCacheRepository {
+
+ private DynamoDbEnhancedClient dynamoDbEnhancedClient;
+
+ private String tableName;
+
+ private DynamoDbTable getTable() {
+ return dynamoDbEnhancedClient.table(tableName, TableSchema.fromBean(CachedJwkRecord.class));
+ }
+
+ private String getId(@NonNull String url, @NonNull String id) {
+ String urlHash = HashingUtil.sha256(url);
+ String idHash = HashingUtil.sha256(id);
+ return HashingUtil.sha256(urlHash + idHash);
+ }
+
+ @Override
+ public void cacheJwk(@NonNull String url, @NonNull CachedJwk jwk, long ttl) {
+ CachedJwkRecord cachedJwk = CachedJwkRecordMapper.INSTANCE.cachedJwkToCachedJwkRecord(url, ttl, jwk);
+ String pk = getId(url, jwk.getKid());
+ cachedJwk.setPk(pk);
+
+ DynamoDbTable table = getTable();
+ table.putItem(cachedJwk);
+ }
+
+ @Override
+ public void cacheJwkAbsent(@NonNull String url, @NonNull String kid, long ttl) {
+ String pk = getId(url, kid);
+
+ CachedJwkRecord cachedJwk = CachedJwkRecord.builder()
+ .pk(pk)
+ .url(url)
+ .kid(kid)
+ .ttl(ttl)
+ .valid(false)
+ .build();
+
+ DynamoDbTable table = getTable();
+ table.putItem(cachedJwk);
+ }
+
+ @Override
+ public List getJwks(@NonNull String url) {
+ DynamoDbTable table = getTable();
+ DynamoDbIndex index = table.index("url-index");
+
+ QueryConditional queryConditional = QueryConditional
+ .keyEqualTo(Key.builder().partitionValue(url).build());
+
+ QueryEnhancedRequest request = QueryEnhancedRequest.builder()
+ .queryConditional(queryConditional)
+ .build();
+
+ List list = new ArrayList<>();
+ for (Page page : index.query(request)) {
+ for (CachedJwkRecord record : page.items()) {
+ list.add(record);
+ }
+ }
+
+ return Collections.unmodifiableList(list);
+ }
+
+ @Override
+ public CachedJwk getJwk(String url, String kid) {
+ String pk = getId(url, kid);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+ return table.getItem(key);
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbLoginCodeRepository.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbLoginCodeRepository.java
new file mode 100644
index 00000000..1d5ae119
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbLoginCodeRepository.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository;
+
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.LoginCodeRecord;
+import com.unitvectory.serviceauthcentral.datamodel.model.LoginCode;
+import com.unitvectory.serviceauthcentral.datamodel.repository.LoginCodeRepository;
+import com.unitvectory.serviceauthcentral.util.HashingUtil;
+
+import lombok.AllArgsConstructor;
+import lombok.NonNull;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
+import software.amazon.awssdk.enhanced.dynamodb.Key;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+
+/**
+ * The DynamoDB Login Code Repository
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@AllArgsConstructor
+public class DynamoDbLoginCodeRepository implements LoginCodeRepository {
+
+ private DynamoDbEnhancedClient dynamoDbEnhancedClient;
+
+ private String tableName;
+
+ private DynamoDbTable getTable() {
+ return dynamoDbEnhancedClient.table(tableName, TableSchema.fromBean(LoginCodeRecord.class));
+ }
+
+ @Override
+ public void saveCode(@NonNull String code, @NonNull String clientId,
+ @NonNull String redirectUri, @NonNull String codeChallenge,
+ @NonNull String userClientId, long ttl) {
+ // Hashing the code, we are not storing the code in the database directly as it
+ // is sensitive data that we want to keep away from even admins
+ String pk = HashingUtil.sha256(code);
+
+ LoginCodeRecord record = LoginCodeRecord.builder()
+ .pk(pk)
+ .clientId(clientId)
+ .redirectUri(redirectUri)
+ .codeChallenge(codeChallenge)
+ .userClientId(userClientId)
+ .ttl(ttl)
+ .build();
+
+ DynamoDbTable table = getTable();
+ table.putItem(record);
+ }
+
+ @Override
+ public LoginCode getCode(@NonNull String code) {
+ String pk = HashingUtil.sha256(code);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+ return table.getItem(key);
+ }
+
+ @Override
+ public void deleteCode(@NonNull String code) {
+ String pk = HashingUtil.sha256(code);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+ table.deleteItem(key);
+ }
+}
diff --git a/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbLoginStateRepository.java b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbLoginStateRepository.java
new file mode 100644
index 00000000..c3a0a390
--- /dev/null
+++ b/datamodel-dynamodb/src/main/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbLoginStateRepository.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository;
+
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.LoginStateRecord;
+import com.unitvectory.serviceauthcentral.datamodel.model.LoginState;
+import com.unitvectory.serviceauthcentral.datamodel.repository.LoginStateRepository;
+import com.unitvectory.serviceauthcentral.util.HashingUtil;
+
+import lombok.AllArgsConstructor;
+import lombok.NonNull;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
+import software.amazon.awssdk.enhanced.dynamodb.Key;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+
+/**
+ * The DynamoDB Login State Repository
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@AllArgsConstructor
+public class DynamoDbLoginStateRepository implements LoginStateRepository {
+
+ private DynamoDbEnhancedClient dynamoDbEnhancedClient;
+
+ private String tableName;
+
+ private DynamoDbTable getTable() {
+ return dynamoDbEnhancedClient.table(tableName, TableSchema.fromBean(LoginStateRecord.class));
+ }
+
+ @Override
+ public void saveState(@NonNull String sessionId, @NonNull String clientId,
+ @NonNull String redirectUri, @NonNull String primaryState,
+ @NonNull String primaryCodeChallenge, @NonNull String secondaryState, long ttl) {
+ // Hashing the sessionId, it is sensitive data that we want to keep away from
+ // even admins
+ String pk = HashingUtil.sha256(sessionId);
+
+ LoginStateRecord record = LoginStateRecord.builder()
+ .pk(pk)
+ .clientId(clientId)
+ .redirectUri(redirectUri)
+ .primaryState(primaryState)
+ .primaryCodeChallenge(primaryCodeChallenge)
+ .secondaryState(secondaryState)
+ .ttl(ttl)
+ .build();
+
+ DynamoDbTable table = getTable();
+ table.putItem(record);
+ }
+
+ @Override
+ public LoginState getState(@NonNull String sessionId) {
+ String pk = HashingUtil.sha256(sessionId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+ return table.getItem(key);
+ }
+
+ @Override
+ public void deleteState(@NonNull String sessionId) {
+ String pk = HashingUtil.sha256(sessionId);
+ DynamoDbTable table = getTable();
+ Key key = Key.builder().partitionValue(pk).build();
+ table.deleteItem(key);
+ }
+}
diff --git a/datamodel-dynamodb/src/test/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbAuthorizationRepositoryTest.java b/datamodel-dynamodb/src/test/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbAuthorizationRepositoryTest.java
new file mode 100644
index 00000000..496ec6bb
--- /dev/null
+++ b/datamodel-dynamodb/src/test/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbAuthorizationRepositoryTest.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+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.when;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import com.unitvectory.consistgen.epoch.EpochTimeProvider;
+import com.unitvectory.consistgen.epoch.StaticEpochTimeProvider;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.AuthorizationRecord;
+import com.unitvectory.serviceauthcentral.datamodel.model.Authorization;
+import com.unitvectory.serviceauthcentral.util.HashingUtil;
+
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
+import software.amazon.awssdk.enhanced.dynamodb.Key;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+
+import java.util.ArrayList;
+
+/**
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@SuppressWarnings("unchecked")
+public class DynamoDbAuthorizationRepositoryTest {
+
+ private static final String TABLE_AUTHORIZATIONS = "authorizations";
+
+ private final EpochTimeProvider epochTimeProvider = StaticEpochTimeProvider.getInstance();
+
+ @Test
+ public void testGetAuthorizationById_Exists() {
+ DynamoDbEnhancedClient dynamoDbEnhancedClient = mock(DynamoDbEnhancedClient.class);
+ DynamoDbTable table = mock(DynamoDbTable.class);
+
+ AuthorizationRecord record = new AuthorizationRecord();
+ record.setPk("some-id");
+ record.setSubject("subject");
+ record.setAudience("audience");
+
+ when(dynamoDbEnhancedClient.table(TABLE_AUTHORIZATIONS, TableSchema.fromBean(AuthorizationRecord.class)))
+ .thenReturn(table);
+ when(table.getItem((Key) any())).thenReturn(record);
+
+ DynamoDbAuthorizationRepository repository = new DynamoDbAuthorizationRepository(
+ dynamoDbEnhancedClient, TABLE_AUTHORIZATIONS, epochTimeProvider);
+ Authorization authorization = repository.getAuthorization("some-id");
+
+ assertNotNull(authorization);
+ }
+
+ @Test
+ public void testGetAuthorizationById_NotExists() {
+ DynamoDbEnhancedClient dynamoDbEnhancedClient = mock(DynamoDbEnhancedClient.class);
+ DynamoDbTable table = mock(DynamoDbTable.class);
+
+ when(dynamoDbEnhancedClient.table(TABLE_AUTHORIZATIONS, TableSchema.fromBean(AuthorizationRecord.class)))
+ .thenReturn(table);
+ when(table.getItem((Key) any())).thenReturn(null);
+
+ DynamoDbAuthorizationRepository repository = new DynamoDbAuthorizationRepository(
+ dynamoDbEnhancedClient, TABLE_AUTHORIZATIONS, epochTimeProvider);
+ Authorization authorization = repository.getAuthorization("some-id");
+
+ assertNull(authorization);
+ }
+
+ @Test
+ public void testAuthorize() {
+ DynamoDbEnhancedClient dynamoDbEnhancedClient = mock(DynamoDbEnhancedClient.class);
+ DynamoDbTable table = mock(DynamoDbTable.class);
+
+ when(dynamoDbEnhancedClient.table(TABLE_AUTHORIZATIONS, TableSchema.fromBean(AuthorizationRecord.class)))
+ .thenReturn(table);
+
+ DynamoDbAuthorizationRepository repository = new DynamoDbAuthorizationRepository(
+ dynamoDbEnhancedClient, TABLE_AUTHORIZATIONS, epochTimeProvider);
+ repository.authorize("subject", "audience", new ArrayList());
+
+ ArgumentCaptor argument =
+ ArgumentCaptor.forClass(AuthorizationRecord.class);
+ verify(table).putItem(argument.capture());
+ assertEquals("subject", argument.getValue().getSubject());
+ assertEquals("audience", argument.getValue().getAudience());
+ }
+
+ @Test
+ public void testDeauthorize() {
+ DynamoDbEnhancedClient dynamoDbEnhancedClient = mock(DynamoDbEnhancedClient.class);
+ DynamoDbTable table = mock(DynamoDbTable.class);
+
+ when(dynamoDbEnhancedClient.table(TABLE_AUTHORIZATIONS, TableSchema.fromBean(AuthorizationRecord.class)))
+ .thenReturn(table);
+
+ DynamoDbAuthorizationRepository repository = new DynamoDbAuthorizationRepository(
+ dynamoDbEnhancedClient, TABLE_AUTHORIZATIONS, epochTimeProvider);
+ repository.deauthorize("subject", "audience");
+
+ verify(table).deleteItem((Key) any());
+ }
+}
diff --git a/datamodel-dynamodb/src/test/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbClientRepositoryTest.java b/datamodel-dynamodb/src/test/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbClientRepositoryTest.java
new file mode 100644
index 00000000..e83ce8aa
--- /dev/null
+++ b/datamodel-dynamodb/src/test/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbClientRepositoryTest.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+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.when;
+
+import org.junit.jupiter.api.Test;
+
+import com.unitvectory.consistgen.epoch.EpochTimeProvider;
+import com.unitvectory.consistgen.epoch.StaticEpochTimeProvider;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.ClientRecord;
+import com.unitvectory.serviceauthcentral.datamodel.model.Client;
+import com.unitvectory.serviceauthcentral.util.HashingUtil;
+
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
+import software.amazon.awssdk.enhanced.dynamodb.Key;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+
+/**
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@SuppressWarnings("unchecked")
+public class DynamoDbClientRepositoryTest {
+
+ private static final String TABLE_CLIENTS = "clients";
+
+ private final EpochTimeProvider epochTimeProvider = StaticEpochTimeProvider.getInstance();
+
+ @Test
+ public void testNoArgs() {
+ DynamoDbClientRepository repository =
+ new DynamoDbClientRepository(null, TABLE_CLIENTS, epochTimeProvider);
+ assertNotNull(repository);
+ }
+
+ @Test
+ public void testGetClient_NoClientId() {
+ DynamoDbEnhancedClient dynamoDbEnhancedClient = mock(DynamoDbEnhancedClient.class);
+ DynamoDbClientRepository repository =
+ new DynamoDbClientRepository(dynamoDbEnhancedClient, TABLE_CLIENTS, epochTimeProvider);
+
+ NullPointerException thrown =
+ assertThrows(NullPointerException.class, () -> repository.getClient(null),
+ "Expected getClient with null clientId to throw exception");
+
+ assertEquals("clientId is marked non-null but is null", thrown.getMessage());
+ }
+
+ @Test
+ public void testGetClient_InvalidClientId() {
+ DynamoDbEnhancedClient dynamoDbEnhancedClient = mock(DynamoDbEnhancedClient.class);
+ DynamoDbTable table = mock(DynamoDbTable.class);
+
+ when(dynamoDbEnhancedClient.table(TABLE_CLIENTS, TableSchema.fromBean(ClientRecord.class)))
+ .thenReturn(table);
+ when(table.getItem((Key) any())).thenReturn(null);
+
+ DynamoDbClientRepository repository =
+ new DynamoDbClientRepository(dynamoDbEnhancedClient, TABLE_CLIENTS, epochTimeProvider);
+
+ Client client = repository.getClient("invalid-client-id");
+ assertNull(client);
+ }
+
+ @Test
+ public void testGetClient_ClientExists() {
+ DynamoDbEnhancedClient dynamoDbEnhancedClient = mock(DynamoDbEnhancedClient.class);
+ DynamoDbTable table = mock(DynamoDbTable.class);
+
+ ClientRecord clientRecord = new ClientRecord();
+ clientRecord.setPk(HashingUtil.sha256("client-id"));
+ clientRecord.setClientId("client-id");
+
+ when(dynamoDbEnhancedClient.table(TABLE_CLIENTS, TableSchema.fromBean(ClientRecord.class)))
+ .thenReturn(table);
+ when(table.getItem((Key) any())).thenReturn(clientRecord);
+
+ DynamoDbClientRepository repository =
+ new DynamoDbClientRepository(dynamoDbEnhancedClient, TABLE_CLIENTS, epochTimeProvider);
+
+ Client client = repository.getClient("client-id");
+ assertNotNull(client);
+ assertEquals("client-id", client.getClientId());
+ }
+}
diff --git a/datamodel-dynamodb/src/test/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbIntegrationTest.java b/datamodel-dynamodb/src/test/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbIntegrationTest.java
new file mode 100644
index 00000000..e7b8f687
--- /dev/null
+++ b/datamodel-dynamodb/src/test/java/com/unitvectory/serviceauthcentral/datamodel/dynamodb/repository/DynamoDbIntegrationTest.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.unitvectory.serviceauthcentral.datamodel.dynamodb.repository;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+
+import com.unitvectory.consistgen.epoch.StaticEpochTimeProvider;
+import com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.ClientScopeRecord;
+import com.unitvectory.serviceauthcentral.datamodel.model.Authorization;
+import com.unitvectory.serviceauthcentral.datamodel.model.Client;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientScope;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientSummaryConnection;
+import com.unitvectory.serviceauthcentral.datamodel.model.ClientType;
+import com.unitvectory.serviceauthcentral.datamodel.model.LoginCode;
+import com.unitvectory.serviceauthcentral.datamodel.model.LoginState;
+
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+
+/**
+ * Integration tests for DynamoDB repositories against DynamoDB Local
+ *
+ * @author Jared Hatfield (UnitVectorY Labs)
+ */
+@EnabledIfSystemProperty(named = "dynamodb.local.enabled", matches = "true")
+public class DynamoDbIntegrationTest {
+
+ private static DynamoDbEnhancedClient enhancedClient;
+
+ @BeforeAll
+ static void setup() {
+ DynamoDbClient dynamoDbClient = DynamoDbClient.builder()
+ .region(Region.US_EAST_1)
+ .endpointOverride(URI.create("http://localhost:8000"))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create("dummy", "dummy")))
+ .build();
+
+ enhancedClient = DynamoDbEnhancedClient.builder()
+ .dynamoDbClient(dynamoDbClient)
+ .build();
+ }
+
+ @Test
+ void testClientRepository() {
+ DynamoDbClientRepository repo = new DynamoDbClientRepository(
+ enhancedClient, "sac-clients", StaticEpochTimeProvider.getInstance());
+
+ // Create a client
+ List scopes = new ArrayList<>();
+ scopes.add(ClientScopeRecord.builder().scope("read").description("Read access").build());
+
+ repo.putClient("test-client-1", "Test Client 1", "salt123", ClientType.APPLICATION, scopes);
+
+ // Get the client
+ Client client = repo.getClient("test-client-1");
+ assertNotNull(client);
+ assertEquals("test-client-1", client.getClientId());
+ assertEquals("Test Client 1", client.getDescription());
+ assertEquals(ClientType.APPLICATION, client.getClientType());
+ assertEquals("salt123", client.getSalt());
+
+ // List clients
+ ClientSummaryConnection connection = repo.getClients(10, null, null, null);
+ assertNotNull(connection);
+ assertTrue(connection.getEdges().size() > 0);
+
+ // Add a secret
+ repo.saveClientSecret1("test-client-1", "hashed-secret-1");
+ client = repo.getClient("test-client-1");
+ assertEquals("hashed-secret-1", client.getClientSecret1());
+
+ // Clear the secret
+ repo.clearClientSecret1("test-client-1");
+ client = repo.getClient("test-client-1");
+ assertNull(client.getClientSecret1());
+
+ // Delete the client
+ repo.deleteClient("test-client-1");
+ client = repo.getClient("test-client-1");
+ assertNull(client);
+
+ System.out.println("ClientRepository tests passed!");
+ }
+
+ @Test
+ void testAuthorizationRepository() {
+ DynamoDbAuthorizationRepository repo = new DynamoDbAuthorizationRepository(
+ enhancedClient, "sac-authorizations", StaticEpochTimeProvider.getInstance());
+
+ // Create authorization
+ repo.authorize("subject-1", "audience-1", Arrays.asList("read", "write"));
+
+ // Get by subject and audience
+ Authorization auth = repo.getAuthorization("subject-1", "audience-1");
+ assertNotNull(auth);
+ assertEquals("subject-1", auth.getSubject());
+ assertEquals("audience-1", auth.getAudience());
+ assertTrue(auth.getAuthorizedScopes().contains("read"));
+ assertTrue(auth.getAuthorizedScopes().contains("write"));
+
+ // Get by subject
+ Iterator bySubject = repo.getAuthorizationBySubject("subject-1");
+ assertTrue(bySubject.hasNext());
+ assertEquals("subject-1", bySubject.next().getSubject());
+
+ // Get by audience
+ Iterator byAudience = repo.getAuthorizationByAudience("audience-1");
+ assertTrue(byAudience.hasNext());
+ assertEquals("audience-1", byAudience.next().getAudience());
+
+ // Add scope
+ repo.authorizeAddScope("subject-1", "audience-1", "delete");
+ auth = repo.getAuthorization("subject-1", "audience-1");
+ assertTrue(auth.getAuthorizedScopes().contains("delete"));
+
+ // Remove scope
+ repo.authorizeRemoveScope("subject-1", "audience-1", "delete");
+ auth = repo.getAuthorization("subject-1", "audience-1");
+ assertFalse(auth.getAuthorizedScopes().contains("delete"));
+
+ // Deauthorize
+ repo.deauthorize("subject-1", "audience-1");
+ auth = repo.getAuthorization("subject-1", "audience-1");
+ assertNull(auth);
+
+ System.out.println("AuthorizationRepository tests passed!");
+ }
+
+ @Test
+ void testLoginStateRepository() {
+ DynamoDbLoginStateRepository repo = new DynamoDbLoginStateRepository(
+ enhancedClient, "sac-loginStates");
+
+ // Save state
+ repo.saveState("session-1", "client-1", "http://redirect", "primary-state",
+ "code-challenge", "secondary-state", System.currentTimeMillis() + 3600000);
+
+ // Get state
+ LoginState state = repo.getState("session-1");
+ assertNotNull(state);
+ assertEquals("client-1", state.getClientId());
+ assertEquals("http://redirect", state.getRedirectUri());
+ assertEquals("primary-state", state.getPrimaryState());
+ assertEquals("code-challenge", state.getPrimaryCodeChallenge());
+ assertEquals("secondary-state", state.getSecondaryState());
+
+ // Delete state
+ repo.deleteState("session-1");
+ state = repo.getState("session-1");
+ assertNull(state);
+
+ System.out.println("LoginStateRepository tests passed!");
+ }
+
+ @Test
+ void testLoginCodeRepository() {
+ DynamoDbLoginCodeRepository repo = new DynamoDbLoginCodeRepository(
+ enhancedClient, "sac-loginCodes");
+
+ // Save code
+ repo.saveCode("auth-code-1", "client-1", "http://redirect",
+ "code-challenge", "user-client-1", System.currentTimeMillis() + 3600000);
+
+ // Get code
+ LoginCode code = repo.getCode("auth-code-1");
+ assertNotNull(code);
+ assertEquals("client-1", code.getClientId());
+ assertEquals("http://redirect", code.getRedirectUri());
+ assertEquals("code-challenge", code.getCodeChallenge());
+ assertEquals("user-client-1", code.getUserClientId());
+
+ // Delete code
+ repo.deleteCode("auth-code-1");
+ code = repo.getCode("auth-code-1");
+ assertNull(code);
+
+ System.out.println("LoginCodeRepository tests passed!");
+ }
+
+ @Test
+ void testJwkCacheRepository() {
+ DynamoDbJwkCacheRepository repo = new DynamoDbJwkCacheRepository(
+ enhancedClient, "sac-keys");
+
+ // Create a mock JWK
+ com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.CachedJwkRecord jwk =
+ com.unitvectory.serviceauthcentral.datamodel.dynamodb.model.CachedJwkRecord.builder()
+ .kid("kid-1")
+ .kty("RSA")
+ .alg("RS256")
+ .use("sig")
+ .n("modulus-value")
+ .e("AQAB")
+ .valid(true)
+ .build();
+
+ long ttl = System.currentTimeMillis() / 1000 + 3600;
+
+ // Cache the JWK
+ repo.cacheJwk("https://example.com/.well-known/jwks.json", jwk, ttl);
+
+ // Get the JWK by URL and kid
+ com.unitvectory.serviceauthcentral.datamodel.model.CachedJwk cachedJwk =
+ repo.getJwk("https://example.com/.well-known/jwks.json", "kid-1");
+ assertNotNull(cachedJwk);
+ assertEquals("kid-1", cachedJwk.getKid());
+ assertEquals("RSA", cachedJwk.getKty());
+ assertEquals("RS256", cachedJwk.getAlg());
+ assertTrue(cachedJwk.isValid());
+
+ // Get JWKs by URL
+ java.util.List jwks =
+ repo.getJwks("https://example.com/.well-known/jwks.json");
+ assertNotNull(jwks);
+ assertTrue(jwks.size() > 0);
+ assertEquals("kid-1", jwks.get(0).getKid());
+
+ // Cache absent JWK
+ repo.cacheJwkAbsent("https://example.com/.well-known/jwks.json", "kid-absent", ttl);
+ cachedJwk = repo.getJwk("https://example.com/.well-known/jwks.json", "kid-absent");
+ assertNotNull(cachedJwk);
+ assertFalse(cachedJwk.isValid());
+
+ System.out.println("JwkCacheRepository tests passed!");
+ }
+}
diff --git a/docs/modules/datamodel/dynamodb.md b/docs/modules/datamodel/dynamodb.md
new file mode 100644
index 00000000..39a2f757
--- /dev/null
+++ b/docs/modules/datamodel/dynamodb.md
@@ -0,0 +1,189 @@
+# Data Model - DynamoDB
+
+The data model DynamoDB module provides an AWS [DynamoDB](https://aws.amazon.com/dynamodb/) implementation of the the data model interfaces so that the underlying implementation can be swapped out as a runtime dependency.
+
+## Tables
+
+The following tables are used by the DynamoDB data store:
+
+```mermaid
+flowchart TD
+ sacauthorizations[(sac-authorizations)]
+ sacclients[(sac-clients)]
+ sackeys[(sac-keys)]
+ sacloginCodes[(sac-loginCodes)]
+ sacloginStates[(sac-loginStates)]
+```
+
+### Table Schema
+
+Each table uses a single partition key `pk` for efficient lookups. The `sac-authorizations` and `sac-keys` tables also require Global Secondary Indexes (GSIs) for querying by subject/audience and url respectively.
+
+#### sac-authorizations Table
+
+| Attribute | Type | Key |
+| --------- | ---- | --- |
+| pk | String | Partition Key |
+| authorizationCreated | String | - |
+| subject | String | GSI: subject-index (Partition Key) |
+| audience | String | GSI: audience-index (Partition Key) |
+| authorizedScopes | List | - |
+| locked | Boolean | - |
+
+#### sac-clients Table
+
+| Attribute | Type | Key |
+| --------- | ---- | --- |
+| pk | String | Partition Key |
+| clientCreated | String | - |
+| clientId | String | - |
+| description | String | - |
+| salt | String | - |
+| clientType | String | - |
+| clientSecret1 | String | - |
+| clientSecret1Updated | String | - |
+| clientSecret2 | String | - |
+| clientSecret2Updated | String | - |
+| availableScopes | List