Skip to content

Commit 9a1a513

Browse files
feat(auth): add POST /api/auth/signout to clear TOKEN cookie (#3508)
* feat(auth): add POST /api/auth/signout endpoint to clear TOKEN cookie Before: signout was client-side only — the Vue client dropped its in-memory user state but the httpOnly TOKEN cookie stayed in the browser and the user was silently re-logged in on the next /api/auth/token call. Now: POST /api/auth/signout clears the cookie server-side with options (httpOnly, secure, sameSite) mirroring tokenCookieOptions so the browser actually discards it. No JWT middleware — works even if the token is expired, invalid, or missing. Rate-limited via authLimiter. Integration tests cover: - 200 + success body without prior JWT - Set-Cookie header with Max-Age=0/Expires-in-past + HttpOnly - Follow-up /api/auth/token returns 401 on the same agent (cookie killed) Closes #3502 * style(auth): use block-body arrow for signout handler Codacy's shell-syntax rule SC2288 mis-fires on the chained arrow expression-body (`res.status().clearCookie().json()`) and flags it as "Non-serializable expression must be wrapped with $(...)" — a false positive for JS but still shows up as ACTION_REQUIRED on the PR. Split the chain across two explicit statements inside a block body to dodge the parser confusion. Purely cosmetic — same 200 response, same Set-Cookie header (expired TOKEN with HttpOnly/secure/sameSite from tokenCookieOptions). Integration tests unchanged and still pass. * test(auth): add signout controller unit test to satisfy codecov/patch threshold
1 parent 25a0900 commit 9a1a513

5 files changed

Lines changed: 213 additions & 1 deletion

File tree

MIGRATIONS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,38 @@ Breaking changes and upgrade notes for downstream projects.
44

55
---
66

7+
## Auth signout endpoint (2026-04-23)
8+
9+
New `POST /api/auth/signout` endpoint that clears the httpOnly `TOKEN` cookie on the client.
10+
11+
### Why
12+
13+
Before: signout was purely client-side — the Vue client dropped its in-memory user state but the httpOnly `TOKEN` cookie remained in the browser. On the next page load the cookie was replayed to `/api/auth/token`, the user was silently re-logged in, and the signout button was effectively a no-op.
14+
15+
Now: the route calls `res.clearCookie('TOKEN', { httpOnly, secure, sameSite })` with options mirroring `tokenCookieOptions`. Browsers only delete cookies whose `secure`/`sameSite`/`path`/`domain` attributes match the original `Set-Cookie`, so the options must match exactly.
16+
17+
### Contract
18+
19+
- `POST /api/auth/signout``200 { type: 'success', message: 'Signed out' }`
20+
- `Set-Cookie: TOKEN=; Max-Age=0; HttpOnly; …` (expired cookie — browser discards it)
21+
- No JWT middleware: signout works even if the token is expired, invalid, or missing
22+
- Rate-limited via the standard `authLimiter`
23+
24+
### Non-breaking
25+
26+
Additive endpoint. No existing contract changes. Downstream projects can adopt it at their own pace:
27+
28+
- Vue: call `POST /api/auth/signout` from the signout action, then clear the Vuex/Pinia user state
29+
- No env var changes
30+
- No Mongo migration
31+
32+
### Action for downstream
33+
34+
1. Run `/update-stack` to pull the endpoint.
35+
2. (Vue side, separately) wire the signout action to call `POST /api/auth/signout` before resetting client state.
36+
37+
---
38+
739
## OAuth account linking + Express 5 callback fix (2026-04-23)
840

941
Two related auth fixes that ship together.

modules/auth/controllers/auth.controller.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,21 @@ const oauthCallback = async (req, res, next) => {
474474
})(req, res, next);
475475
};
476476

477+
/**
478+
* @desc Endpoint to clear the JWT TOKEN cookie on the client.
479+
* No JWT middleware is required — signout must work even when the token is
480+
* expired, invalid, or missing. clearCookie options mirror tokenCookieOptions
481+
* because browsers only delete cookies whose secure/sameSite/path attributes
482+
* match the Set-Cookie used at login.
483+
* @param {Object} req - Express request object
484+
* @param {Object} res - Express response object
485+
* @returns {void} Sends a 200 JSON response and clears the TOKEN cookie
486+
*/
487+
const signout = (req, res) => {
488+
res.clearCookie('TOKEN', tokenCookieOptions);
489+
return res.status(200).json({ type: 'success', message: 'Signed out' });
490+
};
491+
477492
/**
478493
* @desc Endpoint to expose public auth configuration (sign flags and organizations settings)
479494
* @param {Object} req - Express request object
@@ -567,6 +582,7 @@ export default {
567582
signup,
568583
signinAuthenticate,
569584
signin,
585+
signout,
570586
token,
571587
oauthCall,
572588
oauthCallback,

modules/auth/routes/auth.routes.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export default (app) => {
4141
// Setting up the users authentication api
4242
app.route('/api/auth/signup').post(authLimiter, model.isValid(UsersSchema.User), auth.signup);
4343
app.route('/api/auth/signin').post(authLimiter, auth.signinAuthenticate, auth.signin);
44+
// Signout: intentionally no JWT middleware — must clear the cookie even if the
45+
// token is expired/invalid/missing so the client cannot get silently re-logged in.
46+
app.route('/api/auth/signout').post(authLimiter, auth.signout);
4447

4548
// Email verification
4649
app.route('/api/auth/verify-email/:token').post(authLimiter, auth.verifyEmail);

modules/auth/tests/auth.integration.tests.js

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import logger from '../../../lib/services/logger.js';
1818
describe('Auth integration tests:', () => {
1919
let UserService = null;
2020
let AuthService = null;
21+
let app; // Express app instance for fresh agents inside Signout block (#3502)
2122
let agent;
2223
let credentials;
2324
let user;
@@ -31,7 +32,8 @@ describe('Auth integration tests:', () => {
3132
const init = await bootstrap();
3233
UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default;
3334
AuthService = (await import(path.resolve('./modules/auth/services/auth.service.js'))).default;
34-
agent = request.agent(init.app);
35+
app = init.app;
36+
agent = request.agent(app);
3537
} catch (err) {
3638
console.log(err);
3739
expect(err).toBeFalsy();
@@ -1225,6 +1227,71 @@ describe('Auth integration tests:', () => {
12251227
});
12261228
});
12271229

1230+
describe('Signout', () => {
1231+
const outEmail = 'signout@test.com';
1232+
const outPassword = 'W@os.jsI$Aw3$0m3';
1233+
let outUser;
1234+
1235+
beforeEach(async () => {
1236+
// Clean up stale user from previous runs on shared databases
1237+
try {
1238+
const existing = await UserService.getBrut({ email: outEmail });
1239+
if (existing) await UserService.remove(existing);
1240+
} catch (_) { /* cleanup – ignore errors */ }
1241+
try {
1242+
const signoutAgent = request.agent(app);
1243+
const result = await signoutAgent.post('/api/auth/signup').send({
1244+
firstName: 'Signout',
1245+
lastName: 'Test',
1246+
email: outEmail,
1247+
password: outPassword,
1248+
provider: 'local',
1249+
}).expect(200);
1250+
outUser = result.body.user;
1251+
} catch (err) {
1252+
console.log(err);
1253+
expect(err).toBeFalsy();
1254+
}
1255+
});
1256+
1257+
test('should return 200 with success message even without prior JWT cookie', async () => {
1258+
const result = await request(app).post('/api/auth/signout').expect(200);
1259+
expect(result.body.type).toBe('success');
1260+
expect(result.body.message).toBe('Signed out');
1261+
});
1262+
1263+
test('should set an expired TOKEN cookie so the browser discards it', async () => {
1264+
const result = await request(app).post('/api/auth/signout').expect(200);
1265+
const tokenCookie = result.headers['set-cookie']?.find((c) => c.startsWith('TOKEN='));
1266+
expect(tokenCookie).toBeDefined();
1267+
// Cleared cookie: value is empty and either Max-Age=0 or Expires is in the past
1268+
expect(tokenCookie).toMatch(/^TOKEN=;/);
1269+
const hasMaxAgeZero = /Max-Age=0/i.test(tokenCookie);
1270+
const expiresMatch = tokenCookie.match(/Expires=([^;]+)/i);
1271+
const expiresInPast = expiresMatch ? new Date(expiresMatch[1]).getTime() < Date.now() : false;
1272+
expect(hasMaxAgeZero || expiresInPast).toBe(true);
1273+
// Must mirror login cookie attributes (httpOnly) so the browser deletes it
1274+
expect(tokenCookie).toMatch(/HttpOnly/i);
1275+
});
1276+
1277+
test('should invalidate the session so subsequent JWT-guarded calls return 401', async () => {
1278+
// Use a dedicated agent with a clean cookie jar so the signin cookie is the only state
1279+
const signoutAgent = request.agent(app);
1280+
await signoutAgent.post('/api/auth/signin').send({ email: outEmail, password: outPassword }).expect(200);
1281+
// Cookie present → token endpoint works
1282+
await signoutAgent.get('/api/auth/token').expect(200);
1283+
// Signout clears the cookie on the agent's jar
1284+
await signoutAgent.post('/api/auth/signout').expect(200);
1285+
// Next JWT-guarded call with the same agent must be 401 (cookie gone)
1286+
await signoutAgent.get('/api/auth/token').expect(401);
1287+
});
1288+
1289+
afterEach(async () => {
1290+
try { if (outUser) await UserService.remove(outUser); } catch (_) { /* cleanup */ }
1291+
outUser = null;
1292+
});
1293+
});
1294+
12281295
describe('Error paths', () => {
12291296
test('should redirect to invalid when validateResetToken getBrut throws', async () => {
12301297
jest.spyOn(UserService, 'getBrut').mockRejectedValueOnce(new Error('DB error'));
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
5+
6+
describe('auth.controller signout:', () => {
7+
beforeEach(() => {
8+
jest.resetModules();
9+
10+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
11+
default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() },
12+
}));
13+
jest.unstable_mockModule('../../../config/index.js', () => ({
14+
default: {
15+
sign: { up: true, in: true },
16+
jwt: { secret: 's', expiresIn: 3600 },
17+
cookie: { secure: true, sameSite: 'lax' },
18+
organizations: { enabled: false },
19+
app: { title: 'Test', contact: 'a@b.com' },
20+
},
21+
}));
22+
jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
23+
default: { create: jest.fn(), getBrut: jest.fn(), update: jest.fn(), remove: jest.fn(), search: jest.fn() },
24+
}));
25+
jest.unstable_mockModule('../../../modules/users/repositories/users.repository.js', () => ({
26+
default: { update: jest.fn() },
27+
}));
28+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
29+
default: { handleSignupOrganization: jest.fn() },
30+
}));
31+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({
32+
default: { autoSetCurrentOrganization: jest.fn() },
33+
}));
34+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({
35+
default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) },
36+
}));
37+
jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({
38+
default: { User: {} },
39+
}));
40+
jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({
41+
default: { getResultFromZod: jest.fn(), checkError: jest.fn() },
42+
}));
43+
jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
44+
default: { defineAbilityFor: jest.fn().mockResolvedValue({}) },
45+
}));
46+
jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
47+
default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() },
48+
}));
49+
jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({
50+
default: {
51+
success: jest.fn().mockReturnValue(jest.fn()),
52+
error: jest.fn().mockReturnValue(jest.fn()),
53+
},
54+
}));
55+
jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({
56+
default: { getMessage: jest.fn().mockReturnValue('error') },
57+
}));
58+
jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({
59+
default: class AppError extends Error {
60+
constructor(msg, opts) {
61+
super(msg);
62+
this.code = opts?.code;
63+
this.details = opts?.details;
64+
}
65+
},
66+
}));
67+
jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({
68+
default: jest.fn().mockReturnValue([]),
69+
}));
70+
jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({
71+
default: jest.fn().mockReturnValue('http://localhost:3000'),
72+
}));
73+
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
74+
default: { identify: jest.fn(), groupIdentify: jest.fn() },
75+
}));
76+
});
77+
78+
test('clears TOKEN cookie with options mirroring tokenCookieOptions and returns success', async () => {
79+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
80+
81+
const cleared = [];
82+
const res = {
83+
clearCookie: (name, opts) => { cleared.push({ name, opts }); return res; },
84+
status: jest.fn().mockReturnThis(),
85+
json: jest.fn().mockReturnThis(),
86+
};
87+
88+
AuthController.signout({}, res);
89+
90+
expect(cleared).toEqual([{ name: 'TOKEN', opts: { httpOnly: true, secure: true, sameSite: 'lax' } }]);
91+
expect(res.status).toHaveBeenCalledWith(200);
92+
expect(res.json).toHaveBeenCalledWith({ type: 'success', message: 'Signed out' });
93+
});
94+
});

0 commit comments

Comments
 (0)