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
5 changes: 5 additions & 0 deletions src/db/sqlite/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dirname, join } from "path";

import { AVATAR_THUMB_PX } from "../../constants/media";
import { migrateRankGatesToScopes } from "./rankGateMigration";
import { reissueAccessTokensAfterLeak } from "./tokenReissueMigration";
import {
backfillFor,
BUILT_IN_ROLES,
Expand Down Expand Up @@ -821,6 +822,10 @@ function runMigrations(d: DatabaseSync): void {
// After the roles, whose ranks it reads, and before anything serves a request,
// or a gated channel is open to everybody in between.
migrateRankGatesToScopes(d);

// Last, and only on an existing server: retires access tokens minted before
// the GRYT-1239 fix, since one that leaked cannot be recalled.
reissueAccessTokensAfterLeak(d);
}

/**
Expand Down
199 changes: 199 additions & 0 deletions src/db/sqlite/tokenReissueMigration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, beforeEach, describe, it } from "node:test";

import { requireAuth } from "../../socket/middleware/auth";
import { registerJoinHelpers } from "../../socket/handlers/joinHelpers";
import type { HandlerContext } from "../../socket/handlers/types";
import type { Clients } from "../../types";
import { generateAccessToken } from "../../utils/jwt";
import { getSqliteDb, initSqlite } from "./connection";
import { createServerConfigIfNotExists, setServerRole } from "./servers";
import { createRefreshToken } from "./tokens";
import { upsertUser } from "./users";
import { reissueAccessTokensAfterLeak, TOKEN_REISSUE_MIGRATION_KEY } from "./tokenReissueMigration";

/**
* The one-shot that retires access tokens leaked before GRYT-1239, and proof it
* fires once, spares a fresh install, and refuses the old token afterwards.
*/

const HOST = "reissue.test:5001";

let dir: string;

function tokenVersion(): number {
const row = getSqliteDb().prepare(`SELECT token_version FROM server_config`).get() as
| { token_version: number }
| undefined;
return row?.token_version ?? 0;
}

function marker(): string | null {
const row = getSqliteDb().prepare(`SELECT value FROM schema_meta WHERE key = ?`).get(TOKEN_REISSUE_MIGRATION_KEY) as
| { value: string }
| undefined;
return row?.value ?? null;
}

/** initSqlite has already run the migration once against the empty database, so
every test starts from a clean slate: no marker, and the counter at zero. */
beforeEach(() => {
const db = getSqliteDb();
db.prepare(`DELETE FROM schema_meta WHERE key = ?`).run(TOKEN_REISSUE_MIGRATION_KEY);
db.prepare(`UPDATE server_config SET token_version = 0`).run();
db.prepare(`DELETE FROM users`).run();
});

before(async () => {
dir = mkdtempSync(join(tmpdir(), "gryt-reissue-"));
process.env.DATA_DIR = dir;
await initSqlite();
await createServerConfigIfNotExists();
});

after(() => {
delete process.env.DATA_DIR;
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// Windows keeps the SQLite file open past the run.
}
});

describe("retiring the leaked access tokens", () => {
it("bumps the counter once on a server that had members", async () => {
await upsertUser("account-alice", "Alice");
assert.equal(tokenVersion(), 0);

const to = reissueAccessTokensAfterLeak(getSqliteDb());
assert.equal(to, 1);
assert.equal(tokenVersion(), 1);
assert.ok(marker(), "the marker was not written");
});

it("does nothing on the second boot", async () => {
await upsertUser("account-alice", "Alice");
reissueAccessTokensAfterLeak(getSqliteDb());
assert.equal(tokenVersion(), 1);

const again = reissueAccessTokensAfterLeak(getSqliteDb());
assert.equal(again, null, "it ran a second time");
assert.equal(tokenVersion(), 1, "the second run bumped the counter again");
});

it("spares a fresh install that never had a member", () => {
assert.equal(tokenVersion(), 0);
const to = reissueAccessTokensAfterLeak(getSqliteDb());
assert.equal(to, null, "it bumped a database nobody had joined");
assert.equal(tokenVersion(), 0);
assert.ok(marker(), "the marker must still be set, so it never reconsiders");
});
});

/** A fake socket that records what it was emitted, enough for requireAuth and the
token:refresh handler. */
function fakeSocket() {
const emitted: { event: string; payload?: unknown }[] = [];
const socket = {
id: "sock-alice",
rooms: new Set<string>(["sock-alice"]),
handshake: { headers: { host: HOST }, address: "127.0.0.1" },
emit(event: string, payload?: unknown) {
emitted.push({ event, payload });
return true;
},
join() {},
leave() {},
to: () => ({ emit() {} }),
};
return { socket, emitted };
}

describe("what the bump does to the tokens that leaked", () => {
let serverUserId: string;
let oldAccessToken: string;

beforeEach(async () => {
const alice = await upsertUser("account-alice", "Alice");
serverUserId = alice.server_user_id;
await setServerRole(serverUserId, "member");
// Minted at the version before the bump, the way a captured token was.
oldAccessToken = generateAccessToken({
grytUserId: "account-alice",
serverUserId,
nickname: "Alice",
serverHost: HOST,
tokenVersion: 0,
userTokenVersion: alice.token_version ?? 0,
});
reissueAccessTokensAfterLeak(getSqliteDb());
assert.equal(tokenVersion(), 1);
});

it("refuses the old access token at requireAuth", async () => {
const { socket, emitted } = fakeSocket();
const auth = await requireAuth(socket as never, { accessToken: oldAccessToken });
assert.equal(auth, null, "requireAuth accepted a token from before the bump");
assert.ok(
emitted.some((e) => e.event === "token:revoked" && (e.payload as { reason?: string })?.reason === "token_version_mismatch"),
`expected token:revoked, got ${JSON.stringify(emitted)}`,
);
});

it("refuses the old access token at token:refresh", async () => {
const { socket, emitted } = fakeSocket();
const clientsInfo: Clients = {
"sock-alice": { serverUserId, grytUserId: "account-alice", nickname: "Alice" } as Clients[string],
};
const ctx = {
io: { sockets: { sockets: new Map() }, to: () => ({ emit() {} }), emit() {} },
socket,
clientId: "sock-alice",
serverId: "reissue-test",
clientsInfo,
sfuClient: null,
getClientIp: () => "127.0.0.1",
clientAddressIsOwn: () => true,
} as unknown as HandlerContext;

await registerJoinHelpers(ctx)["token:refresh"]({ accessToken: oldAccessToken });
assert.ok(
emitted.some((e) => e.event === "token:revoked"),
`token:refresh renewed a stale token: ${JSON.stringify(emitted)}`,
);
assert.ok(
!emitted.some((e) => e.event === "token:refreshed"),
"token:refresh handed back a fresh token for a stale one",
);
});

it("still mints a fresh access token from a refresh token", async () => {
const { socket, emitted } = fakeSocket();
const record = await createRefreshToken({ grytUserId: "account-alice", serverUserId });
const clientsInfo: Clients = {
"sock-alice": { serverUserId, grytUserId: "account-alice", nickname: "Alice", isServerMuted: false, isServerDeafened: false } as Clients[string],
};
const ctx = {
io: { sockets: { sockets: new Map() }, to: () => ({ emit() {} }), emit() {} },
socket,
clientId: "sock-alice",
serverId: "reissue-test",
clientsInfo,
sfuClient: null,
getClientIp: () => "127.0.0.1",
clientAddressIsOwn: () => true,
} as unknown as HandlerContext;

await registerJoinHelpers(ctx)["token:refresh"]({ refreshToken: record.token_id });
const refreshed = emitted.find((e) => e.event === "token:refreshed")?.payload as { accessToken?: string } | undefined;
assert.ok(refreshed?.accessToken, `a refresh token did not mint a new access token: ${JSON.stringify(emitted)}`);

// The new token carries the current version, so it is accepted where the old one was refused.
const { socket: s2, emitted: e2 } = fakeSocket();
const auth = await requireAuth(s2 as never, { accessToken: refreshed.accessToken });
assert.ok(auth, `the freshly minted token was refused: ${JSON.stringify(e2)}`);
});
});
55 changes: 55 additions & 0 deletions src/db/sqlite/tokenReissueMigration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import consola from "consola";
import type { DatabaseSync } from "node:sqlite";

/* Every member's access token leaked through server:clients until GRYT-1239, and
a leaked token cannot be recalled. This retires them once on upgrade. */

/* The bump moves server_config.token_version, which requireAuth and token:refresh
compare against. Refresh tokens are untouched, so a live client re-mints. */

export const TOKEN_REISSUE_MIGRATION_KEY = "access_token_reissue_gryt1239";

/**
* Bumps `server_config.token_version` once, and only on a database that already
* had members. Returns the new version, or null when nothing was bumped.
*/
export function reissueAccessTokensAfterLeak(d: DatabaseSync): number | null {
const already = d.prepare(`SELECT value FROM schema_meta WHERE key = ?`).get(TOKEN_REISSUE_MIGRATION_KEY) as
| { value: string }
| undefined;
if (already) return null;

// Nobody ever joined, so no token was ever minted and nothing leaked. The
// marker is still written below, so a later boot never re-checks a fresh DB.
const members = d.prepare(`SELECT COUNT(*) AS n FROM users`).get() as { n: number };
const now = new Date().toISOString();

d.exec("BEGIN");
try {
let bumpedTo: number | null = null;
if (members.n > 0) {
d.prepare(`UPDATE server_config SET token_version = token_version + 1`).run();
const row = d.prepare(`SELECT token_version FROM server_config`).get() as
| { token_version: number }
| undefined;
bumpedTo = row?.token_version ?? null;
}
// In the same transaction as the bump, or a crash between them retires
// tokens again on the next boot.
d.prepare(
`INSERT INTO schema_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?`,
).run(TOKEN_REISSUE_MIGRATION_KEY, now, now);
d.exec("COMMIT");

if (bumpedTo !== null) {
consola.info(
`[migration] GRYT-1239: retired access tokens minted before this version; ` +
`server_config.token_version is now ${bumpedTo}. Live clients re-mint from their refresh token.`,
);
}
return bumpedTo;
} catch (err) {
d.exec("ROLLBACK");
throw err;
}
}
Loading