Skip to content

Commit 87ae4e9

Browse files
authored
Add expiry and replay-protection tests for auth challenge flow (#132) (#147)
Fix atomic challenge consumption to close a replay race: verify was reading the challenge, then doing an async signature check, and only deleting it afterwards. Two concurrent verify calls with the same signature could both read the still-valid nonce before either cleared it, producing two sessions from one challenge. consumeChallenge now deletes at read time, before the async verify call. Closes #132
1 parent 43c8c96 commit 87ae4e9

4 files changed

Lines changed: 130 additions & 8 deletions

File tree

src/auth/session.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ import {
9999
createChallenge,
100100
getChallenge,
101101
clearChallenge,
102+
consumeChallenge,
102103
createSession,
103104
requireSession,
104105
revokeSession,
@@ -134,6 +135,31 @@ describe("challenges", () => {
134135
clearChallenge("0xfeed");
135136
expect(getChallenge("0xfeed")).toBeNull();
136137
});
138+
139+
it("consumeChallenge returns the record exactly once, then null on reuse", () => {
140+
const { nonce } = createChallenge("0xC0FFEE");
141+
const first = consumeChallenge("0xc0ffee");
142+
expect(first?.nonce).toBe(nonce);
143+
const second = consumeChallenge("0xc0ffee");
144+
expect(second).toBeNull();
145+
});
146+
147+
it("consumeChallenge rejects an expired challenge instead of returning it", () => {
148+
createChallenge("0xdeadbeef");
149+
vi.advanceTimersByTime(CHALLENGE_TTL_MS + 1);
150+
expect(consumeChallenge("0xdeadbeef")).toBeNull();
151+
});
152+
153+
it("consumeChallenge deletes before any caller can read it again (closes the replay race)", () => {
154+
createChallenge("0xrace");
155+
// Simulates two concurrent /auth/verify requests reading the same nonce:
156+
// only the first should ever see a non-null record.
157+
const attempt1 = consumeChallenge("0xrace");
158+
const attempt2 = consumeChallenge("0xrace");
159+
expect(attempt1).not.toBeNull();
160+
expect(attempt2).toBeNull();
161+
expect(getChallenge("0xrace")).toBeNull();
162+
});
137163
});
138164

139165
describe("sessions", () => {
@@ -234,4 +260,4 @@ describe("sessions", () => {
234260
const ok = await requireSession("0xabc", token);
235261
expect(ok).toBe(false);
236262
});
237-
});
263+
});

src/auth/session.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,26 @@ export function clearChallenge(address: string) {
6363
challenges.delete(address.toLowerCase());
6464
}
6565

66+
/**
67+
* Atomically reads and deletes the challenge for an address in a single step.
68+
*
69+
* This must be used (instead of getChallenge + a later clearChallenge) anywhere a
70+
* challenge is about to be verified. getChallenge is read-only, so if it's read at
71+
* the start of an async verification and only cleared afterwards, two concurrent
72+
* requests can both read the same still-valid nonce before either one clears it —
73+
* letting the same challenge be consumed twice (a replay bypass). Deleting it at
74+
* read time closes that gap: the second concurrent caller sees it already gone.
75+
*
76+
* @param address - The user's Starknet wallet address
77+
* @returns The challenge record if it existed and was still valid, otherwise null
78+
*/
79+
export function consumeChallenge(address: string) {
80+
const rec = getChallenge(address);
81+
if (!rec) return null;
82+
challenges.delete(address.toLowerCase());
83+
return rec;
84+
}
85+
6686
/**
6787
* Creates a new session in PostgreSQL for the given wallet address.
6888
* Generates a random 24-byte hex token, hashes it with SHA-256 for database storage,
@@ -184,4 +204,4 @@ if (env.NODE_ENV !== "test") {
184204
});
185205
}, SESSION_SWEEP_INTERVAL_MS).unref();
186206
}
187-
/* v8 ignore stop */
207+
/* v8 ignore stop */

src/routes/auth.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,81 @@ describe("Auth Routes Integration", () => {
215215
expect(logoutPostLogoutRes.status).toBe(401);
216216
});
217217

218+
it("rejects verify once the challenge TTL has elapsed", async () => {
219+
const address = "0xExpiredChallenge";
220+
const appInstance = makeApp();
221+
222+
const challengeRes = await request(appInstance)
223+
.post("/api/v1/auth/challenge")
224+
.send({ address });
225+
expect(challengeRes.status).toBe(200);
226+
227+
vi.advanceTimersByTime(challengeRes.body.expires_in_ms + 1);
228+
229+
mockProvider.verifyMessageInStarknet.mockResolvedValue(true);
230+
const verifyRes = await request(appInstance)
231+
.post("/api/v1/auth/verify")
232+
.send({ address, signature: ["0xsig1", "0xsig2"] });
233+
234+
expect(verifyRes.status).toBe(400);
235+
expect(verifyRes.body.error).toMatch(/No active challenge/);
236+
});
237+
238+
it("rejects a replayed verify call reusing an already-consumed challenge", async () => {
239+
const address = "0xReplayAttempt";
240+
const appInstance = makeApp();
241+
242+
const challengeRes = await request(appInstance)
243+
.post("/api/v1/auth/challenge")
244+
.send({ address });
245+
expect(challengeRes.status).toBe(200);
246+
247+
mockProvider.verifyMessageInStarknet.mockResolvedValue(true);
248+
249+
const firstVerify = await request(appInstance)
250+
.post("/api/v1/auth/verify")
251+
.send({ address, signature: ["0xsig1", "0xsig2"] });
252+
expect(firstVerify.status).toBe(200);
253+
expect(firstVerify.body.ok).toBe(true);
254+
255+
// Replay: same address/signature submitted again after the challenge was consumed.
256+
const secondVerify = await request(appInstance)
257+
.post("/api/v1/auth/verify")
258+
.send({ address, signature: ["0xsig1", "0xsig2"] });
259+
260+
expect(secondVerify.status).toBe(400);
261+
expect(secondVerify.body.error).toMatch(/No active challenge/);
262+
// Only one session should have ever been created from the one valid challenge.
263+
expect(mockState.sessions).toHaveLength(1);
264+
});
265+
266+
it("accepts a valid challenge exactly once, even when verify is attempted concurrently", async () => {
267+
const address = "0xConcurrentVerify";
268+
const appInstance = makeApp();
269+
270+
const challengeRes = await request(appInstance)
271+
.post("/api/v1/auth/challenge")
272+
.send({ address });
273+
expect(challengeRes.status).toBe(200);
274+
275+
mockProvider.verifyMessageInStarknet.mockResolvedValue(true);
276+
277+
// Fire two verify requests concurrently off the same still-valid challenge.
278+
const [res1, res2] = await Promise.all([
279+
request(appInstance)
280+
.post("/api/v1/auth/verify")
281+
.send({ address, signature: ["0xsig1", "0xsig2"] }),
282+
request(appInstance)
283+
.post("/api/v1/auth/verify")
284+
.send({ address, signature: ["0xsig1", "0xsig2"] }),
285+
]);
286+
287+
const statuses = [res1.status, res2.status].sort();
288+
// Exactly one succeeds; the other finds the challenge already consumed.
289+
expect(statuses).toEqual([200, 400]);
290+
expect(mockState.sessions).toHaveLength(1);
291+
});
292+
218293
it("returns 401 for unauthorized endpoints with generic message", async () => {
219294
const appInstance = makeApp();
220295

@@ -226,4 +301,4 @@ describe("Auth Routes Integration", () => {
226301
expect(logoutRes.status).toBe(401);
227302
expect(logoutRes.body.error).toBe("Unauthorized");
228303
});
229-
});
304+
});

src/routes/auth.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import { z } from "zod";
33
import { provider, getCachedNetworkInfo } from "../starknet/client.js";
44
import { buildTypedChallenge } from "../auth/challenge.js";
55
import {
6-
clearChallenge,
6+
consumeChallenge,
77
createChallenge,
88
createSession,
9-
getChallenge,
109
requireSession,
1110
revokeSession,
1211
} from "../auth/session.js";
@@ -60,7 +59,10 @@ authRouter.post("/auth/challenge", async (req, res, next) => {
6059
authRouter.post("/auth/verify", async (req, res, next) => {
6160
try {
6261
const { address, signature } = VerifyBody.parse(req.body);
63-
const ch = getChallenge(address);
62+
// Consume (read + delete) the challenge atomically, before the async verify call,
63+
// so two concurrent requests can't both read it while it's still valid and both
64+
// pass verification off the same nonce.
65+
const ch = consumeChallenge(address);
6466
if (!ch) {
6567
res
6668
.status(400)
@@ -76,7 +78,6 @@ authRouter.post("/auth/verify", async (req, res, next) => {
7678
res.status(401).json({ error: "Invalid signature" });
7779
return;
7880
}
79-
clearChallenge(address);
8081
const session = await createSession(address);
8182
res.json({
8283
ok: true,
@@ -117,4 +118,4 @@ authRouter.post("/auth/logout", requireAuth, async (req, res, next) => {
117118
} catch (e) {
118119
next(e);
119120
}
120-
});
121+
});

0 commit comments

Comments
 (0)