Skip to content

Commit 56db73b

Browse files
committed
fix(core): revive date strings in safeJSONParse for pre-parsed objects (#8248)
1 parent 8a0475f commit 56db73b

File tree

2 files changed

+187
-12
lines changed

2 files changed

+187
-12
lines changed

packages/better-auth/src/db/internal-adapter.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,4 +1344,148 @@ describe("internal adapter test", async () => {
13441344
expect(ttl).toBeLessThanOrEqual(300);
13451345
});
13461346
});
1347+
1348+
describe("safeJSONParse date revival in secondary storage", () => {
1349+
/**
1350+
* Simulates a Redis client that auto-parses JSON (e.g. ioredis with
1351+
* certain configurations). The `get` method returns a pre-parsed object
1352+
* where date fields are still ISO 8601 strings, not Date instances.
1353+
*/
1354+
function createPreParsedStorage() {
1355+
const dataMap = new Map<string, any>();
1356+
const ttlMap = new Map<string, number>();
1357+
return {
1358+
dataMap,
1359+
ttlMap,
1360+
storage: {
1361+
set(key: string, value: string, ttl?: number) {
1362+
// Store as pre-parsed object (simulating Redis auto-parse)
1363+
dataMap.set(key, JSON.parse(value));
1364+
if (ttl) ttlMap.set(key, ttl);
1365+
},
1366+
get(key: string) {
1367+
return dataMap.get(key) ?? null;
1368+
},
1369+
delete(key: string) {
1370+
dataMap.delete(key);
1371+
ttlMap.delete(key);
1372+
},
1373+
},
1374+
};
1375+
}
1376+
1377+
it("should return Date objects from findVerificationValue when storage returns pre-parsed objects", async () => {
1378+
const { storage } = createPreParsedStorage();
1379+
1380+
const opts = {
1381+
database: new DatabaseSync(":memory:"),
1382+
secondaryStorage: storage,
1383+
} satisfies BetterAuthOptions;
1384+
1385+
(await getMigrations(opts)).runMigrations();
1386+
const ctx = await init(opts);
1387+
1388+
await ctx.internalAdapter.createVerificationValue({
1389+
identifier: "date-test",
1390+
value: "test-value",
1391+
expiresAt: new Date(Date.now() + 60000),
1392+
});
1393+
1394+
const found =
1395+
await ctx.internalAdapter.findVerificationValue("date-test");
1396+
expect(found).not.toBeNull();
1397+
expect(found!.expiresAt).toBeInstanceOf(Date);
1398+
expect(found!.createdAt).toBeInstanceOf(Date);
1399+
expect(found!.updatedAt).toBeInstanceOf(Date);
1400+
});
1401+
1402+
it("should correctly detect expired verification when storage returns pre-parsed objects", async () => {
1403+
const { storage } = createPreParsedStorage();
1404+
1405+
const opts = {
1406+
database: new DatabaseSync(":memory:"),
1407+
secondaryStorage: storage,
1408+
} satisfies BetterAuthOptions;
1409+
1410+
await (await getMigrations(opts)).runMigrations();
1411+
const ctx = await init(opts);
1412+
1413+
await ctx.internalAdapter.createVerificationValue({
1414+
identifier: "expiry-check",
1415+
value: "test-value",
1416+
expiresAt: new Date(Date.now() + 60000),
1417+
});
1418+
1419+
const found =
1420+
await ctx.internalAdapter.findVerificationValue("expiry-check");
1421+
expect(found).not.toBeNull();
1422+
// This comparison would silently fail if expiresAt were a string
1423+
// because string < Date coerces to NaN, making it always false
1424+
expect(found!.expiresAt > new Date()).toBe(true);
1425+
expect(found!.expiresAt < new Date(Date.now() + 120000)).toBe(true);
1426+
});
1427+
1428+
it("should return Date objects for all date fields across multiple reads", async () => {
1429+
const { storage } = createPreParsedStorage();
1430+
1431+
const opts = {
1432+
database: new DatabaseSync(":memory:"),
1433+
secondaryStorage: storage,
1434+
} satisfies BetterAuthOptions;
1435+
1436+
await (await getMigrations(opts)).runMigrations();
1437+
const ctx = await init(opts);
1438+
1439+
const expiresAt = new Date(Date.now() + 60000);
1440+
await ctx.internalAdapter.createVerificationValue({
1441+
identifier: "multi-read-test",
1442+
value: "test-value",
1443+
expiresAt,
1444+
});
1445+
1446+
// First read: safeJSONParse receives pre-parsed object from storage
1447+
const first =
1448+
await ctx.internalAdapter.findVerificationValue("multi-read-test");
1449+
expect(first).not.toBeNull();
1450+
expect(first!.expiresAt).toBeInstanceOf(Date);
1451+
expect(first!.createdAt).toBeInstanceOf(Date);
1452+
expect(first!.updatedAt).toBeInstanceOf(Date);
1453+
1454+
// Second read: verify consistency (the stored object wasn't mutated)
1455+
const second =
1456+
await ctx.internalAdapter.findVerificationValue("multi-read-test");
1457+
expect(second).not.toBeNull();
1458+
expect(second!.expiresAt).toBeInstanceOf(Date);
1459+
expect(second!.expiresAt.getTime()).toBe(first!.expiresAt.getTime());
1460+
});
1461+
1462+
it("should preserve non-date string fields when reviving dates", async () => {
1463+
const { storage } = createPreParsedStorage();
1464+
1465+
const opts = {
1466+
database: new DatabaseSync(":memory:"),
1467+
secondaryStorage: storage,
1468+
} satisfies BetterAuthOptions;
1469+
1470+
await (await getMigrations(opts)).runMigrations();
1471+
const ctx = await init(opts);
1472+
1473+
await ctx.internalAdapter.createVerificationValue({
1474+
identifier: "string-field-test",
1475+
value: "my-token-value-123",
1476+
expiresAt: new Date(Date.now() + 60000),
1477+
});
1478+
1479+
const found =
1480+
await ctx.internalAdapter.findVerificationValue("string-field-test");
1481+
expect(found).not.toBeNull();
1482+
// Non-date strings must NOT be converted
1483+
expect(found!.identifier).toBe("string-field-test");
1484+
expect(typeof found!.identifier).toBe("string");
1485+
expect(found!.value).toBe("my-token-value-123");
1486+
expect(typeof found!.value).toBe("string");
1487+
// Date strings MUST be converted
1488+
expect(found!.expiresAt).toBeInstanceOf(Date);
1489+
});
1490+
});
13471491
});

packages/core/src/utils/json.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,54 @@
11
import { logger } from "../env";
22

3-
export function safeJSONParse<T>(data: unknown): T | null {
4-
function reviver(_: string, value: any): any {
5-
if (typeof value === "string") {
6-
const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
7-
if (iso8601Regex.test(value)) {
8-
const date = new Date(value);
9-
if (!isNaN(date.getTime())) {
10-
return date;
11-
}
12-
}
3+
const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
4+
5+
function reviveDate(value: unknown): any {
6+
if (typeof value === "string" && iso8601Regex.test(value)) {
7+
const date = new Date(value);
8+
if (!isNaN(date.getTime())) {
9+
return date;
1310
}
11+
}
12+
return value;
13+
}
14+
15+
/**
16+
* Recursively walk a pre-parsed object and convert ISO 8601 date strings
17+
* to Date instances. This handles the case where a Redis client (or similar)
18+
* returns already-parsed JSON objects whose date fields are still strings.
19+
*/
20+
function reviveDates(value: unknown): any {
21+
if (value === null || value === undefined) {
22+
return value;
23+
}
24+
if (typeof value === "string") {
25+
return reviveDate(value);
26+
}
27+
if (value instanceof Date) {
1428
return value;
1529
}
30+
if (Array.isArray(value)) {
31+
return value.map(reviveDates);
32+
}
33+
if (typeof value === "object") {
34+
const result: Record<string, any> = {};
35+
for (const key of Object.keys(value)) {
36+
result[key] = reviveDates((value as Record<string, any>)[key]);
37+
}
38+
return result;
39+
}
40+
return value;
41+
}
42+
43+
export function safeJSONParse<T>(data: unknown): T | null {
1644
try {
1745
if (typeof data !== "string") {
18-
return data as T;
46+
if (data === null || data === undefined) {
47+
return null;
48+
}
49+
return reviveDates(data) as T;
1950
}
20-
return JSON.parse(data, reviver);
51+
return JSON.parse(data, (_, value) => reviveDate(value));
2152
} catch (e) {
2253
logger.error("Error parsing JSON", { error: e });
2354
return null;

0 commit comments

Comments
 (0)