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 | - | +| jwtBearer | List | - | +| locked | Boolean | - | + +#### sac-keys Table + +| Attribute | Type | Key | +| --------- | ---- | --- | +| pk | String | Partition Key | +| url | String | GSI: url-index (Partition Key) | +| ttl | Number | TTL attribute for auto-expiration | +| valid | Boolean | - | +| kid | String | - | +| kty | String | - | +| alg | String | - | +| use | String | - | +| n | String | - | +| e | String | - | + +#### sac-loginCodes Table + +| Attribute | Type | Key | +| --------- | ---- | --- | +| pk | String | Partition Key | +| clientId | String | - | +| redirectUri | String | - | +| codeChallenge | String | - | +| userClientId | String | - | +| ttl | Number | TTL attribute for auto-expiration | + +#### sac-loginStates Table + +| Attribute | Type | Key | +| --------- | ---- | --- | +| pk | String | Partition Key | +| clientId | String | - | +| redirectUri | String | - | +| primaryState | String | - | +| primaryCodeChallenge | String | - | +| secondaryState | String | - | +| ttl | Number | TTL attribute for auto-expiration | + +## Spring Boot Profile + +Spring Boot 3's dependency injection is used to initialize the relevant Beans for interacting with DynamoDB. This is accomplished through profiles. + +The `datamodel-dynamodb` profile is enabled to utilize AWS DynamoDB. + +## Configuration + +The following configuration attributes: + +| Property | Required | Description | +| -------------------------------------------- | --------------------------------- | --------------------------- | +| aws.region | No (default: 'us-east-1') | AWS Region | +| aws.dynamodb.endpoint | No | Custom DynamoDB endpoint URL (for local testing) | +| sac.datamodel.dynamodb.table.authorizations | No (default: 'sac-authorizations')| DynamoDB table name | +| sac.datamodel.dynamodb.table.clients | No (default: 'sac-clients') | DynamoDB table name | +| sac.datamodel.dynamodb.table.keys | No (default: 'sac-keys') | DynamoDB table name | +| sac.datamodel.dynamodb.table.logincodes | No (default: 'sac-loginCodes') | DynamoDB table name | +| sac.datamodel.dynamodb.table.loginstates | No (default: 'sac-loginStates') | DynamoDB table name | + +## AWS Credentials + +This module uses the AWS SDK default credentials provider chain. This means it will automatically look for credentials in the following order: + +1. Environment variables (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`) +2. Java system properties (`aws.accessKeyId` and `aws.secretAccessKey`) +3. Web identity token credentials from environment or container +4. Credential profiles file (`~/.aws/credentials`) +5. ECS container credentials +6. EC2 instance profile credentials + +For production deployments, it is recommended to use IAM roles for service accounts (IRSA) when running in EKS, or instance profiles when running on EC2. + +When `aws.dynamodb.endpoint` is set, the module uses static dummy credentials for local DynamoDB testing. + +## Performance Considerations + +- **Backward pagination**: The client listing functionality with backward pagination (`last` parameter) requires scanning all records. For large datasets, forward pagination (`first` parameter) is recommended for better performance. +- **TTL expiration**: DynamoDB TTL can be enabled on the `sac-keys`, `sac-loginCodes`, and `sac-loginStates` tables to automatically delete expired records. + +## Local Development with DynamoDB Local + +For local development and testing, you can use [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html): + +```bash +# Start DynamoDB Local with Docker +docker run -d -p 8000:8000 amazon/dynamodb-local:latest + +# Create required tables +aws dynamodb create-table \ + --table-name sac-clients \ + --attribute-definitions AttributeName=pk,AttributeType=S \ + --key-schema AttributeName=pk,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --endpoint-url http://localhost:8000 + +aws dynamodb create-table \ + --table-name sac-authorizations \ + --attribute-definitions \ + AttributeName=pk,AttributeType=S \ + AttributeName=subject,AttributeType=S \ + AttributeName=audience,AttributeType=S \ + --key-schema AttributeName=pk,KeyType=HASH \ + --global-secondary-indexes \ + "[{\"IndexName\":\"subject-index\",\"KeySchema\":[{\"AttributeName\":\"subject\",\"KeyType\":\"HASH\"}],\"Projection\":{\"ProjectionType\":\"ALL\"}},{\"IndexName\":\"audience-index\",\"KeySchema\":[{\"AttributeName\":\"audience\",\"KeyType\":\"HASH\"}],\"Projection\":{\"ProjectionType\":\"ALL\"}}]" \ + --billing-mode PAY_PER_REQUEST \ + --endpoint-url http://localhost:8000 + +aws dynamodb create-table \ + --table-name sac-keys \ + --attribute-definitions \ + AttributeName=pk,AttributeType=S \ + AttributeName=url,AttributeType=S \ + --key-schema AttributeName=pk,KeyType=HASH \ + --global-secondary-indexes \ + "[{\"IndexName\":\"url-index\",\"KeySchema\":[{\"AttributeName\":\"url\",\"KeyType\":\"HASH\"}],\"Projection\":{\"ProjectionType\":\"ALL\"}}]" \ + --billing-mode PAY_PER_REQUEST \ + --endpoint-url http://localhost:8000 + +aws dynamodb create-table \ + --table-name sac-loginCodes \ + --attribute-definitions AttributeName=pk,AttributeType=S \ + --key-schema AttributeName=pk,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --endpoint-url http://localhost:8000 + +aws dynamodb create-table \ + --table-name sac-loginStates \ + --attribute-definitions AttributeName=pk,AttributeType=S \ + --key-schema AttributeName=pk,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --endpoint-url http://localhost:8000 +``` + +Then configure the application: + +```yaml +spring.profiles.active: datamodel-dynamodb +aws.dynamodb.endpoint: http://localhost:8000 +aws.region: us-east-1 +``` diff --git a/docs/modules/datamodel/index.md b/docs/modules/datamodel/index.md index 63c7609e..86bbdf1c 100644 --- a/docs/modules/datamodel/index.md +++ b/docs/modules/datamodel/index.md @@ -19,7 +19,8 @@ A flexible deployment is supported by allowing different underlying database tec There are multiple data model implementations that are available. Exactly one module must be enabled at runtime. !!! note - Each module implementation will have additional properties that are required to be set for it to work correctly when it is enabled, typically through envirionment variables. + Each module implementation will have additional properties that are required to be set for it to work correctly when it is enabled, typically through environment variables. +- [Data Model - DynamoDB](./dynamodb.md): DynamoDB implementation for the repository interfaces - [Data Model - Firestore](./firestore.md): Firestore implementation for the repository interfaces - [Data Model - Memory](./memory.md): In-memory implementation for the repository interfaces used for testing and development diff --git a/pom.xml b/pom.xml index 22fb86c6..6703c9cd 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,7 @@ util datamodel datamodel-memory + datamodel-dynamodb @@ -54,6 +55,7 @@ 1.5.5.Final 0.2.0 7.4.3 + 2.31.62 0.0.9 0.1.2 @@ -74,6 +76,13 @@ pom import + + software.amazon.awssdk + bom + ${aws-sdk.version} + pom + import + com.unitvectory consistgen diff --git a/server-manage/pom.xml b/server-manage/pom.xml index 5fefde20..cc017b26 100644 --- a/server-manage/pom.xml +++ b/server-manage/pom.xml @@ -37,6 +37,12 @@ ${project.version} runtime + + com.unitvectory.serviceauthcentral + datamodel-dynamodb + ${project.version} + runtime + org.springframework.boot spring-boot-starter-web diff --git a/server-token/pom.xml b/server-token/pom.xml index cce212fb..60f3531a 100644 --- a/server-token/pom.xml +++ b/server-token/pom.xml @@ -37,6 +37,12 @@ ${project.version} runtime + + com.unitvectory.serviceauthcentral + datamodel-dynamodb + ${project.version} + runtime + com.unitvectory.serviceauthcentral sign