From 9130c0da36b2ed524366cd19e1249378f01cf220 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Tue, 28 Apr 2026 13:12:10 -0400 Subject: [PATCH] fix(webapp): treat cascade-deleted PAT as auth miss, not stale success --- .../services/personalAccessToken.server.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index 6c4eabe4f..d950e82ab 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -205,23 +205,22 @@ export async function authenticatePersonalAccessToken( return; } - // Best-effort touch. The token can vanish between the findFirst above and - // this update if a User cascade-delete happens concurrently (admin delete - // flow), so swallow not-found errors rather than 500-ing the auth path. - try { - await prisma.personalAccessToken.update({ - where: { - id: personalAccessToken.id, - }, - data: { - lastAccessedAt: new Date(), - }, - }); - } catch (error) { - logger.warn("Failed to touch PersonalAccessToken.lastAccessedAt", { + // Touch lastAccessedAt with updateMany rather than update so a missing + // row (e.g. the PAT was cascade-deleted by a concurrent User delete + // between the findFirst above and this call) yields count = 0 instead + // of throwing. count = 0 means the token no longer exists — treat that + // as an authentication miss rather than handing a userId for a deleted + // user back to callers that don't re-verify the user. + const touchResult = await prisma.personalAccessToken.updateMany({ + where: { id: personalAccessToken.id }, + data: { lastAccessedAt: new Date() }, + }); + + if (touchResult.count === 0) { + logger.warn("PersonalAccessToken vanished between findFirst and update", { personalAccessTokenId: personalAccessToken.id, - error, }); + return; } const decryptedToken = decryptPersonalAccessToken(personalAccessToken);