From 1bd684a6b0108cc713fefbb99f2cb5beeac08d87 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:48:32 +0100 Subject: [PATCH] feat: self-healing login for federated users with stale password hashes When a federated user's local password hash is stale (e.g. they changed their password on the home instance and sync failed), the login handler now falls back to verifying credentials against the home instance. If the home instance accepts the password, the local hash is silently updated without touching passwordChangedAt, so existing valid JWTs remain valid. --- packages/server/src/routes/auth.ts | 43 +++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 861dfd18..ff353376 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -198,7 +198,48 @@ export async function authRoutes(app: FastifyInstance): Promise { const validPassword = await verifyPassword(password, user.passwordHash); if (!validPassword) { - return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); + // For federated users, try verifying against the home instance. + // If the password is valid there but stale here, self-heal the local hash. + if (user.homeInstance) { + try { + const homeUsername = user.username.includes('@') + ? user.username.split('@')[0]! + : user.username; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + const homeResponse = await fetch(`https://${user.homeInstance}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: homeUsername, password }), + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (homeResponse.ok) { + // Home instance accepted the password — update our stale hash. + // Do NOT set passwordChangedAt: this is a state correction, not a + // password change. Setting it would invalidate existing valid JWTs. + const newHash = await hashPassword(password); + db.update(schema.users) + .set({ passwordHash: newHash }) + .where(eq(schema.users.id, user.id)) + .run(); + + app.log.info(`Self-healed password hash for federated user ${user.username} via ${user.homeInstance}`); + } else { + // Home instance also rejected — password is genuinely wrong + return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); + } + } catch { + // Home instance unreachable — fall back to local-only rejection + return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); + } + } else { + return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); + } } db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, user.id)).run();