diff --git a/.changeset/mongo-objectid-normalize.md b/.changeset/mongo-objectid-normalize.md new file mode 100644 index 0000000000..124e62d12a --- /dev/null +++ b/.changeset/mongo-objectid-normalize.md @@ -0,0 +1,5 @@ +--- +"@better-auth/mongo-adapter": patch +--- + +Recognize ObjectId and UUID values returned by a `mongodb` driver that loaded a different `bson` module instance, so ids are normalized to strings instead of failing `instanceof`. diff --git a/packages/mongo-adapter/src/mongodb-adapter.test.ts b/packages/mongo-adapter/src/mongodb-adapter.test.ts index 5142aa33d2..1d6ba53509 100644 --- a/packages/mongo-adapter/src/mongodb-adapter.test.ts +++ b/packages/mongo-adapter/src/mongodb-adapter.test.ts @@ -1,3 +1,4 @@ +import type { BetterAuthOptions } from "@better-auth/core"; import type { Db } from "mongodb"; import { ObjectId, UUID } from "mongodb"; import { describe, expect, it, vi } from "vitest"; @@ -535,3 +536,74 @@ describe("uuid support", () => { expect((result as Record).id).toBe(uuid); }); }); + +/** + * When the driver and the adapter resolve different `bson` module instances + * (CJS vs ESM entry point), driver values fail `instanceof` against the + * adapter's classes and must be recognized structurally instead. + * @see https://github.com/better-auth/better-auth/issues/11065 + */ +describe("ids from a different bson instance", () => { + const hex = "000000000000000000000001"; + const uuid = "550e8400-e29b-41d4-a716-446655440000"; + const foreignObjectId = { _bsontype: "ObjectId", toHexString: () => hex }; + const foreignUUID = { + _bsontype: "Binary", + sub_type: 4, + toHexString: () => uuid, + toString: () => uuid, + }; + + function createAdapterReturning( + document: Record, + generateId?: "uuid", + ) { + const aggregate = vi.fn(() => ({ + toArray: vi.fn(async () => [document]), + })); + const db = { collection: vi.fn(() => ({ aggregate })) } as unknown as Db; + const adapter = mongodbAdapter(db, { transaction: false })({ + advanced: { database: generateId ? { generateId } : {} }, + } as unknown as BetterAuthOptions); + return { adapter, aggregate }; + } + + it("normalizes a foreign ObjectId to its hex string", async () => { + const { adapter } = createAdapterReturning({ + _id: foreignObjectId, + email: "example@example.test", + }); + const user = await adapter.findOne<{ id: unknown }>({ + model: "user", + where: [], + }); + expect(user?.id).toBe(hex); + }); + + it("normalizes a foreign UUID to its string form", async () => { + const { adapter } = createAdapterReturning( + { _id: foreignUUID, email: "example@example.test" }, + "uuid", + ); + const user = await adapter.findOne<{ id: unknown }>({ + model: "user", + where: [], + }); + expect(user?.id).toBe(uuid); + }); + + it("accepts a foreign ObjectId as a where clause value", async () => { + const { adapter, aggregate } = createAdapterReturning({ + _id: foreignObjectId, + email: "example@example.test", + }); + await adapter.findOne({ + model: "user", + where: [{ field: "id", value: foreignObjectId as unknown as string }], + }); + expect(aggregate).toHaveBeenCalledWith( + expect.arrayContaining([{ $match: { _id: foreignObjectId } }]), + expect.anything(), + ); + }); +}); diff --git a/packages/mongo-adapter/src/mongodb-adapter.ts b/packages/mongo-adapter/src/mongodb-adapter.ts index b70c58d474..e2b9606ccc 100644 --- a/packages/mongo-adapter/src/mongodb-adapter.ts +++ b/packages/mongo-adapter/src/mongodb-adapter.ts @@ -21,6 +21,32 @@ import { insensitiveStartsWith, } from "./query-builders"; +/** + * Structural checks for BSON ids. The driver may resolve a different `bson` + * module instance than the adapter (CJS vs ESM entry point), in which case + * its values fail `instanceof` against the adapter's classes. + * @see https://github.com/better-auth/better-auth/issues/11065 + */ +function isObjectIdLike(value: unknown): value is ObjectId { + if (typeof value !== "object" || value === null) return false; + const { _bsontype, toHexString } = value as Record; + return ( + (_bsontype === "ObjectId" || _bsontype === "ObjectID") && + typeof toHexString === "function" + ); +} + +function isUUIDLike(value: unknown): value is UUID { + if (typeof value !== "object" || value === null) return false; + const { _bsontype, sub_type, toHexString } = value as Record; + // A BSON UUID is a Binary with sub_type 4. + return ( + _bsontype === "Binary" && + sub_type === 4 && + typeof toHexString === "function" + ); +} + class MongoAdapterError extends Error { constructor( public code: "INVALID_ID" | "UNSUPPORTED_OPERATOR", @@ -157,8 +183,8 @@ export const mongodbAdapter = ( } function isIdInstance(value: unknown): value is ObjectId | UUID { - if (useUUIDs) return value instanceof UUID; - return value instanceof ObjectId; + if (useUUIDs) return isUUIDLike(value); + return isObjectIdLike(value); } function serializeID({ @@ -801,9 +827,9 @@ export const mongodbAdapter = ( if (action !== "create" && action !== "update") { return data; } - const IdClass = - options.advanced?.database?.generateId === "uuid" ? UUID : ObjectId; - if (data instanceof IdClass) { + const useUUIDs = options.advanced?.database?.generateId === "uuid"; + const IdClass = useUUIDs ? UUID : ObjectId; + if (useUUIDs ? isUUIDLike(data) : isObjectIdLike(data)) { return data; } if (Array.isArray(data)) { @@ -841,18 +867,18 @@ export const mongodbAdapter = ( }, customTransformOutput({ data, field, fieldAttributes }) { if (field === "id" || fieldAttributes.references?.field === "id") { - if (data instanceof UUID) { + if (isUUIDLike(data)) { return data.toString(); } - if (data instanceof ObjectId) { + if (isObjectIdLike(data)) { return data.toHexString(); } if (Array.isArray(data)) { return data.map((v) => { - if (v instanceof UUID) { + if (isUUIDLike(v)) { return v.toString(); } - if (v instanceof ObjectId) { + if (isObjectIdLike(v)) { return v.toHexString(); } return v;