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