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
6 changes: 6 additions & 0 deletions .changeset/better-auth-refresh.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/node/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
15 changes: 13 additions & 2 deletions apps/server/api/docs/auth-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
8 changes: 4 additions & 4 deletions apps/server/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,8 @@ async function setEmailRegistration(stored: number | null): Promise<void> {
async function insertProviderAccount(userId: string, providerId: string): Promise<void> {
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);
}

Expand Down
32 changes: 32 additions & 0 deletions apps/server/api/src/api/__tests__/oidc-signin-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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([]);
Expand All @@ -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([
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/api/src/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -191,6 +192,7 @@ export async function runMigrations(): Promise<void> {
"0066-node-display-provider": nodeDisplayProviderMigration,
"0067-display-provider-choices": displayProviderChoicesMigration,
"0068-preset-enabled": presetEnabledMigration,
"0069-auth-account-provider-key": authAccountProviderKeyMigration,
};
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<any>): Promise<void> {
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<AccountKey>`SELECT id, "providerId", "accountId", issuer FROM account`.execute(db)
: await sql<AccountKey>`SELECT id, "providerId", "accountId", NULL AS issuer FROM account`.execute(db);
const keys = new Set<string>();
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<void> {
throw new Error("The authentication account migration cannot be reversed; restore a backup to downgrade.");
}
Loading
Loading