Skip to content

Commit 0eb4454

Browse files
committed
fix(pwa): claim device codes atomically in /cli/device/token
The read-check-claim sequence let two concurrent polls both read "approved" before either UPDATE landed, so one single-use device code minted two API keys. Make the claim conditional (status = 'approved') and reject the loser, mirroring the /cli/token fix (#46).
1 parent 5029919 commit 0eb4454

2 files changed

Lines changed: 144 additions & 1 deletion

File tree

apps/pwa/src/routes/cli.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,13 @@ cliRouter.post("/cli/device/token", async (req, res) => {
162162
if (row.status === "denied") return res.status(400).json({ error: "access_denied" });
163163
if (row.status !== "approved") return res.status(400).json({ error: "authorization_pending" });
164164

165-
await run(`UPDATE device_codes SET status = 'claimed' WHERE device_code = ?`, [row.device_code]);
165+
// Claim the code atomically, the same way /cli/token claims auth codes: the
166+
// status check above is not enough on its own — with a remote (network)
167+
// database, two concurrent polls can both read "approved" before either
168+
// UPDATE lands, and one single-use code would mint two API keys. Only the
169+
// first claim wins.
170+
const claimed = await run(`UPDATE device_codes SET status = 'claimed' WHERE device_code = ? AND status = 'approved'`, [row.device_code]);
171+
if (!claimed.rowsAffected) return res.status(400).json({ error: "expired_token" });
166172
const user = await get(`SELECT * FROM users WHERE id = ?`, [row.user_id]);
167173
const { plaintext } = await createApiKey(user.id, row.name || "moshcode cli");
168174
res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } });
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Integration tests for the device-code exchange (POST /cli/device/token).
2+
//
3+
// These boot the real router against a throwaway libsql file database. They
4+
// skip cleanly when the PWA dependencies are not installed (a fresh repo
5+
// clone only has the root CLI deps), so the root `npm test` stays green
6+
// either way. Run `npm install` in apps/pwa to enable them.
7+
import assert from "node:assert/strict";
8+
import http from "node:http";
9+
import fs from "node:fs";
10+
import { mkdtempSync } from "node:fs";
11+
import { tmpdir } from "node:os";
12+
import path from "node:path";
13+
import { createRequire } from "node:module";
14+
import test from "node:test";
15+
16+
const require = createRequire(import.meta.url);
17+
let deps = null;
18+
try {
19+
deps = { express: require("express"), cookieParser: require("cookie-parser") };
20+
} catch {
21+
deps = null; // pwa dependencies not installed — tests below skip
22+
}
23+
24+
// Point the app at a throwaway database BEFORE importing its modules (config
25+
// reads the environment once, at import time).
26+
const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pwa-test-"));
27+
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
28+
process.env.SESSION_SECRET = "test-secret";
29+
30+
async function boot() {
31+
const { migrate } = await import("../src/migrate.mjs");
32+
await migrate();
33+
const { run, all, db } = await import("../src/db.mjs");
34+
// The local libsql driver resolves statements in microtasks, which fully
35+
// serializes concurrent request handlers and hides read-check-write races.
36+
// Production runs against a network database (Turso), where every statement
37+
// is a round trip. Defer each statement to a macrotask so two in-flight
38+
// handlers genuinely interleave, like they would against the remote DB.
39+
const execute = db.execute.bind(db);
40+
db.execute = (stmt) => new Promise((resolve, reject) => {
41+
setTimeout(() => execute(stmt).then(resolve, reject), 2);
42+
});
43+
const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs");
44+
const { cliRouter } = await import("../src/routes/cli.mjs");
45+
46+
const app = deps.express();
47+
app.use(deps.express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }));
48+
app.use(deps.express.urlencoded({ extended: false }));
49+
app.use(deps.cookieParser());
50+
app.use(sessionMiddleware);
51+
app.use(csrfGuard);
52+
app.use(cliRouter);
53+
const server = await new Promise((resolve) => {
54+
const s = app.listen(0, "127.0.0.1", () => resolve(s));
55+
});
56+
const { port } = server.address();
57+
58+
const seedDeviceCode = async (deviceCode, { status = "approved", ageMs = 0 } = {}) => {
59+
await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','demo',1)`);
60+
const now = Date.now();
61+
await run(
62+
`INSERT INTO device_codes (device_code,user_code,user_id,status,name,interval_s,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?)`,
63+
[deviceCode, "ABCD-2345", status === "pending" ? null : "u1", status, "test", 5, now - ageMs, now - ageMs + 10 * 60 * 1000]
64+
);
65+
};
66+
// Raw http with a fresh connection per request: fetch()/undici would reuse a
67+
// keep-alive socket for same-origin calls and serialize the "concurrent"
68+
// polls, hiding the race this exercises.
69+
const poll = (deviceCode) => new Promise((resolve, reject) => {
70+
const req = http.request({
71+
host: "127.0.0.1", port, path: "/cli/device/token", method: "POST", agent: false,
72+
headers: { "content-type": "application/json" },
73+
}, (res) => {
74+
let data = "";
75+
res.on("data", (chunk) => { data += chunk; });
76+
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(data) }));
77+
});
78+
req.on("error", reject);
79+
req.end(JSON.stringify({ device_code: deviceCode }));
80+
});
81+
82+
return { run, all, db, server, seedDeviceCode, poll };
83+
}
84+
85+
// One shared app/db for the whole file (db.mjs is a module-level singleton —
86+
// closing it between tests would break the next boot).
87+
let booted = null;
88+
const app = () => (booted ||= boot());
89+
90+
test.after(() => {
91+
if (!booted) return;
92+
booted.then(({ server, db }) => { server.close(); db.close?.(); })
93+
.finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } });
94+
});
95+
96+
test("cli/device/token: an approved code exchanges exactly once, then is rejected", { skip: !deps && "apps/pwa deps not installed" }, async () => {
97+
const { all, seedDeviceCode, poll } = await app();
98+
99+
await seedDeviceCode("dev-once");
100+
const before = (await all(`SELECT id FROM api_keys`)).length;
101+
102+
const first = await poll("dev-once");
103+
assert.equal(first.status, 200);
104+
assert.ok(first.body.access_token, "first poll must mint an API key");
105+
assert.equal((await all(`SELECT id FROM api_keys`)).length, before + 1);
106+
107+
// Replay with the same device code must fail — the code is single-use.
108+
const replay = await poll("dev-once");
109+
assert.equal(replay.status, 400);
110+
assert.equal((await all(`SELECT id FROM api_keys`)).length, before + 1, "replay must not mint a second key");
111+
});
112+
113+
test("cli/device/token: concurrent polls claim the code once — one key, not two", { skip: !deps && "apps/pwa deps not installed" }, async () => {
114+
const { all, seedDeviceCode, poll } = await app();
115+
116+
await seedDeviceCode("dev-race");
117+
const before = (await all(`SELECT id FROM api_keys`)).length;
118+
119+
// Two CLI polls in flight at once (retry after a timeout, double-clicked
120+
// CI job, …). Both read status "approved" before either claim lands; only
121+
// one may win.
122+
const [a, b] = await Promise.all([poll("dev-race"), poll("dev-race")]);
123+
const wins = [a, b].filter((r) => r.status === 200);
124+
assert.equal(wins.length, 1, `exactly one poll may mint a key, got statuses ${a.status}/${b.status}`);
125+
assert.equal((await all(`SELECT id FROM api_keys`)).length, before + 1, "one device code must mint exactly one API key");
126+
});
127+
128+
test("cli/device/token: a pending code is not exchangeable", { skip: !deps && "apps/pwa deps not installed" }, async () => {
129+
const { all, seedDeviceCode, poll } = await app();
130+
131+
await seedDeviceCode("dev-pending", { status: "pending" });
132+
const before = (await all(`SELECT id FROM api_keys`)).length;
133+
const res = await poll("dev-pending");
134+
assert.equal(res.status, 400);
135+
assert.equal(res.body.error, "authorization_pending");
136+
assert.equal((await all(`SELECT id FROM api_keys`)).length, before);
137+
});

0 commit comments

Comments
 (0)