Skip to content

Commit 832408d

Browse files
conorluddyclaude
andcommitted
fix(review): graceful logout; correct error messages; delete-before-validate comment
logout: degrade gracefully when refresh token not in DB (expired/purged) β€” cookie is already cleared so user is effectively logged out regardless. Fix misleading MISSING_USER_ID β†’ REFRESH_TOKEN_REQUIRED error message. Add test for token-not-found path. refreshToken: add explanatory comment on the intentional delete-before-validate ordering (token theft detection: nuke all sessions on any suspicious reuse). Use guard clause to remove redundant if(token)/if(!token) double-check. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b096ba7 commit 832408d

5 files changed

Lines changed: 26 additions & 17 deletions

File tree

β€Ž.github/type-coverage.jsonβ€Ž

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"succeeded": true,
33
"atLeast": 90,
44
"atLeastFailed": false,
5-
"correctCount": 6556,
6-
"percent": 98.55,
7-
"percentString": "98.55",
8-
"totalCount": 6652
5+
"correctCount": 6488,
6+
"percent": 98.54,
7+
"percentString": "98.54",
8+
"totalCount": 6584
99
}

β€Žpackage.jsonβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "residents",
3-
"version": "0.3.8",
3+
"version": "0.3.9",
44
"author": "Conor Luddy <conorluddy@gmail.com>",
55
"license": "MIT",
66
"description": "Residents is a Node.js Express back-end foundation designed for bootstrapping new projects quickly and efficiently. Its main goal is to set up a robust infrastructure for user management, because users are the core of any application. These users are your Residents. It leverages a robust stack including Postgres, Drizzle ORM, JWT, PassportJS, Docker and Swagger to streamline development and deployment processes.",

β€Žsrc/controllers/auth/logout.test.tsβ€Ž

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,21 @@ describe('Controller: Logout', () => {
2929
}
3030
})
3131

32-
it('Throws an error if missing the user data', async () => {
32+
it('Throws an error if the refresh token cookie is absent', async () => {
3333
mockRequest.cookies = undefined
3434
await expect(logout(mockRequest as ResidentRequest, mockResponse as Response)).rejects.toThrow(
35-
MESSAGES.MISSING_USER_ID
35+
MESSAGES.REFRESH_TOKEN_REQUIRED
3636
)
3737
})
3838

39+
it('Still succeeds if the refresh token is not found in the DB (graceful degradation)', async () => {
40+
const SERVICES = jest.requireMock('../../services/index')
41+
SERVICES.getToken.mockImplementationOnce(() => null)
42+
await logout(mockRequest as ResidentRequest, mockResponse as Response)
43+
expect(mockResponse.json).toHaveBeenCalledWith({ message: MESSAGES.LOGOUT_SUCCESS })
44+
expect(mockResponse.status).toHaveBeenCalledWith(HTTP_SUCCESS.OK)
45+
})
46+
3947
it('logs out a user by deleting any of their refresh tokens', async () => {
4048
await logout(mockRequest as ResidentRequest, mockResponse as Response)
4149
expect(mockResponse.json).toHaveBeenCalledWith({ message: MESSAGES.LOGOUT_SUCCESS })

β€Žsrc/controllers/auth/logout.tsβ€Ž

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ export const logout = async (req: ResidentRequest, res: Response<ResidentRespons
2121
// Look up userId via the refresh token β€” avoids trusting a separate cookie
2222
const refreshTokenId = req.cookies?.[REFRESH_TOKEN]
2323
if (!refreshTokenId) {
24-
throw new BadRequestError(MESSAGES.MISSING_USER_ID)
24+
throw new BadRequestError(MESSAGES.REFRESH_TOKEN_REQUIRED)
2525
}
2626

27+
// Best-effort DB cleanup: token may already be expired/deleted (e.g. admin purge).
28+
// Cookie is already cleared above, so the user is effectively logged out regardless.
2729
const token = await SERVICES.getToken({ tokenId: refreshTokenId })
28-
if (!token?.userId) {
29-
throw new BadRequestError(MESSAGES.MISSING_USER_ID)
30+
if (token?.userId) {
31+
await SERVICES.deleteRefreshTokensByUserId({ userId: token.userId })
3032
}
3133

32-
await SERVICES.deleteRefreshTokensByUserId({ userId: token.userId })
33-
3434
handleSuccessResponse({ res, message: MESSAGES.LOGOUT_SUCCESS })
3535
}

β€Žsrc/controllers/auth/refreshToken.tsβ€Ž

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@ export const refreshToken = async (req: ResidentRequest, res: Response<ResidentR
2525
// Get the refresh token from the DB β€” userId is authoritative from here, not a cookie
2626
const token = await SERVICES.getToken({ tokenId: refreshTokenId })
2727

28-
// Regardless of validity, clear tokens once we've fetched the record
29-
if (token) {
30-
await SERVICES.deleteRefreshTokensByUserId({ userId: token.userId })
31-
}
32-
3328
if (!token) {
3429
throw new ForbiddenError(MESSAGES.TOKEN_NOT_FOUND)
3530
}
31+
32+
// Intentional: delete ALL sessions for this user before validating the token further.
33+
// If the token is reused or tampered with, nuking all sessions is the correct response
34+
// (indicates likely token theft). Trade-off: a replayed expired token will log out all
35+
// active sessions for that user.
36+
await SERVICES.deleteRefreshTokensByUserId({ userId: token.userId })
3637
if (token.used) {
3738
throw new ForbiddenError(MESSAGES.TOKEN_USED)
3839
}

0 commit comments

Comments
Β (0)