Docstash
Bootstrap

JPA Entity Design

The 10 Entities

EntityPrimary KeyDescription
OAuthClientclient_id (String)Client registry — id, secret hash, redirect URIs, scopes, grant types
AuthCodecode (String)Short-lived auth codes with PKCE challenge
Tokenjti (UUID)Access/refresh/ID tokens — JTI, subject, expiry, revoked
ScimUserid (UUID)SCIM user — username, emails, phones, groups, JSONB attributes
ScimGroupid (UUID)SCIM group — display name, members, JSONB attributes
WebAuthnCredentialcredential_id (String)FIDO2 credential — transports, attestation type
TotpCredentialid (UUID)TOTP secret — Base32 secret, digits, period, algorithm
DeviceCodedevice_code (String)RFC 8628 device flow — user code, status, polling count
DirectoryLinkid (UUID)Hybrid identity — LDAP/Entra ID user linkage
AuditEventid (UUID)Structured audit log — event type, actor, subject, JTI, IP

Primary Key Strategy

  • UUID for Token, SCIM, MFA, Directory, Audit — PostgreSQL gen_random_uuid(), never guessable
  • String for OAuthClient, AuthCode, DeviceCode — opaque tokens generated externally or as secure random strings, not auto-generated

Schema Decisions

Comma-Separated TEXT for Arrays

Array columns use comma-separated TEXT, not JSON columns:

@Column(name = "redirect_uris", columnDefinition = "TEXT")
private String redirectUris;  // "https://app.example.com/cb,https://localhost:3000/cb"

Only attributes on SCIM entities uses JSONB — the SCIM spec requires flexible key-value pairs.

Lifecycle Callbacks

Entities with both created_at and updated_at use both @PrePersist and @PreUpdate:

@PrePersist
protected void onCreate() {
    this.createdAt = Instant.now();
    this.updatedAt = Instant.now();
}

@PreUpdate
protected void onUpdate() {
    this.updatedAt = Instant.now();
}

Critical Repository Pattern: clearAutomatically = true

Every bulk UPDATE or DELETE query must include clearAutomatically = true:

@Modifying(clearAutomatically = true)
@Query("UPDATE Token t SET t.revoked = true WHERE t.jti = :jti")
int revokeByJti(@Param("jti") String jti);

Without this, the persistence context is not cleared after the bulk operation. A subsequent findByJtiAndRevokedFalse() can return a stale cached entity with revoked = false, even though the database was updated.

Named Parameter Queries

All bulk operations use @Param named parameters:

@Modifying(clearAutomatically = true)
@Query("DELETE FROM Token t WHERE t.expiresAt < :now")
int deleteExpired(@Param("now") Instant now);

The Schema: 10 Tables, 16 Indexes

V1__init.sql creates all tables with proper indexes:

CREATE TABLE oauth_client (
    client_id         VARCHAR(128) PRIMARY KEY,
    client_secret_hash VARCHAR(256) NOT NULL,
    client_name        VARCHAR(256) NOT NULL,
    redirect_uris      TEXT NOT NULL,
    scopes             TEXT,
    grant_types        TEXT NOT NULL,
    client_type        VARCHAR(32) NOT NULL,
    id_token_signed_response_alg VARCHAR(16) NOT NULL DEFAULT 'RS256',
    created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_token_subject ON token(subject);
CREATE INDEX idx_token_jti ON token(jti);
CREATE INDEX idx_auth_code_expires ON auth_code(expires_at);
CREATE INDEX idx_device_code_status ON device_code(status);
-- ... 16 indexes total

On this page