diff --git a/datamodel-postgres/README.md b/datamodel-postgres/README.md new file mode 100644 index 00000000..2c7ada54 --- /dev/null +++ b/datamodel-postgres/README.md @@ -0,0 +1,5 @@ +# datamodel-postgres + +This module provides a PostgreSQL implementation of 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 Postgres documentation](/docs/modules/datamodel/postgres.md). diff --git a/datamodel-postgres/pom.xml b/datamodel-postgres/pom.xml new file mode 100644 index 00000000..2a9ae5fd --- /dev/null +++ b/datamodel-postgres/pom.xml @@ -0,0 +1,120 @@ + + + 4.0.0 + + com.unitvectory + serviceauthcentral + 0.0.1-SNAPSHOT + + + com.unitvectory.serviceauthcentral + datamodel-postgres + + + + + + + com.unitvectory + consistgen + + + com.unitvectory.serviceauthcentral + util + ${project.version} + + + com.unitvectory.serviceauthcentral + datamodel + ${project.version} + + + org.springframework + spring-context + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + runtime + + + org.mockito + mockito-core + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.springframework.boot + spring-boot-starter-test + 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-postgres/src/lombok.config b/datamodel-postgres/src/lombok.config new file mode 100644 index 00000000..fc53b467 --- /dev/null +++ b/datamodel-postgres/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-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/config/DatamodelPostgresConfig.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/config/DatamodelPostgresConfig.java new file mode 100644 index 00000000..409bc889 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/config/DatamodelPostgresConfig.java @@ -0,0 +1,93 @@ +/* + * 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.postgres.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +import com.unitvectory.consistgen.epoch.EpochTimeProvider; +import com.unitvectory.serviceauthcentral.datamodel.postgres.repository.AuthorizationJpaRepository; +import com.unitvectory.serviceauthcentral.datamodel.postgres.repository.CachedJwkJpaRepository; +import com.unitvectory.serviceauthcentral.datamodel.postgres.repository.ClientJpaRepository; +import com.unitvectory.serviceauthcentral.datamodel.postgres.repository.LoginCodeJpaRepository; +import com.unitvectory.serviceauthcentral.datamodel.postgres.repository.LoginStateJpaRepository; +import com.unitvectory.serviceauthcentral.datamodel.postgres.repository.PostgresAuthorizationRepository; +import com.unitvectory.serviceauthcentral.datamodel.postgres.repository.PostgresClientRepository; +import com.unitvectory.serviceauthcentral.datamodel.postgres.repository.PostgresJwkCacheRepository; +import com.unitvectory.serviceauthcentral.datamodel.postgres.repository.PostgresLoginCodeRepository; +import com.unitvectory.serviceauthcentral.datamodel.postgres.repository.PostgresLoginStateRepository; +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; + +/** + * The data model config for PostgreSQL + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Configuration +@Profile("datamodel-postgres") +@EntityScan(basePackages = "com.unitvectory.serviceauthcentral.datamodel.postgres.entity") +@EnableJpaRepositories(basePackages = "com.unitvectory.serviceauthcentral.datamodel.postgres.repository") +public class DatamodelPostgresConfig { + + @Autowired + private EpochTimeProvider epochTimeProvider; + + @Autowired + private AuthorizationJpaRepository authorizationJpaRepository; + + @Autowired + private ClientJpaRepository clientJpaRepository; + + @Autowired + private CachedJwkJpaRepository cachedJwkJpaRepository; + + @Autowired + private LoginCodeJpaRepository loginCodeJpaRepository; + + @Autowired + private LoginStateJpaRepository loginStateJpaRepository; + + @Bean + public AuthorizationRepository authorizationRepository() { + return new PostgresAuthorizationRepository(this.authorizationJpaRepository, this.epochTimeProvider); + } + + @Bean + public ClientRepository clientRepository() { + return new PostgresClientRepository(this.clientJpaRepository, this.epochTimeProvider); + } + + @Bean + public JwkCacheRepository jwkCacheRepository() { + return new PostgresJwkCacheRepository(this.cachedJwkJpaRepository); + } + + @Bean + public LoginCodeRepository loginCodeRepository() { + return new PostgresLoginCodeRepository(this.loginCodeJpaRepository); + } + + @Bean + public LoginStateRepository loginStateRepository() { + return new PostgresLoginStateRepository(this.loginStateJpaRepository); + } +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/AuthorizationEntity.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/AuthorizationEntity.java new file mode 100644 index 00000000..3f4c5f1e --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/AuthorizationEntity.java @@ -0,0 +1,65 @@ +/* + * 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.postgres.entity; + +import java.util.ArrayList; +import java.util.List; + +import com.unitvectory.serviceauthcentral.datamodel.model.Authorization; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The Authorization Entity for PostgreSQL + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "authorizations") +public class AuthorizationEntity implements Authorization { + + @Id + @Column(name = "document_id", nullable = false, length = 64) + private String documentId; + + @Column(name = "authorization_created") + private String authorizationCreated; + + @Column(name = "subject", nullable = false) + private String subject; + + @Column(name = "audience", nullable = false) + private String audience; + + @Builder.Default + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "authorization_scopes", joinColumns = @JoinColumn(name = "authorization_id")) + @Column(name = "scope") + private List authorizedScopes = new ArrayList<>(); +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/CachedJwkEntity.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/CachedJwkEntity.java new file mode 100644 index 00000000..111ddc26 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/CachedJwkEntity.java @@ -0,0 +1,75 @@ +/* + * 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.postgres.entity; + +import com.unitvectory.serviceauthcentral.datamodel.model.CachedJwk; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The Cached JWK Entity for PostgreSQL + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "cached_jwks") +public class CachedJwkEntity implements CachedJwk { + + @Id + @Column(name = "document_id", nullable = false, length = 64) + private String documentId; + + @Column(name = "url", nullable = false) + private String url; + + @Column(name = "ttl", nullable = false) + private Long ttl; + + @Column(name = "valid") + private boolean valid; + + @Column(name = "kid", nullable = false) + private String kid; + + @Column(name = "kty") + private String kty; + + @Column(name = "alg") + private String alg; + + @Column(name = "use_value") + private String use; + + @Column(name = "n", columnDefinition = "TEXT") + private String n; + + @Column(name = "e") + private String e; + + @Override + public boolean isExpired(long now) { + return ttl < now; + } +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/ClientEntity.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/ClientEntity.java new file mode 100644 index 00000000..fb9bab56 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/ClientEntity.java @@ -0,0 +1,115 @@ +/* + * 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.postgres.entity; + +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 jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The Client Entity for PostgreSQL + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "clients") +public class ClientEntity implements Client { + + @Id + @Column(name = "document_id", nullable = false, length = 64) + private String documentId; + + @Column(name = "client_created") + private String clientCreated; + + @Column(name = "client_id", nullable = false, unique = true) + private String clientId; + + @Column(name = "description") + private String description; + + @Column(name = "salt", nullable = false) + private String salt; + + @Enumerated(EnumType.STRING) + @Column(name = "client_type", nullable = false) + private ClientType clientType; + + @Column(name = "client_secret1") + private String clientSecret1; + + @Column(name = "client_secret1_updated") + private String clientSecret1Updated; + + @Column(name = "client_secret2") + private String clientSecret2; + + @Column(name = "client_secret2_updated") + private String clientSecret2Updated; + + @Column(name = "locked") + private Boolean locked; + + @Builder.Default + @OneToMany(mappedBy = "client", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + private List availableScopesEntities = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "client", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + private List jwtBearerEntities = new ArrayList<>(); + + @Override + public List getAvailableScopes() { + if (this.availableScopesEntities == null) { + return Collections.emptyList(); + } + + return availableScopesEntities.stream().map(obj -> (ClientScope) obj) + .collect(Collectors.toCollection(ArrayList::new)); + } + + @Override + public List getJwtBearer() { + if (this.jwtBearerEntities == null) { + return Collections.emptyList(); + } + + return jwtBearerEntities.stream().map(obj -> (ClientJwtBearer) obj) + .collect(Collectors.toCollection(ArrayList::new)); + } +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/ClientJwtBearerEntity.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/ClientJwtBearerEntity.java new file mode 100644 index 00000000..c30f9ef0 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/ClientJwtBearerEntity.java @@ -0,0 +1,61 @@ +/* + * 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.postgres.entity; + +import com.unitvectory.serviceauthcentral.datamodel.model.ClientJwtBearer; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The Client JWT Bearer Entity for PostgreSQL + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "client_jwt_bearers") +public class ClientJwtBearerEntity implements ClientJwtBearer { + + @Id + @Column(name = "id", nullable = false) + private String id; + + @ManyToOne + @JoinColumn(name = "client_id", nullable = false) + private ClientEntity client; + + @Column(name = "jwks_url", nullable = false) + private String jwksUrl; + + @Column(name = "iss", nullable = false) + private String iss; + + @Column(name = "sub", nullable = false) + private String sub; + + @Column(name = "aud", nullable = false) + private String aud; +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/ClientScopeEntity.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/ClientScopeEntity.java new file mode 100644 index 00000000..47b590d7 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/ClientScopeEntity.java @@ -0,0 +1,57 @@ +/* + * 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.postgres.entity; + +import com.unitvectory.serviceauthcentral.datamodel.model.ClientScope; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The Client Scope Entity for PostgreSQL + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "client_scopes") +public class ClientScopeEntity implements ClientScope { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "client_id", nullable = false) + private ClientEntity client; + + @Column(name = "scope", nullable = false) + private String scope; + + @Column(name = "description") + private String description; +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/LoginCodeEntity.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/LoginCodeEntity.java new file mode 100644 index 00000000..f663cd37 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/LoginCodeEntity.java @@ -0,0 +1,67 @@ +/* + * 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.postgres.entity; + +import com.unitvectory.serviceauthcentral.datamodel.model.LoginCode; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The Login Code Entity for PostgreSQL + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "login_codes") +public class LoginCodeEntity implements LoginCode { + + @Id + @Column(name = "document_id", nullable = false, length = 64) + private String documentId; + + @Column(name = "client_id", nullable = false) + private String clientId; + + @Column(name = "redirect_uri", nullable = false) + private String redirectUri; + + @Column(name = "code_challenge", nullable = false) + private String codeChallenge; + + @Column(name = "user_client_id", nullable = false) + private String userClientId; + + @Column(name = "ttl", nullable = false) + private Long ttl; + + @Override + public long getTimeToLive() { + if (this.ttl != null) { + return this.ttl; + } else { + return 0; + } + } +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/LoginStateEntity.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/LoginStateEntity.java new file mode 100644 index 00000000..cb7f153b --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/entity/LoginStateEntity.java @@ -0,0 +1,61 @@ +/* + * 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.postgres.entity; + +import com.unitvectory.serviceauthcentral.datamodel.model.LoginState; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The Login State Entity for PostgreSQL + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "login_states") +public class LoginStateEntity implements LoginState { + + @Id + @Column(name = "document_id", nullable = false, length = 64) + private String documentId; + + @Column(name = "client_id", nullable = false) + private String clientId; + + @Column(name = "redirect_uri", nullable = false) + private String redirectUri; + + @Column(name = "primary_state", nullable = false) + private String primaryState; + + @Column(name = "primary_code_challenge", nullable = false) + private String primaryCodeChallenge; + + @Column(name = "secondary_state", nullable = false) + private String secondaryState; + + @Column(name = "ttl", nullable = false) + private Long ttl; +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/mapper/ClientSummaryMapper.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/mapper/ClientSummaryMapper.java new file mode 100644 index 00000000..9f02525c --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/mapper/ClientSummaryMapper.java @@ -0,0 +1,62 @@ +/* + * 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.postgres.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import com.unitvectory.serviceauthcentral.datamodel.model.ClientSummary; +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.ClientEntity; + +/** + * The mapper for ClientSummary from ClientEntity + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Mapper +public interface ClientSummaryMapper { + + ClientSummaryMapper INSTANCE = Mappers.getMapper(ClientSummaryMapper.class); + + @Mapping(target = "clientId", source = "clientId") + @Mapping(target = "description", source = "description") + ClientSummaryDto clientEntityToClientSummary(ClientEntity entity); + + /** + * DTO class for ClientSummary + */ + public class ClientSummaryDto implements ClientSummary { + private String clientId; + private String description; + + @Override + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + @Override + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/AuthorizationJpaRepository.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/AuthorizationJpaRepository.java new file mode 100644 index 00000000..99d29827 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/AuthorizationJpaRepository.java @@ -0,0 +1,37 @@ +/* + * 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.postgres.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.AuthorizationEntity; + +/** + * JPA Repository for Authorization entities + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Repository +public interface AuthorizationJpaRepository extends JpaRepository { + + Optional findBySubjectAndAudience(String subject, String audience); + + List findBySubject(String subject); + + List findByAudience(String audience); +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/CachedJwkJpaRepository.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/CachedJwkJpaRepository.java new file mode 100644 index 00000000..1d0b4e4b --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/CachedJwkJpaRepository.java @@ -0,0 +1,32 @@ +/* + * 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.postgres.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.CachedJwkEntity; + +/** + * JPA Repository for CachedJwk entities + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Repository +public interface CachedJwkJpaRepository extends JpaRepository { + + List findByUrl(String url); +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/ClientJpaRepository.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/ClientJpaRepository.java new file mode 100644 index 00000000..8e14096d --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/ClientJpaRepository.java @@ -0,0 +1,42 @@ +/* + * 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.postgres.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.ClientEntity; + +/** + * JPA Repository for Client entities + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Repository +public interface ClientJpaRepository extends JpaRepository { + + Optional findByClientId(String clientId); + + Page findAllByOrderByClientIdAsc(Pageable pageable); + + Page findAllByOrderByClientIdDesc(Pageable pageable); + + Page findByClientIdGreaterThanOrderByClientIdAsc(String clientId, Pageable pageable); + + Page findByClientIdLessThanOrderByClientIdDesc(String clientId, Pageable pageable); +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/LoginCodeJpaRepository.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/LoginCodeJpaRepository.java new file mode 100644 index 00000000..a1b1d553 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/LoginCodeJpaRepository.java @@ -0,0 +1,28 @@ +/* + * 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.postgres.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.LoginCodeEntity; + +/** + * JPA Repository for LoginCode entities + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Repository +public interface LoginCodeJpaRepository extends JpaRepository { +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/LoginStateJpaRepository.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/LoginStateJpaRepository.java new file mode 100644 index 00000000..96191235 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/LoginStateJpaRepository.java @@ -0,0 +1,28 @@ +/* + * 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.postgres.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.LoginStateEntity; + +/** + * JPA Repository for LoginState entities + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Repository +public interface LoginStateJpaRepository extends JpaRepository { +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresAuthorizationRepository.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresAuthorizationRepository.java new file mode 100644 index 00000000..4544fed5 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresAuthorizationRepository.java @@ -0,0 +1,152 @@ +/* + * 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.postgres.repository; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + +import org.springframework.transaction.annotation.Transactional; + +import com.unitvectory.consistgen.epoch.EpochTimeProvider; +import com.unitvectory.serviceauthcentral.datamodel.model.Authorization; +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.AuthorizationEntity; +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; + +/** + * The PostgreSQL Authorization Repository + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@AllArgsConstructor +public class PostgresAuthorizationRepository implements AuthorizationRepository { + + private final AuthorizationJpaRepository authorizationJpaRepository; + + private final EpochTimeProvider epochTimeProvider; + + @Override + public Authorization getAuthorization(@NonNull String id) { + Optional entity = authorizationJpaRepository.findById(id); + return entity.orElse(null); + } + + @Override + @Transactional + public void deleteAuthorization(@NonNull String id) { + authorizationJpaRepository.deleteById(id); + } + + @Override + public Authorization getAuthorization(@NonNull String subject, @NonNull String audience) { + Optional entity = authorizationJpaRepository.findBySubjectAndAudience(subject, audience); + return entity.orElse(null); + } + + @Override + public Iterator getAuthorizationBySubject(@NonNull String subject) { + List entities = authorizationJpaRepository.findBySubject(subject); + List list = new ArrayList<>(); + for (AuthorizationEntity entity : entities) { + list.add(entity); + } + return list.iterator(); + } + + @Override + public Iterator getAuthorizationByAudience(@NonNull String audience) { + List entities = authorizationJpaRepository.findByAudience(audience); + List list = new ArrayList<>(); + for (AuthorizationEntity entity : entities) { + list.add(entity); + } + return list.iterator(); + } + + @Override + @Transactional + public void authorize(@NonNull String subject, @NonNull String audience, + @NonNull List authorizedScopes) { + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + String documentId = getDocumentId(subject, audience); + + AuthorizationEntity entity = AuthorizationEntity.builder() + .documentId(documentId) + .authorizationCreated(now) + .subject(subject) + .audience(audience) + .authorizedScopes(new ArrayList<>(authorizedScopes)) + .build(); + + authorizationJpaRepository.save(entity); + } + + @Override + @Transactional + public void deauthorize(@NonNull String subject, @NonNull String audience) { + String documentId = getDocumentId(subject, audience); + authorizationJpaRepository.deleteById(documentId); + } + + @Override + @Transactional + public void authorizeAddScope(@NonNull String subject, @NonNull String audience, + @NonNull String authorizedScope) { + String documentId = getDocumentId(subject, audience); + + Optional optionalEntity = authorizationJpaRepository.findById(documentId); + if (optionalEntity.isEmpty()) { + throw new NotFoundException("Client not found"); + } + + AuthorizationEntity entity = optionalEntity.get(); + + if (entity.getAuthorizedScopes().contains(authorizedScope)) { + throw new BadRequestException("Scope already authorized"); + } + + entity.getAuthorizedScopes().add(authorizedScope); + authorizationJpaRepository.save(entity); + } + + @Override + @Transactional + public void authorizeRemoveScope(@NonNull String subject, @NonNull String audience, + @NonNull String authorizedScope) { + String documentId = getDocumentId(subject, audience); + + Optional optionalEntity = authorizationJpaRepository.findById(documentId); + if (optionalEntity.isEmpty()) { + throw new NotFoundException("Client not found"); + } + + AuthorizationEntity entity = optionalEntity.get(); + entity.getAuthorizedScopes().remove(authorizedScope); + authorizationJpaRepository.save(entity); + } + + private String getDocumentId(@NonNull String subject, @NonNull String audience) { + String subjectHash = HashingUtil.sha256(subject); + String audienceHash = HashingUtil.sha256(audience); + return HashingUtil.sha256(subjectHash + audienceHash); + } +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresClientRepository.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresClientRepository.java new file mode 100644 index 00000000..8c0d38c1 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresClientRepository.java @@ -0,0 +1,347 @@ +/* + * 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.postgres.repository; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; + +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.postgres.entity.ClientEntity; +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.ClientJwtBearerEntity; +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.ClientScopeEntity; +import com.unitvectory.serviceauthcentral.datamodel.postgres.mapper.ClientSummaryMapper; +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; + +/** + * The PostgreSQL Client Repository + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@AllArgsConstructor +public class PostgresClientRepository implements ClientRepository { + + private final ClientJpaRepository clientJpaRepository; + + private final EpochTimeProvider epochTimeProvider; + + @Override + public ClientSummaryConnection getClients(Integer first, String after, Integer last, String before) { + List edges = new ArrayList<>(); + boolean hasNextPage = false; + boolean hasPreviousPage = false; + + // Forward pagination + if (first != null) { + Pageable pageable = PageRequest.of(0, first + 1); + Page page; + + if (after != null && !after.isEmpty()) { + String afterDecoded = new String(Base64.getDecoder().decode(after), StandardCharsets.UTF_8); + page = clientJpaRepository.findByClientIdGreaterThanOrderByClientIdAsc(afterDecoded, pageable); + hasPreviousPage = true; // Since we have an after cursor, there's at least one page before + } else { + page = clientJpaRepository.findAllByOrderByClientIdAsc(pageable); + } + + List content = page.getContent(); + hasNextPage = content.size() > first; + + int limit = Math.min(content.size(), first); + for (int i = 0; i < limit; i++) { + ClientEntity entity = content.get(i); + ClientSummary summary = ClientSummaryMapper.INSTANCE.clientEntityToClientSummary(entity); + String cursor = Base64.getEncoder().encodeToString(entity.getClientId().getBytes(StandardCharsets.UTF_8)); + edges.add(ClientSummaryEdge.builder().cursor(cursor).node(summary).build()); + } + } + // Backward pagination + else if (last != null) { + Pageable pageable = PageRequest.of(0, last + 1); + Page page; + + if (before != null && !before.isEmpty()) { + String beforeDecoded = new String(Base64.getDecoder().decode(before), StandardCharsets.UTF_8); + page = clientJpaRepository.findByClientIdLessThanOrderByClientIdDesc(beforeDecoded, pageable); + hasNextPage = true; // Since we have a before cursor, there's at least one page after + } else { + page = clientJpaRepository.findAllByOrderByClientIdDesc(pageable); + } + + List content = page.getContent(); + hasPreviousPage = content.size() > last; + + // Reverse the list and take only the required items + List reversedContent = new ArrayList<>(content); + Collections.reverse(reversedContent); + int startIndex = hasPreviousPage ? 1 : 0; + int endIndex = reversedContent.size(); + + for (int i = startIndex; i < endIndex; i++) { + ClientEntity entity = reversedContent.get(i); + ClientSummary summary = ClientSummaryMapper.INSTANCE.clientEntityToClientSummary(entity); + String cursor = Base64.getEncoder().encodeToString(entity.getClientId().getBytes(StandardCharsets.UTF_8)); + edges.add(ClientSummaryEdge.builder().cursor(cursor).node(summary).build()); + } + } + + // Determine cursors for pageInfo + 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) { + String documentId = HashingUtil.sha256(clientId); + Optional entity = clientJpaRepository.findById(documentId); + return entity.orElse(null); + } + + @Override + @Transactional + public void deleteClient(@NonNull String clientId) { + String documentId = HashingUtil.sha256(clientId); + clientJpaRepository.deleteById(documentId); + } + + @Override + @Transactional + public void putClient(@NonNull String clientId, String description, @NonNull String salt, + @NonNull ClientType clientType, @NonNull List availableScopes) { + + String documentId = HashingUtil.sha256(clientId); + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + + Optional existingEntity = clientJpaRepository.findById(documentId); + if (existingEntity.isPresent()) { + throw new ConflictException("clientId already exists"); + } + + ClientEntity entity = ClientEntity.builder() + .documentId(documentId) + .clientCreated(now) + .clientId(clientId) + .description(description) + .salt(salt) + .clientType(clientType) + .availableScopesEntities(new ArrayList<>()) + .jwtBearerEntities(new ArrayList<>()) + .build(); + + // Add available scopes + for (ClientScope scope : availableScopes) { + ClientScopeEntity scopeEntity = ClientScopeEntity.builder() + .client(entity) + .scope(scope.getScope()) + .description(scope.getDescription()) + .build(); + entity.getAvailableScopesEntities().add(scopeEntity); + } + + clientJpaRepository.save(entity); + } + + @Override + @Transactional + public void addClientAvailableScope(@NonNull String clientId, @NonNull ClientScope availableScope) { + String documentId = HashingUtil.sha256(clientId); + + Optional optionalEntity = clientJpaRepository.findById(documentId); + if (optionalEntity.isEmpty()) { + throw new NotFoundException("Client not found"); + } + + ClientEntity entity = optionalEntity.get(); + + // Check for duplicates + for (ClientScope scope : entity.getAvailableScopes()) { + if (scope.getScope().equals(availableScope.getScope())) { + throw new BadRequestException("Duplicate scope"); + } + } + + ClientScopeEntity scopeEntity = ClientScopeEntity.builder() + .client(entity) + .scope(availableScope.getScope()) + .description(availableScope.getDescription()) + .build(); + entity.getAvailableScopesEntities().add(scopeEntity); + + clientJpaRepository.save(entity); + } + + @Override + @Transactional + public void addAuthorizedJwt(@NonNull String clientId, @NonNull String id, + @NonNull String jwksUrl, @NonNull String iss, @NonNull String sub, + @NonNull String aud) { + + String documentId = HashingUtil.sha256(clientId); + + Optional optionalEntity = clientJpaRepository.findById(documentId); + if (optionalEntity.isEmpty()) { + throw new NotFoundException("Client not found"); + } + + ClientEntity entity = optionalEntity.get(); + + // Check for duplicates + ClientJwtBearerEntity newJwt = ClientJwtBearerEntity.builder() + .id(id) + .client(entity) + .jwksUrl(jwksUrl) + .iss(iss) + .sub(sub) + .aud(aud) + .build(); + + for (ClientJwtBearer cjb : entity.getJwtBearer()) { + if (newJwt.matches(cjb)) { + throw new BadRequestException("Duplicate authorization"); + } + } + + entity.getJwtBearerEntities().add(newJwt); + clientJpaRepository.save(entity); + } + + @Override + @Transactional + public void removeAuthorizedJwt(@NonNull String clientId, @NonNull String id) { + String documentId = HashingUtil.sha256(clientId); + + Optional optionalEntity = clientJpaRepository.findById(documentId); + if (optionalEntity.isEmpty()) { + throw new NotFoundException("Client not found"); + } + + ClientEntity entity = optionalEntity.get(); + + ClientJwtBearerEntity jwtToRemove = null; + for (ClientJwtBearerEntity jwt : entity.getJwtBearerEntities()) { + if (id.equals(jwt.getId())) { + jwtToRemove = jwt; + break; + } + } + + if (jwtToRemove != null) { + entity.getJwtBearerEntities().remove(jwtToRemove); + clientJpaRepository.save(entity); + } else { + throw new NotFoundException("JWT not found with the provided id"); + } + } + + @Override + @Transactional + public void saveClientSecret1(@NonNull String clientId, @NonNull String hashedSecret) { + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + String documentId = HashingUtil.sha256(clientId); + + Optional optionalEntity = clientJpaRepository.findById(documentId); + if (optionalEntity.isEmpty()) { + throw new NotFoundException("Client not found"); + } + + ClientEntity entity = optionalEntity.get(); + entity.setClientSecret1(hashedSecret); + entity.setClientSecret1Updated(now); + clientJpaRepository.save(entity); + } + + @Override + @Transactional + public void saveClientSecret2(@NonNull String clientId, @NonNull String hashedSecret) { + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + String documentId = HashingUtil.sha256(clientId); + + Optional optionalEntity = clientJpaRepository.findById(documentId); + if (optionalEntity.isEmpty()) { + throw new NotFoundException("Client not found"); + } + + ClientEntity entity = optionalEntity.get(); + entity.setClientSecret2(hashedSecret); + entity.setClientSecret2Updated(now); + clientJpaRepository.save(entity); + } + + @Override + @Transactional + public void clearClientSecret1(@NonNull String clientId) { + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + String documentId = HashingUtil.sha256(clientId); + + Optional optionalEntity = clientJpaRepository.findById(documentId); + if (optionalEntity.isEmpty()) { + throw new NotFoundException("Client not found"); + } + + ClientEntity entity = optionalEntity.get(); + entity.setClientSecret1(null); + entity.setClientSecret1Updated(now); + clientJpaRepository.save(entity); + } + + @Override + @Transactional + public void clearClientSecret2(@NonNull String clientId) { + String now = TimeUtil.getCurrentTimestamp(this.epochTimeProvider.epochTimeSeconds()); + String documentId = HashingUtil.sha256(clientId); + + Optional optionalEntity = clientJpaRepository.findById(documentId); + if (optionalEntity.isEmpty()) { + throw new NotFoundException("Client not found"); + } + + ClientEntity entity = optionalEntity.get(); + entity.setClientSecret2(null); + entity.setClientSecret2Updated(now); + clientJpaRepository.save(entity); + } +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresJwkCacheRepository.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresJwkCacheRepository.java new file mode 100644 index 00000000..2fd24546 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresJwkCacheRepository.java @@ -0,0 +1,97 @@ +/* + * 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.postgres.repository; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.transaction.annotation.Transactional; + +import com.unitvectory.serviceauthcentral.datamodel.model.CachedJwk; +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.CachedJwkEntity; +import com.unitvectory.serviceauthcentral.datamodel.repository.JwkCacheRepository; +import com.unitvectory.serviceauthcentral.util.HashingUtil; + +import lombok.AllArgsConstructor; +import lombok.NonNull; + +/** + * The PostgreSQL JWK Cache Repository + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@AllArgsConstructor +public class PostgresJwkCacheRepository implements JwkCacheRepository { + + private final CachedJwkJpaRepository cachedJwkJpaRepository; + + 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 + @Transactional + public void cacheJwk(@NonNull String url, @NonNull CachedJwk jwk, long ttl) { + String documentId = getId(url, jwk.getKid()); + + CachedJwkEntity entity = CachedJwkEntity.builder() + .documentId(documentId) + .url(url) + .ttl(ttl) + .valid(jwk.isValid()) + .kid(jwk.getKid()) + .kty(jwk.getKty()) + .alg(jwk.getAlg()) + .use(jwk.getUse()) + .n(jwk.getN()) + .e(jwk.getE()) + .build(); + + cachedJwkJpaRepository.save(entity); + } + + @Override + @Transactional + public void cacheJwkAbsent(@NonNull String url, @NonNull String kid, long ttl) { + String documentId = getId(url, kid); + + CachedJwkEntity entity = CachedJwkEntity.builder() + .documentId(documentId) + .url(url) + .kid(kid) + .ttl(ttl) + .valid(false) + .build(); + + cachedJwkJpaRepository.save(entity); + } + + @Override + public List getJwks(@NonNull String url) { + List entities = cachedJwkJpaRepository.findByUrl(url); + return Collections.unmodifiableList( + entities.stream().map(e -> (CachedJwk) e).collect(Collectors.toList())); + } + + @Override + public CachedJwk getJwk(String url, String kid) { + String id = getId(url, kid); + Optional entity = cachedJwkJpaRepository.findById(id); + return entity.orElse(null); + } +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresLoginCodeRepository.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresLoginCodeRepository.java new file mode 100644 index 00000000..bc4709d4 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresLoginCodeRepository.java @@ -0,0 +1,73 @@ +/* + * 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.postgres.repository; + +import java.util.Optional; + +import org.springframework.transaction.annotation.Transactional; + +import com.unitvectory.serviceauthcentral.datamodel.model.LoginCode; +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.LoginCodeEntity; +import com.unitvectory.serviceauthcentral.datamodel.repository.LoginCodeRepository; +import com.unitvectory.serviceauthcentral.util.HashingUtil; + +import lombok.AllArgsConstructor; +import lombok.NonNull; + +/** + * The PostgreSQL Login Code Repository + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@AllArgsConstructor +public class PostgresLoginCodeRepository implements LoginCodeRepository { + + private final LoginCodeJpaRepository loginCodeJpaRepository; + + @Override + @Transactional + 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 documentId = HashingUtil.sha256(code); + + LoginCodeEntity entity = LoginCodeEntity.builder() + .documentId(documentId) + .clientId(clientId) + .redirectUri(redirectUri) + .codeChallenge(codeChallenge) + .userClientId(userClientId) + .ttl(ttl) + .build(); + + loginCodeJpaRepository.save(entity); + } + + @Override + public LoginCode getCode(@NonNull String code) { + String documentId = HashingUtil.sha256(code); + Optional entity = loginCodeJpaRepository.findById(documentId); + return entity.orElse(null); + } + + @Override + @Transactional + public void deleteCode(@NonNull String code) { + String documentId = HashingUtil.sha256(code); + loginCodeJpaRepository.deleteById(documentId); + } +} diff --git a/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresLoginStateRepository.java b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresLoginStateRepository.java new file mode 100644 index 00000000..c6efdae4 --- /dev/null +++ b/datamodel-postgres/src/main/java/com/unitvectory/serviceauthcentral/datamodel/postgres/repository/PostgresLoginStateRepository.java @@ -0,0 +1,74 @@ +/* + * 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.postgres.repository; + +import java.util.Optional; + +import org.springframework.transaction.annotation.Transactional; + +import com.unitvectory.serviceauthcentral.datamodel.model.LoginState; +import com.unitvectory.serviceauthcentral.datamodel.postgres.entity.LoginStateEntity; +import com.unitvectory.serviceauthcentral.datamodel.repository.LoginStateRepository; +import com.unitvectory.serviceauthcentral.util.HashingUtil; + +import lombok.AllArgsConstructor; +import lombok.NonNull; + +/** + * The PostgreSQL Login State Repository + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@AllArgsConstructor +public class PostgresLoginStateRepository implements LoginStateRepository { + + private final LoginStateJpaRepository loginStateJpaRepository; + + @Override + @Transactional + 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 documentId = HashingUtil.sha256(sessionId); + + LoginStateEntity entity = LoginStateEntity.builder() + .documentId(documentId) + .clientId(clientId) + .redirectUri(redirectUri) + .primaryState(primaryState) + .primaryCodeChallenge(primaryCodeChallenge) + .secondaryState(secondaryState) + .ttl(ttl) + .build(); + + loginStateJpaRepository.save(entity); + } + + @Override + public LoginState getState(@NonNull String sessionId) { + String documentId = HashingUtil.sha256(sessionId); + Optional entity = loginStateJpaRepository.findById(documentId); + return entity.orElse(null); + } + + @Override + @Transactional + public void deleteState(@NonNull String sessionId) { + String documentId = HashingUtil.sha256(sessionId); + loginStateJpaRepository.deleteById(documentId); + } +} diff --git a/datamodel-valkey/src/main/resources/.config b/datamodel-postgres/src/main/resources/.gitignore similarity index 100% rename from datamodel-valkey/src/main/resources/.config rename to datamodel-postgres/src/main/resources/.gitignore diff --git a/datamodel-valkey/src/main/resources/.gitignore b/datamodel-valkey/src/main/resources/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/docs/modules/datamodel/index.md b/docs/modules/datamodel/index.md index 18a43606..b40280a8 100644 --- a/docs/modules/datamodel/index.md +++ b/docs/modules/datamodel/index.md @@ -24,3 +24,4 @@ There are multiple data model implementations that are available. Exactly one mo - [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 +- [Data Model - Postgres](./postgres.md): PostgreSQL implementation for the repository interfaces diff --git a/docs/modules/datamodel/postgres.md b/docs/modules/datamodel/postgres.md new file mode 100644 index 00000000..42e44f4f --- /dev/null +++ b/docs/modules/datamodel/postgres.md @@ -0,0 +1,129 @@ +# Data Model - Postgres + +The data model postgres module provides a [PostgreSQL](https://www.postgresql.org/) implementation of 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 PostgreSQL data store: + +```mermaid +flowchart TD + authorizations[(authorizations)] + authorization_scopes[(authorization_scopes)] + clients[(clients)] + client_scopes[(client_scopes)] + client_jwt_bearers[(client_jwt_bearers)] + cached_jwks[(cached_jwks)] + login_codes[(login_codes)] + login_states[(login_states)] +``` + +## Spring Boot Profile + +Spring Boot 3's dependency injection is used to initialize the relevant Beans for interacting with PostgreSQL. This is accomplished through profiles. + +The `datamodel-postgres` profile is enabled to utilize PostgreSQL. + +## Configuration + +The following configuration attributes: + +| Property | Required | Description | +| ------------------------------------- | -------- | ---------------------------------- | +| spring.datasource.url | Yes | PostgreSQL JDBC connection URL | +| spring.datasource.username | Yes | Database username | +| spring.datasource.password | Yes | Database password | +| spring.jpa.hibernate.ddl-auto | No | Schema generation strategy | + +## Database Schema + +The PostgreSQL implementation uses JPA/Hibernate for database interactions. The schema can be automatically generated using `spring.jpa.hibernate.ddl-auto=update` or you can create the tables manually using the following schema: + +```sql +-- Authorizations table +CREATE TABLE authorizations ( + document_id VARCHAR(64) PRIMARY KEY, + authorization_created VARCHAR(255), + subject VARCHAR(255) NOT NULL, + audience VARCHAR(255) NOT NULL +); + +CREATE TABLE authorization_scopes ( + authorization_id VARCHAR(64) NOT NULL, + scope VARCHAR(255), + FOREIGN KEY (authorization_id) REFERENCES authorizations(document_id) +); + +CREATE INDEX idx_authorizations_subject ON authorizations(subject); +CREATE INDEX idx_authorizations_audience ON authorizations(audience); + +-- Clients table +CREATE TABLE clients ( + document_id VARCHAR(64) PRIMARY KEY, + client_created VARCHAR(255), + client_id VARCHAR(255) NOT NULL UNIQUE, + description VARCHAR(255), + salt VARCHAR(255) NOT NULL, + client_type VARCHAR(50) NOT NULL, + client_secret1 VARCHAR(255), + client_secret1_updated VARCHAR(255), + client_secret2 VARCHAR(255), + client_secret2_updated VARCHAR(255), + locked BOOLEAN +); + +CREATE TABLE client_scopes ( + id BIGSERIAL PRIMARY KEY, + client_id VARCHAR(64) NOT NULL, + scope VARCHAR(255) NOT NULL, + description VARCHAR(255), + FOREIGN KEY (client_id) REFERENCES clients(document_id) +); + +CREATE TABLE client_jwt_bearers ( + id VARCHAR(255) PRIMARY KEY, + client_id VARCHAR(64) NOT NULL, + jwks_url VARCHAR(255) NOT NULL, + iss VARCHAR(255) NOT NULL, + sub VARCHAR(255) NOT NULL, + aud VARCHAR(255) NOT NULL, + FOREIGN KEY (client_id) REFERENCES clients(document_id) +); + +-- Cached JWKs table +CREATE TABLE cached_jwks ( + document_id VARCHAR(64) PRIMARY KEY, + url VARCHAR(255) NOT NULL, + ttl BIGINT NOT NULL, + valid BOOLEAN, + kid VARCHAR(255) NOT NULL, + kty VARCHAR(255), + alg VARCHAR(255), + use_value VARCHAR(255), + n TEXT, + e VARCHAR(255) +); + +CREATE INDEX idx_cached_jwks_url ON cached_jwks(url); + +-- Login codes table +CREATE TABLE login_codes ( + document_id VARCHAR(64) PRIMARY KEY, + client_id VARCHAR(255) NOT NULL, + redirect_uri VARCHAR(255) NOT NULL, + code_challenge VARCHAR(255) NOT NULL, + user_client_id VARCHAR(255) NOT NULL, + ttl BIGINT NOT NULL +); + +-- Login states table +CREATE TABLE login_states ( + document_id VARCHAR(64) PRIMARY KEY, + client_id VARCHAR(255) NOT NULL, + redirect_uri VARCHAR(255) NOT NULL, + primary_state VARCHAR(255) NOT NULL, + primary_code_challenge VARCHAR(255) NOT NULL, + secondary_state VARCHAR(255) NOT NULL, + ttl BIGINT NOT NULL +); +``` diff --git a/pom.xml b/pom.xml index 4f83bbef..b93d879a 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,7 @@ util datamodel datamodel-memory + datamodel-postgres datamodel-valkey diff --git a/server-manage/pom.xml b/server-manage/pom.xml index 38e4cdef..e7e9434e 100644 --- a/server-manage/pom.xml +++ b/server-manage/pom.xml @@ -43,6 +43,12 @@ ${project.version} runtime + + com.unitvectory.serviceauthcentral + datamodel-postgres + ${project.version} + runtime + org.springframework.boot spring-boot-starter-web diff --git a/server-token/pom.xml b/server-token/pom.xml index 609b058d..201a9d30 100644 --- a/server-token/pom.xml +++ b/server-token/pom.xml @@ -43,6 +43,12 @@ ${project.version} runtime + + com.unitvectory.serviceauthcentral + datamodel-postgres + ${project.version} + runtime + com.unitvectory.serviceauthcentral sign