Skip to content

Commit 540fb2d

Browse files
wolpertclaude
andcommitted
fix(persistence-jdbi): bind nullable typed columns with explicit SQL type
JDBI's untyped-null fallback uses Types.VARCHAR, which Postgres rejects against UUID, TIMESTAMPTZ, and BYTEA columns. The most-visible symptom was passkey registration failing for platform authenticators (Touch ID, Windows Hello) and attestation=none ceremonies, where CredentialRecord .aaguid() is null: ERROR: column "aaguid" is of type uuid but expression is of type character varying Same defect affected nullable TIMESTAMPTZ binds in refresh_tokens (used_at, revoked_at), backup_codes (consumed_at), credentials (last_used_at), and the nullable BYTEA user_handle in pkauth_audit_events. Introduce a bindNullable helper that calls bindNull(name, sqlType) on the null branch so Postgres receives a typed NULL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7a718cf commit 540fb2d

4 files changed

Lines changed: 256 additions & 88 deletions

File tree

pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiBackupCodeRepository.java

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@
66
import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException;
77
import java.sql.ResultSet;
88
import java.sql.SQLException;
9+
import java.sql.Types;
910
import java.time.Instant;
1011
import java.time.OffsetDateTime;
1112
import java.time.ZoneOffset;
1213
import java.util.List;
1314
import java.util.Objects;
1415
import java.util.function.Supplier;
16+
import org.jdbi.v3.core.Handle;
1517
import org.jdbi.v3.core.Jdbi;
1618
import org.jdbi.v3.core.JdbiException;
1719
import org.jdbi.v3.core.mapper.RowMapper;
20+
import org.jdbi.v3.core.statement.Update;
1821

1922
/**
2023
* {@link BackupCodeRepository} backed by the {@code backup_codes} table (Flyway V3).
@@ -36,27 +39,42 @@ public void save(StoredBackupCode code) {
3639
wrap(
3740
"backupCodes.save",
3841
() -> {
39-
jdbi.useHandle(
40-
h ->
41-
h.createUpdate(
42-
"INSERT INTO backup_codes (code_id, user_handle, hashed_code, consumed,"
43-
+ " consumed_at, created_at)"
44-
+ " VALUES (:cid, :uh, :hash, :consumed, :consumedAt, :createdAt)")
45-
.bind("cid", code.codeId())
46-
.bind("uh", code.userHandle().value())
47-
.bind("hash", code.hashedCode())
48-
.bind("consumed", code.consumed())
49-
.bind(
50-
"consumedAt",
51-
code.consumedAt() == null
52-
? null
53-
: OffsetDateTime.ofInstant(code.consumedAt(), ZoneOffset.UTC))
54-
.bind("createdAt", OffsetDateTime.ofInstant(code.createdAt(), ZoneOffset.UTC))
55-
.execute());
42+
jdbi.useHandle(h -> insertBackupCode(h, code));
5643
return null;
5744
});
5845
}
5946

47+
private static void insertBackupCode(Handle h, StoredBackupCode code) {
48+
Update update =
49+
h.createUpdate(
50+
"INSERT INTO backup_codes (code_id, user_handle, hashed_code, consumed,"
51+
+ " consumed_at, created_at)"
52+
+ " VALUES (:cid, :uh, :hash, :consumed, :consumedAt, :createdAt)")
53+
.bind("cid", code.codeId())
54+
.bind("uh", code.userHandle().value())
55+
.bind("hash", code.hashedCode())
56+
.bind("consumed", code.consumed())
57+
.bind("createdAt", OffsetDateTime.ofInstant(code.createdAt(), ZoneOffset.UTC));
58+
// consumed_at is TIMESTAMPTZ; force Types.TIMESTAMP_WITH_TIMEZONE on the null branch so PG
59+
// does not reject the untyped-null (Types.VARCHAR) default.
60+
bindNullable(
61+
update,
62+
"consumedAt",
63+
code.consumedAt() == null
64+
? null
65+
: OffsetDateTime.ofInstant(code.consumedAt(), ZoneOffset.UTC),
66+
Types.TIMESTAMP_WITH_TIMEZONE);
67+
update.execute();
68+
}
69+
70+
private static void bindNullable(Update update, String name, Object value, int sqlType) {
71+
if (value == null) {
72+
update.bindNull(name, sqlType);
73+
} else {
74+
update.bind(name, value);
75+
}
76+
}
77+
6078
/** Returns only active (not yet revoked) codes for the given user handle. */
6179
@Override
6280
public List<StoredBackupCode> findByUserHandle(UserHandle userHandle) {
@@ -164,21 +182,7 @@ public void replaceAll(UserHandle userHandle, List<StoredBackupCode> records) {
164182
.bind("uh", userHandle.value())
165183
.execute();
166184
for (StoredBackupCode code : records) {
167-
h.createUpdate(
168-
"INSERT INTO backup_codes (code_id, user_handle, hashed_code, consumed,"
169-
+ " consumed_at, created_at)"
170-
+ " VALUES (:cid, :uh, :hash, :consumed, :consumedAt, :createdAt)")
171-
.bind("cid", code.codeId())
172-
.bind("uh", code.userHandle().value())
173-
.bind("hash", code.hashedCode())
174-
.bind("consumed", code.consumed())
175-
.bind(
176-
"consumedAt",
177-
code.consumedAt() == null
178-
? null
179-
: OffsetDateTime.ofInstant(code.consumedAt(), ZoneOffset.UTC))
180-
.bind("createdAt", OffsetDateTime.ofInstant(code.createdAt(), ZoneOffset.UTC))
181-
.execute();
185+
insertBackupCode(h, code);
182186
}
183187
});
184188
return null;
@@ -207,16 +211,20 @@ private static <T> T wrap(String op, Supplier<T> body) {
207211
private void insertAuditEvent(
208212
String eventType, byte[] userHandle, String subjectId, String detail) {
209213
jdbi.useHandle(
210-
h ->
211-
h.createUpdate(
212-
"INSERT INTO pkauth_audit_events"
213-
+ " (event_type, user_handle, subject_id, detail)"
214-
+ " VALUES (:eventType, :userHandle, :subjectId, :detail)")
215-
.bind("eventType", eventType)
216-
.bind("userHandle", userHandle)
217-
.bind("subjectId", subjectId)
218-
.bind("detail", detail)
219-
.execute());
214+
h -> {
215+
Update update =
216+
h.createUpdate(
217+
"INSERT INTO pkauth_audit_events"
218+
+ " (event_type, user_handle, subject_id, detail)"
219+
+ " VALUES (:eventType, :userHandle, :subjectId, :detail)")
220+
.bind("eventType", eventType)
221+
.bind("subjectId", subjectId)
222+
.bind("detail", detail);
223+
// user_handle is BYTEA and nullable (unknown attacker). Untyped-null default
224+
// (Types.VARCHAR) is rejected against BYTEA — force Types.BINARY on the null branch.
225+
bindNullable(update, "userHandle", userHandle, Types.BINARY);
226+
update.execute();
227+
});
220228
}
221229

222230
private static final RowMapper<StoredBackupCode> MAPPER = (rs, ctx) -> readRow(rs);

pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepository.java

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import java.sql.Array;
1212
import java.sql.ResultSet;
1313
import java.sql.SQLException;
14+
import java.sql.Types;
1415
// Array import kept for the row-mapper path that reads back the text[] column.
1516
import java.time.Instant;
1617
import java.time.OffsetDateTime;
@@ -25,6 +26,7 @@
2526
import org.jdbi.v3.core.Jdbi;
2627
import org.jdbi.v3.core.JdbiException;
2728
import org.jdbi.v3.core.mapper.RowMapper;
29+
import org.jdbi.v3.core.statement.Update;
2830

2931
/**
3032
* {@link CredentialRepository} backed by the Phase 5 {@code credentials} table.
@@ -56,32 +58,40 @@ public void save(CredentialRecord record) {
5658
"credentials.save",
5759
() ->
5860
jdbi.withHandle(
59-
h ->
60-
h.createUpdate(
61-
"INSERT INTO credentials (credential_id, user_handle,"
62-
+ " public_key_cose, sign_count, label, aaguid, transports,"
63-
+ " backup_eligible, backup_state, created_at, last_used_at)"
64-
+ " VALUES (:cid, :uh, :pk, :sc, :label, :aaguid,"
65-
+ " :transports, :be, :bs, :createdAt, :lastUsedAt)"
66-
+ " ON CONFLICT (credential_id) DO NOTHING")
67-
.bind("cid", record.credentialId().value())
68-
.bind("uh", record.userHandle().value())
69-
.bind("pk", record.publicKeyCose())
70-
.bind("sc", record.signCount())
71-
.bind("label", record.label())
72-
.bind("aaguid", record.aaguid())
73-
.bindArray("transports", String.class, (Object[]) transportWire)
74-
.bind("be", record.backupEligible())
75-
.bind("bs", record.backupState())
76-
.bind(
77-
"createdAt",
78-
OffsetDateTime.ofInstant(record.createdAt(), ZoneOffset.UTC))
79-
.bind(
80-
"lastUsedAt",
81-
record.lastUsedAt() == null
82-
? null
83-
: OffsetDateTime.ofInstant(record.lastUsedAt(), ZoneOffset.UTC))
84-
.execute()));
61+
h -> {
62+
Update update =
63+
h.createUpdate(
64+
"INSERT INTO credentials (credential_id, user_handle,"
65+
+ " public_key_cose, sign_count, label, aaguid, transports,"
66+
+ " backup_eligible, backup_state, created_at, last_used_at)"
67+
+ " VALUES (:cid, :uh, :pk, :sc, :label, :aaguid,"
68+
+ " :transports, :be, :bs, :createdAt, :lastUsedAt)"
69+
+ " ON CONFLICT (credential_id) DO NOTHING")
70+
.bind("cid", record.credentialId().value())
71+
.bind("uh", record.userHandle().value())
72+
.bind("pk", record.publicKeyCose())
73+
.bind("sc", record.signCount())
74+
.bind("label", record.label())
75+
.bindArray("transports", String.class, (Object[]) transportWire)
76+
.bind("be", record.backupEligible())
77+
.bind("bs", record.backupState())
78+
.bind(
79+
"createdAt",
80+
OffsetDateTime.ofInstant(record.createdAt(), ZoneOffset.UTC));
81+
// aaguid is null for platform authenticators / attestation=none. The default
82+
// untyped-null binding uses Types.VARCHAR, which Postgres rejects against a
83+
// UUID column ("column ... is of type uuid but expression is of type
84+
// character varying"). Force Types.OTHER for the null case.
85+
bindNullable(update, "aaguid", record.aaguid(), Types.OTHER);
86+
bindNullable(
87+
update,
88+
"lastUsedAt",
89+
record.lastUsedAt() == null
90+
? null
91+
: OffsetDateTime.ofInstant(record.lastUsedAt(), ZoneOffset.UTC),
92+
Types.TIMESTAMP_WITH_TIMEZONE);
93+
return update.execute();
94+
}));
8595
if (inserted == 0) {
8696
throw new DuplicateCredentialException(
8797
"Duplicate credential id; refusing to overwrite an existing credential");
@@ -195,6 +205,20 @@ public int deleteByUserHandle(UserHandle userHandle) {
195205
* {@link PkAuthPersistenceException} (including {@link DuplicateCredentialException}) is
196206
* re-thrown unchanged so the duplicate-credential branch reaches the caller intact.
197207
*/
208+
/**
209+
* Binds a nullable value. When non-null, defers to JDBI's standard binding. When null, calls
210+
* {@code bindNull} with the supplied SQL type so Postgres receives a typed NULL rather than the
211+
* untyped-null default (Types.VARCHAR), which strict typing rejects against UUID and TIMESTAMPTZ
212+
* columns. See bug report for the original {@code aaguid} failure mode.
213+
*/
214+
private static void bindNullable(Update update, String name, Object value, int sqlType) {
215+
if (value == null) {
216+
update.bindNull(name, sqlType);
217+
} else {
218+
update.bind(name, value);
219+
}
220+
}
221+
198222
private static <T> T wrap(String op, Supplier<T> body) {
199223
try {
200224
return body.get();

pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException;
99
import java.sql.ResultSet;
1010
import java.sql.SQLException;
11+
import java.sql.Types;
1112
import java.time.Instant;
1213
import java.time.OffsetDateTime;
1314
import java.time.ZoneOffset;
@@ -19,6 +20,7 @@
1920
import org.jdbi.v3.core.Jdbi;
2021
import org.jdbi.v3.core.JdbiException;
2122
import org.jdbi.v3.core.mapper.RowMapper;
23+
import org.jdbi.v3.core.statement.Update;
2224

2325
/**
2426
* {@link RefreshTokenRepository} backed by the {@code refresh_tokens} table (Flyway V9). The
@@ -173,26 +175,46 @@ public int deleteExpiredBefore(Instant cutoff) {
173175
// -- Internals --------------------------------------------------------------------------
174176

175177
private static void insert(Handle h, RefreshTokenRecord r) {
176-
h.createUpdate(
177-
"INSERT INTO refresh_tokens"
178-
+ " (refresh_id, token_hash, user_handle, audience, device_id, family_id,"
179-
+ " parent_refresh_id, issued_at, expires_at, used_at, revoked_at, revoked_reason)"
180-
+ " VALUES (:rid, :hash, :uh, :aud, :did, :fam, :pid, :iat, :exp, :uat, :rat,"
181-
+ " :reason)")
182-
.bind("rid", r.refreshId())
183-
.bind("hash", r.tokenHash())
184-
.bind("uh", r.userHandle().value())
185-
.bind("aud", r.audience())
186-
.bind("did", r.deviceId().orElse(null))
187-
.bind("fam", r.familyId())
188-
.bind("pid", r.parentRefreshId().orElse(null))
189-
.bind("iat", OffsetDateTime.ofInstant(r.issuedAt(), ZoneOffset.UTC))
190-
.bind("exp", OffsetDateTime.ofInstant(r.expiresAt(), ZoneOffset.UTC))
191-
.bind("uat", r.usedAt().map(t -> OffsetDateTime.ofInstant(t, ZoneOffset.UTC)).orElse(null))
192-
.bind(
193-
"rat", r.revokedAt().map(t -> OffsetDateTime.ofInstant(t, ZoneOffset.UTC)).orElse(null))
194-
.bind("reason", r.revokedReason().map(Enum::name).orElse(null))
195-
.execute();
178+
Update update =
179+
h.createUpdate(
180+
"INSERT INTO refresh_tokens"
181+
+ " (refresh_id, token_hash, user_handle, audience, device_id, family_id,"
182+
+ " parent_refresh_id, issued_at, expires_at, used_at, revoked_at,"
183+
+ " revoked_reason)"
184+
+ " VALUES (:rid, :hash, :uh, :aud, :did, :fam, :pid, :iat, :exp, :uat, :rat,"
185+
+ " :reason)")
186+
.bind("rid", r.refreshId())
187+
.bind("hash", r.tokenHash())
188+
.bind("uh", r.userHandle().value())
189+
.bind("aud", r.audience())
190+
.bind("did", r.deviceId().orElse(null))
191+
.bind("fam", r.familyId())
192+
.bind("pid", r.parentRefreshId().orElse(null))
193+
.bind("iat", OffsetDateTime.ofInstant(r.issuedAt(), ZoneOffset.UTC))
194+
.bind("exp", OffsetDateTime.ofInstant(r.expiresAt(), ZoneOffset.UTC))
195+
.bind("reason", r.revokedReason().map(Enum::name).orElse(null));
196+
// used_at and revoked_at are TIMESTAMPTZ; JDBI's untyped-null default (Types.VARCHAR) is
197+
// rejected by Postgres against a TIMESTAMPTZ column. Force Types.TIMESTAMP_WITH_TIMEZONE on
198+
// the null branch.
199+
bindNullable(
200+
update,
201+
"uat",
202+
r.usedAt().map(t -> OffsetDateTime.ofInstant(t, ZoneOffset.UTC)).orElse(null),
203+
Types.TIMESTAMP_WITH_TIMEZONE);
204+
bindNullable(
205+
update,
206+
"rat",
207+
r.revokedAt().map(t -> OffsetDateTime.ofInstant(t, ZoneOffset.UTC)).orElse(null),
208+
Types.TIMESTAMP_WITH_TIMEZONE);
209+
update.execute();
210+
}
211+
212+
private static void bindNullable(Update update, String name, Object value, int sqlType) {
213+
if (value == null) {
214+
update.bindNull(name, sqlType);
215+
} else {
216+
update.bind(name, value);
217+
}
196218
}
197219

198220
private static <T> T wrap(String op, Supplier<T> body) {

0 commit comments

Comments
 (0)