Skip to content

Commit 510ce3a

Browse files
authored
Merge pull request #287 from devondragon/fix/286-webauthn-varbinary-row-size-limit
fix(webauthn): use Length.LONG32 for byte[] columns to fix MariaDB row size limit
2 parents 96bc1e2 + dd213d5 commit 510ce3a

7 files changed

Lines changed: 268 additions & 7 deletions

File tree

MIGRATION.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,32 @@ The `/user/updateUser` endpoint now uses `UserProfileUpdateDto`.
359359

360360
---
361361

362+
**Issue: `user_credentials` table not created on MariaDB/MySQL (WebAuthn)**
363+
364+
With `ddl-auto: update` or `create`, Hibernate previously mapped the `attestationObject` and
365+
`attestationClientDataJson` columns to `VARBINARY(65535)`. Two such columns exceed MariaDB's
366+
InnoDB 65,535-byte row-size limit, causing silent table creation failure. Symptoms include 500
367+
errors on `/user/auth-methods` or `/user/webauthn/credentials`.
368+
369+
**Solution (upgrading from a version prior to this fix):**
370+
371+
If the `user_credentials` table was never created, it will be created automatically on next
372+
startup with `ddl-auto: update` once you upgrade to this version.
373+
374+
If the table exists with `VARBINARY` columns (created on a non-MariaDB database), run:
375+
376+
```sql
377+
ALTER TABLE user_credentials
378+
MODIFY COLUMN public_key LONGBLOB NOT NULL,
379+
MODIFY COLUMN attestation_object LONGBLOB,
380+
MODIFY COLUMN attestation_client_data_json LONGBLOB;
381+
```
382+
383+
With `ddl-auto: update`, Hibernate will handle this automatically on MariaDB/MySQL. On
384+
PostgreSQL no schema change is needed — the columns map to `bytea` in both old and new versions.
385+
386+
---
387+
362388
**Issue: Java version incompatibility**
363389

364390
Spring Boot 4.0 requires Java 21.

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ dependencies {
9090

9191
// Additional test dependencies for improved testing
9292
testImplementation 'org.testcontainers:testcontainers:2.0.3'
93+
testImplementation 'org.testcontainers:testcontainers-junit-jupiter:2.0.3'
9394
testImplementation 'org.testcontainers:testcontainers-mariadb:2.0.3'
95+
testImplementation 'org.testcontainers:testcontainers-postgresql:2.0.3'
9496
testImplementation 'com.github.tomakehurst:wiremock:3.0.1'
9597
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1'
9698
testImplementation 'org.assertj:assertj-core:3.27.7'

src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
package com.digitalsanctuary.spring.user.persistence.model;
22

3-
import java.time.Instant;
43
import jakarta.persistence.Column;
54
import jakarta.persistence.Entity;
65
import jakarta.persistence.FetchType;
76
import jakarta.persistence.Id;
87
import jakarta.persistence.JoinColumn;
98
import jakarta.persistence.ManyToOne;
109
import jakarta.persistence.Table;
10+
import java.time.Instant;
1111
import lombok.Data;
12+
import org.hibernate.Length;
1213

1314
/**
1415
* JPA entity for the {@code user_credentials} table. Stores WebAuthn credentials (public keys) for passkey
@@ -29,8 +30,16 @@ public class WebAuthnCredential {
2930
@JoinColumn(name = "user_entity_user_id", nullable = false)
3031
private WebAuthnUserEntity userEntity;
3132

32-
/** COSE-encoded public key (typically 77-300 bytes, RSA keys can be larger). */
33-
@Column(name = "public_key", nullable = false, length = 2048)
33+
/**
34+
* COSE-encoded public key (typically 77-300 bytes, RSA keys can be larger).
35+
*
36+
* <p>{@code length = Length.LONG32} is intentional: it forces Hibernate to emit {@code LONGBLOB} on
37+
* MariaDB/MySQL (stored off-page, avoiding the 65,535-byte InnoDB row-size limit) while mapping to
38+
* {@code bytea} on PostgreSQL. Do not reduce this to a smaller value — doing so reintroduces the
39+
* MariaDB DDL failure described in GitHub issue #286. Do not replace with {@code @Lob}, which maps
40+
* to {@code OID} on PostgreSQL.</p>
41+
*/
42+
@Column(name = "public_key", nullable = false, length = Length.LONG32)
3443
private byte[] publicKey;
3544

3645
/** Counter to detect cloned authenticators. */
@@ -57,12 +66,20 @@ public class WebAuthnCredential {
5766
@Column(name = "backup_state", nullable = false)
5867
private boolean backupState;
5968

60-
/** Attestation data from registration (can be several KB). */
61-
@Column(name = "attestation_object", length = 65535)
69+
/**
70+
* Attestation data from registration (can be several KB).
71+
*
72+
* <p>See {@link #publicKey} for why {@code length = Length.LONG32} is used here.</p>
73+
*/
74+
@Column(name = "attestation_object", length = Length.LONG32)
6275
private byte[] attestationObject;
6376

64-
/** Client data JSON from registration (can be several KB). */
65-
@Column(name = "attestation_client_data_json", length = 65535)
77+
/**
78+
* Client data JSON from registration (can be several KB).
79+
*
80+
* <p>See {@link #publicKey} for why {@code length = Length.LONG32} is used here.</p>
81+
*/
82+
@Column(name = "attestation_client_data_json", length = Length.LONG32)
6683
private byte[] attestationClientDataJson;
6784

6885
/** Creation timestamp. */
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.digitalsanctuary.spring.user.persistence.model;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import jakarta.persistence.Column;
5+
import java.lang.reflect.Field;
6+
import org.hibernate.Length;
7+
import org.junit.jupiter.api.DisplayName;
8+
import org.junit.jupiter.params.ParameterizedTest;
9+
import org.junit.jupiter.params.provider.ValueSource;
10+
11+
@DisplayName("WebAuthnCredential Column Mapping Tests")
12+
class WebAuthnCredentialColumnMappingTest {
13+
14+
@ParameterizedTest
15+
@ValueSource(strings = {"attestationObject", "attestationClientDataJson", "publicKey"})
16+
@DisplayName("should use Length.LONG32 on byte[] fields for cross-database BLOB compatibility")
17+
void shouldUseLengthLong32OnBlobFields(String fieldName) throws NoSuchFieldException {
18+
Field field = WebAuthnCredential.class.getDeclaredField(fieldName);
19+
Column column = field.getAnnotation(Column.class);
20+
assertThat(column)
21+
.as("Field '%s' must have @Column annotation", fieldName)
22+
.isNotNull();
23+
assertThat(column.length())
24+
.as("Field '%s' @Column length must be Length.LONG32 (%d) to auto-upgrade "
25+
+ "to LONGBLOB on MariaDB/MySQL and remain bytea on PostgreSQL",
26+
fieldName, Length.LONG32)
27+
.isEqualTo(Length.LONG32);
28+
}
29+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.digitalsanctuary.spring.user.persistence.schema;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import com.digitalsanctuary.spring.user.test.app.TestApplication;
5+
import java.util.List;
6+
import java.util.Set;
7+
import org.junit.jupiter.api.DisplayName;
8+
import org.junit.jupiter.api.Test;
9+
import org.springframework.beans.factory.annotation.Autowired;
10+
import org.springframework.boot.test.context.SpringBootTest;
11+
import org.springframework.jdbc.core.JdbcTemplate;
12+
13+
/**
14+
* Abstract base class for database schema validation tests. Subclasses provide a real database via Testcontainers and
15+
* configure Spring to connect to it. This test verifies that Hibernate can create the full schema without errors on each
16+
* target database.
17+
*
18+
* <p>
19+
* The test uses {@code ddl-auto: create} (via Spring Boot properties) and then queries
20+
* {@code INFORMATION_SCHEMA.TABLES} to verify all expected tables were created. This catches silent DDL failures like
21+
* the one described in GitHub issue #286.
22+
* </p>
23+
*/
24+
@SpringBootTest(classes = TestApplication.class)
25+
abstract class AbstractSchemaValidationTest {
26+
27+
/**
28+
* All tables expected to be created by Hibernate from the entity model. Includes entity tables and join tables.
29+
*/
30+
private static final Set<String> EXPECTED_TABLES = Set.of(
31+
// Entity tables
32+
"user_account", "role", "privilege", "verification_token", "password_reset_token",
33+
"password_history_entry", "user_entities", "user_credentials",
34+
// Join tables
35+
"users_roles", "roles_privileges");
36+
37+
@Autowired
38+
private JdbcTemplate jdbcTemplate;
39+
40+
@Test
41+
@DisplayName("should create all expected tables without errors")
42+
void shouldCreateAllExpectedTables() {
43+
List<String> tables = jdbcTemplate.queryForList(
44+
"SELECT LOWER(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE LOWER(table_schema) = LOWER(?)",
45+
String.class, getSchemaName());
46+
47+
assertThat(tables)
48+
.as("All entity and join tables should be created by Hibernate on %s", getDatabaseName())
49+
.containsAll(EXPECTED_TABLES);
50+
}
51+
52+
@Test
53+
@DisplayName("should create WebAuthn byte[] columns as BLOB-compatible types (not inline VARBINARY)")
54+
void shouldCreateWebAuthnBlobColumns() {
55+
List<String> blobColumns = List.of("public_key", "attestation_object", "attestation_client_data_json");
56+
57+
for (String column : blobColumns) {
58+
String dataType = jdbcTemplate.queryForObject(
59+
"SELECT LOWER(data_type) FROM INFORMATION_SCHEMA.COLUMNS "
60+
+ "WHERE LOWER(table_schema) = LOWER(?) AND LOWER(table_name) = 'user_credentials' "
61+
+ "AND LOWER(column_name) = ?",
62+
String.class, getSchemaName(), column);
63+
64+
assertThat(dataType)
65+
.as("Column '%s' on %s should be a BLOB-compatible type, not VARBINARY", column, getDatabaseName())
66+
.isIn(getAllowedBlobTypes());
67+
}
68+
}
69+
70+
/**
71+
* Returns the human-readable database name for assertion messages.
72+
*/
73+
protected abstract String getDatabaseName();
74+
75+
/**
76+
* Returns the schema name used in INFORMATION_SCHEMA queries. MariaDB/MySQL uses the database name as schema;
77+
* PostgreSQL uses 'public' by default.
78+
*/
79+
protected abstract String getSchemaName();
80+
81+
/**
82+
* Returns the set of column data types considered acceptable for BLOB columns on this database.
83+
*/
84+
protected abstract Set<String> getAllowedBlobTypes();
85+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.digitalsanctuary.spring.user.persistence.schema;
2+
3+
import java.util.Set;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.springframework.test.context.DynamicPropertyRegistry;
6+
import org.springframework.test.context.DynamicPropertySource;
7+
import org.testcontainers.containers.MariaDBContainer;
8+
import org.testcontainers.junit.jupiter.Container;
9+
import org.testcontainers.junit.jupiter.Testcontainers;
10+
11+
/**
12+
* Validates that Hibernate can create the full schema on MariaDB without errors. This specifically catches the InnoDB
13+
* row-size limit issue described in GitHub issue #286 where VARBINARY(65535) columns caused silent table creation
14+
* failure.
15+
*/
16+
@Testcontainers
17+
@DisplayName("MariaDB Schema Validation Tests")
18+
class MariaDBSchemaValidationTest extends AbstractSchemaValidationTest {
19+
20+
@Container
21+
static final MariaDBContainer<?> MARIADB = new MariaDBContainer<>("mariadb:11.4")
22+
.withDatabaseName("testdb")
23+
.withUsername("test")
24+
.withPassword("test");
25+
26+
@DynamicPropertySource
27+
static void configureProperties(DynamicPropertyRegistry registry) {
28+
registry.add("spring.datasource.url", MARIADB::getJdbcUrl);
29+
registry.add("spring.datasource.username", MARIADB::getUsername);
30+
registry.add("spring.datasource.password", MARIADB::getPassword);
31+
registry.add("spring.datasource.driver-class-name", () -> "org.mariadb.jdbc.Driver");
32+
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
33+
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MariaDBDialect");
34+
registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MariaDBDialect");
35+
}
36+
37+
@Override
38+
protected String getDatabaseName() {
39+
return "MariaDB";
40+
}
41+
42+
@Override
43+
protected String getSchemaName() {
44+
return "testdb";
45+
}
46+
47+
@Override
48+
protected Set<String> getAllowedBlobTypes() {
49+
return Set.of("longblob", "mediumblob", "blob");
50+
}
51+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.digitalsanctuary.spring.user.persistence.schema;
2+
3+
import java.util.Set;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.springframework.test.context.DynamicPropertyRegistry;
6+
import org.springframework.test.context.DynamicPropertySource;
7+
import org.testcontainers.containers.PostgreSQLContainer;
8+
import org.testcontainers.junit.jupiter.Container;
9+
import org.testcontainers.junit.jupiter.Testcontainers;
10+
11+
/**
12+
* Validates that Hibernate can create the full schema on PostgreSQL without errors. Ensures the byte[] columns map to
13+
* {@code bytea} (not {@code oid}), which would happen if {@code @Lob} were used instead of
14+
* {@code length = Length.LONG32}.
15+
*/
16+
@Testcontainers
17+
@DisplayName("PostgreSQL Schema Validation Tests")
18+
class PostgreSQLSchemaValidationTest extends AbstractSchemaValidationTest {
19+
20+
@Container
21+
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17")
22+
.withDatabaseName("testdb")
23+
.withUsername("test")
24+
.withPassword("test");
25+
26+
@DynamicPropertySource
27+
static void configureProperties(DynamicPropertyRegistry registry) {
28+
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
29+
registry.add("spring.datasource.username", POSTGRES::getUsername);
30+
registry.add("spring.datasource.password", POSTGRES::getPassword);
31+
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
32+
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
33+
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
34+
registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect");
35+
}
36+
37+
@Override
38+
protected String getDatabaseName() {
39+
return "PostgreSQL";
40+
}
41+
42+
@Override
43+
protected String getSchemaName() {
44+
return "public";
45+
}
46+
47+
@Override
48+
protected Set<String> getAllowedBlobTypes() {
49+
return Set.of("bytea");
50+
}
51+
}

0 commit comments

Comments
 (0)