diff --git a/MIGRATION.md b/MIGRATION.md index 5ba128f..e8cc4ed 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -359,6 +359,32 @@ The `/user/updateUser` endpoint now uses `UserProfileUpdateDto`. --- +**Issue: `user_credentials` table not created on MariaDB/MySQL (WebAuthn)** + +With `ddl-auto: update` or `create`, Hibernate previously mapped the `attestationObject` and +`attestationClientDataJson` columns to `VARBINARY(65535)`. Two such columns exceed MariaDB's +InnoDB 65,535-byte row-size limit, causing silent table creation failure. Symptoms include 500 +errors on `/user/auth-methods` or `/user/webauthn/credentials`. + +**Solution (upgrading from a version prior to this fix):** + +If the `user_credentials` table was never created, it will be created automatically on next +startup with `ddl-auto: update` once you upgrade to this version. + +If the table exists with `VARBINARY` columns (created on a non-MariaDB database), run: + +```sql +ALTER TABLE user_credentials + MODIFY COLUMN public_key LONGBLOB NOT NULL, + MODIFY COLUMN attestation_object LONGBLOB, + MODIFY COLUMN attestation_client_data_json LONGBLOB; +``` + +With `ddl-auto: update`, Hibernate will handle this automatically on MariaDB/MySQL. On +PostgreSQL no schema change is needed — the columns map to `bytea` in both old and new versions. + +--- + **Issue: Java version incompatibility** Spring Boot 4.0 requires Java 21. diff --git a/build.gradle b/build.gradle index 1058748..61c2875 100644 --- a/build.gradle +++ b/build.gradle @@ -90,7 +90,9 @@ dependencies { // Additional test dependencies for improved testing testImplementation 'org.testcontainers:testcontainers:2.0.3' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter:2.0.3' testImplementation 'org.testcontainers:testcontainers-mariadb:2.0.3' + testImplementation 'org.testcontainers:testcontainers-postgresql:2.0.3' testImplementation 'com.github.tomakehurst:wiremock:3.0.1' testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1' testImplementation 'org.assertj:assertj-core:3.27.7' diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java index 6620644..0fe7f72 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java @@ -1,6 +1,5 @@ package com.digitalsanctuary.spring.user.persistence.model; -import java.time.Instant; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -8,7 +7,9 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.time.Instant; import lombok.Data; +import org.hibernate.Length; /** * JPA entity for the {@code user_credentials} table. Stores WebAuthn credentials (public keys) for passkey @@ -29,8 +30,16 @@ public class WebAuthnCredential { @JoinColumn(name = "user_entity_user_id", nullable = false) private WebAuthnUserEntity userEntity; - /** COSE-encoded public key (typically 77-300 bytes, RSA keys can be larger). */ - @Column(name = "public_key", nullable = false, length = 2048) + /** + * COSE-encoded public key (typically 77-300 bytes, RSA keys can be larger). + * + *
{@code length = Length.LONG32} is intentional: it forces Hibernate to emit {@code LONGBLOB} on + * MariaDB/MySQL (stored off-page, avoiding the 65,535-byte InnoDB row-size limit) while mapping to + * {@code bytea} on PostgreSQL. Do not reduce this to a smaller value — doing so reintroduces the + * MariaDB DDL failure described in GitHub issue #286. Do not replace with {@code @Lob}, which maps + * to {@code OID} on PostgreSQL.
+ */ + @Column(name = "public_key", nullable = false, length = Length.LONG32) private byte[] publicKey; /** Counter to detect cloned authenticators. */ @@ -57,12 +66,20 @@ public class WebAuthnCredential { @Column(name = "backup_state", nullable = false) private boolean backupState; - /** Attestation data from registration (can be several KB). */ - @Column(name = "attestation_object", length = 65535) + /** + * Attestation data from registration (can be several KB). + * + *See {@link #publicKey} for why {@code length = Length.LONG32} is used here.
+ */ + @Column(name = "attestation_object", length = Length.LONG32) private byte[] attestationObject; - /** Client data JSON from registration (can be several KB). */ - @Column(name = "attestation_client_data_json", length = 65535) + /** + * Client data JSON from registration (can be several KB). + * + *See {@link #publicKey} for why {@code length = Length.LONG32} is used here.
+ */ + @Column(name = "attestation_client_data_json", length = Length.LONG32) private byte[] attestationClientDataJson; /** Creation timestamp. */ diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java new file mode 100644 index 0000000..2369736 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java @@ -0,0 +1,29 @@ +package com.digitalsanctuary.spring.user.persistence.model; + +import static org.assertj.core.api.Assertions.assertThat; +import jakarta.persistence.Column; +import java.lang.reflect.Field; +import org.hibernate.Length; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("WebAuthnCredential Column Mapping Tests") +class WebAuthnCredentialColumnMappingTest { + + @ParameterizedTest + @ValueSource(strings = {"attestationObject", "attestationClientDataJson", "publicKey"}) + @DisplayName("should use Length.LONG32 on byte[] fields for cross-database BLOB compatibility") + void shouldUseLengthLong32OnBlobFields(String fieldName) throws NoSuchFieldException { + Field field = WebAuthnCredential.class.getDeclaredField(fieldName); + Column column = field.getAnnotation(Column.class); + assertThat(column) + .as("Field '%s' must have @Column annotation", fieldName) + .isNotNull(); + assertThat(column.length()) + .as("Field '%s' @Column length must be Length.LONG32 (%d) to auto-upgrade " + + "to LONGBLOB on MariaDB/MySQL and remain bytea on PostgreSQL", + fieldName, Length.LONG32) + .isEqualTo(Length.LONG32); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/AbstractSchemaValidationTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/AbstractSchemaValidationTest.java new file mode 100644 index 0000000..48e7797 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/AbstractSchemaValidationTest.java @@ -0,0 +1,85 @@ +package com.digitalsanctuary.spring.user.persistence.schema; + +import static org.assertj.core.api.Assertions.assertThat; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Abstract base class for database schema validation tests. Subclasses provide a real database via Testcontainers and + * configure Spring to connect to it. This test verifies that Hibernate can create the full schema without errors on each + * target database. + * + *+ * The test uses {@code ddl-auto: create} (via Spring Boot properties) and then queries + * {@code INFORMATION_SCHEMA.TABLES} to verify all expected tables were created. This catches silent DDL failures like + * the one described in GitHub issue #286. + *
+ */ +@SpringBootTest(classes = TestApplication.class) +abstract class AbstractSchemaValidationTest { + + /** + * All tables expected to be created by Hibernate from the entity model. Includes entity tables and join tables. + */ + private static final Set