From da9fe88a319c75616c15699106e4c5f54dae174a Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 27 Oct 2026 18:32:49 -0700 Subject: [PATCH 1/2] fix(auth): upgrade Better Auth and preserve issuer-scoped identities --- .changeset/better-auth-refresh.md | 6 + apps/node/agent/package.json | 2 +- apps/server/api/docs/auth-providers.md | 15 +- apps/server/api/package.json | 8 +- .../__tests__/oidc-held-email-signup.test.ts | 4 +- .../api/__tests__/oidc-signin-flow.test.ts | 32 ++++ .../__tests__/backup-cli-subprocess.test.ts | 13 +- apps/server/api/src/db/migrate.ts | 2 + .../0069-auth-account-provider-key.ts | 48 +++++ .../0069-auth-account-provider-key.test.ts | 176 ++++++++++++++++++ .../src/db/repositories/users.repository.ts | 6 +- .../__tests__/provider-policy-service.test.ts | 4 +- .../auth/__tests__/provider-rows.test.ts | 14 +- apps/server/api/src/modules/auth/auth.ts | 7 +- .../api/src/modules/auth/provider-rows.ts | 21 ++- .../api/src/modules/backups/database.ts | 2 +- .../__tests__/pending-expiry-sweep.test.ts | 4 +- apps/server/web/package.json | 4 +- bun.lock | 52 +++--- docs/security.md | 10 +- packages/mcp-core/package.json | 2 +- packages/subshell-protocol/package.json | 2 +- 22 files changed, 365 insertions(+), 69 deletions(-) create mode 100644 .changeset/better-auth-refresh.md create mode 100644 apps/server/api/src/db/migrations/0069-auth-account-provider-key.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0069-auth-account-provider-key.test.ts diff --git a/.changeset/better-auth-refresh.md b/.changeset/better-auth-refresh.md new file mode 100644 index 000000000..979859450 --- /dev/null +++ b/.changeset/better-auth-refresh.md @@ -0,0 +1,6 @@ +--- +"@internal/server": patch +"@internal/node": patch +--- + +Update Better Auth and its API-key and passkey plugins to 1.7.14, and JOSE to 6.2.13. The server migrates existing authentication accounts to Better Auth's provider-based identity keys while preserving issuer separation, credentials and sessions. Downgrading the server requires restoring a backup made before this upgrade. diff --git a/apps/node/agent/package.json b/apps/node/agent/package.json index 811fa4fac..093bdade0 100644 --- a/apps/node/agent/package.json +++ b/apps/node/agent/package.json @@ -33,7 +33,7 @@ "@internal/subshell-protocol": "workspace:*", "@subshell-ai/plugin-api": "workspace:*", "elysia": "1.4.29", - "jose": "6.2.9", + "jose": "6.2.13", "loglayer": "9.4.0" }, "devDependencies": { diff --git a/apps/server/api/docs/auth-providers.md b/apps/server/api/docs/auth-providers.md index e846ace2b..a0b56bfa8 100644 --- a/apps/server/api/docs/auth-providers.md +++ b/apps/server/api/docs/auth-providers.md @@ -8,8 +8,8 @@ the legacy dynamic window; migration 0038 adds `user_meta.approval_state`). when the better-auth singleton is constructed, and `invalidateAuth()` (a sibling of `resetAuthForTests()`) drops the memo so every successful `/api/auth-providers` write takes effect without a restart. Providers are -built FAIL-TOLERANT: each config entry pins `accountIssuer` to the stored -issuer and carries the endpoints discovery resolved AT SAVE TIME, so a rebuild +built FAIL-TOLERANT: each config entry carries the endpoints discovery resolved +AT SAVE TIME, so a rebuild never re-fetches a dead issuer, `genericOAuth` stays out of `AUTH_OPTIONS` (a throwing provider must not crash boot's migration path), and a throwing rebuild serves the last-known-good instance. **Provider policy is one global @@ -52,3 +52,14 @@ changes need a server restart; provider overrides invalidate auth immediately. `genericOAuth.redirectURI` remains a static string, never derived from a request. The Google preset sends `prompt=select_account`. Flow-matrix case 14 pins the explicit override through the emitted authorization URL. + +Better Auth 1.7.14 identifies accounts by `(providerId, accountId)`, not issuer +([upstream migration guide](https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-keeps-the-provider-key)). +Migration 0069 removes the required account issuer column and its index from +1.7.1 databases before auth starts. It refuses duplicate provider keys rather +than merging identities. OAuth account subjects become a JSON pair of the old +issuer and subject, matching `accountSubject` in `provider-rows.ts`. This keeps +a replacement issuer from claiming an old identity when a provider ID is reused. +Credential subjects, account row IDs, passwords, tokens and sessions stay intact; +downgrade requires a pre-upgrade backup. Providers sharing an issuer now keep +separate account rows, with the same verified-email linking policy. diff --git a/apps/server/api/package.json b/apps/server/api/package.json index 42c61a83e..6d4b1e67e 100644 --- a/apps/server/api/package.json +++ b/apps/server/api/package.json @@ -37,8 +37,8 @@ "verify-types": "tsc --noEmit" }, "dependencies": { - "@better-auth/api-key": "1.7.1", - "@better-auth/passkey": "1.7.1", + "@better-auth/api-key": "1.7.14", + "@better-auth/passkey": "1.7.14", "@clack/prompts": "1.8.1", "@dotenvx/dotenvx": "2.21.0", "@elysiajs/cors": "1.4.2", @@ -49,13 +49,13 @@ "@internal/subshell-protocol": "workspace:*", "@loglayer/elysia": "2.4.0", "@loglayer/transport-simple-pretty-terminal": "3.3.0", - "better-auth": "1.7.1", + "better-auth": "1.7.14", "dotenv": "17.4.2", "elysia": "1.4.29", "env-var": "7.5.0", "expo-server-sdk": "7.2.0", "file-type": "22.0.2", - "jose": "6.2.9", + "jose": "6.2.13", "kysely": "0.29.5", "kysely-bun-sqlite-dialect": "1.0.0", "loglayer": "9.4.0", diff --git a/apps/server/api/src/api/__tests__/oidc-held-email-signup.test.ts b/apps/server/api/src/api/__tests__/oidc-held-email-signup.test.ts index 4c7359f36..d973bb73f 100644 --- a/apps/server/api/src/api/__tests__/oidc-held-email-signup.test.ts +++ b/apps/server/api/src/api/__tests__/oidc-held-email-signup.test.ts @@ -187,8 +187,8 @@ async function setEmailRegistration(stored: number | null): Promise { async function insertProviderAccount(userId: string, providerId: string): Promise { const now = new Date().toISOString(); await sql` - INSERT INTO account (id, issuer, accountId, providerId, userId, createdAt, updatedAt) - VALUES (${crypto.randomUUID()}, ${idp.url}, ${`sub-${providerId}-${userId}`}, ${providerId}, ${userId}, ${now}, ${now}) + INSERT INTO account (id, accountId, providerId, userId, createdAt, updatedAt) + VALUES (${crypto.randomUUID()}, ${`sub-${providerId}-${userId}`}, ${providerId}, ${userId}, ${now}, ${now}) `.execute(db); } diff --git a/apps/server/api/src/api/__tests__/oidc-signin-flow.test.ts b/apps/server/api/src/api/__tests__/oidc-signin-flow.test.ts index 385f01458..d3698962d 100644 --- a/apps/server/api/src/api/__tests__/oidc-signin-flow.test.ts +++ b/apps/server/api/src/api/__tests__/oidc-signin-flow.test.ts @@ -342,6 +342,38 @@ describe("OIDC sign-in matrix (spec §4/§5/§6/§7, fake issuer)", () => { await purgeByEmail(email); }); + it("changing or recreating a provider cannot bind a new issuer's subject to the old user", async () => { + const provider = await mkProvider(); + const originalEmail = emailN("issuer-original"); + const replacementEmail = emailN("issuer-replacement"); + const subject = "same-subject-at-two-issuers"; + expect((await runFlow(provider, profileFor(originalEmail, { id: subject }))).kind).toBe("session"); + const originalId = await userIdFor(originalEmail); + expect(originalId).toBeDefined(); + const originalSessions = await sessionCount(originalId ?? ""); + + // The endpoints stay on the test issuer; changing the stored authority + // models the same subject being issued by another configured authority. + await providers.update(provider, { issuer: "https://replacement.example.test" }); + invalidateAuth(); + expect((await runFlow(provider, profileFor(replacementEmail, { id: subject }))).kind).toBe("session"); + const replacementId = await userIdFor(replacementEmail); + expect(replacementId).toBeDefined(); + expect(replacementId).not.toBe(originalId); + expect(await sessionCount(originalId ?? "")).toBe(originalSessions); + + const stored = await providers.getById(provider); + if (!stored) throw new Error("fixture provider disappeared"); + await providers.remove(provider); + await providers.create({ ...stored, issuer: idp.url }); + invalidateAuth(); + expect((await runFlow(provider, profileFor(originalEmail, { id: subject }))).kind).toBe("session"); + expect(await sessionCount(originalId ?? "")).toBe(originalSessions + 1); + expect(await accountProviders(originalId ?? "")).toEqual([provider]); + await purgeByEmail(originalEmail); + await purgeByEmail(replacementEmail); + }); + // Case 2 — §5's inversion proof. The local row starts at emailVerified=0, // which is what every account this instance writes carries; with better-auth's // default `requireLocalEmailVerified` the implicit link would die here with diff --git a/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts index ed7a88997..008de7432 100644 --- a/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,17 +392,17 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0047..0068 together: these migrations are the tail of the + // WHY 0047..0069 together: these migrations are the tail of the // registered ledger, so the prefix runs contiguously through 0046 and boot // re-applies the whole tail. Advancing LATEST_BACKUP_MIGRATION means the strip // set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE presets DROP COLUMN enabled; ALTER TABLE nodes DROP COLUMN virtual_display_providers; ALTER TABLE presets DROP COLUMN screen_config; ALTER TABLE nodes DROP COLUMN virtual_display_provider; ALTER TABLE subshells DROP COLUMN screen_config; ALTER TABLE nodes DROP COLUMN virtual_display_enabled; DROP TABLE subshell_services; ALTER TABLE subshells DROP COLUMN parent_subshell_id; ALTER TABLE nodes DROP COLUMN system_specs; ALTER TABLE presets DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN recommended_starting_dirs; ALTER TABLE workspaces ADD COLUMN draft integer NOT NULL DEFAULT 0; CREATE UNIQUE INDEX idx_workspaces_user_name ON workspaces (user_id, name) WHERE draft = 0; DROP INDEX idx_workspace_panes_workspace_subshell; DROP TABLE preset_shares; DROP TABLE ssh_preset_shares; ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN preview_enabled_at; ALTER TABLE nodes DROP COLUMN web_previews_enabled; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE presets DROP COLUMN enabled; ALTER TABLE nodes DROP COLUMN virtual_display_providers; ALTER TABLE presets DROP COLUMN screen_config; ALTER TABLE nodes DROP COLUMN virtual_display_provider; ALTER TABLE subshells DROP COLUMN screen_config; ALTER TABLE nodes DROP COLUMN virtual_display_enabled; DROP TABLE subshell_services; ALTER TABLE subshells DROP COLUMN parent_subshell_id; ALTER TABLE nodes DROP COLUMN system_specs; ALTER TABLE presets DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN recommended_starting_dirs; ALTER TABLE workspaces ADD COLUMN draft integer NOT NULL DEFAULT 0; CREATE UNIQUE INDEX idx_workspaces_user_name ON workspaces (user_id, name) WHERE draft = 0; DROP INDEX idx_workspace_panes_workspace_subshell; DROP TABLE preset_shares; DROP TABLE ssh_preset_shares; ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN preview_enabled_at; ALTER TABLE nodes DROP COLUMN web_previews_enabled; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key')", ) .all(), ).toEqual([]); @@ -430,7 +430,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key') ORDER BY name", ) .all(), ).toEqual([ @@ -456,6 +456,7 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0066-node-display-provider" }, { name: "0067-display-provider-choices" }, { name: "0068-preset-enabled" }, + { name: "0069-auth-account-provider-key" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( @@ -521,7 +522,7 @@ for (const container of [false, true]) .run(adminId, "Restore admin", "restore@example.test", now, now); database .query( - "INSERT INTO account(id,issuer,accountId,providerId,userId,password,createdAt,updatedAt) VALUES (?,'local:credential',?,'credential',?,?,?,?)", + "INSERT INTO account(id,accountId,providerId,userId,password,createdAt,updatedAt) VALUES (?,?,'credential',?,?,?,?)", ) .run(adminId, adminId, adminId, await hashPassword("headless-restore-password"), now, now); database.query("INSERT INTO user_meta(user_id,role) VALUES (?,'admin')").run(adminId); diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 48a7a224b..098341f30 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -62,6 +62,7 @@ import * as nodeSystemSpecsMigration from "@/db/migrations/0061-node-system-spec import * as nodePreviewEnabledMigration from "@/db/migrations/0062-node-preview-enabled.js"; import * as subshellServicesMigration from "@/db/migrations/0063-subshell-services.js"; import * as presetEnabledMigration from "@/db/migrations/0068-preset-enabled.js"; +import * as authAccountProviderKeyMigration from "@/db/migrations/0069-auth-account-provider-key.js"; import * as dropSshGrantsMigration from "./migrations/0053-drop-ssh-grants.js"; import * as presetSharingMigration from "./migrations/0057-preset-sharing.js"; import * as nodeVirtualDisplayEnabledMigration from "./migrations/0064-node-virtual-display-enabled.js"; @@ -191,6 +192,7 @@ export async function runMigrations(): Promise { "0066-node-display-provider": nodeDisplayProviderMigration, "0067-display-provider-choices": displayProviderChoicesMigration, "0068-preset-enabled": presetEnabledMigration, + "0069-auth-account-provider-key": authAccountProviderKeyMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0069-auth-account-provider-key.ts b/apps/server/api/src/db/migrations/0069-auth-account-provider-key.ts new file mode 100644 index 000000000..06e260882 --- /dev/null +++ b/apps/server/api/src/db/migrations/0069-auth-account-provider-key.ts @@ -0,0 +1,48 @@ +import { type Kysely, sql } from "kysely"; + +interface AccountKey { + id: string; + providerId: string; + accountId: string; + issuer: string | null; +} + +/** Better Auth 1.7.3+ no longer writes the required issuer added by 1.7.1. */ +export async function up(db: Kysely): Promise { + const columns = await sql<{ name: string }>`PRAGMA table_info(account)`.execute(db); + if (columns.rows.length === 0) return; + const hasIssuer = columns.rows.some((column) => column.name === "issuer"); + const { rows } = hasIssuer + ? await sql`SELECT id, "providerId", "accountId", issuer FROM account`.execute(db) + : await sql`SELECT id, "providerId", "accountId", NULL AS issuer FROM account`.execute(db); + const keys = new Set(); + const updates: { id: string; accountId: string }[] = []; + for (const row of rows) { + // Freeze this encoding here: provider-rows.ts must keep producing the + // same key. Credential IDs are user IDs and must remain unchanged. + const accountId = + hasIssuer && row.providerId !== "credential" ? JSON.stringify([row.issuer, row.accountId]) : row.accountId; + const key = JSON.stringify([row.providerId, accountId]); + if (keys.has(key)) { + throw new Error( + "Duplicate authentication account keys prevent this upgrade. Resolve duplicate providerId/accountId pairs before restarting.", + ); + } + keys.add(key); + if (accountId !== row.accountId) updates.push({ id: row.id, accountId }); + } + if (!hasIssuer) return; + + // Validate every key before writing. Drop the old unique index before + // encoding: a raw subject could equal another row's encoded subject. + await sql`DROP INDEX IF EXISTS account_issuer_accountId_uidx`.execute(db); + for (const row of updates) { + await sql`UPDATE account SET "accountId" = ${row.accountId} WHERE id = ${row.id}`.execute(db); + } + await sql`ALTER TABLE account DROP COLUMN issuer`.execute(db); +} + +/** New accounts have no issuer to restore; a downgrade needs the old snapshot. */ +export async function down(): Promise { + throw new Error("The authentication account migration cannot be reversed; restore a backup to downgrade."); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0069-auth-account-provider-key.test.ts b/apps/server/api/src/db/migrations/__tests__/0069-auth-account-provider-key.test.ts new file mode 100644 index 000000000..02e031e60 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0069-auth-account-provider-key.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { betterAuth } from "better-auth"; +import { hashPassword } from "better-auth/crypto"; +import { getMigrations } from "better-auth/db/migration"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { up } from "@/db/migrations/0069-auth-account-provider-key.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; +import { toGenericOAuthConfig } from "@/modules/auth/provider-rows.js"; + +const handles: Kysely[] = []; +afterEach(async () => { + for (const db of handles.splice(0)) await db.destroy(); +}); + +async function fixture(legacy: boolean) { + const sqlite = openSqliteDatabase(":memory:"); + const db = new Kysely({ dialect: new BunSqliteDialect({ database: sqlite }) }); + handles.push(db); + const options = { + database: sqlite, + baseURL: "http://localhost:5173", + secret: "migration-test-secret-at-least-thirty-two-characters", + emailAndPassword: { enabled: true }, + }; + await (await getMigrations(options)).runMigrations(); + if (legacy) { + // Reproduce the 1.7.1 constraint on an otherwise unchanged account schema. + sqlite.exec("ALTER TABLE account ADD COLUMN issuer TEXT NOT NULL"); + sqlite.exec('CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, "accountId")'); + } + return { db, sqlite, options }; +} + +describe("Better Auth account-key upgrade", () => { + it("preserves existing password sign-in and sessions, and allows new accounts", async () => { + const { db, sqlite, options } = await fixture(true); + const now = new Date().toISOString(); + sqlite + .query("INSERT INTO user (id,name,email,emailVerified,createdAt,updatedAt) VALUES (?,?,?,0,?,?)") + .run("u", "Existing", "existing@example.test", now, now); + sqlite + .query( + "INSERT INTO account (id,issuer,accountId,providerId,userId,password,createdAt,updatedAt) VALUES (?,?,?,?,?,?,?,?)", + ) + .run("a", "local:credential", "u", "credential", "u", await hashPassword("existing-password"), now, now); + sqlite + .query("INSERT INTO session (id,token,userId,expiresAt,createdAt,updatedAt) VALUES (?,?,?,?,?,?)") + .run("s", "existing-token", "u", new Date(Date.now() + 86400000).toISOString(), now, now); + await db.transaction().execute(up); + await up(db); + expect(sqlite.query("SELECT token FROM session").get()).toEqual({ token: "existing-token" }); + expect(sqlite.query("SELECT id,accountId,providerId,userId FROM account").get()).toEqual({ + id: "a", + accountId: "u", + providerId: "credential", + userId: "u", + }); + const auth = betterAuth(options); + const signIn = await auth.api.signInEmail({ + body: { email: "existing@example.test", password: "existing-password" }, + }); + expect(signIn.user.id).toBe("u"); + const signUp = await auth.api.signUpEmail({ + body: { name: "New", email: "new@example.test", password: "new-password" }, + }); + expect(signUp.user.email).toBe("new@example.test"); + expect(sqlite.query("PRAGMA foreign_key_check").all()).toEqual([]); + }); + + it("refuses duplicate provider keys without changing either identity", async () => { + const { db, sqlite } = await fixture(true); + const now = new Date().toISOString(); + sqlite.exec("DROP INDEX account_issuer_accountId_uidx"); + for (const id of ["one", "two"]) { + sqlite + .query("INSERT INTO user (id,name,email,emailVerified,createdAt,updatedAt) VALUES (?,?,?,0,?,?)") + .run(id, id, `${id}@example.test`, now, now); + sqlite + .query("INSERT INTO account (id,issuer,accountId,providerId,userId,createdAt,updatedAt) VALUES (?,?,?,?,?,?,?)") + .run(id, "https://same.test", "shared", "oidc", id, now, now); + } + await expect(db.transaction().execute(up)).rejects.toThrow("Duplicate authentication account keys"); + expect(sqlite.query("SELECT issuer FROM account ORDER BY id").all()).toEqual([ + { issuer: "https://same.test" }, + { issuer: "https://same.test" }, + ]); + }); + + it("preserves OAuth ownership and tokens while separating equal subjects at different issuers", async () => { + const { db, sqlite, options } = await fixture(true); + const now = new Date().toISOString(); + for (const id of ["one", "two", "fallback"]) { + sqlite + .query("INSERT INTO user (id,name,email,emailVerified,createdAt,updatedAt) VALUES (?,?,?,1,?,?)") + .run(id, id, `${id}@example.test`, now, now); + sqlite + .query( + "INSERT INTO account (id,issuer,accountId,providerId,userId,accessToken,refreshToken,createdAt,updatedAt) VALUES (?,?,?,?,?,?,?,?,?)", + ) + .run( + id, + id === "fallback" ? "local:oauth:oidc" : `https://${id}.test`, + "shared", + "oidc", + id, + `access-${id}`, + `refresh-${id}`, + now, + now, + ); + } + await db.transaction().execute(up); + // Use the production resolver to look up each migrated identity through + // Better Auth, so a mismatch between migration and runtime cannot pass. + const context = await betterAuth(options).$context; + for (const id of ["one", "two", "fallback"]) { + const config = toGenericOAuthConfig( + { + id: "oidc", + kind: "oidc", + name: "Fixture", + issuer: id === "fallback" ? null : `https://${id}.test`, + clientId: "fixture", + clientSecret: null, + endpoints: null, + entryOrigins: [], + allowedDomains: [], + signInEnabled: true, + registrationEnabled: true, + requireApproval: false, + }, + options.baseURL, + ); + const accountId = await config.accountSubject?.({ profile: { id: "shared", emailVerified: true }, tokens: {} }); + const owner = await context.internalAdapter.findAccountOwnerByKey({ + providerId: "oidc", + accountId: String(accountId), + }); + expect(owner?.kind).toBe("owned"); + if (owner?.kind !== "owned") throw new Error("migrated account is not owned"); + expect(owner.user.id).toBe(id); + expect(owner.account).toMatchObject({ + id, + userId: id, + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + }); + } + }); + + it("checks duplicate keys even when the legacy issuer column is absent", async () => { + const { db, sqlite } = await fixture(false); + const now = new Date().toISOString(); + sqlite + .query( + "INSERT INTO user (id,name,email,emailVerified,createdAt,updatedAt) VALUES ('u','User','u@example.test',0,?,?)", + ) + .run(now, now); + for (const id of ["one", "two"]) { + sqlite + .query( + "INSERT INTO account (id,accountId,providerId,userId,createdAt,updatedAt) VALUES (?,'shared','oidc','u',?,?)", + ) + .run(id, now, now); + } + await expect(up(db)).rejects.toThrow("Duplicate authentication account keys"); + }); + + it("accepts fresh and already-upgraded databases", async () => { + const { db } = await fixture(false); + await up(db); + await sql`DROP TABLE account`.execute(db); + await up(db); + }); +}); diff --git a/apps/server/api/src/db/repositories/users.repository.ts b/apps/server/api/src/db/repositories/users.repository.ts index 2817f06ce..9cbdd208f 100644 --- a/apps/server/api/src/db/repositories/users.repository.ts +++ b/apps/server/api/src/db/repositories/users.repository.ts @@ -211,15 +211,15 @@ export class UsersRepository extends BaseRepository { async createUser(input: { name: string; email: string; passwordHash: string; role: UserRole }): Promise { const id = crypto.randomUUID(); const now = new Date().toISOString(); - // user id doubles as the credential accountId (issuer local:credential), + // user id doubles as the credential accountId, // matching what better-auth itself writes on sign-up. await sql` INSERT INTO user (id, name, email, emailVerified, image, createdAt, updatedAt) VALUES (${id}, ${input.name}, ${input.email}, 0, NULL, ${now}, ${now}) `.execute(this.db); await sql` - INSERT INTO account (id, issuer, accountId, providerId, userId, password, createdAt, updatedAt) - VALUES (${id}, ${"local:credential"}, ${id}, ${"credential"}, ${id}, ${input.passwordHash}, ${now}, ${now}) + INSERT INTO account (id, accountId, providerId, userId, password, createdAt, updatedAt) + VALUES (${id}, ${id}, ${"credential"}, ${id}, ${input.passwordHash}, ${now}, ${now}) `.execute(this.db); await this.db.insertInto("userMeta").values({ userId: id, role: input.role }).execute(); return id; diff --git a/apps/server/api/src/modules/auth/__tests__/provider-policy-service.test.ts b/apps/server/api/src/modules/auth/__tests__/provider-policy-service.test.ts index b49ad9816..aabb33bda 100644 --- a/apps/server/api/src/modules/auth/__tests__/provider-policy-service.test.ts +++ b/apps/server/api/src/modules/auth/__tests__/provider-policy-service.test.ts @@ -60,8 +60,8 @@ async function makeOidcProvider(id: string, over: Record = {}): async function makeAccount(userId: string, providerId: string): Promise { const id = crypto.randomUUID(); const now = new Date().toISOString(); - await sql`INSERT INTO account (id, issuer, accountId, providerId, userId, createdAt, updatedAt) - VALUES (${id}, ${providerId}, ${id}, ${providerId}, ${userId}, ${now}, ${now})`.execute(db); + await sql`INSERT INTO account (id, accountId, providerId, userId, createdAt, updatedAt) + VALUES (${id}, ${id}, ${providerId}, ${userId}, ${now}, ${now})`.execute(db); createdAccountIds.push(id); return id; } diff --git a/apps/server/api/src/modules/auth/__tests__/provider-rows.test.ts b/apps/server/api/src/modules/auth/__tests__/provider-rows.test.ts index 7c1b86989..c1df94b5a 100644 --- a/apps/server/api/src/modules/auth/__tests__/provider-rows.test.ts +++ b/apps/server/api/src/modules/auth/__tests__/provider-rows.test.ts @@ -33,14 +33,24 @@ const row = (over: Partial): StoredProviderRow => ({ }); describe("toGenericOAuthConfig", () => { - test("explicit endpoints + accountIssuer are carried; discovery is never re-fetched", () => { + test("issuer-scoped subjects preserve numeric IDs and reject missing or invalid subjects", async () => { + const resolve = toGenericOAuthConfig(row({}), "https://sub.acme").accountSubject; + expect(resolve).toBeDefined(); + for (const id of [undefined, null, "", " ", "null", "undefined", Number.NaN, Number.POSITIVE_INFINITY]) { + expect(await resolve?.({ profile: { id, emailVerified: true }, tokens: {} })).toBe(""); + } + expect(await resolve?.({ profile: { id: 0, emailVerified: true }, tokens: {} })).toBe( + JSON.stringify(["https://id.acme", "0"]), + ); + }); + + test("explicit endpoints are carried; discovery is never re-fetched", () => { const cfg = toGenericOAuthConfig(row({}), "https://sub.acme"); expect(cfg).toMatchObject({ providerId: "acme", name: "Acme", clientId: "cid", clientSecret: "sec", - accountIssuer: "https://id.acme", authorizationUrl: "https://id.acme/authorize", tokenUrl: "https://id.acme/token", userInfoUrl: "https://id.acme/userinfo", diff --git a/apps/server/api/src/modules/auth/auth.ts b/apps/server/api/src/modules/auth/auth.ts index dceb2080a..a8754041d 100644 --- a/apps/server/api/src/modules/auth/auth.ts +++ b/apps/server/api/src/modules/auth/auth.ts @@ -68,10 +68,9 @@ export const AUTH_OPTIONS = { // generic "account not linked". The inversion makes the PROVIDER's // verified claim the link defense; that claim's enforcement (provider-policy // + mapProfileToUser) is what pays for it, and docs/security.md §5 - // accounting lands with it (final docs task). 1.7.1 marks the member - // deprecated ("gate becomes unconditional"); upgrading better-auth - // therefore re-arms the default this line removes, and the upgrade is a - // spec-§5 decision, not a dependency bump. + // accounting lands with it. Better Auth 1.7.14 still honors this option; + // oidc-signin-flow.test.ts verifies linking to an unverified local user. + // If a later release removes it, revisit this policy before upgrading. requireLocalEmailVerified: false, }, }, diff --git a/apps/server/api/src/modules/auth/provider-rows.ts b/apps/server/api/src/modules/auth/provider-rows.ts index 4047f88e7..093c42ad4 100644 --- a/apps/server/api/src/modules/auth/provider-rows.ts +++ b/apps/server/api/src/modules/auth/provider-rows.ts @@ -29,7 +29,7 @@ export interface StoredProviderRow { kind: AuthProviderKind; /** Display name shown on the sign-in page. */ name: string; - /** OIDC issuer; becomes the config's `accountIssuer` (null = none stored). */ + /** OIDC issuer used to resolve endpoints when saving the provider. */ issuer: string | null; /** OAuth client id (null only on malformed/hand-edited rows; config gets ""). */ clientId: string | null; @@ -191,8 +191,8 @@ function splitDomains(raw: unknown): string[] { } /** - * One provider's genericOAuth config (measured member set, 1.7.1): explicit - * endpoints + accountIssuer so plugin init NEVER fetches discovery — the + * One provider's genericOAuth config (Better Auth 1.7.14): explicit + * endpoints so plugin init NEVER fetches discovery — the * failure mode that otherwise throws at build and, since AUTH_OPTIONS feeds * `runAuthMigrations`, crash-loops boot (spec §3). Explicit endpoints are * also what a rebuild runs on when the issuer is briefly dead: the table @@ -206,7 +206,20 @@ export function toGenericOAuthConfig(row: StoredProviderRow, canonicalOrigin: st name: row.name, // measured: GenericOAuthConfig spells the display name `name` clientId: row.clientId ?? "", clientSecret: row.clientSecret ?? undefined, - accountIssuer: row.issuer ?? undefined, + // Better Auth now keys by providerId rather than issuer. Keep the issuer + // in the subject so changing/recreating a provider cannot reuse another + // issuer's identities. Migration 0069 applies this encoding to old rows. + accountSubject: ({ profile }) => { + const subject = profile.id; + if (typeof subject !== "string" && (typeof subject !== "number" || !Number.isFinite(subject))) return ""; + const text = String(subject); + // Encoding must not turn a subject Better Auth rejects into a valid key. + if (text.trim() === "" || text === "undefined" || text === "null") return ""; + // Match 1.7.1's fallback for an endpoint-only provider without an issuer. + const issuer = row.issuer ?? `local:oauth:${encodeURIComponent(row.id)}`; + if (issuer.trim() === "" || issuer === "undefined" || issuer === "null") return ""; + return JSON.stringify([issuer, text]); + }, ...(row.endpoints && { authorizationUrl: row.endpoints.authorizationUrl, tokenUrl: row.endpoints.tokenUrl, diff --git a/apps/server/api/src/modules/backups/database.ts b/apps/server/api/src/modules/backups/database.ts index 784867b33..8fb410cc9 100644 --- a/apps/server/api/src/modules/backups/database.ts +++ b/apps/server/api/src/modules/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0068-preset-enabled"; +export const LATEST_BACKUP_MIGRATION = "0069-auth-account-provider-key"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/modules/users/__tests__/pending-expiry-sweep.test.ts b/apps/server/api/src/modules/users/__tests__/pending-expiry-sweep.test.ts index 1faf31524..f64d8f0a4 100644 --- a/apps/server/api/src/modules/users/__tests__/pending-expiry-sweep.test.ts +++ b/apps/server/api/src/modules/users/__tests__/pending-expiry-sweep.test.ts @@ -68,8 +68,8 @@ async function makeProvider(id: string, requireApproval: 0 | 1): Promise { async function makeAccount(userId: string, providerId: string): Promise { const id = crypto.randomUUID(); const now = new Date().toISOString(); - await sql`INSERT INTO account (id, issuer, accountId, providerId, userId, createdAt, updatedAt) - VALUES (${id}, ${providerId}, ${id}, ${providerId}, ${userId}, ${now}, ${now})`.execute(db); + await sql`INSERT INTO account (id, accountId, providerId, userId, createdAt, updatedAt) + VALUES (${id}, ${id}, ${providerId}, ${userId}, ${now}, ${now})`.execute(db); createdAccountIds.push(id); return id; } diff --git a/apps/server/web/package.json b/apps/server/web/package.json index 28053cd05..29af22d27 100644 --- a/apps/server/web/package.json +++ b/apps/server/web/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@base-ui/react": "1.7.0", - "@better-auth/passkey": "1.7.1", + "@better-auth/passkey": "1.7.14", "@internal/assistant": "workspace:*", "@internal/backend-client": "workspace:*", "@internal/backend-errors": "workspace:*", @@ -39,7 +39,7 @@ "@xterm/addon-web-links": "0.13.0-beta.301", "@xterm/xterm": "6.1.0-beta.304", "bcrypt-pbkdf": "1.0.2", - "better-auth": "1.7.1", + "better-auth": "1.7.14", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "dockview-react": "8.2.0", diff --git a/bun.lock b/bun.lock index 5a19303d0..59639cfa3 100644 --- a/bun.lock +++ b/bun.lock @@ -136,7 +136,7 @@ "@internal/subshell-protocol": "workspace:*", "@subshell-ai/plugin-api": "workspace:*", "elysia": "1.4.29", - "jose": "6.2.9", + "jose": "6.2.13", "loglayer": "9.4.0", }, "devDependencies": { @@ -182,8 +182,8 @@ "name": "@internal/server", "version": "3.0.2", "dependencies": { - "@better-auth/api-key": "1.7.1", - "@better-auth/passkey": "1.7.1", + "@better-auth/api-key": "1.7.14", + "@better-auth/passkey": "1.7.14", "@clack/prompts": "1.8.1", "@dotenvx/dotenvx": "2.21.0", "@elysiajs/cors": "1.4.2", @@ -194,13 +194,13 @@ "@internal/subshell-protocol": "workspace:*", "@loglayer/elysia": "2.4.0", "@loglayer/transport-simple-pretty-terminal": "3.3.0", - "better-auth": "1.7.1", + "better-auth": "1.7.14", "dotenv": "17.4.2", "elysia": "1.4.29", "env-var": "7.5.0", "expo-server-sdk": "7.2.0", "file-type": "22.0.2", - "jose": "6.2.9", + "jose": "6.2.13", "kysely": "0.29.5", "kysely-bun-sqlite-dialect": "1.0.0", "loglayer": "9.4.0", @@ -262,7 +262,7 @@ "version": "1.0.0", "dependencies": { "@base-ui/react": "1.7.0", - "@better-auth/passkey": "1.7.1", + "@better-auth/passkey": "1.7.14", "@internal/assistant": "workspace:*", "@internal/backend-client": "workspace:*", "@internal/backend-errors": "workspace:*", @@ -284,7 +284,7 @@ "@xterm/addon-web-links": "0.13.0-beta.301", "@xterm/xterm": "6.1.0-beta.304", "bcrypt-pbkdf": "1.0.2", - "better-auth": "1.7.1", + "better-auth": "1.7.14", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "dockview-react": "8.2.0", @@ -419,7 +419,7 @@ "dependencies": { "@internal/subshell-protocol": "workspace:*", "@modelcontextprotocol/server": "2.3.1", - "jose": "6.2.9", + "jose": "6.2.13", "zod": "4.6.7", }, "devDependencies": { @@ -665,7 +665,7 @@ "version": "1.0.0", "dependencies": { "cbor2": "2.3.0", - "jose": "6.2.9", + "jose": "6.2.13", "libsodium-wrappers-sumo": "0.8.4", }, "devDependencies": { @@ -865,27 +865,27 @@ "@base-ui/utils": ["@base-ui/utils@0.3.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ=="], - "@better-auth/api-key": ["@better-auth/api-key@1.7.1", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "better-auth": "^1.7.1", "better-call": "1.4.0" } }, "sha512-lZr5RAiAE7RnPB4QCGK4GhcYJtG3xHtd2vw7DOBPscVTCBOs4vvxR93ACQFoCAAsDxMUbduwDkh92UYaI/MvgQ=="], + "@better-auth/api-key": ["@better-auth/api-key@1.7.14", "", { "dependencies": { "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "better-auth": "^1.7.14", "better-call": "1.4.4" } }, "sha512-4eKtRgzsHdti+w47nEMaSKm6Hu3fIFlnge6ha6wfKx3Go4uKQoVhhg2PRqUR9/+bPcRKKzPwsfvDIhCKCTaO6g=="], - "@better-auth/core": ["@better-auth/core@1.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-eZ9lqcnVLMZ3QtUByRo4VZqkB1ESyRddd9NfWjBdDPgh+jcwLScoIUAqhtHLR8zaSUJZah8OLGlkzObyPdUH7A=="], + "@better-auth/core": ["@better-auth/core@1.7.14", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.4", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-ByhdrGVwp/el+hoTYKSVOHIH23/lQj9TQ1txdGcWOg22tnfr3hJ6ukzySooeYj/R36XsQgBhkJqIp8mKsNeErA=="], - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-qlqNyg5V9bXHSP68/vtlsiZayhR4hgvEGiS/E3SIj8bCpWWFGmyQkxJbQCqpBmC7vT30wE/kNtJMHIgnV3rkiw=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-5RC8Ec5Ur15FWsS4As9/VrlRbB3+9EfShxIpA5A1FcD6IgPxFvzQNkAm2+FYx9LUSqx47NPf9hBTG2GUCMP9Ig=="], - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-yWCpE1cZpMUj37nD6JFDK+GDR8zS37L5WI73il3qbU9TXtWsxUQKc/5c3IHsHizWQsmcQI8uv2pAFKxsRDa+AQ=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-4wkqj8a8JVpWDYLlgzfbk7eOaWCFkL6HHQd6CLamFKaC8vNsXew1x4v5dE5O6dGr8j4g5qMISS+BAvOuIvBevg=="], - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2" } }, "sha512-6NX1yv88DeqdoG7owYFqKwlrDGaIPhsC52JUGrUgeGVKyOq8a/6hHlHsqG1C2FwT23SHQiKVYCEAG9N6aH5OvQ=="], + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0" } }, "sha512-Ojv9BcmFyHuTHha9ZM/NUW1ut9J84wLngta+07Es0Ejn8kekqP5aHBpZd5Z8AvADs5iJUOWvQGFmGlPWAKJgBg=="], - "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-9ILTcNqhG37QK//qR4UhYLyKzNqq6w6zVTf5KX6xkiTjNcV7Oh1yS31lkIJEVTqRNcy9AoV6FZMW6Bbsm8IMDA=="], + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-6IA0RSPZFMdRwwnLZ9oWnDIiD0y0o0azTHUfa01BNU2AFJgMDkeiASUV3bKcJBskzQoyQrff74eFYTsTSeeVUQ=="], - "@better-auth/passkey": ["@better-auth/passkey@1.7.1", "", { "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.1", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.1", "better-call": "1.4.0", "nanostores": "^1.0.1" } }, "sha512-bGnCBDL2udKniHI4IZpjh4lU8lQWRVk3SG/ZtTtZz6QZLCJ2YpVjjwv2O0VJt/jw9SLuXyccx3IFh4s0PKGTRQ=="], + "@better-auth/passkey": ["@better-auth/passkey@1.7.14", "", { "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "better-auth": "^1.7.14", "better-call": "1.4.4", "nanostores": "^1.0.1" } }, "sha512-ZUODCRp9vnbk2GThOXRja+bpTLweJar5bJppSoobck2DhM7XCRPaQjzrCIJllnjy6VUBcCkjGm2oKmy2yvepeg=="], - "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-ZiUcafQ85InAofcUjyGgCPjKLfQjXr9SvDmMjuFUW8oEbreA6C6GaFAEA77VuV2doZUQlzvQQ4gCoTmS28W92A=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-mF6t5xqeZNWzUe0ErmrSdzQmtslJscmu9X8Mp5QmROlpnnAzvSc70RILu8lHms8CePt7N34+KRH1QvYjGFrVGQ=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-kLKjMfFlTbyt49DGeI9okHAsn0MtBZcMoQYKaEdgR0H3BHzqqyzePcQz/hxAmRgjB4p/6inise3zJwhX0sgXrQ=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3" } }, "sha512-PtKFzsH7gje/WJuUPDjckOoNFazsq8NMt3AdiXIPzHl7tZULcNGTKAX8K7+yn0rG6cO/4mtMgpOjhtilElOnmg=="], - "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + "@better-auth/utils": ["@better-auth/utils@0.5.1", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg=="], - "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.3", "", {}, "sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg=="], "@biomejs/biome": ["@biomejs/biome@2.5.9", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.9", "@biomejs/cli-darwin-x64": "2.5.9", "@biomejs/cli-linux-arm64": "2.5.9", "@biomejs/cli-linux-arm64-musl": "2.5.9", "@biomejs/cli-linux-x64": "2.5.9", "@biomejs/cli-linux-x64-musl": "2.5.9", "@biomejs/cli-win32-arm64": "2.5.9", "@biomejs/cli-win32-x64": "2.5.9" }, "bin": { "biome": "bin/biome" } }, "sha512-KkgCvdHB4IhtpHpF564plA9jo6fDOwWGQ/3jvreLzgOtRLEDoPqr7QO9qejNA8jKwDsSkAKr77hqBHnyUbIw4g=="], @@ -1351,8 +1351,6 @@ "@novnc/novnc": ["@novnc/novnc@1.7.0", "", {}, "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@oxc-project/types": ["@oxc-project/types@0.144.0", "", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="], "@peculiar/asn1-android": ["@peculiar/asn1-android@2.9.3", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.3", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-hiRJvr5ydif9fbTA7czZw1OfgzYDhu5gXNzhDfS3wSXzWYoSS/BHY+Wu2c36CNbn3mI6FoVLf0yHvQy0D4rZWw=="], @@ -2045,9 +2043,9 @@ "beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="], - "better-auth": ["better-auth@1.7.1", "", { "dependencies": { "@better-auth/core": "1.7.1", "@better-auth/drizzle-adapter": "1.7.1", "@better-auth/kysely-adapter": "1.7.1", "@better-auth/memory-adapter": "1.7.1", "@better-auth/mongo-adapter": "1.7.1", "@better-auth/prisma-adapter": "1.7.1", "@better-auth/telemetry": "1.7.1", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-g8WlTQijxXWJjPVZfFu1+EJg9cwwHrKDmIkcYMzx8CzYA+tDxl6NI7qQbKkbgw5UtHILsT5VH+RMzFzwnVJqAg=="], + "better-auth": ["better-auth@1.7.14", "", { "dependencies": { "@better-auth/core": "1.7.14", "@better-auth/drizzle-adapter": "1.7.14", "@better-auth/kysely-adapter": "1.7.14", "@better-auth/memory-adapter": "1.7.14", "@better-auth/mongo-adapter": "1.7.14", "@better-auth/prisma-adapter": "1.7.14", "@better-auth/telemetry": "1.7.14", "@better-auth/utils": "0.5.1", "@better-fetch/fetch": "1.3.3", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.4", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0 || ^3.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-QxDCI0pOCCHDYmruisKhKyrvOJ6Icv9xg0RA6ZUysfKFXENYwhNNpwUlkE/NmViH2nDMgkKcymmLr55pnpR9vw=="], - "better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="], + "better-call": ["better-call@1.4.4", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Gq4XdGFy4dmUxFEAX2nj0ycFcFbD9ZWh8YROouZoyXeF1M11TpYbplk0pDByTFF8Wz+MYa6W9052c9m7dD+mfw=="], "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], @@ -2609,7 +2607,7 @@ "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], - "jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="], + "jose": ["jose@6.2.13", "", {}, "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -3679,8 +3677,6 @@ "@kwsites/file-exists/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@modelcontextprotocol/client/jose": ["jose@6.2.10", "", {}, "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g=="], - "@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.87.1", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.87.1" } }, "sha512-DfnyLAHG7jH4pqeID7ib6AeD6gG8T+KvM3c/w/yEa5ONkfRcHvTC8JMIRdhUqs4ruA+8xbYcNVQzewYRCPm1Xw=="], "@react-native/babel-preset/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.36.1", "", { "dependencies": { "hermes-parser": "0.36.1" } }, "sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA=="], @@ -3757,8 +3753,6 @@ "babel-preset-expo/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], - "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], diff --git a/docs/security.md b/docs/security.md index 39afe1185..b230fef65 100644 --- a/docs/security.md +++ b/docs/security.md @@ -365,7 +365,7 @@ against the token endpoint the discovery document itself declares, never a URL composed from the issuer, where the metadata advertises that grant (400 `CREDENTIALS_REJECTED`; the 2026-09-25 ruling deleted the separate `POST /test` probe, so the save is the only verification), and stores the resolved endpoints beside -`accountIssuer`, so every later rebuild runs offline and one drifted issuer +the issuer, so every later rebuild runs offline and one drifted issuer cannot crash-loop boot; a rebuild that throws anyway serves the last-known-good auth instance (spec §3). The id slug is `[a-z0-9-]` ≤ 40 chars, `email` is reserved, and a collision answers `SLUG_TAKEN` (409). **The last open @@ -384,8 +384,12 @@ config array in the better-auth singleton is current without a restart. (spec §7): `kind` is a PRESET (which endpoints and prefills, `google` or generic `oidc`), the id slug is the unique thing, and login buttons label by the provider's NAME because two Google Workspaces rows rendered kind-first would be -indistinguishable to the clicker. Providers that share an `accountIssuer` (two -Workspaces of one Google account) land the same person on the same account. +indistinguishable to the clicker. Better Auth 1.7.14 keys accounts by provider ID +and account subject. Subshell encodes the issuer and original subject together +in that subject, including when migrating existing accounts, so changing or +recreating a provider cannot bind another issuer's subject to an old user. Two +providers sharing an issuer keep separate account rows; linking to the same +local user still requires the verified-email policy below. **Adding a provider is the same class of trust decision as installing a plugin** (spec §5, priced here rather than discovered later). Implicit email diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index 8d7831f48..d43e46f2d 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -26,7 +26,7 @@ "dependencies": { "@internal/subshell-protocol": "workspace:*", "@modelcontextprotocol/server": "2.3.1", - "jose": "6.2.9", + "jose": "6.2.13", "zod": "4.6.7" }, "devDependencies": { diff --git a/packages/subshell-protocol/package.json b/packages/subshell-protocol/package.json index a52524da2..2b2e76d83 100644 --- a/packages/subshell-protocol/package.json +++ b/packages/subshell-protocol/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "cbor2": "2.3.0", - "jose": "6.2.9", + "jose": "6.2.13", "libsodium-wrappers-sumo": "0.8.4" }, "devDependencies": { From 3d3446384f3f62236e6a9f2a51fd583d59be9712 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 27 Oct 2026 18:38:50 -0700 Subject: [PATCH 2/2] fix(deps): patch deepmerge prototype poisoning in Expo tooling --- bun.lock | 1 + package.json | 3 +- patches/deepmerge@4.3.1.patch | 51 +++++++++++++++++++ scripts/__tests__/dependency-security.test.ts | 12 +++++ .../fixtures/deepmerge-consumers.cjs | 31 +++++++++++ scripts/dep-audit.ignore.json | 7 +++ 6 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 patches/deepmerge@4.3.1.patch create mode 100644 scripts/__tests__/fixtures/deepmerge-consumers.cjs diff --git a/bun.lock b/bun.lock index 59639cfa3..5a93954f1 100644 --- a/bun.lock +++ b/bun.lock @@ -686,6 +686,7 @@ "node-forge@1.4.0": "patches/node-forge@1.4.0.patch", "@novnc/novnc@1.7.0": "patches/@novnc%2Fnovnc@1.7.0.patch", "metro@0.87.0": "patches/metro@0.87.0.patch", + "deepmerge@4.3.1": "patches/deepmerge@4.3.1.patch", }, "overrides": { "@expo/cli": { diff --git a/package.json b/package.json index 79c9b78be..6e90fbcea 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,8 @@ "query-string@7.1.3": "patches/query-string@7.1.3.patch", "metro@0.87.0": "patches/metro@0.87.0.patch", "@novnc/novnc@1.7.0": "patches/@novnc%2Fnovnc@1.7.0.patch", - "node-forge@1.4.0": "patches/node-forge@1.4.0.patch" + "node-forge@1.4.0": "patches/node-forge@1.4.0.patch", + "deepmerge@4.3.1": "patches/deepmerge@4.3.1.patch" }, "workspaces": [ "apps/*", diff --git a/patches/deepmerge@4.3.1.patch b/patches/deepmerge@4.3.1.patch new file mode 100644 index 000000000..805b7b7e7 --- /dev/null +++ b/patches/deepmerge@4.3.1.patch @@ -0,0 +1,51 @@ +diff --git a/dist/cjs.js b/dist/cjs.js +index 7c36cbdd06cc22c92d590be3f87a08a91ca06b40..90feb4ad2ee04e863606d2bb2eb0a7c65fa62a0a 100644 +--- a/dist/cjs.js ++++ b/dist/cjs.js +@@ -80,10 +80,12 @@ function mergeObject(target, source, options) { + var destination = {}; + if (options.isMergeableObject(target)) { + getKeys(target).forEach(function(key) { ++ if (key === '__proto__') return + destination[key] = cloneUnlessOtherwiseSpecified(target[key], options); + }); + } + getKeys(source).forEach(function(key) { ++ if (key === '__proto__') return + if (propertyIsUnsafe(target, key)) { + return + } +diff --git a/dist/umd.js b/dist/umd.js +index 4071e7c56ec8e5273fe166f6e723ac460fab8b72..ab8b91e63241bee050d6d85b2c8b45bcc8f3bc36 100644 +--- a/dist/umd.js ++++ b/dist/umd.js +@@ -84,10 +84,12 @@ + var destination = {}; + if (options.isMergeableObject(target)) { + getKeys(target).forEach(function(key) { ++ if (key === '__proto__') return + destination[key] = cloneUnlessOtherwiseSpecified(target[key], options); + }); + } + getKeys(source).forEach(function(key) { ++ if (key === '__proto__') return + if (propertyIsUnsafe(target, key)) { + return + } +diff --git a/index.js b/index.js +index 77968aed5d456cccc294f60912ef381fa1e52a7b..6b95d9a9f29134b69455e815d299b84749d65296 100644 +--- a/index.js ++++ b/index.js +@@ -55,10 +55,12 @@ function mergeObject(target, source, options) { + var destination = {} + if (options.isMergeableObject(target)) { + getKeys(target).forEach(function(key) { ++ if (key === '__proto__') return + destination[key] = cloneUnlessOtherwiseSpecified(target[key], options) + }) + } + getKeys(source).forEach(function(key) { ++ if (key === '__proto__') return + if (propertyIsUnsafe(target, key)) { + return + } diff --git a/scripts/__tests__/dependency-security.test.ts b/scripts/__tests__/dependency-security.test.ts index 7ef9f15b0..24864ba65 100644 --- a/scripts/__tests__/dependency-security.test.ts +++ b/scripts/__tests__/dependency-security.test.ts @@ -49,3 +49,15 @@ test("Forge rejects nested DigestAlgorithm garbage without breaking Expo consume expect(result.status).toBe(0); expect(result.stdout).toContain("Forge rejects nested garbage"); }, 15_000); + +test("Deepmerge blocks prototype poisoning in Expo configuration merges", () => { + const result = spawnSync("node", [fileURLToPath(new URL("./fixtures/deepmerge-consumers.cjs", import.meta.url))], { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + encoding: "utf8", + timeout: 10_000, + }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Deepmerge rejects prototype poisoning"); +}, 15_000); diff --git a/scripts/__tests__/fixtures/deepmerge-consumers.cjs b/scripts/__tests__/fixtures/deepmerge-consumers.cjs new file mode 100644 index 000000000..1bda2e34a --- /dev/null +++ b/scripts/__tests__/fixtures/deepmerge-consumers.cjs @@ -0,0 +1,31 @@ +const assert = require("node:assert/strict"); +const { createRequire } = require("node:module"); +const { resolve } = require("node:path"); + +const mobile = createRequire(resolve("apps/client/mobile/package.json")); +const expo = createRequire(mobile.resolve("expo/package.json")); +const config = createRequire(expo.resolve("@expo/config/package.json")); +const cjs = config("deepmerge"); +const umd = config(resolve(config.resolve("deepmerge/package.json"), "../dist/umd.js")); + +for (const merge of [cjs, umd]) { + const poison = JSON.parse('{"__proto__":{"injected":true}}'); + for (const merged of [ + merge(Object.create(null), poison), + merge(poison, { safe: true }), + merge({ nested: null }, { nested: poison }).nested, + merge({ nested: poison }, {}).nested, + merge.all([poison, { safe: true }]), + ]) { + assert.equal(Object.getPrototypeOf(merged), Object.prototype); + assert.equal(merged.injected, undefined); + assert.equal(Object.hasOwn(merged, "__proto__"), false); + } + const symbol = Symbol("config"); + const first = { nested: { one: 1 }, values: [1], [symbol]: "kept" }; + const second = { nested: { two: 2 }, values: [2] }; + assert.deepEqual(merge(first, second), { nested: { one: 1, two: 2 }, values: [1, 2], [symbol]: "kept" }); + assert.deepEqual(first.nested, { one: 1 }); + assert.deepEqual(second.nested, { two: 2 }); +} +console.log("Deepmerge rejects prototype poisoning and preserves Expo configuration merges"); diff --git a/scripts/dep-audit.ignore.json b/scripts/dep-audit.ignore.json index ae87187a0..29fc5c5e1 100644 --- a/scripts/dep-audit.ignore.json +++ b/scripts/dep-audit.ignore.json @@ -5,5 +5,12 @@ "GHSA-86w9-cpqp-85rv" ], "reason": "Expo mobile build/dev tooling only. node-forge 1.4.0 has no published fixed release (checked 2026-10-25). patches/node-forge@1.4.0.patch backports the nested DigestAlgorithm element-count check proposed in https://github.com/digitalbazaar/forge/pull/1152. scripts/__tests__/dependency-security.test.ts checks the installed copies used by Expo CLI and code-signing-certificates: malformed signatures are rejected while valid signatures and certificates work. bun audit still matches the original version; replace the patch and remove this exception when a fixed release ships." + }, + { + "package": "deepmerge", + "advisories": [ + "GHSA-gcqx-w48m-w2h4" + ], + "reason": "Expo configuration build/dev tooling. deepmerge 4.3.1 has no published fixed release (checked 2026-10-27). patches/deepmerge@4.3.1.patch blocks __proto__ in both target and source copy loops, including both distributed bundles. scripts/__tests__/fixtures/deepmerge-consumers.cjs exercises the installed Expo consumer under Node, reproducing null-prototype, nested-placeholder and target-copy poisoning while verifying ordinary configuration merges. The regression fails without the patch. bun audit matches the original version; remove this patch and exception when a fixed release ships." } ]