diff --git a/datamodel-valkey/pom.xml b/datamodel-valkey/pom.xml new file mode 100644 index 00000000..3fabf16c --- /dev/null +++ b/datamodel-valkey/pom.xml @@ -0,0 +1,109 @@ + + + 4.0.0 + + com.unitvectory + serviceauthcentral + 0.0.1-SNAPSHOT + + + com.unitvectory.serviceauthcentral + datamodel-valkey + + + + + + + com.unitvectory + consistgen + + + com.unitvectory.serviceauthcentral + util + ${project.version} + + + com.unitvectory.serviceauthcentral + datamodel + ${project.version} + + + org.springframework.boot + spring-boot-starter-data-redis + + + com.fasterxml.jackson.core + jackson-databind + + + org.mapstruct + mapstruct + + + org.projectlombok + lombok + provided + + + org.projectlombok + lombok-mapstruct-binding + + + org.junit.jupiter + junit-jupiter-api + test + + + org.mockito + mockito-core + test + + + + + + + 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-valkey/src/lombok.config b/datamodel-valkey/src/lombok.config new file mode 100644 index 00000000..fc53b467 --- /dev/null +++ b/datamodel-valkey/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 \ No newline at end of file diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/config/ValkeyConfig.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/config/ValkeyConfig.java new file mode 100644 index 00000000..bfc1278f --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/config/ValkeyConfig.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 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.valkey.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.unitvectory.consistgen.epoch.EpochTimeProvider; +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 com.unitvectory.serviceauthcentral.datamodel.valkey.repository.ValkeyAuthorizationRepository; +import com.unitvectory.serviceauthcentral.datamodel.valkey.repository.ValkeyClientRepository; +import com.unitvectory.serviceauthcentral.datamodel.valkey.repository.ValkeyJwkCacheRepository; +import com.unitvectory.serviceauthcentral.datamodel.valkey.repository.ValkeyLoginCodeRepository; +import com.unitvectory.serviceauthcentral.datamodel.valkey.repository.ValkeyLoginStateRepository; + +@Configuration +@Profile("datamodel-valkey") +public class ValkeyConfig { + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Autowired + private EpochTimeProvider epochTimeProvider; + + @Bean + public AuthorizationRepository authorizationRepository() { + return new ValkeyAuthorizationRepository(stringRedisTemplate, epochTimeProvider); + } + + @Bean + public ClientRepository clientRepository() { + return new ValkeyClientRepository(stringRedisTemplate, epochTimeProvider); + } + + @Bean + public JwkCacheRepository jwkCacheRepository() { + return new ValkeyJwkCacheRepository(stringRedisTemplate); + } + + @Bean + public LoginCodeRepository loginCodeRepository() { + return new ValkeyLoginCodeRepository(stringRedisTemplate); + } + + @Bean + public LoginStateRepository loginStateRepository() { + return new ValkeyLoginStateRepository(stringRedisTemplate); + } +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/mapper/ClientScopeMapper.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/mapper/ClientScopeMapper.java new file mode 100644 index 00000000..f3653b78 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/mapper/ClientScopeMapper.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026 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.valkey.mapper; + +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import com.unitvectory.serviceauthcentral.datamodel.valkey.model.ValkeyClientScope; +import com.unitvectory.serviceauthcentral.datamodel.model.ClientScope; + +/** + * The mapper for ClientScope + * + * @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") + ValkeyClientScope clientScopeToValkeyClientScope(ClientScope clientScope); + + List clientScopeToValkeyClientScope(List clientScopes); +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/mapper/ValkeyCachedJwkMapper.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/mapper/ValkeyCachedJwkMapper.java new file mode 100644 index 00000000..13beae9e --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/mapper/ValkeyCachedJwkMapper.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 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.valkey.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import com.unitvectory.serviceauthcentral.datamodel.valkey.model.ValkeyCachedJwk; +import com.unitvectory.serviceauthcentral.datamodel.model.CachedJwk; + +/** + * The mapper for the ValkeyCachedJwk + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Mapper +public interface ValkeyCachedJwkMapper { + + ValkeyCachedJwkMapper INSTANCE = Mappers.getMapper(ValkeyCachedJwkMapper.class); + + @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") + ValkeyCachedJwk cachedJwkToValkeyCachedJwk(String url, long ttl, CachedJwk jwk); +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/mapper/ValkeyClientSummaryMapper.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/mapper/ValkeyClientSummaryMapper.java new file mode 100644 index 00000000..8e98ce71 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/mapper/ValkeyClientSummaryMapper.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026 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.valkey.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import com.unitvectory.serviceauthcentral.datamodel.valkey.model.ValkeyClient; +import com.unitvectory.serviceauthcentral.datamodel.valkey.model.ValkeyClientSummary; + +/** + * The mapper for the ValkeyClientSummary + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Mapper +public interface ValkeyClientSummaryMapper { + + ValkeyClientSummaryMapper INSTANCE = Mappers.getMapper(ValkeyClientSummaryMapper.class); + + @Mapping(target = "clientId", source = "clientId") + @Mapping(target = "description", source = "description") + ValkeyClientSummary valkeyClientToValkeyClientSummary(ValkeyClient client); +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyAuthorization.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyAuthorization.java new file mode 100644 index 00000000..b170876c --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyAuthorization.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 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.valkey.model; + +import java.util.List; +import com.unitvectory.serviceauthcentral.datamodel.model.Authorization; +import com.unitvectory.serviceauthcentral.util.HashingUtil; + +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; + +/** + * The Valkey Authorization + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Value +@Builder +public class ValkeyAuthorization implements Authorization { + + private String authorizationCreated; + + private String subject; + + private String audience; + + private List authorizedScopes; + + public boolean matches(@NonNull String subject, @NonNull String audience) { + return subject.equals(this.subject) && audience.equals(this.audience); + } + + @Override + public String getDocumentId() { + String subjectHash = HashingUtil.sha256(subject); + String audienceHash = HashingUtil.sha256(audience); + return HashingUtil.sha256(subjectHash + audienceHash); + } +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyCachedJwk.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyCachedJwk.java new file mode 100644 index 00000000..0fccf258 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyCachedJwk.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 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.valkey.model; + +import com.unitvectory.serviceauthcentral.datamodel.model.CachedJwk; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Value; + +/** + * The Valkey Cached JWK + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Value +@Builder +@AllArgsConstructor +public class ValkeyCachedJwk implements CachedJwk { + + 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; + + @Override + public boolean isExpired(long now) { + return ttl < now; + } +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClient.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClient.java new file mode 100644 index 00000000..eda3b5f1 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClient.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026 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.valkey.model; + +import java.util.List; + +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.Builder; +import lombok.Value; + +/** + * The Valkey Client + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Value +@Builder(toBuilder = true) +public class ValkeyClient implements Client { + + private String clientId; + + private String clientCreated; + + 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 List jwtBearer; + + private Boolean locked; +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClientJwtBearer.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClientJwtBearer.java new file mode 100644 index 00000000..20ab5eb2 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClientJwtBearer.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026 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.valkey.model; + +import com.unitvectory.serviceauthcentral.datamodel.model.ClientJwtBearer; + +import lombok.Builder; +import lombok.Value; + +/** + * The Valkey Client JWT Bearer + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Value +@Builder(toBuilder = true) +public class ValkeyClientJwtBearer implements ClientJwtBearer { + + private String id; + + private String jwksUrl; + + private String iss; + + private String sub; + + private String aud; +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClientScope.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClientScope.java new file mode 100644 index 00000000..c181605d --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClientScope.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 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.valkey.model; + +import com.unitvectory.serviceauthcentral.datamodel.model.ClientScope; + +import lombok.Builder; +import lombok.Value; + +/** + * The Valkey Client Scope + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Value +@Builder(toBuilder = true) +public class ValkeyClientScope implements ClientScope { + + private String scope; + + private String description; +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClientSummary.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClientSummary.java new file mode 100644 index 00000000..6b4b3095 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyClientSummary.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 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.valkey.model; + +import com.unitvectory.serviceauthcentral.datamodel.model.ClientSummary; + +import lombok.Builder; +import lombok.Value; + +/** + * The Valkey Client Summary + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Value +@Builder(toBuilder = true) +public class ValkeyClientSummary implements ClientSummary { + + private String clientId; + + private String description; +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyLoginCode.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyLoginCode.java new file mode 100644 index 00000000..43ae8ef1 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyLoginCode.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 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.valkey.model; + +import com.unitvectory.serviceauthcentral.datamodel.model.LoginCode; + +import lombok.Builder; +import lombok.Value; + +/** + * The Valkey Login Code + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Value +@Builder(toBuilder = true) +public class ValkeyLoginCode implements LoginCode { + + private String clientId; + + private String redirectUri; + + private String codeChallenge; + + private String userClientId; + + private long ttl; + + public long getTimeToLive() { + return this.ttl; + } +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyLoginState.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyLoginState.java new file mode 100644 index 00000000..4edca443 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/model/ValkeyLoginState.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026 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.valkey.model; + +import com.unitvectory.serviceauthcentral.datamodel.model.LoginState; + +import lombok.Builder; +import lombok.Value; + +/** + * The Valkey Login State + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Value +@Builder(toBuilder = true) +public class ValkeyLoginState implements LoginState { + + private String clientId; + + private String redirectUri; + + private String primaryState; + + private String primaryCodeChallenge; + + private String secondaryState; + + private long ttl; +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyAuthorizationRepository.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyAuthorizationRepository.java new file mode 100644 index 00000000..f3d617ba --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyAuthorizationRepository.java @@ -0,0 +1,272 @@ +/* + * Copyright 2026 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.valkey.repository; + +import static com.unitvectory.serviceauthcentral.datamodel.valkey.repository.ValkeyHashUtil.*; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.unitvectory.consistgen.epoch.EpochTimeProvider; +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.datamodel.valkey.model.ValkeyAuthorization; +import com.unitvectory.serviceauthcentral.util.HashingUtil; +import com.unitvectory.serviceauthcentral.util.exception.InternalServerErrorException; + +import lombok.NonNull; + +/** + * The Valkey Authorization Repository + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +public class ValkeyAuthorizationRepository implements AuthorizationRepository { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String AUTH_KEY_PREFIX = "sac:auth:"; + private static final String AUTH_SUBJECT_INDEX_PREFIX = "sac:auth:subject:"; + private static final String AUTH_AUDIENCE_INDEX_PREFIX = "sac:auth:audience:"; + private static final String AUTH_LOOKUP_PREFIX = "sac:auth:lookup:"; + + private final StringRedisTemplate redisTemplate; + private final EpochTimeProvider epochTimeProvider; + + public ValkeyAuthorizationRepository(StringRedisTemplate redisTemplate, + EpochTimeProvider epochTimeProvider) { + this.redisTemplate = redisTemplate; + this.epochTimeProvider = epochTimeProvider; + } + + private String authKey(String documentId) { + return AUTH_KEY_PREFIX + documentId; + } + + private String subjectIndexKey(String subject) { + return AUTH_SUBJECT_INDEX_PREFIX + subject; + } + + private String audienceIndexKey(String audience) { + return AUTH_AUDIENCE_INDEX_PREFIX + audience; + } + + private String lookupKey(String subject, String audience) { + String subjectHash = HashingUtil.sha256(subject); + String audienceHash = HashingUtil.sha256(audience); + return AUTH_LOOKUP_PREFIX + subjectHash + ":" + audienceHash; + } + + @Override + public Authorization getAuthorization(@NonNull String id) { + String key = authKey(id); + Map entries = redisTemplate.opsForHash().entries(key); + if (entries == null || entries.isEmpty()) { + return null; + } + return hashToAuthorization(entries); + } + + @Override + public void deleteAuthorization(@NonNull String id) { + Authorization auth = getAuthorization(id); + if (auth != null) { + // Clean up indexes + redisTemplate.opsForSet().remove(subjectIndexKey(auth.getSubject()), id); + redisTemplate.opsForSet().remove(audienceIndexKey(auth.getAudience()), id); + redisTemplate.delete(lookupKey(auth.getSubject(), auth.getAudience())); + redisTemplate.delete(authKey(id)); + } + } + + @Override + public Authorization getAuthorization(@NonNull String subject, @NonNull String audience) { + String lookup = lookupKey(subject, audience); + String documentId = redisTemplate.opsForValue().get(lookup); + if (documentId == null) { + return null; + } + return getAuthorization(documentId); + } + + @Override + public Iterator getAuthorizationBySubject(@NonNull String subject) { + Set documentIds = redisTemplate.opsForSet().members(subjectIndexKey(subject)); + List list = new ArrayList<>(); + if (documentIds != null) { + for (String docId : documentIds) { + Authorization auth = getAuthorization(docId); + if (auth != null) { + list.add(auth); + } + } + } + return list.iterator(); + } + + @Override + public Iterator getAuthorizationByAudience(@NonNull String audience) { + Set documentIds = redisTemplate.opsForSet().members(audienceIndexKey(audience)); + List list = new ArrayList<>(); + if (documentIds != null) { + for (String docId : documentIds) { + Authorization auth = getAuthorization(docId); + if (auth != null) { + list.add(auth); + } + } + } + return list.iterator(); + } + + @Override + public void authorize(@NonNull String subject, @NonNull String audience, + @NonNull List authorizedScopes) { + + String lookup = lookupKey(subject, audience); + // Check if already exists + String existingDocId = redisTemplate.opsForValue().get(lookup); + if (existingDocId != null) { + return; + } + + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + + ValkeyAuthorization auth = ValkeyAuthorization.builder().authorizationCreated(now) + .subject(subject).audience(audience) + .authorizedScopes(new ArrayList<>(authorizedScopes)).build(); + + String documentId = auth.getDocumentId(); + String key = authKey(documentId); + + saveAuthorizationToHash(key, auth); + + // Add to indexes + redisTemplate.opsForValue().set(lookup, documentId); + redisTemplate.opsForSet().add(subjectIndexKey(subject), documentId); + redisTemplate.opsForSet().add(audienceIndexKey(audience), documentId); + } + + @Override + public void deauthorize(@NonNull String subject, @NonNull String audience) { + String lookup = lookupKey(subject, audience); + String documentId = redisTemplate.opsForValue().get(lookup); + if (documentId != null) { + redisTemplate.delete(authKey(documentId)); + redisTemplate.delete(lookup); + redisTemplate.opsForSet().remove(subjectIndexKey(subject), documentId); + redisTemplate.opsForSet().remove(audienceIndexKey(audience), documentId); + } + } + + @Override + public void authorizeAddScope(@NonNull String subject, @NonNull String audience, + @NonNull String authorizedScope) { + String lookup = lookupKey(subject, audience); + String documentId = redisTemplate.opsForValue().get(lookup); + if (documentId == null) { + throw new InternalServerErrorException("Authorization not found"); + } + + String key = authKey(documentId); + Map entries = redisTemplate.opsForHash().entries(key); + if (entries == null || entries.isEmpty()) { + throw new InternalServerErrorException("Authorization not found"); + } + + ValkeyAuthorization auth = hashToAuthorization(entries); + List scopes = new ArrayList<>(auth.getAuthorizedScopes()); + scopes.add(authorizedScope); + + auth = ValkeyAuthorization.builder().authorizationCreated(auth.getAuthorizationCreated()) + .subject(auth.getSubject()).audience(auth.getAudience()) + .authorizedScopes(scopes).build(); + + saveAuthorizationToHash(key, auth); + } + + @Override + public void authorizeRemoveScope(@NonNull String subject, @NonNull String audience, + @NonNull String authorizedScope) { + String lookup = lookupKey(subject, audience); + String documentId = redisTemplate.opsForValue().get(lookup); + if (documentId == null) { + throw new InternalServerErrorException("Authorization not found"); + } + + String key = authKey(documentId); + Map entries = redisTemplate.opsForHash().entries(key); + if (entries == null || entries.isEmpty()) { + throw new InternalServerErrorException("Authorization not found"); + } + + ValkeyAuthorization auth = hashToAuthorization(entries); + List scopes = new ArrayList<>(auth.getAuthorizedScopes()); + scopes.remove(authorizedScope); + + auth = ValkeyAuthorization.builder().authorizationCreated(auth.getAuthorizationCreated()) + .subject(auth.getSubject()).audience(auth.getAudience()) + .authorizedScopes(scopes).build(); + + saveAuthorizationToHash(key, auth); + } + + private void saveAuthorizationToHash(String key, ValkeyAuthorization auth) { + redisTemplate.delete(key); + + redisTemplate.opsForHash().put(key, "authorizationCreated", + nullSafe(auth.getAuthorizationCreated())); + redisTemplate.opsForHash().put(key, "subject", nullSafe(auth.getSubject())); + redisTemplate.opsForHash().put(key, "audience", nullSafe(auth.getAudience())); + + try { + String scopesJson = OBJECT_MAPPER.writeValueAsString(auth.getAuthorizedScopes()); + redisTemplate.opsForHash().put(key, "authorizedScopes", scopesJson); + } catch (JsonProcessingException e) { + throw new InternalServerErrorException("Failed to serialize authorization scopes"); + } + } + + private ValkeyAuthorization hashToAuthorization(Map entries) { + String authorizationCreated = getStr(entries, "authorizationCreated"); + String subject = getStr(entries, "subject"); + String audience = getStr(entries, "audience"); + + List authorizedScopes = new ArrayList<>(); + String scopesJson = getStr(entries, "authorizedScopes"); + if (scopesJson != null && !scopesJson.isEmpty()) { + try { + authorizedScopes = OBJECT_MAPPER.readValue(scopesJson, + new TypeReference>() { + }); + } catch (JsonProcessingException e) { + throw new InternalServerErrorException( + "Failed to deserialize authorization scopes"); + } + } + + return ValkeyAuthorization.builder().authorizationCreated(authorizationCreated) + .subject(subject).audience(audience).authorizedScopes(authorizedScopes).build(); + } + +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyClientRepository.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyClientRepository.java new file mode 100644 index 00000000..a947c170 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyClientRepository.java @@ -0,0 +1,464 @@ +/* + * Copyright 2026 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.valkey.repository; + +import static com.unitvectory.serviceauthcentral.datamodel.valkey.repository.ValkeyHashUtil.*; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.unitvectory.consistgen.epoch.EpochTimeProvider; +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.datamodel.valkey.mapper.ClientScopeMapper; +import com.unitvectory.serviceauthcentral.datamodel.valkey.mapper.ValkeyClientSummaryMapper; +import com.unitvectory.serviceauthcentral.datamodel.valkey.model.ValkeyClient; +import com.unitvectory.serviceauthcentral.datamodel.valkey.model.ValkeyClientJwtBearer; +import com.unitvectory.serviceauthcentral.datamodel.valkey.model.ValkeyClientScope; +import com.unitvectory.serviceauthcentral.util.exception.BadRequestException; +import com.unitvectory.serviceauthcentral.util.exception.InternalServerErrorException; +import com.unitvectory.serviceauthcentral.util.exception.NotFoundException; + +import lombok.NonNull; + +/** + * The Valkey Client Repository + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +public class ValkeyClientRepository implements ClientRepository { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String CLIENT_KEY_PREFIX = "sac:client:"; + private static final String CLIENTS_INDEX_KEY = "sac:clients"; + + private final StringRedisTemplate redisTemplate; + private final EpochTimeProvider epochTimeProvider; + + public ValkeyClientRepository(StringRedisTemplate redisTemplate, + EpochTimeProvider epochTimeProvider) { + this.redisTemplate = redisTemplate; + this.epochTimeProvider = epochTimeProvider; + } + + private String clientKey(String clientId) { + return CLIENT_KEY_PREFIX + clientId; + } + + @Override + public ClientSummaryConnection getClients(Integer first, String after, Integer last, + String before) { + List edges = new ArrayList<>(); + boolean hasNextPage = false; + boolean hasPreviousPage = false; + + Long totalSize = redisTemplate.opsForZSet().zCard(CLIENTS_INDEX_KEY); + if (totalSize == null) { + totalSize = 0L; + } + int total = totalSize.intValue(); + + Integer afterIndex = after != null + ? Integer.parseInt( + new String(Base64.getDecoder().decode(after), StandardCharsets.UTF_8)) + : null; + Integer beforeIndex = before != null + ? Integer.parseInt( + new String(Base64.getDecoder().decode(before), StandardCharsets.UTF_8)) + : null; + + int startIndex = 0; + int endIndex = total; + if (first != null && afterIndex != null) { + startIndex = afterIndex; + endIndex = Math.min(startIndex + first, total); + hasNextPage = endIndex < total; + hasPreviousPage = startIndex > 0; + } else if (last != null && beforeIndex != null) { + endIndex = beforeIndex; + startIndex = Math.max(endIndex - last, 0); + hasNextPage = endIndex < total; + hasPreviousPage = startIndex > 0; + } else if (first != null) { + endIndex = Math.min(first, total); + hasNextPage = endIndex < total; + } else if (last != null) { + startIndex = Math.max(total - last, 0); + hasPreviousPage = startIndex > 0; + } + + if (startIndex < endIndex) { + Set clientIds = + redisTemplate.opsForZSet().range(CLIENTS_INDEX_KEY, startIndex, endIndex - 1); + if (clientIds != null) { + int index = startIndex; + for (String clientId : clientIds) { + ValkeyClient client = getValkeyClient(clientId); + if (client != null) { + ClientSummary summary = ValkeyClientSummaryMapper.INSTANCE + .valkeyClientToValkeyClientSummary(client); + String cursor = Base64.getEncoder() + .encodeToString( + String.valueOf(index).getBytes(StandardCharsets.UTF_8)); + edges.add(ClientSummaryEdge.builder().cursor(cursor).node(summary).build()); + } + index++; + } + } + } + + String startCursor = !edges.isEmpty() ? edges.get(0).getCursor() : null; + String endCursor = !edges.isEmpty() ? edges.get(edges.size() - 1).getCursor() : null; + + PageInfo pageInfo = PageInfo.builder().hasNextPage(hasNextPage) + .hasPreviousPage(hasPreviousPage).startCursor(startCursor).endCursor(endCursor) + .build(); + + return ClientSummaryConnection.builder().edges(edges).pageInfo(pageInfo).build(); + } + + @Override + public Client getClient(@NonNull String clientId) { + return getValkeyClient(clientId); + } + + @Override + public void deleteClient(@NonNull String clientId) { + redisTemplate.delete(clientKey(clientId)); + redisTemplate.opsForZSet().remove(CLIENTS_INDEX_KEY, clientId); + } + + @Override + public void putClient(@NonNull String clientId, @NonNull String description, + @NonNull String salt, @NonNull ClientType clientType, + @NonNull List availableScopes) { + + String key = clientKey(clientId); + + if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { + throw new BadRequestException("client record already exists"); + } + + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + + List availableScopesList = new ArrayList<>(); + for (ClientScope scope : availableScopes) { + availableScopesList + .add(ClientScopeMapper.INSTANCE.clientScopeToValkeyClientScope(scope)); + } + + ValkeyClient record = ValkeyClient.builder().clientCreated(now).clientId(clientId) + .description(description).salt(salt).clientType(clientType) + .availableScopes(Collections.unmodifiableList(availableScopesList)).build(); + + saveClientToHash(key, record); + redisTemplate.opsForZSet().add(CLIENTS_INDEX_KEY, clientId, 0); + } + + @Override + public void addClientAvailableScope(@NonNull String clientId, + @NonNull ClientScope availableScope) { + String key = clientKey(clientId); + ValkeyClient record = getValkeyClient(clientId); + if (record == null) { + throw new NotFoundException("client not found"); + } + + ValkeyClientScope scope = + ClientScopeMapper.INSTANCE.clientScopeToValkeyClientScope(availableScope); + + List list = record.getAvailableScopes(); + if (list == null) { + list = new ArrayList<>(); + } else { + list = new ArrayList<>(list); + } + + for (ClientScope cs : list) { + if (scope.getScope().equals(cs.getScope())) { + throw new BadRequestException("duplicate scope"); + } + } + + list.add(scope); + + record = record.toBuilder().availableScopes(Collections.unmodifiableList(list)).build(); + saveClientToHash(key, record); + } + + @Override + public void addAuthorizedJwt(@NonNull String clientId, @NonNull String id, + @NonNull String jwksUrl, @NonNull String iss, @NonNull String sub, + @NonNull String aud) { + String key = clientKey(clientId); + ValkeyClient record = getValkeyClient(clientId); + if (record == null) { + throw new NotFoundException("client not found"); + } + + ValkeyClientJwtBearer jwt = ValkeyClientJwtBearer.builder().id(id).jwksUrl(jwksUrl).iss(iss) + .sub(sub).aud(aud).build(); + + List list = record.getJwtBearer(); + + if (list == null) { + list = new ArrayList<>(); + } else { + for (ClientJwtBearer cjb : list) { + if (jwt.matches(cjb)) { + throw new BadRequestException("duplicate authorization"); + } + } + list = new ArrayList<>(list); + } + + list.add(jwt); + + record = record.toBuilder().jwtBearer(Collections.unmodifiableList(list)).build(); + saveClientToHash(key, record); + } + + @Override + public void removeAuthorizedJwt(@NonNull String clientId, @NonNull String id) { + String key = clientKey(clientId); + ValkeyClient record = getValkeyClient(clientId); + if (record == null) { + throw new NotFoundException("client not found"); + } + + ClientJwtBearer match = null; + if (record.getJwtBearer() != null) { + for (ClientJwtBearer cjb : record.getJwtBearer()) { + if (id.equals(cjb.getId())) { + match = cjb; + } + } + } + + if (match != null) { + List list = new ArrayList<>(record.getJwtBearer()); + list.remove(match); + + record = record.toBuilder().jwtBearer(list).build(); + saveClientToHash(key, record); + } + } + + @Override + public void saveClientSecret1(@NonNull String clientId, @NonNull String hashedSecret) { + String key = clientKey(clientId); + ValkeyClient record = getValkeyClient(clientId); + if (record == null) { + throw new NotFoundException("client not found"); + } + + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + + record = + record.toBuilder().clientSecret1(hashedSecret).clientSecret1Updated(now).build(); + saveClientToHash(key, record); + } + + @Override + public void saveClientSecret2(@NonNull String clientId, @NonNull String hashedSecret) { + String key = clientKey(clientId); + ValkeyClient record = getValkeyClient(clientId); + if (record == null) { + throw new NotFoundException("client not found"); + } + + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + + record = + record.toBuilder().clientSecret2(hashedSecret).clientSecret2Updated(now).build(); + saveClientToHash(key, record); + } + + @Override + public void clearClientSecret1(@NonNull String clientId) { + String key = clientKey(clientId); + ValkeyClient record = getValkeyClient(clientId); + if (record == null) { + throw new NotFoundException("client not found"); + } + + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + + record = record.toBuilder().clientSecret1(null).clientSecret1Updated(now).build(); + saveClientToHash(key, record); + } + + @Override + public void clearClientSecret2(@NonNull String clientId) { + String key = clientKey(clientId); + ValkeyClient record = getValkeyClient(clientId); + if (record == null) { + throw new NotFoundException("client not found"); + } + + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + + record = record.toBuilder().clientSecret2(null).clientSecret2Updated(now).build(); + saveClientToHash(key, record); + } + + private ValkeyClient getValkeyClient(String clientId) { + String key = clientKey(clientId); + Map entries = redisTemplate.opsForHash().entries(key); + if (entries == null || entries.isEmpty()) { + return null; + } + return hashToClient(entries); + } + + private void saveClientToHash(String key, ValkeyClient client) { + // Delete the key first to ensure a clean state + redisTemplate.delete(key); + + redisTemplate.opsForHash().put(key, "clientId", nullSafe(client.getClientId())); + redisTemplate.opsForHash().put(key, "clientCreated", nullSafe(client.getClientCreated())); + redisTemplate.opsForHash().put(key, "description", nullSafe(client.getDescription())); + redisTemplate.opsForHash().put(key, "salt", nullSafe(client.getSalt())); + redisTemplate.opsForHash().put(key, "clientType", + client.getClientType() != null ? client.getClientType().name() : ""); + redisTemplate.opsForHash().put(key, "clientSecret1", + nullSafe(client.getClientSecret1())); + redisTemplate.opsForHash().put(key, "clientSecret1Updated", + nullSafe(client.getClientSecret1Updated())); + redisTemplate.opsForHash().put(key, "clientSecret2", + nullSafe(client.getClientSecret2())); + redisTemplate.opsForHash().put(key, "clientSecret2Updated", + nullSafe(client.getClientSecret2Updated())); + redisTemplate.opsForHash().put(key, "locked", + client.getLocked() != null ? client.getLocked().toString() : ""); + + try { + String scopesJson = OBJECT_MAPPER.writeValueAsString(serializeScopes(client)); + redisTemplate.opsForHash().put(key, "availableScopes", scopesJson); + + String jwtBearerJson = + OBJECT_MAPPER.writeValueAsString(serializeJwtBearers(client)); + redisTemplate.opsForHash().put(key, "jwtBearer", jwtBearerJson); + } catch (JsonProcessingException e) { + throw new InternalServerErrorException("Failed to serialize client data"); + } + } + + private List> serializeScopes(ValkeyClient client) { + List> result = new ArrayList<>(); + if (client.getAvailableScopes() != null) { + for (ClientScope scope : client.getAvailableScopes()) { + result.add(Map.of("scope", nullSafe(scope.getScope()), "description", + nullSafe(scope.getDescription()))); + } + } + return result; + } + + private List> serializeJwtBearers(ValkeyClient client) { + List> result = new ArrayList<>(); + if (client.getJwtBearer() != null) { + for (ClientJwtBearer jwt : client.getJwtBearer()) { + result.add(Map.of("id", nullSafe(jwt.getId()), "jwksUrl", + nullSafe(jwt.getJwksUrl()), "iss", nullSafe(jwt.getIss()), "sub", + nullSafe(jwt.getSub()), "aud", nullSafe(jwt.getAud()))); + } + } + return result; + } + + @SuppressWarnings("unchecked") + private ValkeyClient hashToClient(Map entries) { + String clientId = getStr(entries, "clientId"); + String clientCreated = getStr(entries, "clientCreated"); + String description = getStr(entries, "description"); + String salt = getStr(entries, "salt"); + String clientTypeStr = getStr(entries, "clientType"); + ClientType clientType = + clientTypeStr != null && !clientTypeStr.isEmpty() + ? ClientType.valueOf(clientTypeStr) + : null; + String clientSecret1 = getStr(entries, "clientSecret1"); + String clientSecret1Updated = getStr(entries, "clientSecret1Updated"); + String clientSecret2 = getStr(entries, "clientSecret2"); + String clientSecret2Updated = getStr(entries, "clientSecret2Updated"); + String lockedStr = getStr(entries, "locked"); + Boolean locked = + lockedStr != null && !lockedStr.isEmpty() ? Boolean.valueOf(lockedStr) : null; + + List availableScopes = new ArrayList<>(); + String scopesJson = getStr(entries, "availableScopes"); + if (scopesJson != null && !scopesJson.isEmpty()) { + try { + List> scopesList = OBJECT_MAPPER.readValue(scopesJson, + new TypeReference>>() { + }); + for (Map s : scopesList) { + availableScopes.add(ValkeyClientScope.builder() + .scope(s.get("scope")) + .description(s.get("description")).build()); + } + } catch (JsonProcessingException e) { + throw new InternalServerErrorException("Failed to deserialize scopes"); + } + } + + List jwtBearers = new ArrayList<>(); + String jwtBearerJson = getStr(entries, "jwtBearer"); + if (jwtBearerJson != null && !jwtBearerJson.isEmpty()) { + try { + List> jwtList = OBJECT_MAPPER.readValue(jwtBearerJson, + new TypeReference>>() { + }); + for (Map j : jwtList) { + jwtBearers.add(ValkeyClientJwtBearer.builder() + .id(j.get("id")) + .jwksUrl(j.get("jwksUrl")) + .iss(j.get("iss")) + .sub(j.get("sub")) + .aud(j.get("aud")).build()); + } + } catch (JsonProcessingException e) { + throw new InternalServerErrorException("Failed to deserialize JWT bearers"); + } + } + + return ValkeyClient.builder().clientId(clientId).clientCreated(clientCreated) + .description(description).salt(salt).clientType(clientType) + .clientSecret1(clientSecret1).clientSecret1Updated(clientSecret1Updated) + .clientSecret2(clientSecret2).clientSecret2Updated(clientSecret2Updated) + .availableScopes(Collections.unmodifiableList(availableScopes)) + .jwtBearer(Collections.unmodifiableList(jwtBearers)).locked(locked).build(); + } + +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyHashUtil.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyHashUtil.java new file mode 100644 index 00000000..b418c852 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyHashUtil.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026 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.valkey.repository; + +import java.util.Map; + +/** + * Shared utility methods for Valkey repository hash operations. + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +final class ValkeyHashUtil { + + private ValkeyHashUtil() { + } + + static String getStr(Map entries, String field) { + Object val = entries.get(field); + if (val == null) { + return null; + } + String str = val.toString(); + return str.isEmpty() ? null : str; + } + + static String getStrOrDefault(Map entries, String field, + String defaultValue) { + Object val = entries.get(field); + if (val == null) { + return defaultValue; + } + String str = val.toString(); + return str.isEmpty() ? defaultValue : str; + } + + static long getLong(Map entries, String field) { + Object val = entries.get(field); + if (val == null) { + return 0; + } + return Long.parseLong(val.toString()); + } + + static String nullSafe(String value) { + return value != null ? value : ""; + } +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyJwkCacheRepository.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyJwkCacheRepository.java new file mode 100644 index 00000000..ecd36f17 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyJwkCacheRepository.java @@ -0,0 +1,139 @@ +/* + * Copyright 2026 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.valkey.repository; + +import static com.unitvectory.serviceauthcentral.datamodel.valkey.repository.ValkeyHashUtil.*; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.unitvectory.serviceauthcentral.datamodel.model.CachedJwk; +import com.unitvectory.serviceauthcentral.datamodel.repository.JwkCacheRepository; +import com.unitvectory.serviceauthcentral.datamodel.valkey.mapper.ValkeyCachedJwkMapper; +import com.unitvectory.serviceauthcentral.datamodel.valkey.model.ValkeyCachedJwk; + +import lombok.NonNull; + +/** + * The Valkey JWK Cache Repository + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +public class ValkeyJwkCacheRepository implements JwkCacheRepository { + + private static final String JWK_KEY_PREFIX = "sac:jwk:"; + private static final String JWK_URL_INDEX_PREFIX = "sac:jwk:url:"; + + private final StringRedisTemplate redisTemplate; + + public ValkeyJwkCacheRepository(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + private String jwkKey(String url, String kid) { + return JWK_KEY_PREFIX + url + ":" + kid; + } + + private String urlIndexKey(String url) { + return JWK_URL_INDEX_PREFIX + url; + } + + @Override + public void cacheJwk(@NonNull String url, @NonNull CachedJwk jwk, long ttl) { + ValkeyCachedJwk cachedJwk = + ValkeyCachedJwkMapper.INSTANCE.cachedJwkToValkeyCachedJwk(url, ttl, jwk); + + String key = jwkKey(url, jwk.getKid()); + + saveJwkToHash(key, cachedJwk); + redisTemplate.expireAt(key, Instant.ofEpochSecond(ttl)); + + // Add kid to URL index + redisTemplate.opsForSet().add(urlIndexKey(url), jwk.getKid()); + } + + @Override + public void cacheJwkAbsent(@NonNull String url, @NonNull String kid, long ttl) { + ValkeyCachedJwk cachedJwk = + ValkeyCachedJwk.builder().url(url).kid(kid).ttl(ttl).valid(false).build(); + + String key = jwkKey(url, kid); + + saveJwkToHash(key, cachedJwk); + redisTemplate.expireAt(key, Instant.ofEpochSecond(ttl)); + + // Add kid to URL index + redisTemplate.opsForSet().add(urlIndexKey(url), kid); + } + + @Override + public List getJwks(@NonNull String url) { + Set kids = redisTemplate.opsForSet().members(urlIndexKey(url)); + List list = new ArrayList<>(); + if (kids != null) { + for (String kid : kids) { + CachedJwk jwk = getJwk(url, kid); + if (jwk != null) { + list.add(jwk); + } + } + } + return Collections.unmodifiableList(list); + } + + @Override + public CachedJwk getJwk(@NonNull String url, @NonNull String kid) { + String key = jwkKey(url, kid); + Map entries = redisTemplate.opsForHash().entries(key); + if (entries == null || entries.isEmpty()) { + return null; + } + return hashToJwk(entries); + } + + private void saveJwkToHash(String key, ValkeyCachedJwk jwk) { + redisTemplate.delete(key); + + redisTemplate.opsForHash().put(key, "url", nullSafe(jwk.getUrl())); + redisTemplate.opsForHash().put(key, "ttl", String.valueOf(jwk.getTtl())); + redisTemplate.opsForHash().put(key, "valid", String.valueOf(jwk.isValid())); + redisTemplate.opsForHash().put(key, "kid", nullSafe(jwk.getKid())); + redisTemplate.opsForHash().put(key, "kty", nullSafe(jwk.getKty())); + redisTemplate.opsForHash().put(key, "alg", nullSafe(jwk.getAlg())); + redisTemplate.opsForHash().put(key, "use", nullSafe(jwk.getUse())); + redisTemplate.opsForHash().put(key, "n", nullSafe(jwk.getN())); + redisTemplate.opsForHash().put(key, "e", nullSafe(jwk.getE())); + } + + private ValkeyCachedJwk hashToJwk(Map entries) { + return ValkeyCachedJwk.builder() + .url(getStr(entries, "url")) + .ttl(getLong(entries, "ttl")) + .valid(Boolean.parseBoolean(getStrOrDefault(entries, "valid", "false"))) + .kid(getStr(entries, "kid")) + .kty(getStr(entries, "kty")) + .alg(getStr(entries, "alg")) + .use(getStr(entries, "use")) + .n(getStr(entries, "n")) + .e(getStr(entries, "e")) + .build(); + } + +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyLoginCodeRepository.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyLoginCodeRepository.java new file mode 100644 index 00000000..0e83c70c --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyLoginCodeRepository.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026 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.valkey.repository; + +import static com.unitvectory.serviceauthcentral.datamodel.valkey.repository.ValkeyHashUtil.*; + +import java.time.Instant; +import java.util.Map; + +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.unitvectory.serviceauthcentral.datamodel.model.LoginCode; +import com.unitvectory.serviceauthcentral.datamodel.repository.LoginCodeRepository; +import com.unitvectory.serviceauthcentral.datamodel.valkey.model.ValkeyLoginCode; + +/** + * The Valkey Login Code Repository + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +public class ValkeyLoginCodeRepository implements LoginCodeRepository { + + private static final String LOGIN_CODE_KEY_PREFIX = "sac:logincode:"; + + private final StringRedisTemplate redisTemplate; + + public ValkeyLoginCodeRepository(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + private String codeKey(String code) { + return LOGIN_CODE_KEY_PREFIX + code; + } + + @Override + public void saveCode(String code, String clientId, String redirectUri, String codeChallenge, + String userClientId, long ttl) { + String key = codeKey(code); + + redisTemplate.opsForHash().put(key, "clientId", nullSafe(clientId)); + redisTemplate.opsForHash().put(key, "redirectUri", nullSafe(redirectUri)); + redisTemplate.opsForHash().put(key, "codeChallenge", nullSafe(codeChallenge)); + redisTemplate.opsForHash().put(key, "userClientId", nullSafe(userClientId)); + redisTemplate.opsForHash().put(key, "ttl", String.valueOf(ttl)); + + redisTemplate.expireAt(key, Instant.ofEpochSecond(ttl)); + } + + @Override + public LoginCode getCode(String code) { + String key = codeKey(code); + Map entries = redisTemplate.opsForHash().entries(key); + if (entries == null || entries.isEmpty()) { + return null; + } + return hashToLoginCode(entries); + } + + @Override + public void deleteCode(String code) { + redisTemplate.delete(codeKey(code)); + } + + private ValkeyLoginCode hashToLoginCode(Map entries) { + return ValkeyLoginCode.builder() + .clientId(getStr(entries, "clientId")) + .redirectUri(getStr(entries, "redirectUri")) + .codeChallenge(getStr(entries, "codeChallenge")) + .userClientId(getStr(entries, "userClientId")) + .ttl(getLong(entries, "ttl")) + .build(); + } + +} diff --git a/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyLoginStateRepository.java b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyLoginStateRepository.java new file mode 100644 index 00000000..d3eb20e5 --- /dev/null +++ b/datamodel-valkey/src/main/java/com/unitvectory/serviceauthcentral/datamodel/valkey/repository/ValkeyLoginStateRepository.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 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.valkey.repository; + +import static com.unitvectory.serviceauthcentral.datamodel.valkey.repository.ValkeyHashUtil.*; + +import java.time.Instant; +import java.util.Map; + +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.unitvectory.serviceauthcentral.datamodel.model.LoginState; +import com.unitvectory.serviceauthcentral.datamodel.repository.LoginStateRepository; +import com.unitvectory.serviceauthcentral.datamodel.valkey.model.ValkeyLoginState; + +/** + * The Valkey Login State Repository + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +public class ValkeyLoginStateRepository implements LoginStateRepository { + + private static final String LOGIN_STATE_KEY_PREFIX = "sac:loginstate:"; + + private final StringRedisTemplate redisTemplate; + + public ValkeyLoginStateRepository(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + private String stateKey(String sessionId) { + return LOGIN_STATE_KEY_PREFIX + sessionId; + } + + @Override + public void saveState(String sessionId, String clientId, String redirectUri, + String primaryState, String primaryCodeChallenge, String secondaryState, long ttl) { + String key = stateKey(sessionId); + + redisTemplate.opsForHash().put(key, "clientId", nullSafe(clientId)); + redisTemplate.opsForHash().put(key, "redirectUri", nullSafe(redirectUri)); + redisTemplate.opsForHash().put(key, "primaryState", nullSafe(primaryState)); + redisTemplate.opsForHash().put(key, "primaryCodeChallenge", + nullSafe(primaryCodeChallenge)); + redisTemplate.opsForHash().put(key, "secondaryState", nullSafe(secondaryState)); + redisTemplate.opsForHash().put(key, "ttl", String.valueOf(ttl)); + + redisTemplate.expireAt(key, Instant.ofEpochSecond(ttl)); + } + + @Override + public LoginState getState(String sessionId) { + String key = stateKey(sessionId); + Map entries = redisTemplate.opsForHash().entries(key); + if (entries == null || entries.isEmpty()) { + return null; + } + return hashToLoginState(entries); + } + + @Override + public void deleteState(String sessionId) { + redisTemplate.delete(stateKey(sessionId)); + } + + private ValkeyLoginState hashToLoginState(Map entries) { + return ValkeyLoginState.builder() + .clientId(getStr(entries, "clientId")) + .redirectUri(getStr(entries, "redirectUri")) + .primaryState(getStr(entries, "primaryState")) + .primaryCodeChallenge(getStr(entries, "primaryCodeChallenge")) + .secondaryState(getStr(entries, "secondaryState")) + .ttl(getLong(entries, "ttl")) + .build(); + } + +} diff --git a/datamodel-valkey/src/main/resources/.config b/datamodel-valkey/src/main/resources/.config new file mode 100644 index 00000000..e69de29b diff --git a/docs/modules/datamodel/index.md b/docs/modules/datamodel/index.md index 63c7609e..18a43606 100644 --- a/docs/modules/datamodel/index.md +++ b/docs/modules/datamodel/index.md @@ -22,4 +22,5 @@ There are multiple data model implementations that are available. Exactly one mo 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. - [Data Model - Firestore](./firestore.md): Firestore implementation for the repository interfaces +- [Data Model - Valkey](./valkey.md): Valkey 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/docs/modules/datamodel/valkey.md b/docs/modules/datamodel/valkey.md new file mode 100644 index 00000000..c39c5dee --- /dev/null +++ b/docs/modules/datamodel/valkey.md @@ -0,0 +1,37 @@ +# Data Model - Valkey + +The data model Valkey module provides a [Valkey](https://valkey.io/) implementation of the data model interfaces so that the underlying implementation can be swapped out as a runtime dependency. + +Valkey is an open source, high-performance key/value datastore that is compatible with Redis. This module uses Spring Data Redis with the Lettuce client to connect to Valkey. + +## Data Storage + +Data is stored in Valkey using Hash structures with the following key prefixes: + +- `sac:client:{clientId}` - Client records +- `sac:clients` - Sorted set index for client pagination +- `sac:auth:{documentId}` - Authorization records +- `sac:auth:subject:{subject}` - Set index of authorizations by subject +- `sac:auth:audience:{audience}` - Set index of authorizations by audience +- `sac:auth:lookup:{subjectHash}:{audienceHash}` - Lookup key for authorization by subject and audience +- `sac:loginstate:{sessionId}` - Login state records (with TTL) +- `sac:logincode:{code}` - Login code records (with TTL) +- `sac:jwk:{url}:{kid}` - Cached JWK records (with TTL) +- `sac:jwk:url:{url}` - Set index of JWK kids by URL + +## Spring Boot Profile + +Spring Boot 3's dependency injection is used to initialize the relevant Beans for interacting with Valkey. This is accomplished through profiles. + +The `datamodel-valkey` profile is enabled to utilize Valkey. + +## Configuration + +The following configuration attributes are available: + +| Property | Required | Description | +| ------------------- | -------- | ------------------------- | +| spring.data.redis.host | Yes | Valkey server hostname | +| spring.data.redis.port | No (default: 6379) | Valkey server port | +| spring.data.redis.password | No | Valkey server password | +| spring.data.redis.ssl.enabled | No (default: false) | Enable SSL/TLS | diff --git a/mkdocs.yml b/mkdocs.yml index 7c4eecd6..a786043a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,7 @@ nav: - Data Model: - modules/datamodel/index.md - Firestore: modules/datamodel/firestore.md + - Valkey: modules/datamodel/valkey.md - In-memory: modules/datamodel/memory.md - Sign: - modules/sign/index.md diff --git a/pom.xml b/pom.xml index 5436f971..4f83bbef 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,7 @@ util datamodel datamodel-memory + datamodel-valkey diff --git a/server-manage/pom.xml b/server-manage/pom.xml index 5fefde20..38e4cdef 100644 --- a/server-manage/pom.xml +++ b/server-manage/pom.xml @@ -37,6 +37,12 @@ ${project.version} runtime + + com.unitvectory.serviceauthcentral + datamodel-valkey + ${project.version} + runtime + org.springframework.boot spring-boot-starter-web diff --git a/server-token/pom.xml b/server-token/pom.xml index cce212fb..609b058d 100644 --- a/server-token/pom.xml +++ b/server-token/pom.xml @@ -37,6 +37,12 @@ ${project.version} runtime + + com.unitvectory.serviceauthcentral + datamodel-valkey + ${project.version} + runtime + com.unitvectory.serviceauthcentral sign