mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
fix(core): revive date strings in safeJSONParse for pre-parsed objects (#8248)
This commit is contained in:
@@ -1344,4 +1344,148 @@ describe("internal adapter test", async () => {
|
||||
expect(ttl).toBeLessThanOrEqual(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeJSONParse date revival in secondary storage", () => {
|
||||
/**
|
||||
* Simulates a Redis client that auto-parses JSON (e.g. ioredis with
|
||||
* certain configurations). The `get` method returns a pre-parsed object
|
||||
* where date fields are still ISO 8601 strings, not Date instances.
|
||||
*/
|
||||
function createPreParsedStorage() {
|
||||
const dataMap = new Map<string, any>();
|
||||
const ttlMap = new Map<string, number>();
|
||||
return {
|
||||
dataMap,
|
||||
ttlMap,
|
||||
storage: {
|
||||
set(key: string, value: string, ttl?: number) {
|
||||
// Store as pre-parsed object (simulating Redis auto-parse)
|
||||
dataMap.set(key, JSON.parse(value));
|
||||
if (ttl) ttlMap.set(key, ttl);
|
||||
},
|
||||
get(key: string) {
|
||||
return dataMap.get(key) ?? null;
|
||||
},
|
||||
delete(key: string) {
|
||||
dataMap.delete(key);
|
||||
ttlMap.delete(key);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("should return Date objects from findVerificationValue when storage returns pre-parsed objects", async () => {
|
||||
const { storage } = createPreParsedStorage();
|
||||
|
||||
const opts = {
|
||||
database: new DatabaseSync(":memory:"),
|
||||
secondaryStorage: storage,
|
||||
} satisfies BetterAuthOptions;
|
||||
|
||||
(await getMigrations(opts)).runMigrations();
|
||||
const ctx = await init(opts);
|
||||
|
||||
await ctx.internalAdapter.createVerificationValue({
|
||||
identifier: "date-test",
|
||||
value: "test-value",
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
});
|
||||
|
||||
const found =
|
||||
await ctx.internalAdapter.findVerificationValue("date-test");
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.expiresAt).toBeInstanceOf(Date);
|
||||
expect(found!.createdAt).toBeInstanceOf(Date);
|
||||
expect(found!.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should correctly detect expired verification when storage returns pre-parsed objects", async () => {
|
||||
const { storage } = createPreParsedStorage();
|
||||
|
||||
const opts = {
|
||||
database: new DatabaseSync(":memory:"),
|
||||
secondaryStorage: storage,
|
||||
} satisfies BetterAuthOptions;
|
||||
|
||||
await (await getMigrations(opts)).runMigrations();
|
||||
const ctx = await init(opts);
|
||||
|
||||
await ctx.internalAdapter.createVerificationValue({
|
||||
identifier: "expiry-check",
|
||||
value: "test-value",
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
});
|
||||
|
||||
const found =
|
||||
await ctx.internalAdapter.findVerificationValue("expiry-check");
|
||||
expect(found).not.toBeNull();
|
||||
// This comparison would silently fail if expiresAt were a string
|
||||
// because string < Date coerces to NaN, making it always false
|
||||
expect(found!.expiresAt > new Date()).toBe(true);
|
||||
expect(found!.expiresAt < new Date(Date.now() + 120000)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return Date objects for all date fields across multiple reads", async () => {
|
||||
const { storage } = createPreParsedStorage();
|
||||
|
||||
const opts = {
|
||||
database: new DatabaseSync(":memory:"),
|
||||
secondaryStorage: storage,
|
||||
} satisfies BetterAuthOptions;
|
||||
|
||||
await (await getMigrations(opts)).runMigrations();
|
||||
const ctx = await init(opts);
|
||||
|
||||
const expiresAt = new Date(Date.now() + 60000);
|
||||
await ctx.internalAdapter.createVerificationValue({
|
||||
identifier: "multi-read-test",
|
||||
value: "test-value",
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// First read: safeJSONParse receives pre-parsed object from storage
|
||||
const first =
|
||||
await ctx.internalAdapter.findVerificationValue("multi-read-test");
|
||||
expect(first).not.toBeNull();
|
||||
expect(first!.expiresAt).toBeInstanceOf(Date);
|
||||
expect(first!.createdAt).toBeInstanceOf(Date);
|
||||
expect(first!.updatedAt).toBeInstanceOf(Date);
|
||||
|
||||
// Second read: verify consistency (the stored object wasn't mutated)
|
||||
const second =
|
||||
await ctx.internalAdapter.findVerificationValue("multi-read-test");
|
||||
expect(second).not.toBeNull();
|
||||
expect(second!.expiresAt).toBeInstanceOf(Date);
|
||||
expect(second!.expiresAt.getTime()).toBe(first!.expiresAt.getTime());
|
||||
});
|
||||
|
||||
it("should preserve non-date string fields when reviving dates", async () => {
|
||||
const { storage } = createPreParsedStorage();
|
||||
|
||||
const opts = {
|
||||
database: new DatabaseSync(":memory:"),
|
||||
secondaryStorage: storage,
|
||||
} satisfies BetterAuthOptions;
|
||||
|
||||
await (await getMigrations(opts)).runMigrations();
|
||||
const ctx = await init(opts);
|
||||
|
||||
await ctx.internalAdapter.createVerificationValue({
|
||||
identifier: "string-field-test",
|
||||
value: "my-token-value-123",
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
});
|
||||
|
||||
const found =
|
||||
await ctx.internalAdapter.findVerificationValue("string-field-test");
|
||||
expect(found).not.toBeNull();
|
||||
// Non-date strings must NOT be converted
|
||||
expect(found!.identifier).toBe("string-field-test");
|
||||
expect(typeof found!.identifier).toBe("string");
|
||||
expect(found!.value).toBe("my-token-value-123");
|
||||
expect(typeof found!.value).toBe("string");
|
||||
// Date strings MUST be converted
|
||||
expect(found!.expiresAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,54 @@
|
||||
import { logger } from "../env";
|
||||
|
||||
export function safeJSONParse<T>(data: unknown): T | null {
|
||||
function reviver(_: string, value: any): any {
|
||||
if (typeof value === "string") {
|
||||
const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
||||
if (iso8601Regex.test(value)) {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
||||
|
||||
function reviveDate(value: unknown): any {
|
||||
if (typeof value === "string" && iso8601Regex.test(value)) {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk a pre-parsed object and convert ISO 8601 date strings
|
||||
* to Date instances. This handles the case where a Redis client (or similar)
|
||||
* returns already-parsed JSON objects whose date fields are still strings.
|
||||
*/
|
||||
function reviveDates(value: unknown): any {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return reviveDate(value);
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(reviveDates);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const result: Record<string, any> = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
result[key] = reviveDates((value as Record<string, any>)[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function safeJSONParse<T>(data: unknown): T | null {
|
||||
try {
|
||||
if (typeof data !== "string") {
|
||||
return data as T;
|
||||
if (data === null || data === undefined) {
|
||||
return null;
|
||||
}
|
||||
return reviveDates(data) as T;
|
||||
}
|
||||
return JSON.parse(data, reviver);
|
||||
return JSON.parse(data, (_, value) => reviveDate(value));
|
||||
} catch (e) {
|
||||
logger.error("Error parsing JSON", { error: e });
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user