[Regression on closed #553] Still no logout endpoint — the httpOnly `access_token` cookie cannot be cleared by any client

Open
#737 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
59/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
typescript

Research direction

Start with src/auth/auth.controller.ts and read how githubCallback() sets the access_token cookie, then check src/auth/strategies/jwt.strategy.ts to confirm how requests authenticate from cookies. Add a logout route that clears the cookie with matching attributes, and cover it in auth.controller.spec.ts and test/auth.e2e-spec.ts. Done means the cookie is cleared and a cookie-only request to /auth/me returns 401 afterward.

Written by the indexing model from the issue text.

Description

bug security

Location

  • src/auth/auth.controller.ts:61-67 — AuthController.githubCallback() sets the cookie
  • src/auth/auth.controller.ts — no logout route exists anywhere in the controller
  • src/auth/strategies/jwt.strategy.ts:14-33 — cookieExtractor accepts that cookie on every request

Problem

The OAuth callback issues a session cookie:

res.cookie('access_token', accessToken, {
  httpOnly: true,
  secure: isProd,
  sameSite: 'lax',
  maxAge: 7 * 24 * 60 * 60 * 1000,
  path: '/',
});

grep -rni 'logout\|clearCookie' src/ returns nothing. There is no POST /auth/logout, no res.clearCookie('access_token') call, and AuthController exposes only github, github/callback, handoff, exchange and me.

Because the cookie is httpOnly, frontend JavaScript cannot delete it either — that is the entire point of the flag. The only parties who can clear it are the server (which offers no route) and the user manually, through browser devtools or by clearing site data.

This is closed issue #553, unchanged.

Why it matters

JwtStrategy is configured to accept the token from either the Authorization header or the cookie (jwt.strategy.ts:42-45). So "signing out" in the frontend — discarding the bearer token it received from POST /auth/handoff — does not end the session at all: every subsequent request from that browser still carries the cookie, cookieExtractor finds it, and the user remains fully authenticated for the cookie's entire 7-day maxAge.

Concretely:

  • On a shared or public machine, a user who clicks "Sign out" and walks away leaves a live session behind. The next person to open the site is authenticated as them and can read GET /auth/me, GET /users, and — depending on the roles on that account — reach the money-moving routes under /bounties, /escrow and /milestones.
  • There is also no JWT revocation mechanism (closed #10 / #341, still unaddressed), so there is no server-side way to invalidate the token even administratively. The cookie plus the absence of revocation means a session genuinely cannot be ended for seven days by any means available to the product.
  • The cookie's maxAge is a hard-coded 7 days independent of JWT_EXPIRES_IN (closed #552), so shortening the JWT lifetime does not shorten the window either — it just produces a cookie that is present but rejected, with no way to remove it.

Reproduction / failure path

  1. Complete GitHub OAuth in a browser; confirm the access_token cookie is set (DevTools -> Application -> Cookies).
  2. In the frontend, sign out (or simply clear localStorage/sessionStorage — whatever holds the bearer token).
  3. With no Authorization header, request GET /api/auth/me from the same browser.
  4. Observed: HTTP 200 with { userId, username } — still authenticated. Expected: 401.
  5. Confirm there is no way to fix this from the client: document.cookie = 'access_token=; Max-Age=0' has no effect, because the cookie is httpOnly.

Suggested fix

Add an authenticated logout route to AuthController that clears the cookie with the same attributes it was set with (Express only matches path/domain/sameSite/secure when clearing; a mismatch silently leaves the cookie in place):

@Post('logout')
@HttpCode(204)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
logout(@Res({ passthrough: true }) res: Response) {
  const isProd = this.configService.get('env', { infer: true }) === 'production';
  res.clearCookie('access_token', {
    httpOnly: true,
    secure: isProd,
    sameSite: 'lax',
    path: '/',
  });
}

Consider not requiring JwtAuthGuard on this route (or catching the 401): a user whose token has already expired should still be able to clear the stale cookie, and a logout that 401s is a confusing dead end. A separate unguarded POST /auth/logout that only ever clears the cookie is harmless — it grants nothing.

Derive the cookie's maxAge from the configured JWT_EXPIRES_IN rather than hard-coding 7 days (closed #552), so the cookie and the token it carries expire together.

Add an auth.controller.spec.ts case asserting clearCookie is called with the matching attribute set, and a test/auth.e2e-spec.ts case asserting that GET /auth/me returns 401 after POST /auth/logout when the request carries only the cookie.

Dominant language
TypeScript
Stars
2
Forks
90
Avg merge
31m
Merged PRs (30d)
20

Getting set up

  • Ships a Dockerfile or Docker Compose file
  • No pull request template
  • No contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from MergeFi/backend

All issues in MergeFi/backend

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.