Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/plugin-auth-better-auth-17-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/platform-objects': patch
---

fix(auth): align the better-auth family on 1.7.0-rc.1, implement the new adapter methods, and add the new sys_jwks columns (#2974)

Remediating GHSA-p2fr-6hmx-4528 (`@better-auth/oauth-provider`) requires the
1.7 plugin line, which imports `CLIENT_ASSERTION_TYPE` and other symbols that
only exist in `@better-auth/core` 1.7.x — so the whole better-auth family is
pinned to `1.7.0-rc.1` together (mixing a 1.7 plugin with 1.6.23 core 500s on
sign-in). better-auth 1.7 also extends its `CustomAdapter` contract with two
new methods, which the ObjectQL adapter now implements:

- `consumeOne` — atomic single-row consume (find the guarded row, delete it,
return it), used by better-auth for single-use credential consumption
(e.g. verification tokens on the sign-in path).
- `incrementOne` — guarded counter mutation (`field = field + delta` per
`increment` entry plus any absolute `set` values), returning the updated row
or `null` when the guard matches nothing.

Both are find-then-write mirrors of the existing `delete` / `update` methods
(ObjectQL exposes no native atomic primitive) and honour the same core/plugin
field-name bridging.

better-auth 1.7 also extends its `jwks` model with two new optional columns,
`alg` (signing algorithm, e.g. `EdDSA`) and `crv` (curve, e.g. `Ed25519`), and
writes them when minting signing keys. The `sys_jwks` platform object gains the
matching fields — without them every JWKS write failed (`table sys_jwks has no
column named alg`), 500ing token signing and breaking session validation
(sign-in succeeded but every authenticated request 401'd).
26 changes: 21 additions & 5 deletions .github/workflows/validate-deps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
- '.changeset/config.json'
- 'pnpm-workspace.yaml'
- 'scripts/check-changeset-fixed.mjs'
# Re-run when the workflow itself changes, so edits to these gates are
# exercised on the PR that introduces them.
- '.github/workflows/validate-deps.yml'
schedule:
# Run weekly on Monday at 03:00 UTC
- cron: '0 3 * * 1'
Expand Down Expand Up @@ -56,11 +59,24 @@ jobs:
- name: Verify Changesets "fixed" group covers every public package
run: node scripts/check-changeset-fixed.mjs

- name: Check for dependency issues
run: |
# Fail the workflow if high-severity vulnerabilities are found
# This enforces security compliance before merging
pnpm audit --audit-level=high
# Fail the workflow if known vulnerabilities are found — enforces
# security compliance before merging.
#
# Previously this ran `pnpm audit --audit-level=high`, but npm retired the
# audit endpoint (HTTP 410, "This endpoint is being retired. Use the bulk
# advisory endpoint instead.") and pnpm has not migrated, so `pnpm audit`
# now fails unconditionally on every branch (see issue #2974). OSV-Scanner
# reads pnpm-lock.yaml directly against the OSV database and exits non-zero
# when any advisory matches, restoring a working blocking gate.
#
# Note: OSV-Scanner blocks on any severity, not just high/critical. To
# accept a specific advisory, add an osv-scanner.toml `[[IgnoredVulns]]`
# entry rather than lowering the gate.
- name: Audit dependencies for known vulnerabilities (OSV-Scanner)
uses: google/osv-scanner-action/osv-scanner-action@8dc09193bb540e09b23da07ad7e30bd33bf87018 # v2.3.8
with:
scan-args: |-
--lockfile=pnpm-lock.yaml

- name: List outdated packages
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
Expand Down
3 changes: 2 additions & 1 deletion docs/HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ Run the production hardening checklist before each release:
- [ ] `revokeSessionsOnPasswordReset: true`
- [ ] CORS origin list is explicit, not `*`
- [ ] `enforceProjectMembership: true` on scoped routes
- [ ] `pnpm audit` clean (no `high`/`critical`)
- [ ] `osv-scanner --lockfile=pnpm-lock.yaml` clean (npm retired the
`pnpm audit` endpoint — see issue #2974; CI enforces this via OSV-Scanner)
- [ ] `pnpm outdated` reviewed
- [ ] Backups: restore tested in the last 30 days
- [ ] Audit log: `sys_audit_log` is append-only at the DB level
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@types/node": "^26.1.1",
"@typescript-eslint/parser": "^8.63.0",
"eslint": "^10.7.0",
"svelte": "^5.55.7",
"tsup": "^8.5.1",
"tsx": "^4.23.0",
"turbo": "^2.10.4",
Expand Down
17 changes: 17 additions & 0 deletions packages/platform-objects/src/identity/sys-jwks.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ export const SysJwks = ObjectSchema.create({
description: 'JSON-serialized JWK private key (encrypted at rest)',
}),

// better-auth 1.7 records the key's signing algorithm and (for EdDSA/EC
// keys) its curve alongside the key material, so tokens can advertise the
// correct `alg`/`crv` in the JWKS response. Both are optional — legacy rows
// minted before 1.7 leave them null and better-auth falls back to the
// configured `keyPairConfig.alg` (default `EdDSA`).
alg: Field.text({
label: 'Algorithm',
required: false,
description: 'JWK signing algorithm, e.g. `EdDSA` (better-auth 1.7+)',
}),

crv: Field.text({
label: 'Curve',
required: false,
description: 'JWK curve for EdDSA/EC keys, e.g. `Ed25519` (better-auth 1.7+)',
}),

created_at: Field.datetime({
label: 'Created At',
required: true,
Expand Down
49 changes: 49 additions & 0 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,55 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
}
return records.length;
},

// Atomic single-row consume (better-auth 1.7+). ObjectQL has no native
// `DELETE ... RETURNING`, so we find the single guarded row, delete it,
// and return the consumed record — a find-then-write mirror of `delete`.
consumeOne: async <T>(
{ model, where }: { model: string; where: CleanedWhere[] },
): Promise<T | null> => {
const objectName = resolveProtocolName(model);
const bridged = objectName !== model;
const filter = convertWhere(bridged ? remapWhere(where) : where);

const record = await dataEngine.findOne(objectName, { where: filter });
if (!record) return null;
await dataEngine.delete(objectName, { where: { id: record.id } });
const norm = normaliseLegacyDates(model, record);
return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T;
},

// Guarded counter mutation (better-auth 1.7+). ObjectQL has no native
// `SET n = n + $delta ... RETURNING`, so we read the guarded row, apply
// `field = field + delta` for each `increment` entry (negative deltas
// decrement) plus any absolute `set` values, and write it back. `where`
// is both selector and guard, so a non-matching guard returns null.
incrementOne: async <T>(
{ model, where, increment, set }: {
model: string; where: CleanedWhere[];
increment: Record<string, number>; set?: Record<string, unknown>;
},
): Promise<T | null> => {
const objectName = resolveProtocolName(model);
const bridged = objectName !== model;
const filter = convertWhere(bridged ? remapWhere(where) : where);

const record = await dataEngine.findOne(objectName, { where: filter });
if (!record) return null;

const patch: Record<string, any> = {};
for (const [field, delta] of Object.entries(increment)) {
const col = bridged ? camelToSnake(field) : field;
const current = Number((record as Record<string, any>)[col] ?? 0);
patch[col] = current + delta;
}
if (set) Object.assign(patch, bridged ? remapKeys(set, camelToSnake) : set);

const result = await dataEngine.update(objectName, { ...patch, id: record.id });
if (!result) return null;
const norm = normaliseLegacyDates(model, result);
return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T;
},
}),
});
}
Expand Down
Loading