diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index 16248d25..8d1a0c65 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -665,7 +665,7 @@ describe("AuthService.refresh", () => { }), ); expect(jwtService.signAsync).toHaveBeenCalledWith( - expect.objectContaining({ sub: user.id }), + expect.objectContaining({ sub: user.id, sid: "rt-1" }), expect.objectContaining({ algorithm: "HS256", issuer: "loomkeep-api", diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index ed6a1fdc..e15fdfd3 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -465,7 +465,7 @@ export class AuthService { ); } - const signed = await this.signTokens(stored.user); + const signed = await this.signTokens(stored.user, stored.id); const rotated = await this.prisma.$transaction(async (tx) => { const update = await tx.refreshToken.updateMany({ where: { id: stored.id, tokenHash }, @@ -680,13 +680,20 @@ export class AuthService { } /** Signs a fresh access/refresh pair. Persistence is the caller's job. */ - private async signTokens(user: User): Promise<{ + private async signTokens( + user: User, + sessionId: string, + ): Promise<{ accessToken: string; refreshToken: string; jti: string; expiresAt: Date; }> { - const payload: JwtPayload = { sub: user.id, email: user.email }; + const payload: JwtPayload = { + sub: user.id, + email: user.email, + sid: sessionId, + }; const accessToken = await this.jwtService.signAsync(payload, { secret: this.configService.getOrThrow("JWT_ACCESS_SECRET"), @@ -776,12 +783,14 @@ export class AuthService { user: User, userAgent?: string, ): Promise { - const signed = await this.signTokens(user); + const sessionId = randomUUID(); + const signed = await this.signTokens(user, sessionId); await this.prisma.refreshToken.deleteMany({ where: { userId: user.id, userAgent: userAgent ?? null }, }); await this.prisma.refreshToken.create({ data: { + id: sessionId, userId: user.id, tokenHash: hashToken(signed.refreshToken), jti: signed.jti, diff --git a/apps/api/src/auth/decorators/current-user.decorator.ts b/apps/api/src/auth/decorators/current-user.decorator.ts index a9806810..ca08fe60 100644 --- a/apps/api/src/auth/decorators/current-user.decorator.ts +++ b/apps/api/src/auth/decorators/current-user.decorator.ts @@ -5,6 +5,8 @@ export interface JwtPayload { /** User ID. */ sub: string; email: string; + /** Stable server-side session ID. */ + sid?: string; } export interface AuthenticatedRequest extends FastifyRequest { diff --git a/apps/api/src/auth/mfa.service.spec.ts b/apps/api/src/auth/mfa.service.spec.ts index 9aee0da0..9c91cf7d 100644 --- a/apps/api/src/auth/mfa.service.spec.ts +++ b/apps/api/src/auth/mfa.service.spec.ts @@ -141,15 +141,21 @@ describe("MfaService.confirmTotp / setEmailMfaEnabled — recovery code generati }), ); - const first = await service.confirmTotp("user-1", code); + const first = await service.confirmTotp("user-1", code, "session-1"); expect(first.recoveryCodes).toHaveLength(RECOVERY_CODE_COUNT); expect(prisma.refreshToken.deleteMany).toHaveBeenCalledWith({ - where: { userId: "user-1" }, + where: { userId: "user-1", id: { not: "session-1" } }, }); - const second = await service.setEmailMfaEnabled("user-1", true); + const second = await service.setEmailMfaEnabled( + "user-1", + true, + "session-1", + ); expect(second.recoveryCodes).toBeUndefined(); - expect(prisma.refreshToken.deleteMany).toHaveBeenCalledTimes(2); + expect(prisma.refreshToken.deleteMany).toHaveBeenLastCalledWith({ + where: { userId: "user-1", id: { not: "session-1" } }, + }); }); it("confirmTotp rejects an invalid code", async () => { @@ -185,14 +191,14 @@ describe("MfaService.disableTotp", () => { makeUser({ passwordHash: await bcrypt.hash("correct", 4) }), ); - await service.disableTotp("user-1", "correct"); + await service.disableTotp("user-1", "correct", "session-1"); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, data: { mfaTotpEnabled: false, mfaTotpSecretEnc: null }, }); expect(prisma.refreshToken.deleteMany).toHaveBeenCalledWith({ - where: { userId: "user-1" }, + where: { userId: "user-1", id: { not: "session-1" } }, }); }); }); diff --git a/apps/api/src/auth/mfa.service.ts b/apps/api/src/auth/mfa.service.ts index beb5e81b..4aa9728b 100644 --- a/apps/api/src/auth/mfa.service.ts +++ b/apps/api/src/auth/mfa.service.ts @@ -73,6 +73,7 @@ export class MfaService { async confirmTotp( userId: string, code: string, + currentSessionId?: string, ): Promise<{ recoveryCodes?: string[] }> { const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, @@ -97,14 +98,18 @@ export class MfaService { where: { id: userId }, data: { mfaTotpEnabled: true }, }), - this.prisma.refreshToken.deleteMany({ where: { userId } }), + this.deleteOtherSessionsQuery(userId, currentSessionId), ]); const recoveryCodes = await this.ensureRecoveryCodes(userId); return { recoveryCodes }; } - async disableTotp(userId: string, currentPassword: string): Promise { + async disableTotp( + userId: string, + currentPassword: string, + currentSessionId?: string, + ): Promise { const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, }); @@ -121,20 +126,21 @@ export class MfaService { where: { id: userId }, data: { mfaTotpEnabled: false, mfaTotpSecretEnc: null }, }), - this.prisma.refreshToken.deleteMany({ where: { userId } }), + this.deleteOtherSessionsQuery(userId, currentSessionId), ]); } async setEmailMfaEnabled( userId: string, enabled: boolean, + currentSessionId?: string, ): Promise<{ recoveryCodes?: string[] }> { await this.prisma.$transaction([ this.prisma.user.update({ where: { id: userId }, data: { mfaEmailEnabled: enabled }, }), - this.prisma.refreshToken.deleteMany({ where: { userId } }), + this.deleteOtherSessionsQuery(userId, currentSessionId), ]); const recoveryCodes = enabled @@ -163,10 +169,14 @@ export class MfaService { } /** Generates a fresh batch of 10, deleting any existing ones first. */ - async regenerateRecoveryCodes(userId: string): Promise { + async regenerateRecoveryCodes( + userId: string, + currentSessionId?: string, + ): Promise { const codes = await this.generateRecoveryCodes(userId, { deleteExisting: true, revokeSessions: true, + currentSessionId, }); return codes; } @@ -182,6 +192,7 @@ export class MfaService { return this.generateRecoveryCodes(userId, { deleteExisting: false, revokeSessions: false, + currentSessionId: undefined, }); } @@ -190,7 +201,12 @@ export class MfaService { { deleteExisting, revokeSessions, - }: { deleteExisting: boolean; revokeSessions: boolean }, + currentSessionId, + }: { + deleteExisting: boolean; + revokeSessions: boolean; + currentSessionId?: string; + }, ): Promise { const codes = Array.from({ length: RECOVERY_CODE_COUNT }, () => generateRecoveryCode(), @@ -207,13 +223,21 @@ export class MfaService { this.prisma.mfaRecoveryCode.create({ data: { userId, codeHash } }), ), ...(revokeSessions - ? [this.prisma.refreshToken.deleteMany({ where: { userId } })] + ? [this.deleteOtherSessionsQuery(userId, currentSessionId)] : []), ]); return codes; } + private deleteOtherSessionsQuery(userId: string, currentSessionId?: string) { + return this.prisma.refreshToken.deleteMany({ + where: currentSessionId + ? { userId, id: { not: currentSessionId } } + : { userId }, + }); + } + /** Normalizes (strips separators, uppercases), matches, and deletes the consumed row. */ async verifyRecoveryCode(userId: string, rawCode: string): Promise { const normalized = rawCode.replace(/[\s-]/g, "").toUpperCase(); diff --git a/apps/api/src/users/mfa.controller.ts b/apps/api/src/users/mfa.controller.ts index b12f254c..e7ccf10a 100644 --- a/apps/api/src/users/mfa.controller.ts +++ b/apps/api/src/users/mfa.controller.ts @@ -43,7 +43,7 @@ export class MfaController { @CurrentUser() payload: JwtPayload, @Body() dto: ConfirmTotpDto, ): Promise { - return this.mfaService.confirmTotp(payload.sub, dto.code); + return this.mfaService.confirmTotp(payload.sub, dto.code, payload.sid); } @Post("totp/disable") @@ -51,7 +51,11 @@ export class MfaController { @CurrentUser() payload: JwtPayload, @Body() dto: DisableTotpDto, ): Promise { - await this.mfaService.disableTotp(payload.sub, dto.currentPassword); + await this.mfaService.disableTotp( + payload.sub, + dto.currentPassword, + payload.sid, + ); } @Patch("email") @@ -60,7 +64,11 @@ export class MfaController { @CurrentUser() payload: JwtPayload, @Body() dto: SetEmailMfaDto, ): Promise { - return this.mfaService.setEmailMfaEnabled(payload.sub, dto.enabled); + return this.mfaService.setEmailMfaEnabled( + payload.sub, + dto.enabled, + payload.sid, + ); } // Authenticated-only, but still a sensitive/spammy-if-abused action. @@ -71,7 +79,10 @@ export class MfaController { @CurrentUser() payload: JwtPayload, ): Promise { return { - codes: await this.mfaService.regenerateRecoveryCodes(payload.sub), + codes: await this.mfaService.regenerateRecoveryCodes( + payload.sub, + payload.sid, + ), }; } }