import { and, eq } from 'drizzle-orm'; import * as z from 'zod'; import { providers } from '~~/drizzle/schema'; import { auth } from '~~/lib/auth'; import { db } from '~~/server/lib/db'; /** * Changes the password and swaps in the provider API keys re-encrypted with * the new password in one go. API keys are encrypted in the browser with a key * derived from the password, so a password change that leaves the old * ciphertexts behind would make every saved key unreadable. */ export default defineEventHandler(async (event) => { await protectRoute(event); const userId = event.context.user!.id as string; const result = await readValidatedBody(event, (body) => z .object({ currentPassword: z.string().min(1), newPassword: z.string().min(8).max(128), providerKeys: z.array(z.object({ id: z.string(), apiKey: z.string().min(1), })), }) .safeParse(body), ); if (!result.success) { throw createError({ statusCode: 400, message: result.error.issues[0]!.message, }); } const { currentPassword, newPassword, providerKeys } = result.data; const existing = await db.query.providers.findMany({ where: { userId, }, }); const newKeys = new Map(providerKeys.map(p => [p.id, p.apiKey])); // Every saved key has to come back re-encrypted, otherwise it would be // stuck encrypted with the old password. const withKey = existing.filter(p => !!p.config.apiKey); const complete = withKey.every(p => newKeys.has(p.id)) && newKeys.size === withKey.length; if (!complete) { throw createError({ statusCode: 409, statusMessage: 'Provider keys out of date', data: { code: 'PROVIDER_KEYS_OUT_OF_DATE', }, }); } let cookies: string[] = []; try { // The password change runs inside the transaction so a rejected // password rolls the key updates back. await db.transaction(async (tx) => { for (const provider of withKey) { await tx.update(providers) .set({ config: { ...provider.config, apiKey: newKeys.get(provider.id)!, }, }) .where(and(eq(providers.id, provider.id), eq(providers.userId, userId))); } const { headers } = await auth.api.changePassword({ body: { currentPassword, newPassword, // other devices still hold an encryption key derived from the // old password, so they have to sign in again revokeOtherSessions: true, }, headers: event.headers, returnHeaders: true, }); cookies = headers.getSetCookie(); }); } catch (error: any) { const code = error?.body?.code as string | undefined; if (code === undefined) { console.error('Failed to change password:', error); } throw createError({ statusCode: code === undefined ? 500 : 400, statusMessage: 'Failed to change password', message: error?.body?.message ?? 'Failed to change password', data: { code: code ?? 'INTERNAL_ERROR', }, }); } for (const cookie of cookies) { appendResponseHeader(event, 'set-cookie', cookie); } return { ok: true }; });