@kitiumai/auth-mongo
Version:
Enterprise-grade MongoDB storage adapter for @kitiumai/auth with full support for users, sessions, OAuth links, API keys, 2FA, RBAC, and SSO
1 lines • 85.5 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/mongo.ts"],"sourcesContent":["/**\n * MongoDB Storage Adapter for Kitium Auth\n * Production-ready MongoDB database adapter\n */\n\nexport { MongoStorageAdapter } from './mongo';\nexport type { MongoStorageAdapter as MongoAdapter } from './mongo';\n","import {\n MongoClient,\n Db,\n Collection,\n MongoClientOptions,\n Document,\n Filter,\n UpdateFilter,\n} from 'mongodb';\nimport { getLogger } from '@kitiumai/logger';\nimport { InternalError } from '@kitiumai/error';\nimport { randomBytes, createHash, scryptSync, timingSafeEqual } from 'node:crypto';\n\n// Utility functions\nconst generateId = (): string => {\n return randomBytes(16).toString('hex');\n};\n\nconst generateApiKey = (prefix: string): string => {\n const secret = randomBytes(32).toString('hex');\n return `${prefix}_${secret}`;\n};\n\ntype ApiKeyHashAlgorithm = 'sha256' | 'scrypt';\n\nconst hashApiKey = (\n key: string,\n algorithm: ApiKeyHashAlgorithm,\n salt?: Buffer,\n // eslint-disable-next-line @typescript-eslint/naming-convention\n scryptParams: { N: number; r: number; p: number } = { N: 16384, r: 8, p: 1 }\n): string => {\n if (algorithm === 'scrypt') {\n const result = scryptSync(key, salt ?? Buffer.alloc(0), 32, scryptParams);\n return result.toString('hex');\n }\n\n return createHash('sha256').update(key).digest('hex');\n};\nimport { setTimeout as delay } from 'node:timers/promises';\nimport type {\n StorageAdapter,\n ApiKeyRecord,\n SessionRecord,\n OrganizationRecord,\n AuthEvent,\n UserRecord,\n CreateUserInput,\n UpdateUserInput,\n OAuthLink,\n EmailVerificationToken,\n OrganizationMember,\n RoleRecord,\n TwoFactorDevice,\n BackupCode,\n TwoFactorSession,\n SSOLink,\n SSOSession,\n Permission,\n SSOProviderType,\n TwoFactorMethod,\n} from '@kitiumai/auth';\n\ninterface MongoDocument extends Document {\n _id?: string;\n [key: string]: unknown;\n}\n\ntype MongoAdapterOptions = MongoClientOptions & {\n operationTimeoutMS?: number;\n maxRetries?: number;\n databaseName?: string;\n apiKeyHashAlgorithm?: ApiKeyHashAlgorithm;\n apiKeySalt?: Buffer;\n};\n\nexport class MongoStorageAdapter implements StorageAdapter {\n private client: MongoClient;\n private db: Db | null = null;\n private readonly logger = getLogger();\n private readonly defaultRetries: number;\n private readonly databaseName: string;\n private readonly operationTimeoutMS: number | null;\n private readonly apiKeyHashAlgorithm: ApiKeyHashAlgorithm;\n private readonly apiKeySalt?: Buffer;\n\n constructor(connectionString: string, options?: MongoAdapterOptions) {\n const {\n operationTimeoutMS,\n maxRetries,\n databaseName,\n apiKeyHashAlgorithm,\n apiKeySalt,\n ...clientOptions\n } = options ?? {};\n\n this.client = new MongoClient(connectionString, {\n maxPoolSize: 10,\n minPoolSize: 2,\n maxIdleTimeMS: 30_000,\n serverSelectionTimeoutMS: 5_000,\n ...clientOptions,\n });\n\n this.defaultRetries = maxRetries ?? 2;\n this.databaseName = databaseName ?? 'auth';\n this.operationTimeoutMS = operationTimeoutMS ?? null;\n this.apiKeyHashAlgorithm = apiKeyHashAlgorithm ?? 'sha256';\n if (apiKeySalt !== undefined) {\n this.apiKeySalt = apiKeySalt;\n }\n }\n\n async connect(): Promise<void> {\n try {\n await this.client.connect();\n this.db = this.client.db(this.databaseName);\n await this.runMigrations();\n this.logger.info('MongoDB adapter connected successfully');\n } catch (error) {\n this.logger.error('Failed to connect to MongoDB', { error });\n throw new InternalError({\n code: 'auth-mongo/connection_failed',\n message: 'Failed to connect to MongoDB',\n severity: 'error',\n retryable: true,\n cause: error,\n });\n }\n }\n\n async disconnect(): Promise<void> {\n try {\n await this.client.close();\n this.logger.info('MongoDB adapter disconnected');\n } catch (error) {\n this.logger.error('Error disconnecting from MongoDB', { error });\n throw new InternalError({\n code: 'auth-mongo/disconnect_failed',\n message: 'Failed to disconnect from MongoDB',\n severity: 'error',\n retryable: false,\n cause: error,\n });\n }\n }\n\n private getDatabase(): Db {\n if (!this.db) {\n throw new InternalError({\n code: 'auth-mongo/not_connected',\n message: 'MongoDB client not connected',\n severity: 'error',\n retryable: false,\n });\n }\n return this.db;\n }\n\n private getCollection<T extends MongoDocument>(name: string): Collection<T> {\n return this.getDatabase().collection<T>(name);\n }\n\n private async withRetry<T>(operation: () => Promise<T>, operationName: string): Promise<T> {\n let lastError: unknown;\n\n for (let attempt = 0; attempt <= this.defaultRetries; attempt += 1) {\n try {\n const start = Date.now();\n let timeoutHandle: NodeJS.Timeout | undefined;\n const timeoutPromise =\n this.operationTimeoutMS !== null\n ? new Promise<never>((_, reject) => {\n timeoutHandle = setTimeout(() => {\n reject(\n new InternalError({\n code: 'auth-mongo/operation_timeout',\n message: `MongoDB operation exceeded ${this.operationTimeoutMS}ms: ${operationName}`,\n severity: 'error',\n retryable: false,\n })\n );\n }, this.operationTimeoutMS as number);\n })\n : null;\n\n const result = timeoutPromise\n ? await Promise.race([operation(), timeoutPromise])\n : await operation();\n\n if (timeoutHandle) {\n clearTimeout(timeoutHandle);\n }\n\n const durationMs = Date.now() - start;\n\n this.logger.debug('mongo.operation', {\n operation: operationName,\n durationMs,\n });\n\n return result;\n } catch (error) {\n lastError = error;\n const retryable = attempt < this.defaultRetries;\n\n this.logger.warn('MongoDB operation failed', {\n operation: operationName,\n attempt,\n retryable,\n error,\n });\n\n if (!retryable) {\n if (error instanceof InternalError && error.code === 'auth-mongo/operation_timeout') {\n throw error;\n }\n throw new InternalError({\n code: 'auth-mongo/operation_failed',\n message: `Failed to execute MongoDB operation: ${operationName}`,\n severity: 'error',\n retryable: false,\n cause: error,\n });\n }\n\n await delay(50 * 2 ** attempt);\n }\n }\n\n throw lastError;\n }\n\n async healthCheck(): Promise<{ status: 'ok' | 'error'; latencyMs: number }> {\n const start = Date.now();\n try {\n await this.getDatabase().admin().ping();\n return { status: 'ok', latencyMs: Date.now() - start };\n } catch (error) {\n this.logger.error('MongoDB health check failed', { error });\n return { status: 'error', latencyMs: Date.now() - start };\n }\n }\n\n private async runMigrations(): Promise<void> {\n const migrationsCollection = this.getCollection<{ id: string; appliedAt: Date }>(\n 'auth_migrations'\n );\n\n const migrationId = '0001_initial_schema_v2';\n const existing = await migrationsCollection.findOne({ id: migrationId });\n\n if (existing) {\n return;\n }\n\n // Create all collections and indexes\n await this.createCollections();\n await this.createIndexes();\n\n // Record migration\n await migrationsCollection.insertOne({\n id: migrationId,\n appliedAt: new Date(),\n });\n }\n\n private async createCollections(): Promise<void> {\n const db = this.getDatabase();\n const collections = [\n 'users',\n 'api_keys',\n 'sessions',\n 'organizations',\n 'email_verification_tokens',\n 'email_verification_token_attempts',\n 'auth_events',\n 'roles',\n 'user_roles',\n 'sso_providers',\n 'sso_links',\n 'sso_sessions',\n 'twofa_devices',\n 'twofa_backup_codes',\n 'twofa_sessions',\n ];\n\n const existingCollections = await db.listCollections().toArray();\n const existingNames = new Set(existingCollections.map((c) => c.name));\n\n for (const collectionName of collections) {\n if (!existingNames.has(collectionName)) {\n await db.createCollection(collectionName);\n }\n }\n }\n\n private async createIndexes(): Promise<void> {\n // Users indexes\n await this.getCollection('users').createIndexes([\n { key: { email: 1 }, unique: true, sparse: true },\n { key: { createdAt: 1 } },\n ]);\n\n // API Keys indexes\n await this.getCollection('api_keys').createIndexes([\n { key: { principalId: 1 } },\n { key: { hash: 1 }, unique: true },\n { key: { prefix: 1, lastFour: 1 } },\n { key: { expiresAt: 1 }, sparse: true },\n ]);\n\n // Sessions indexes\n await this.getCollection('sessions').createIndexes([\n { key: { userId: 1 } },\n { key: { expiresAt: 1 }, expireAfterSeconds: 0 }, // TTL index\n ]);\n\n // Organizations indexes\n await this.getCollection('organizations').createIndexes([\n { key: { plan: 1 } },\n { key: { createdAt: 1 } },\n ]);\n\n // Email verification tokens indexes\n await this.getCollection('email_verification_tokens').createIndexes([\n { key: { email: 1 } },\n { key: { type: 1 } },\n { key: { codeHash: 1 } },\n { key: { expiresAt: 1 }, expireAfterSeconds: 0 }, // TTL index\n ]);\n\n // Auth events indexes\n await this.getCollection('auth_events').createIndexes([\n { key: { principalId: 1 } },\n { key: { type: 1 } },\n { key: { timestamp: 1 } },\n ]);\n\n // Roles indexes\n await this.getCollection('roles').createIndexes([\n { key: { orgId: 1 } },\n { key: { createdAt: 1 } },\n ]);\n\n // User roles indexes\n await this.getCollection('user_roles').createIndexes([\n { key: { userId: 1 } },\n { key: { roleId: 1 } },\n { key: { orgId: 1 } },\n ]);\n\n // SSO providers indexes\n await this.getCollection('sso_providers').createIndexes([\n { key: { orgId: 1 } },\n { key: { type: 1 } },\n ]);\n\n // SSO links indexes\n await this.getCollection('sso_links').createIndexes([\n { key: { userId: 1 } },\n { key: { providerId: 1 } },\n { key: { providerType: 1, providerSubject: 1 } },\n ]);\n\n // SSO sessions indexes\n await this.getCollection('sso_sessions').createIndexes([\n { key: { userId: 1 } },\n { key: { providerId: 1 } },\n { key: { expiresAt: 1 }, expireAfterSeconds: 0 }, // TTL index\n ]);\n\n // 2FA devices indexes\n await this.getCollection('twofa_devices').createIndexes([\n { key: { userId: 1 } },\n { key: { createdAt: 1 } },\n ]);\n\n // 2FA backup codes indexes\n await this.getCollection('twofa_backup_codes').createIndexes([\n { key: { userId: 1 } },\n { key: { used: 1 } },\n ]);\n\n // 2FA sessions indexes\n await this.getCollection('twofa_sessions').createIndexes([\n { key: { userId: 1 } },\n { key: { sessionId: 1 } },\n { key: { expiresAt: 1 }, expireAfterSeconds: 0 }, // TTL index\n ]);\n }\n\n /**\n * Create an API key with plaintext secret (convenience method)\n */\n async createApiKeyWithSecret(\n principalId: string,\n scopes: string[],\n prefix: string = 'api'\n ): Promise<{ record: ApiKeyRecord; key: string }> {\n const key = generateApiKey(prefix);\n const hash = hashApiKey(key, this.apiKeyHashAlgorithm, this.apiKeySalt);\n const parts = key.split('_');\n const lastFour = parts[parts.length - 1]!.slice(-4);\n\n const record = await this.createApiKey({\n principalId,\n hash,\n prefix,\n lastFour,\n scopes,\n });\n\n return { record, key };\n }\n\n private hashApiKeySecret(secret: string): string {\n return hashApiKey(secret, this.apiKeyHashAlgorithm, this.apiKeySalt);\n }\n\n async verifyApiKeySecret(rawKey: string): Promise<ApiKeyRecord | null> {\n const parts = rawKey.split('_');\n if (parts.length < 2) {\n return null;\n }\n\n const prefix = parts[0]!;\n const lastFour = parts[parts.length - 1]!.slice(-4);\n const candidates = await this.getApiKeysByPrefixAndLastFour(prefix, lastFour);\n\n if (!candidates.length) {\n return null;\n }\n\n const hashed = this.hashApiKeySecret(rawKey);\n const hashedBuffer = Buffer.from(hashed, 'hex');\n\n for (const candidate of candidates) {\n const candidateHash = Buffer.from(candidate.hash, 'hex');\n if (\n candidateHash.length === hashedBuffer.length &&\n timingSafeEqual(candidateHash, hashedBuffer)\n ) {\n return candidate;\n }\n }\n\n return null;\n }\n\n async rotateApiKey(\n id: string,\n options?: { scopes?: string[]; expiresOldKeysAt?: Date }\n ): Promise<{ record: ApiKeyRecord; key: string }> {\n const existing = await this.getApiKey(id);\n\n if (!existing) {\n throw new InternalError({\n code: 'auth-mongo/api_key_not_found',\n message: 'API key not found for rotation',\n severity: 'error',\n retryable: false,\n });\n }\n\n const rotationExpiry = options?.expiresOldKeysAt ?? new Date();\n await this.updateApiKey(id, { expiresAt: rotationExpiry });\n\n return this.createApiKeyWithSecret(\n existing.principalId,\n options?.scopes ?? existing.scopes,\n existing.prefix\n );\n }\n\n // API Key methods\n async createApiKey(\n data: Omit<ApiKeyRecord, 'id' | 'createdAt' | 'updatedAt'>\n ): Promise<ApiKeyRecord> {\n return this.withRetry(async () => {\n const id = generateId();\n const now = new Date();\n\n const doc = {\n _id: id,\n principalId: data.principalId,\n hash: data.hash,\n prefix: data.prefix,\n lastFour: data.lastFour,\n scopes: data.scopes,\n metadata: data.metadata ?? {},\n expiresAt: data.expiresAt ?? null,\n createdAt: now,\n updatedAt: now,\n };\n\n await this.getCollection('api_keys').insertOne(doc);\n return this.mapApiKeyRecord(doc);\n }, 'createApiKey');\n }\n\n async getApiKey(id: string): Promise<ApiKeyRecord | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('api_keys').findOne({ _id: id });\n return doc ? this.mapApiKeyRecord(doc) : null;\n }, 'getApiKey');\n }\n\n async getApiKeyByHash(hash: string): Promise<ApiKeyRecord | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('api_keys').findOne({ hash });\n return doc ? this.mapApiKeyRecord(doc) : null;\n }, 'getApiKeyByHash');\n }\n\n async getApiKeysByPrefixAndLastFour(prefix: string, lastFour: string): Promise<ApiKeyRecord[]> {\n return this.withRetry(async () => {\n const docs = await this.getCollection('api_keys').find({ prefix, lastFour }).toArray();\n return docs.map((doc) => this.mapApiKeyRecord(doc));\n }, 'getApiKeysByPrefixAndLastFour');\n }\n\n async updateApiKey(id: string, data: Partial<ApiKeyRecord>): Promise<ApiKeyRecord> {\n return this.withRetry(async () => {\n const updateDoc: UpdateFilter<MongoDocument> = {\n $set: {\n updatedAt: new Date(),\n },\n };\n\n const setFields = updateDoc.$set as MongoDocument;\n\n for (const [key, value] of Object.entries(data)) {\n if (key === 'id' || key === 'createdAt') {\n continue;\n }\n setFields[key] = value;\n }\n\n const result = await this.getCollection('api_keys').findOneAndUpdate({ _id: id }, updateDoc, {\n returnDocument: 'after',\n });\n\n if (!result) {\n throw new InternalError({\n code: 'auth-mongo/update_failed',\n message: 'Failed to update API key',\n severity: 'error',\n retryable: false,\n });\n }\n\n return this.mapApiKeyRecord(result);\n }, 'updateApiKey');\n }\n\n async deleteApiKey(id: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('api_keys').deleteOne({ _id: id });\n }, 'deleteApiKey');\n }\n\n async listApiKeys(principalId: string): Promise<ApiKeyRecord[]> {\n return this.withRetry(async () => {\n const docs = await this.getCollection('api_keys')\n .find({ principalId })\n .sort({ createdAt: -1 })\n .toArray();\n return docs.map((doc) => this.mapApiKeyRecord(doc));\n }, 'listApiKeys');\n }\n\n // Session methods\n async createSession(data: Omit<SessionRecord, 'id' | 'createdAt'>): Promise<SessionRecord> {\n return this.withRetry(async () => {\n const id = generateId();\n const now = new Date();\n\n const doc = {\n _id: id,\n userId: data.userId,\n orgId: data.orgId || null,\n plan: data.plan || null,\n entitlements: data.entitlements || [],\n expiresAt: data.expiresAt,\n metadata: data.metadata || {},\n createdAt: now,\n updatedAt: now,\n };\n\n await this.getCollection('sessions').insertOne(doc);\n return this.mapSessionRecord(doc);\n }, 'createSession');\n }\n\n async getSession(id: string): Promise<SessionRecord | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('sessions').findOne({ _id: id });\n return doc ? this.mapSessionRecord(doc) : null;\n }, 'getSession');\n }\n\n async updateSession(id: string, data: Partial<SessionRecord>): Promise<SessionRecord> {\n return this.withRetry(async () => {\n const updateDoc: UpdateFilter<MongoDocument> = {\n $set: {\n updatedAt: new Date(),\n },\n };\n\n const setFields = updateDoc.$set as MongoDocument;\n\n for (const [key, value] of Object.entries(data)) {\n if (key === 'id' || key === 'createdAt') {\n continue;\n }\n setFields[key] = value;\n }\n\n const result = await this.getCollection('sessions').findOneAndUpdate({ _id: id }, updateDoc, {\n returnDocument: 'after',\n });\n\n if (!result) {\n throw new InternalError({\n code: 'auth-mongo/update_failed',\n message: 'Failed to update session',\n severity: 'error',\n retryable: false,\n });\n }\n\n return this.mapSessionRecord(result);\n }, 'updateSession');\n }\n\n async deleteSession(id: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('sessions').deleteOne({ _id: id });\n }, 'deleteSession');\n }\n\n // Organization methods\n async createOrganization(\n data: Omit<OrganizationRecord, 'id' | 'createdAt' | 'updatedAt'>\n ): Promise<OrganizationRecord> {\n return this.withRetry(async () => {\n const id = generateId();\n const now = new Date();\n\n const doc = {\n _id: id,\n name: data.name,\n plan: data.plan,\n seats: data.seats,\n members: data.members,\n metadata: data.metadata || {},\n createdAt: now,\n updatedAt: now,\n };\n\n await this.getCollection('organizations').insertOne(doc);\n return this.mapOrganizationRecord(doc);\n }, 'createOrganization');\n }\n\n async getOrganization(id: string): Promise<OrganizationRecord | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('organizations').findOne({ _id: id });\n return doc ? this.mapOrganizationRecord(doc) : null;\n }, 'getOrganization');\n }\n\n async updateOrganization(\n id: string,\n data: Partial<OrganizationRecord>\n ): Promise<OrganizationRecord> {\n return this.withRetry(async () => {\n const updateDoc: UpdateFilter<MongoDocument> = {\n $set: {\n updatedAt: new Date(),\n },\n };\n\n const setFields = updateDoc.$set as MongoDocument;\n\n for (const [key, value] of Object.entries(data)) {\n if (key === 'id' || key === 'createdAt') {\n continue;\n }\n setFields[key] = value;\n }\n\n const result = await this.getCollection('organizations').findOneAndUpdate(\n { _id: id },\n updateDoc,\n { returnDocument: 'after' }\n );\n\n if (!result) {\n throw new InternalError({\n code: 'auth-mongo/update_failed',\n message: 'Failed to update organization',\n severity: 'error',\n retryable: false,\n });\n }\n\n return this.mapOrganizationRecord(result);\n }, 'updateOrganization');\n }\n\n async deleteOrganization(id: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('organizations').deleteOne({ _id: id });\n }, 'deleteOrganization');\n }\n\n // User methods\n async createUser(data: CreateUserInput): Promise<UserRecord> {\n return this.withRetry(async () => {\n const id = generateId();\n const now = new Date();\n\n const doc = {\n _id: id,\n email: data.email || null,\n name: data.name || null,\n picture: data.picture || null,\n plan: data.plan || 'free',\n entitlements: data.entitlements || [],\n oauth: {},\n metadata: data.metadata || {},\n createdAt: now,\n updatedAt: now,\n };\n\n await this.getCollection('users').insertOne(doc);\n return this.mapUserRecord(doc);\n }, 'createUser');\n }\n\n async getUser(id: string): Promise<UserRecord | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('users').findOne({ _id: id });\n return doc ? this.mapUserRecord(doc) : null;\n }, 'getUser');\n }\n\n async getUserByEmail(email: string): Promise<UserRecord | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('users').findOne({ email });\n return doc ? this.mapUserRecord(doc) : null;\n }, 'getUserByEmail');\n }\n\n async getUserByOAuth(provider: string, sub: string): Promise<UserRecord | null> {\n return this.withRetry(async () => {\n const filter: Filter<MongoDocument> = {};\n filter[`oauth.${provider}.sub`] = sub;\n\n const doc = await this.getCollection('users').findOne(filter);\n return doc ? this.mapUserRecord(doc) : null;\n }, 'getUserByOAuth');\n }\n\n async updateUser(id: string, data: UpdateUserInput): Promise<UserRecord> {\n return this.withRetry(async () => {\n const updateDoc: UpdateFilter<MongoDocument> = {\n $set: {\n updatedAt: new Date(),\n },\n };\n\n const setFields = updateDoc.$set as MongoDocument;\n\n for (const [key, value] of Object.entries(data)) {\n setFields[key] = value;\n }\n\n const result = await this.getCollection('users').findOneAndUpdate({ _id: id }, updateDoc, {\n returnDocument: 'after',\n });\n\n if (!result) {\n throw new InternalError({\n code: 'auth-mongo/update_failed',\n message: 'Failed to update user',\n severity: 'error',\n retryable: false,\n });\n }\n\n return this.mapUserRecord(result);\n }, 'updateUser');\n }\n\n async deleteUser(id: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('users').deleteOne({ _id: id });\n }, 'deleteUser');\n }\n\n async linkOAuthAccount(\n userId: string,\n provider: string,\n oauthLink: OAuthLink\n ): Promise<UserRecord> {\n return this.withRetry(async () => {\n const updateDoc: UpdateFilter<MongoDocument> = {\n $set: {\n updatedAt: new Date(),\n },\n };\n\n const setFields = updateDoc.$set as MongoDocument;\n setFields[`oauth.${provider}`] = {\n provider: oauthLink.provider,\n sub: oauthLink.sub,\n email: oauthLink.email,\n name: oauthLink.name,\n linkedAt: oauthLink.linkedAt,\n };\n\n const result = await this.getCollection('users').findOneAndUpdate(\n { _id: userId },\n updateDoc,\n { returnDocument: 'after' }\n );\n\n if (!result) {\n throw new InternalError({\n code: 'auth-mongo/link_failed',\n message: 'Failed to link OAuth account',\n severity: 'error',\n retryable: false,\n });\n }\n\n return this.mapUserRecord(result);\n }, 'linkOAuthAccount');\n }\n\n // Email Verification Token methods\n async createEmailVerificationToken(\n data: Omit<EmailVerificationToken, 'id'>\n ): Promise<EmailVerificationToken> {\n return this.withRetry(async () => {\n const id = generateId();\n\n const doc = {\n _id: id,\n email: data.email,\n code: data.code,\n codeHash: data.codeHash,\n type: data.type,\n userId: data.userId || null,\n metadata: data.metadata || {},\n expiresAt: data.expiresAt,\n usedAt: null,\n createdAt: new Date(),\n };\n\n await this.getCollection('email_verification_tokens').insertOne(doc);\n return this.mapEmailVerificationToken(doc);\n }, 'createEmailVerificationToken');\n }\n\n async getEmailVerificationTokens(\n email: string,\n type?: string\n ): Promise<EmailVerificationToken[]> {\n return this.withRetry(async () => {\n const filter: Filter<MongoDocument> = { email };\n if (type) {\n filter['type'] = type;\n }\n\n const docs = await this.getCollection('email_verification_tokens')\n .find(filter)\n .sort({ createdAt: -1 })\n .toArray();\n\n return docs.map((doc) => this.mapEmailVerificationToken(doc));\n }, 'getEmailVerificationTokens');\n }\n\n async getEmailVerificationTokenById(id: string): Promise<EmailVerificationToken | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('email_verification_tokens').findOne({ _id: id });\n return doc ? this.mapEmailVerificationToken(doc) : null;\n }, 'getEmailVerificationTokenById');\n }\n\n async markEmailVerificationTokenAsUsed(id: string): Promise<EmailVerificationToken> {\n return this.withRetry(async () => {\n const result = await this.getCollection('email_verification_tokens').findOneAndUpdate(\n { _id: id },\n { $set: { usedAt: new Date() } },\n { returnDocument: 'after' }\n );\n\n if (!result) {\n throw new InternalError({\n code: 'auth-mongo/update_failed',\n message: 'Failed to mark email verification token as used',\n severity: 'error',\n retryable: false,\n });\n }\n\n return this.mapEmailVerificationToken(result);\n }, 'markEmailVerificationTokenAsUsed');\n }\n\n async deleteExpiredEmailVerificationTokens(): Promise<number> {\n return this.withRetry(async () => {\n const result = await this.getCollection('email_verification_tokens').deleteMany({\n expiresAt: { $lt: new Date() },\n });\n return result.deletedCount;\n }, 'deleteExpiredEmailVerificationTokens');\n }\n\n async getEmailVerificationTokenAttempts(tokenId: string): Promise<number> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('email_verification_token_attempts').findOne({\n _id: tokenId,\n });\n return (doc?.['attempts'] as number) || 0;\n }, 'getEmailVerificationTokenAttempts');\n }\n\n async incrementEmailVerificationTokenAttempts(tokenId: string): Promise<number> {\n return this.withRetry(async () => {\n const collection = this.getCollection('email_verification_token_attempts');\n const incrementValue: number = 1;\n await collection.updateOne(\n { _id: tokenId },\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n { $inc: { attempts: incrementValue } } as any,\n {\n upsert: true as boolean,\n }\n );\n\n const doc = await collection.findOne({ _id: tokenId });\n return (doc?.['attempts'] as number) || 1;\n }, 'incrementEmailVerificationTokenAttempts');\n }\n\n // Event methods\n async emitEvent(event: AuthEvent): Promise<void> {\n return this.withRetry(async () => {\n const doc = {\n type: event.type,\n principalId: event.principalId,\n orgId: event.orgId,\n data: event.data,\n timestamp: event.timestamp,\n };\n\n await this.getCollection('auth_events').insertOne(doc);\n }, 'emitEvent');\n }\n\n // RBAC methods\n async createRole(data: Omit<RoleRecord, 'id' | 'createdAt' | 'updatedAt'>): Promise<RoleRecord> {\n return this.withRetry(async () => {\n const id = generateId();\n const now = new Date();\n\n const doc = {\n _id: id,\n orgId: data.orgId,\n name: data.name,\n description: data.description || null,\n isSystem: data.isSystem || false,\n permissions: data.permissions || [],\n metadata: data.metadata || {},\n createdAt: now,\n updatedAt: now,\n };\n\n await this.getCollection('roles').insertOne(doc);\n return this.mapRoleRecord(doc);\n }, 'createRole');\n }\n\n async getRole(roleId: string): Promise<RoleRecord | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('roles').findOne({ _id: roleId });\n return doc ? this.mapRoleRecord(doc) : null;\n }, 'getRole');\n }\n\n async updateRole(roleId: string, data: Partial<RoleRecord>): Promise<RoleRecord> {\n return this.withRetry(async () => {\n const updateDoc: UpdateFilter<MongoDocument> = {\n $set: {\n updatedAt: new Date(),\n },\n };\n\n const setFields = updateDoc.$set as MongoDocument;\n\n for (const [key, value] of Object.entries(data)) {\n if (key === 'id' || key === 'createdAt') {\n continue;\n }\n setFields[key] = value;\n }\n\n const result = await this.getCollection('roles').findOneAndUpdate(\n { _id: roleId },\n updateDoc,\n { returnDocument: 'after' }\n );\n\n if (!result) {\n throw new InternalError({\n code: 'auth-mongo/update_failed',\n message: 'Failed to update role',\n severity: 'error',\n retryable: false,\n });\n }\n\n return this.mapRoleRecord(result);\n }, 'updateRole');\n }\n\n async deleteRole(roleId: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('roles').deleteOne({ _id: roleId });\n }, 'deleteRole');\n }\n\n async listRoles(orgId: string): Promise<RoleRecord[]> {\n return this.withRetry(async () => {\n const docs = await this.getCollection('roles')\n .find({ orgId })\n .sort({ createdAt: -1 })\n .toArray();\n return docs.map((doc) => this.mapRoleRecord(doc));\n }, 'listRoles');\n }\n\n async assignRoleToUser(userId: string, roleId: string, orgId: string): Promise<RoleRecord> {\n return this.withRetry(async () => {\n const id = generateId();\n\n const doc = {\n _id: id,\n userId,\n roleId,\n orgId,\n assignedAt: new Date(),\n };\n\n await this.getCollection('user_roles').insertOne(doc);\n\n const role = await this.getRole(roleId);\n if (!role) {\n throw new InternalError({\n code: 'auth-mongo/role_not_found',\n message: 'Role not found',\n severity: 'error',\n retryable: false,\n context: { roleId },\n });\n }\n return role;\n }, 'assignRoleToUser');\n }\n\n async revokeRoleFromUser(userId: string, roleId: string, orgId: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('user_roles').deleteOne({ userId, roleId, orgId });\n }, 'revokeRoleFromUser');\n }\n\n async getUserRoles(userId: string, orgId: string): Promise<RoleRecord[]> {\n return this.withRetry(async () => {\n const userRoles = await this.getCollection('user_roles').find({ userId, orgId }).toArray();\n\n const roleIds = userRoles.map((ur) => ur['roleId'] as string);\n const roles = await this.getCollection('roles')\n .find({ _id: { $in: roleIds as unknown as string[] } })\n .sort({ createdAt: -1 })\n .toArray();\n\n return roles.map((doc) => this.mapRoleRecord(doc));\n }, 'getUserRoles');\n }\n\n // SSO methods\n async createSSOProvider(data: MongoDocument): Promise<unknown> {\n return this.withRetry(async () => {\n const id = generateId();\n const now = new Date();\n\n const doc = {\n _id: id,\n type: data['type'],\n name: data['name'],\n orgId: data['orgId'] || null,\n metadataUrl: data['metadataUrl'] || data['metadata_url'] || null,\n clientId: data['clientId'] || data['client_id'] || null,\n clientSecret: data['clientSecret'] || data['client_secret'] || null,\n tokenEndpointAuthMethod:\n data['tokenEndpointAuthMethod'] || data['token_endpoint_auth_method'] || null,\n idpEntityId: data['idpEntityId'] || data['idp_entity_id'] || null,\n idpSsoUrl: data['idpSsoUrl'] || data['idp_sso_url'] || null,\n idpSloUrl: data['idpSloUrl'] || data['idp_slo_url'] || null,\n idpCertificate: data['idpCertificate'] || data['idp_certificate'] || null,\n spEntityId: data['spEntityId'] || data['sp_entity_id'] || null,\n spAcsUrl: data['spAcsUrl'] || data['sp_acs_url'] || null,\n spSloUrl: data['spSloUrl'] || data['sp_slo_url'] || null,\n signingCert: data['signingCert'] || data['signing_cert'] || null,\n signingKey: data['signingKey'] || data['signing_key'] || null,\n encryptionEnabled: data['encryptionEnabled'] || data['encryption_enabled'] || false,\n forceAuthn: data['forceAuthn'] || data['force_authn'] || false,\n scopes: data['scopes'] || [],\n redirectUris: data['redirectUris'] || data['redirect_uris'] || [],\n claimMapping: data['claimMapping'] || data['claim_mapping'] || {},\n attributeMapping: data['attributeMapping'] || data['attribute_mapping'] || {},\n metadata: data['metadata'] || {},\n createdAt: now,\n updatedAt: now,\n };\n\n await this.getCollection('sso_providers').insertOne(doc);\n return this.mapSSOProviderRecord(doc);\n }, 'createSSOProvider');\n }\n\n async getSSOProvider(providerId: string): Promise<unknown | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('sso_providers').findOne({ _id: providerId });\n return doc ? this.mapSSOProviderRecord(doc) : null;\n }, 'getSSOProvider');\n }\n\n async updateSSOProvider(providerId: string, data: Partial<unknown>): Promise<unknown> {\n return this.withRetry(async () => {\n const updateDoc: UpdateFilter<MongoDocument> = {\n $set: {\n updatedAt: new Date(),\n },\n };\n\n const setFields = updateDoc.$set as MongoDocument;\n\n for (const [key, value] of Object.entries(data)) {\n if (key === 'id' || key === 'createdAt') {\n continue;\n }\n setFields[key] = value;\n }\n\n const result = await this.getCollection('sso_providers').findOneAndUpdate(\n { _id: providerId },\n updateDoc,\n { returnDocument: 'after' }\n );\n\n if (!result) {\n throw new InternalError({\n code: 'auth-mongo/update_failed',\n message: 'Failed to update SSO provider',\n severity: 'error',\n retryable: false,\n });\n }\n\n return this.mapSSOProviderRecord(result);\n }, 'updateSSOProvider');\n }\n\n async deleteSSOProvider(providerId: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('sso_providers').deleteOne({ _id: providerId });\n }, 'deleteSSOProvider');\n }\n\n async listSSOProviders(orgId?: string): Promise<unknown[]> {\n return this.withRetry(async () => {\n const filter: Filter<MongoDocument> = {};\n if (orgId) {\n filter.$or = [{ orgId }, { orgId: null }];\n }\n\n const docs = await this.getCollection('sso_providers')\n .find(filter)\n .sort({ createdAt: -1 })\n .toArray();\n\n return docs.map((doc) => this.mapSSOProviderRecord(doc));\n }, 'listSSOProviders');\n }\n\n async createSSOLink(data: Omit<SSOLink, 'id' | 'linkedAt'>): Promise<SSOLink> {\n return this.withRetry(async () => {\n const id = generateId();\n\n const doc = {\n _id: id,\n userId: data.userId,\n providerId: data.providerId,\n providerType: data.providerType,\n providerSubject: data.providerSubject,\n providerEmail: data.providerEmail || null,\n autoProvisioned: data.autoProvisioned || false,\n metadata: data.metadata || {},\n linkedAt: new Date(),\n lastAuthAt: data.lastAuthAt || new Date(),\n };\n\n await this.getCollection('sso_links').insertOne(doc);\n return this.mapSSOLinkRecord(doc);\n }, 'createSSOLink');\n }\n\n async getSSOLink(linkId: string): Promise<SSOLink | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('sso_links').findOne({ _id: linkId });\n return doc ? this.mapSSOLinkRecord(doc) : null;\n }, 'getSSOLink');\n }\n\n async getUserSSOLinks(userId: string): Promise<SSOLink[]> {\n return this.withRetry(async () => {\n const docs = await this.getCollection('sso_links')\n .find({ userId })\n .sort({ linkedAt: -1 })\n .toArray();\n return docs.map((doc) => this.mapSSOLinkRecord(doc));\n }, 'getUserSSOLinks');\n }\n\n async deleteSSOLink(linkId: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('sso_links').deleteOne({ _id: linkId });\n }, 'deleteSSOLink');\n }\n\n async createSSOSession(data: Omit<SSOSession, 'id' | 'linkedAt'>): Promise<SSOSession> {\n return this.withRetry(async () => {\n const id = generateId();\n\n const doc = {\n _id: id,\n userId: data.userId,\n providerId: data.providerId,\n providerType: data.providerType,\n providerSubject: data.providerSubject,\n sessionToken: data.sessionToken || null,\n expiresAt: data.expiresAt,\n linkedAt: new Date(),\n lastAuthAt: data.lastAuthAt || new Date(),\n };\n\n await this.getCollection('sso_sessions').insertOne(doc);\n return this.mapSSOSessionRecord(doc);\n }, 'createSSOSession');\n }\n\n async getSSOSession(sessionId: string): Promise<SSOSession | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('sso_sessions').findOne({ _id: sessionId });\n return doc ? this.mapSSOSessionRecord(doc) : null;\n }, 'getSSOSession');\n }\n\n // 2FA methods\n async createTwoFactorDevice(\n data: Omit<TwoFactorDevice, 'id' | 'createdAt'>\n ): Promise<TwoFactorDevice> {\n return this.withRetry(async () => {\n const id = generateId();\n const now = new Date();\n\n const doc = {\n _id: id,\n userId: data.userId,\n method: data.method,\n name: data.name || null,\n verified: data.verified || false,\n phoneNumber: data.phoneNumber || null,\n secret: data.secret || null,\n lastUsedAt: null,\n metadata: data.metadata || {},\n createdAt: now,\n updatedAt: now,\n };\n\n await this.getCollection('twofa_devices').insertOne(doc);\n return this.mapTwoFactorDeviceRecord(doc);\n }, 'createTwoFactorDevice');\n }\n\n async getTwoFactorDevice(deviceId: string): Promise<TwoFactorDevice | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('twofa_devices').findOne({ _id: deviceId });\n return doc ? this.mapTwoFactorDeviceRecord(doc) : null;\n }, 'getTwoFactorDevice');\n }\n\n async listTwoFactorDevices(userId: string): Promise<TwoFactorDevice[]> {\n return this.withRetry(async () => {\n const docs = await this.getCollection('twofa_devices')\n .find({ userId })\n .sort({ createdAt: -1 })\n .toArray();\n return docs.map((doc) => this.mapTwoFactorDeviceRecord(doc));\n }, 'listTwoFactorDevices');\n }\n\n async updateTwoFactorDevice(\n deviceId: string,\n data: Partial<TwoFactorDevice>\n ): Promise<TwoFactorDevice> {\n return this.withRetry(async () => {\n const updateDoc: UpdateFilter<MongoDocument> = {\n $set: {\n updatedAt: new Date(),\n },\n };\n\n const setFields = updateDoc.$set as MongoDocument;\n\n for (const [key, value] of Object.entries(data)) {\n if (key === 'id' || key === 'createdAt') {\n continue;\n }\n setFields[key] = value;\n }\n\n const result = await this.getCollection('twofa_devices').findOneAndUpdate(\n { _id: deviceId },\n updateDoc,\n { returnDocument: 'after' }\n );\n\n if (!result) {\n throw new InternalError({\n code: 'auth-mongo/update_failed',\n message: 'Failed to update two-factor device',\n severity: 'error',\n retryable: false,\n });\n }\n\n return this.mapTwoFactorDeviceRecord(result);\n }, 'updateTwoFactorDevice');\n }\n\n async deleteTwoFactorDevice(deviceId: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('twofa_devices').deleteOne({ _id: deviceId });\n }, 'deleteTwoFactorDevice');\n }\n\n async createBackupCodes(userId: string, codes: BackupCode[]): Promise<BackupCode[]> {\n return this.withRetry(async () => {\n const createdCodes: BackupCode[] = [];\n\n for (const codeData of codes) {\n const id = generateId();\n const codeValue = typeof codeData === 'string' ? codeData : codeData['code'] || '';\n\n const doc = {\n _id: id,\n userId,\n code: codeValue,\n used: false,\n usedAt: null,\n createdAt: new Date(),\n };\n\n await this.getCollection('twofa_backup_codes').insertOne(doc);\n createdCodes.push(this.mapBackupCodeRecord(doc));\n }\n\n return createdCodes;\n }, 'createBackupCodes');\n }\n\n async getBackupCodes(userId: string): Promise<BackupCode[]> {\n return this.withRetry(async () => {\n const docs = await this.getCollection('twofa_backup_codes')\n .find({ userId })\n .sort({ createdAt: -1 })\n .toArray();\n return docs.map((doc) => this.mapBackupCodeRecord(doc));\n }, 'getBackupCodes');\n }\n\n async markBackupCodeUsed(codeId: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('twofa_backup_codes').updateOne(\n { _id: codeId },\n { $set: { used: true, usedAt: new Date() } }\n );\n }, 'markBackupCodeUsed');\n }\n\n async createTwoFactorSession(data: TwoFactorSession): Promise<TwoFactorSession> {\n return this.withRetry(async () => {\n const id = generateId();\n\n const doc = {\n _id: id,\n userId: data.userId,\n sessionId: data.sessionId,\n deviceId: data.deviceId,\n method: data.method,\n verificationCode: data.verificationCode || null,\n attemptCount: data.attemptCount || 0,\n maxAttempts: data.maxAttempts || 5,\n expiresAt: data.expiresAt,\n completedAt: null,\n createdAt: new Date(),\n };\n\n await this.getCollection('twofa_sessions').insertOne(doc);\n return this.mapTwoFactorSessionRecord(doc);\n }, 'createTwoFactorSession');\n }\n\n async getTwoFactorSession(sessionId: string): Promise<TwoFactorSession | null> {\n return this.withRetry(async () => {\n const doc = await this.getCollection('twofa_sessions').findOne({ _id: sessionId });\n return doc ? this.mapTwoFactorSessionRecord(doc) : null;\n }, 'getTwoFactorSession');\n }\n\n async completeTwoFactorSession(sessionId: string): Promise<void> {\n return this.withRetry(async () => {\n await this.getCollection('twofa_sessions').updateOne(\n { _id: sessionId },\n { $set: { completedAt: new Date() } }\n );\n }, 'completeTwoFactorSession');\n }\n\n // Helper mapping methods\n private mapApiKeyRecord(doc: MongoDocument): ApiKeyRecord {\n const result: ApiKeyRecord = {\n id: doc['_id'] as string,\n principalId: doc['principalId'] as string,\n hash: doc['hash'] as string,\n prefix: doc['prefix'] as string,\n lastFour: doc['lastFour'] as string,\n scopes: (doc['scopes'] as string[]) || [],\n metadata: (doc['metadata'] as Record<string, string>) || {},\n createdAt: new Date(doc['createdAt'] as Date),\n updatedAt: new Date(doc['updatedAt'] as Date),\n } as ApiKeyRecord;\n if (doc['expiresAt']) {\n result.expiresAt = new Date(doc['expiresAt'] as Date);\n }\n return result;\n }\n\n private mapSessionRecord(doc: MongoDocument): SessionRecord {\n const result: SessionRecord = {\n id: doc['_id'] as string,\n userId: doc['userId'] as string,\n entitlements: (doc['entitlements'] as string[]) || [],\n expiresAt: new Date(doc['expiresAt'] as Date),\n metadata: (doc['metadata'] as Record<string, unknown>) || {},\n createdAt: new Date(doc['createdAt'] as Date),\n updatedAt: new Date(doc['updatedAt'] as Date),\n } as SessionRecord;\n if (doc['orgId']) {\n result.orgId = doc['orgId'] as string;\n }\n if (doc['plan']) {\n result.plan = doc['plan'] as string;\n }\n return result;\n }\n\n private mapOrganizationRecord(doc: MongoDocument): OrganizationRecord {\n return {\n id: doc['_id'] as string,\n name: doc['name'] as string,\n plan: doc['plan'] as string,\n seats: doc['seats'] as number,\n members: (doc['members'] || []) as OrganizationMember[],\n metadata: (doc['metadata'] as Record<string, unknown>) || {},\n createdAt: new Date(doc['createdAt'] as Date),\n updatedAt: new Date(doc['updatedAt'] as Date),\n };\n }\n\n private mapUserRecord(doc: MongoDocument): UserRecord {\n const result: UserRecord = {\n id: doc['_id'] as string,\n entitlements: (doc['entitlements'] as string[]) || [],\n oauth: (doc['oauth'] || {}) as Record<string, OAuthLink>,\n metadata: (doc['metadata'] as Record<string, unknown>) || {},\n createdAt: new Date(doc['createdAt'] as Date),\n updatedAt: new Date(doc['updatedAt'] as Date),\n } as UserRecord;\n if (doc['email']) {\n result.email = doc['email'] as string;\n }\n if (doc['name']) {\n result.name = doc['name'] as string;\n }\n if (doc['picture']) {\n result.picture = doc['picture'] as string;\n }\n if (doc['plan']) {\n result.plan = doc['plan'] as string;\n }\n return result;\n }\n\n private mapEmailVerificationToken(doc: MongoDocument): EmailVerificationToken {\n const result: EmailVerificationToken = {\n id: doc['_id'] as string,\n email: doc['email'] as string,\n code: doc['code'] as string,\n codeHash: doc['codeHash'] as string,\n type: doc['type'] as EmailVerificationToken['type'],\n metadata: (doc['metadata'] as Record<string, unknown>) || {},\n expiresAt: new Date(doc['expiresAt'] as Date),\n createdAt: new Date(doc['createdAt'] as Date),\n } as EmailVerificationToken;\n if (doc['userId']) {\n result.userId = doc['userId'] as string;\n }\n if (doc['usedAt']) {\n result.usedAt = new Date(doc['usedAt'] as Date);\n }\n return result;\n }\n\n private mapRoleRecord(doc: MongoDocument): RoleRecord {\n return {\n id: doc['_id'] as string,\n orgId: doc['orgId'] as string,\n name: doc['name'] as string,\n description: doc['description'] as string,\n isSystem: doc['isSystem'] as boolean,\n permissions: ((doc['permissions'] || []) as unknown[]).map((p) => p as Permission),\n metadata: (doc['metadata'] as Record<string, unknown>) || {},\n createdAt: doc['createdAt'] as Date,\n updatedAt: doc['updatedAt'] as Date,\n };\n }\n\n private mapSSOProviderRecord(doc: MongoDocument): unknown {\n return {\n id: doc['_id'] as string,\n type: doc['type'] as SSOProviderType,\n name: doc['name'] as string,\n orgId: doc['orgId'] as string,\n metadataUrl: doc['metadataUrl'] as string,\n clientId: doc['clientId'] as string,\n clientSecret: doc['clientSecret'] as string,\n tokenEndpointAuthMethod: doc['tokenEndpointAuthMethod'] as string,\n idpEntityId: doc['idpEntityId'] as string,\n idpSsoUrl: doc['idpSsoUrl'] as string,\n idpSloUrl: doc['idpSloUrl'] as string,\n idpCertificate: doc['idpCertificate'] as string,\n spEntityId: doc['spEntityId'] as string,\n spAcsUrl: doc['spAcsUrl'] as string,\n spSloUrl: doc['spSloUrl'