Skip to content

fix(stores)!: close the security audit findings of the SQL stores - #121

Merged
euskadi31 merged 9 commits into
masterfrom
fix/audit-sql-stores
Oct 13, 2026
Merged

euskadi31 merged 9 commits into
masterfrom
fix/audit-sql-stores

Conversation

@euskadi31

@euskadi31 euskadi31 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the following findings of the internal security audit of 2026-10-12 (commit c5c2ac3): STORE-04, STORE-I1, STORE-I4, STORE-DL. Also closes the CI-02 review nit left by #112 (jq prerequisite).

Important

Operator step for MySQL / MariaDB deployments of oauth2/store/sql. Migrate still never alters a table.
Existing MySQL and MariaDB databases get byte-exact keys only through the manual upgrade in
MIGRATION.md → "oauth2/store/sql: MySQL compares keys byte for byte":

  1. preflight queries;
  2. strict-mode ALTERs, then an INSERT that records orphaned look-alike families as revoked, with token writes
    stopped if the preflight counted merged tokens;
  3. an optional conversion of the codes' table.

PostgreSQL and SQLite need nothing. The store runs before, during and after the conversion. Each ALTER rebuilds
its table: MySQL 8 blocks that table's writes meanwhile.

STORE-04 — concurrent Migrate failed on PostgreSQL (all three SQL stores)

Problem.

  • Every instance of a deployment calls Migrate at boot, and the godoc said "safe to call repeatedly".
  • On an empty PostgreSQL database, concurrent CREATE TABLE IF NOT EXISTS of one table all find it missing. The
    losers then fail on the catalog's row-type index: duplicate key value violates unique constraint "pg_type_typname_nsp_index" (SQLSTATE 23505).
  • Reproduced (TestMigrateConcurrently/postgres, red phase): 90 of 120 calls failed over 10 rounds of 12 instances
    migrating the three stores at once.
  • MySQL 8.0 / 8.4 and MariaDB 11.4 (metadata lock), and SQLite with its required busy timeout (single writer,
    re-prepared statements), showed 0 failures.

Fix (oauth2/store/sql, oidc/client/store/sql, saml/sp/store/sql).

  • The sealed Dialect gets an unexported migrationLock().
  • On PostgreSQL, Migrate runs in one transaction that first takes the transaction-level advisory lock
    pg_advisory_xact_lock(<key>):
    • concurrent calls run one after the other;
    • a waiting call honours its context, and the driver cancels the server-side wait;
    • a failed call creates nothing (transactional DDL) and gives its connection back.
  • The key is the first 8 bytes of SHA-256("<module path>.Migrate"). Advisory locks are per database (PostgreSQL 16
    §13.3.5, §54.12), so each module gets its own key, and each key is documented so that a migration tool can take
    the same lock:
    • oauth2: -2450120788798466590
    • oidc: -3346565968272468758
    • saml: 1266919442082856213
  • A transaction-level lock ends with COMMIT or ROLLBACK, so it is also correct behind PgBouncer transaction pooling.
  • MySQL, MariaDB and SQLite keep their per-statement path. On MySQL a transaction would not help: every DDL commits
    implicitly (MySQL 8.0 §15.3.3).

Breaking change: none (behaviour only). MIGRATION.md notes the lock for migration tools. An engine without
pg_advisory_xact_lock applies Schema itself.

STORE-I1 — MySQL collations made key comparisons inexact (oauth2/store/sql)

Problem.

  • MySQL and MariaDB compare text under the column collation:
    • the defaults (utf8mb4_0900_ai_ci, utf8mb4_uca1400_ai_ci) fold case, accents, canonically equivalent
      forms and ß / ss;
    • every PAD SPACE collation (utf8mb4_bin included) also ignores trailing spaces.
  • Two token families whose IDs differed only that way were one: revoking one revoked the other, and an issuance into
    one joined the other's record. This breaks I7 of oauth2.Storage, and the docs claimed text was "compared
    exactly".
  • Reproduced (red phase): the new conformance case KeysAreComparedExactly failed on MySQL 8.0, MySQL 8.4 (with and
    without CLIENT_FOUND_ROWS) and MariaDB 11.4: revoking fAM-cASE-aZ09 revoked Fam-Case-Az09.
  • Library IDs (random or derived base64url) and hashes (lowercase hex) never collide this way. Custom family IDs can.

Fix.

  • Schema(MySQL) declares the six key columns (code hash, token hashes, family IDs) VARBINARY, through an
    unexported keyType dialect method. MySQL and MariaDB compare binary strings byte for byte (MySQL 8.0 §13.3.3,
    §12.8.5).
  • PostgreSQL (deterministic default collation) and SQLite (BINARY) keep their DDL unchanged.
  • Why not a collation:
    • utf8mb4_bin is rejected: it still merges trailing spaces;
    • utf8mb4_0900_bin (8.0.17+) is absent from older MariaDB.
  • The OIDC and SAML stores need no change: every key is validated lowercase hex (documented).

Breaking change: the MySQL upgrade above. The upgrade revives no revoked family:

  • its INSERT records revoked every family the collation had merged and that has no byte-exact record of its own;
  • a look-alike of an active family therefore reads revoked: its users sign in again.

The opt-in suite runs the very SQL blocks of MIGRATION.md on MySQL 8.0, MySQL 8.4 and MariaDB 11.4:

  • seeded look-alikes, revoked and active: whole pairs, refresh-only, access-only;
  • the retention floor;
  • strict mode refusing a 66-byte family ID;
  • the conformance suite on the upgraded tables.

Custom stores must compare keys byte for byte: storetest.RunConformance now checks it.

STORE-I4 — the I3 contract ignored lost commit acknowledgements

The I3 invariant said a failed SaveTokenPair / RotateRefreshToken "persists no token and consumes nothing". That
holds for a call the store refuses. It does not hold for a backend failure reported after the commit was sent: the
outcome is unknown, every write may have been applied, never a part of them.

What changed:

  • I3, the ConsumeAuthorizationCode godoc and the security considerations now say so.
  • A client retrying the refresh presents a consumed token: a reuse, consistent with "Lost responses look like
    theft".
  • TestRotationWithALostCommitAcknowledgement pins this. It uses a SQLite driver whose COMMIT succeeds and then
    reports a lost acknowledgement.

STORE-DL — documented limitations re-checked

Every listed limitation is accurately documented: Redis eviction, Redis < 6.0, the SQLite busy timeout, no expiry job
and index, Redis Cluster, CI coverage.

What changed:

  • One case was missing: a consumed refresh token evicted from Redis, or deleted from SQL before its expires_at, reads
    as an unknown token. Its reuse is refused but no longer detected, and its family is not revoked. Added to the
    Redis and SQL package docs and to "Fail closed".
  • The statements about CI coverage now name the opt-in suites.

Tests

  • New private test-only module internal/interop/sqlstores, with compose.yaml pinning postgres:16,
    mysql:8.0, mysql:8.4 and mariadb:11.4 by digest, and opt-in SQLSTORES_*_DSN variables:
    • TestMigrateConcurrently: the 3 stores at once, 5 × 12 instances, on SQLite (also in CI), PostgreSQL (default
      and SERIALIZABLE default isolation), MySQL 8.0, MySQL 8.4 and MariaDB.
    • TestMigrateWaitsForItsLock: the documented key holds Migrate back, keys are distinct, and a Migrate that
      ignores its context fails instead of hanging.
    • TestMigrateFailureCreatesNothing: no table left, connection released, no "idle in transaction (aborted)".
    • TestOAuth2Conformance on every backend.
    • TestOAuth2MySQLUpgrade.
  • In-module tests (CI):
    • lock statements, with keys recomputed from the documented derivation;
    • the locked path on SQLite through the generated MockDialect: lock first, rollback, commit and begin failures,
      connection released after a failure;
    • per-statement failures;
    • TestSchemaPerDialect, TestDialectKeyType;
    • KeysAreComparedExactly: case, accent, Unicode form, ß/ss and trailing-space family IDs, plus case and
      trailing-space token and code hashes, on memory, SQLite and miniredis.
  • Red phase: logged per finding (STORE-04: 23505, lock not honoured, earlier tables left behind; STORE-I1:
    KeysAreComparedExactly on MySQL/MariaDB, missing upgrade section).
  • Mutation checks: 38 mutants of mine plus the code reviewer's 43, run with go1.27.2 and no toolchain
    override. A mutant counts as killed only by a failing test.
    • Every non-equivalent mutant is killed. That includes the reviewer's 10 survivors and 1 timeout: a missing
      deferred Rollback, a context-ignoring Migrate, PAD SPACE hash columns, trimmed hashes, either half of the
      INSERT's UNION, the retention floor, strict mode.
    • Equivalent, justified: utf8mb4_0900_bin keys (NO PAD, byte-exact).
    • Not equivalent, and killed: running SQLite on the transactional Migrate path. With one table already
      present, 300 of 320 concurrent calls fail with SQLITE_BUSY / SQLITE_BUSY_SNAPSHOT (0 of 320 on the
      shipped per-statement path); the lock pin test catches it.

Checks

  • Gate on Go 1.27.2: make generate sync build test lint check-modules vuln. All pass: lint 0 issues,
    check-modules ok, govulncheck no finding.
  • Opt-in suite on PostgreSQL 16.15, MySQL 8.0.46, MySQL 8.4.11 and MariaDB 11.4.13: 314 PASS, 0 FAIL,
    0 SKIP.
Package Coverage
oauth2/store/sql 97.6 %
oidc/client/store/sql 98.1 %
saml/sp/store/sql 100 %
oauth2/storetest 98.7 %
oauth2 99.2 %
oauth2/storage/memory 100 %
oauth2/store/redis (doc only) 94.7 % (unchanged code)
internal/interop/sqlstores tests only

Every new function is at 100 %.

Follow-ups (out of scope)

  • Run the real-backend suites in CI: a follow-up ci: PR, as decided.
  • CLAUDE.md's dependency paragraph names the SQL drivers as test dependencies of internal/interop/saml only (also
    used by oidcstack and sqlstores). The CLAUDE.md change in this PR is limited to the SAML text and the new module's
    row.

Notes for the maintainer

  • Commit fe3906b (CLAUDE.md: SAML section and the internal/interop/sqlstores row) is the orchestrator's, as
    approved.
  • To run the opt-in suite:
    export SQLSTORES_DB_PASSWORD="$(openssl rand -hex 16)"
    docker compose -f internal/interop/sqlstores/compose.yaml up -d --wait
    then set the SQLSTORES_*_DSN variables listed in the compose header, and run
    go test -race -count=1 ./... in internal/interop/sqlstores.

Every instance of a deployment typically calls Migrate at boot. On
PostgreSQL, concurrent CREATE TABLE IF NOT EXISTS of one table all find it
missing, and the losers then fail on the unique index of the catalog's row
types ("duplicate key value violates unique constraint
pg_type_typname_nsp_index", SQLSTATE 23505): 90 of 120 calls failed in a
race of 12 instances over 10 rounds. The godoc promised "safe to call
repeatedly".

The three SQL stores (oauth2/store/sql, oidc/client/store/sql,
saml/sp/store/sql) give their sealed Dialect an unexported
migrationLock(). On PostgreSQL, Migrate runs in one transaction that first
takes the transaction-level advisory lock pg_advisory_xact_lock(<key>):
- the key is the first eight bytes of SHA-256("<module path>.Migrate");
- it is distinct per module, since advisory locks are per database and
  the stores may share one, and it is documented;
- the lock is released by COMMIT or ROLLBACK, whatever becomes of the
  pooled connection, PgBouncer transaction pooling included.
Concurrent calls run one after the other. A waiting call honours its
context: the driver cancels the server-side wait. A failed call creates
nothing, since PostgreSQL DDL is transactional.

MySQL and MariaDB serialize CREATE TABLE on the metadata lock of the table
name; SQLite does it with its single writer and re-prepared statements,
given the busy timeout it requires. Both keep their per-statement path:
the same race showed 0 failures on MySQL 8.0, MySQL 8.4 and MariaDB 11.4.

A new private, test-only module, internal/interop/sqlstores, runs Migrate
of the three stores together:
- on SQLite, in CI;
- opt-in through its compose.yaml, on PostgreSQL 16 (server default and
  SERIALIZABLE default isolation), MySQL 8.0, MySQL 8.4 and MariaDB 11.4.
It also checks that Migrate waits for its documented lock and leaves no
table behind when the DDL fails. In-module tests drive the locked path on
SQLite through the generated MockDialect: lock first, rollback on a
failing statement, commit and begin failures.

Audit: STORE-04.
MySQL and MariaDB compare text under the collation of its column:
- the defaults (utf8mb4_0900_ai_ci on MySQL 8, utf8mb4_uca1400_ai_ci on
  MariaDB 11) ignore letter case and accents, and take canonically
  equivalent forms and ß / ss for one character;
- MariaDB's, like every PAD SPACE collation (utf8mb4_bin and ascii_bin
  included), also ignore trailing spaces.

The SQL store kept its keys (token-family IDs, code and token hashes) in
VARCHAR columns, so two families whose IDs differed only that way were
one. Revoking one revoked the other, and a pair issued into one joined the
other's record: this breaks the family isolation (I7) of oauth2.Storage.
The package documentation claimed text was "compared exactly". The
library's own family IDs (random or derived base64url) and hashes
(lowercase hex) never collide this way; custom family IDs can.

Schema(MySQL) now declares the six key columns VARBINARY, which MySQL and
MariaDB compare byte for byte (MySQL 8.0 §13.3.3, §12.8.5). A new
unexported keyType dialect method gives VARCHAR on PostgreSQL
(deterministic default collation) and SQLite (BINARY collation): their
DDL is unchanged, and Schema(nil) keeps the portable DDL. utf8mb4_bin would
still merge trailing spaces, and utf8mb4_0900_bin is missing on older
MariaDB.

Migrate stays create-only. MIGRATION.md gives the MySQL upgrade:
- preflight queries: tokens merged into a look-alike's record, and key
  lengths;
- the ALTERs, in strict mode;
- an INSERT that records revoked every family left without a record of its
  own, so that no look-alike of a revoked family is revived by a later
  issuance (I5, I8);
- an optional conversion of the codes' table.
The opt-in suite runs those very statements, extracted from MIGRATION.md,
on MySQL 8.0, MySQL 8.4 and MariaDB 11.4, then the conformance suite on the
upgraded tables.

storetest gains KeysAreComparedExactly, which fails on MySQL with the
former DDL:
- look-alike family IDs (case, accent, Unicode form, ß / ss, trailing
  space) stay isolated;
- case-variant token and code hashes stay distinct.
The OIDC and SAML stores, whose keys are validated lowercase hex, document
why they need no binary collation.

Audit: STORE-I1.
The I3 invariant of oauth2.Storage said that a failed SaveTokenPair or
RotateRefreshToken "persists no token and consumes nothing". That is true
of a call the store refuses. It is not true of a backend failure reported
after the commit was sent: a dropped connection, or a context ending while
PostgreSQL commits, leaves the outcome unknown. All the writes may have
been applied, never a part of them; the audit measured 59 such outcomes
under tight deadlines. I3, the Atomicity bullet of
docs/security-considerations.md and the ConsumeAuthorizationCode godoc now
say so. A client retrying the refresh then presents a consumed token: a
reuse, which revokes the family, as "Lost responses look like theft"
already states. A SQL store test pins it with a driver that commits, then
reports a lost acknowledgement.

The documented limitations of the stores were re-checked; all hold. One
case was missing: a consumed refresh token whose Redis key is evicted, or
whose SQL row is deleted before its expires_at, reads as an unknown token
when presented again. Its reuse is refused but no longer detected, and its
family is not revoked. The Redis and SQL package documentation and the
"Fail closed" bullet now say so. The statements about what CI exercises
now name the opt-in suites that run the SQL stores on PostgreSQL, MySQL
and MariaDB.

Audit: STORE-I4, STORE-DL.
scripts/release-check.sh stops with "jq is required" when jq is missing
(it reads `go mod edit -json` with it), but docs/releasing.md did not list
it next to git and go as a prerequisite of `make release-check`. GitHub
runners have it; a maintainer checking a tag locally may not.

Audit: CI-02.
The MySQL upgrade notes said every _bin collation ignores trailing
spaces: utf8mb4_0900_bin does not, it is NO PAD. The collations that do
are the PAD SPACE ones, utf8mb4_bin included. The preflight also said the
store refuses every over-long key since #82. That is true of family IDs
(64 bytes); the store does not bound hashes, and its own are 64
characters.

Audit: STORE-I1.
On MySQL and SQLite, Migrate runs its statements one by one. Its error
branch was no longer reached once the faulty-driver test of the SAML
store went through the PostgreSQL path, and no test of the OAuth2 store
reached it. Both stores now fail a Migrate on that path and check the
wrapped error.

Audit: STORE-04.
…ests

The SAML section still described the pipeline before the audit fixes
(consumption after verification), omitted the required
Config.TransactionKeys and gave stale corpus facts. It now gives the
validation order the SP enforces (transaction lookup with its integrity
MAC, status and InResponseTo, atomic consumption, then verification and
the processing rules), the required server-held transaction keys, the
corpus size (390 entries over 360 responses) and the toolchain condition
for a byte-identical regeneration.

The module table lists the new private test module
internal/interop/sqlstores, which runs the three SQL stores on real
servers.

Audit: SAML-01, STORE-02, STORE-04.
Removing the deferred Rollback of the locked Migrate survived every
test. The tests passed t.Context(), which the testing package cancels
before cleanup, so database/sql rolled the transaction back for them.
A Migrate failing under a context that never ends (the usual boot
call) would keep its connection checked out, "idle in transaction
(aborted)" on PostgreSQL, one more at every retry. Each store now has an
in-module test: a one-connection pool, context.Background(), the
connection back after the failure, and the next Migrate going through.
The PostgreSQL failure test checks the same, plus no aborted backend.

A Migrate that ignored its context made the lock test hang until go
test timed out. The waits now run in a goroutine bounded by a timer,
so such a regression fails the test.

Audit: STORE-04.
The upgrade test could not tell the documented orphan INSERT from one
missing either half of its UNION:
- without the refresh half, a revoked look-alike whose access token was
  gone is revived by a new issuance;
- without the access half, an access-only look-alike is revived.
It also missed the retention floor and the strict sql_mode.

The test now seeds both shapes and checks the inserted records
(revoked, kept for FamilyRevocationRetention at least). On a
connection whose own sql_mode is lax, it checks that a 66-byte family
ID stops the ALTER instead of being truncated. The conformance case
also tells apart token and code hashes that differ by a trailing
space.

MIGRATION.md: the instruction to stop token writes named the optional
step 3. It now targets step 2, between its families ALTER and its
INSERT, and only when the preflight counted merged tokens. It also
says that comparisons become exact table by table, and that the hex
display is the interactive mysql client's default.

Audit: STORE-I1.
@euskadi31
euskadi31 merged commit 3ad927d into master Oct 13, 2026
2 checks passed
@euskadi31
euskadi31 deleted the fix/audit-sql-stores branch October 13, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant