2025-09-23 12:04:29 +02:00

512 lines
19 KiB
JavaScript

'use strict';
const { createStrapiInstance } = require('api-tests/strapi');
const { createRequest } = require('api-tests/request');
const { createAuthenticatedUser } = require('../utils');
let strapi;
const internals = {
user: {
username: 'test-refresh',
email: 'test-refresh@strapi.io',
password: 'Test1234',
confirmed: true,
provider: 'local',
},
};
describe('Auth API (refresh mode httpOnly behaviour)', () => {
beforeAll(async () => {
strapi = await createStrapiInstance({
bypassAuth: false,
async bootstrap({ strapi: s }) {
s.config.set('plugin::users-permissions.jwtManagement', 'refresh');
s.config.set('plugin::users-permissions.sessions.httpOnly', false);
},
});
await createAuthenticatedUser({ strapi, userInfo: internals.user });
});
afterAll(async () => {
await strapi.db.query('plugin::users-permissions.user').deleteMany();
await strapi.destroy();
});
test('Default (httpOnly=false): returns jwt and refreshToken in JSON, no cookie set', async () => {
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
const res = await rqAuth({
method: 'POST',
url: '/local',
body: { identifier: internals.user.email, password: internals.user.password },
});
expect(res.statusCode).toBe(200);
expect(res.body.jwt).toEqual(expect.any(String));
expect(res.body.refreshToken).toEqual(expect.any(String));
const setCookie = res.headers['set-cookie'];
expect(Array.isArray(setCookie) ? setCookie.join('\n') : String(setCookie || '')).not.toMatch(
/strapi_up_refresh=/
);
});
test('Per-request opt-in header sets cookie and omits refreshToken', async () => {
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
const res = await rqAuth({
method: 'POST',
url: '/local',
headers: { 'x-strapi-refresh-cookie': 'httpOnly' },
body: { identifier: internals.user.email, password: internals.user.password },
});
expect(res.statusCode).toBe(200);
expect(res.body.jwt).toEqual(expect.any(String));
expect(res.body.refreshToken).toBeUndefined();
const setCookie = res.headers['set-cookie'];
const cookies = Array.isArray(setCookie) ? setCookie.join('\n') : String(setCookie || '');
expect(cookies).toMatch(/strapi_up_refresh=/);
expect(cookies.toLowerCase()).toMatch(/httponly/);
});
test('Config httpOnly=true: always sets cookie and omits refreshToken', async () => {
await strapi.destroy();
strapi = await createStrapiInstance({
bypassAuth: false,
async bootstrap({ strapi: s }) {
s.config.set('plugin::users-permissions.jwtManagement', 'refresh');
s.config.set('plugin::users-permissions.sessions.httpOnly', true);
},
});
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
// Ensure user exists in this new instance
await createAuthenticatedUser({ strapi, userInfo: internals.user });
const res = await rqAuth({
method: 'POST',
url: '/local',
body: { identifier: internals.user.email, password: internals.user.password },
});
expect(res.statusCode).toBe(200);
expect(res.body.jwt).toEqual(expect.any(String));
expect(res.body.refreshToken).toBeUndefined();
const setCookie = res.headers['set-cookie'];
const cookies = Array.isArray(setCookie) ? setCookie.join('\n') : String(setCookie || '');
expect(cookies).toMatch(/strapi_up_refresh=/);
expect(cookies.toLowerCase()).toMatch(/httponly/);
});
describe('Change Password (refresh mode responses)', () => {
beforeAll(async () => {
await strapi.destroy();
strapi = await createStrapiInstance({
bypassAuth: false,
async bootstrap({ strapi: s }) {
s.config.set('plugin::users-permissions.jwtManagement', 'refresh');
s.config.set('plugin::users-permissions.sessions.httpOnly', false);
},
});
// Ensure user exists
await createAuthenticatedUser({ strapi, userInfo: internals.user });
});
test('Fails on unauthenticated request', async () => {
const nonAuthRequest = createRequest({ strapi });
const res = await nonAuthRequest({
method: 'POST',
url: '/api/auth/change-password',
body: {},
});
expect(res.statusCode).toBe(403);
expect(res.body.error.name).toBe('ForbiddenError');
expect(res.body.error.message).toBe('Forbidden');
});
test('Returns jwt and refreshToken after successful password change', async () => {
const rqAuthLogin = createRequest({ strapi }).setURLPrefix('/api/auth');
// Create an isolated user for this test to avoid impacting other tests
const isolatedUser = {
username: 'test-refresh-change',
email: 'test-refresh-change@strapi.io',
password: 'Change1234!',
confirmed: true,
provider: 'local',
};
await createAuthenticatedUser({ strapi, userInfo: isolatedUser });
// Login to obtain a valid jwt to perform change-password
const loginRes = await rqAuthLogin({
method: 'POST',
url: '/local',
body: { identifier: isolatedUser.email, password: isolatedUser.password },
});
expect(loginRes.statusCode).toBe(200);
const jwt = loginRes.body.jwt;
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth').setToken(jwt);
const newPassword = 'Change12345!';
const res = await rqAuth({
method: 'POST',
url: '/change-password',
body: {
password: newPassword,
passwordConfirmation: newPassword,
currentPassword: isolatedUser.password,
},
});
expect(res.statusCode).toBe(200);
expect(res.body.jwt).toEqual(expect.any(String));
expect(res.body.refreshToken).toEqual(expect.any(String));
// Can login with the new password afterwards
const relogin = await rqAuthLogin({
method: 'POST',
url: '/local',
body: { identifier: isolatedUser.email, password: newPassword },
});
expect(relogin.statusCode).toBe(200);
});
test('Fails on invalid confirm password', async () => {
const rqAuthLogin = createRequest({ strapi }).setURLPrefix('/api/auth');
const loginRes = await rqAuthLogin({
method: 'POST',
url: '/local',
body: { identifier: internals.user.email, password: internals.user.password },
});
const jwt = loginRes.body.jwt;
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth').setToken(jwt);
const res = await rqAuth({
method: 'POST',
url: '/change-password',
body: {
password: 'newPassword',
passwordConfirmation: 'somethingElse',
currentPassword: 'Test12345!',
},
});
expect(res.statusCode).toBe(400);
expect(res.body.error.name).toBe('ValidationError');
expect(res.body.error.message).toBe('Passwords do not match');
});
test('Fails on invalid current password', async () => {
const rqAuthLogin = createRequest({ strapi }).setURLPrefix('/api/auth');
const loginRes = await rqAuthLogin({
method: 'POST',
url: '/local',
body: { identifier: internals.user.email, password: internals.user.password },
});
const jwt = loginRes.body.jwt;
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth').setToken(jwt);
const res = await rqAuth({
method: 'POST',
url: '/change-password',
body: {
password: 'newPassword',
passwordConfirmation: 'newPassword',
currentPassword: 'badPassword',
},
});
expect(res.statusCode).toBe(400);
expect(res.body.error.name).toBe('ValidationError');
expect(res.body.error.message).toBe('The provided current password is invalid');
});
test('Fails when current and new password are the same', async () => {
const rqAuthLogin = createRequest({ strapi }).setURLPrefix('/api/auth');
const loginRes = await rqAuthLogin({
method: 'POST',
url: '/local',
body: { identifier: internals.user.email, password: internals.user.password },
});
const jwt = loginRes.body.jwt;
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth').setToken(jwt);
const res = await rqAuth({
method: 'POST',
url: '/change-password',
body: {
password: internals.user.password,
passwordConfirmation: internals.user.password,
currentPassword: internals.user.password,
},
});
expect(res.statusCode).toBe(400);
expect(res.body.error.name).toBe('ValidationError');
expect(res.body.error.message).toBe(
'Your new password must be different than your current password'
);
});
});
describe('Refresh endpoint', () => {
beforeAll(async () => {
await strapi.destroy();
strapi = await createStrapiInstance({
bypassAuth: false,
async bootstrap({ strapi: s }) {
s.config.set('plugin::users-permissions.jwtManagement', 'refresh');
s.config.set('plugin::users-permissions.sessions.httpOnly', false);
// Enable public permission for refresh route
const publicRole = await s.db
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'public' } });
const roleService = s.service('plugin::users-permissions.role');
const publicRoleDetails = await roleService.findOne(publicRole.id);
publicRoleDetails.permissions['plugin::users-permissions'] = publicRoleDetails
.permissions['plugin::users-permissions'] || { controllers: {} };
const controllers =
publicRoleDetails.permissions['plugin::users-permissions'].controllers || {};
const authCtrl = controllers.auth || {};
authCtrl.refresh = { enabled: true, policy: '' };
publicRoleDetails.permissions['plugin::users-permissions'].controllers = {
...controllers,
auth: authCtrl,
};
await roleService.updateRole(publicRole.id, {
permissions: publicRoleDetails.permissions,
});
},
});
await createAuthenticatedUser({ strapi, userInfo: internals.user });
});
test('Missing refresh token returns 400 (public route)', async () => {
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
const res = await rqAuth({ method: 'POST', url: '/refresh', body: {} });
expect(res.statusCode).toBe(400);
expect(res.body.error.message).toBe('Missing refresh token');
});
test('Invalid refresh token returns 401', async () => {
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
const res = await rqAuth({
method: 'POST',
url: '/refresh',
body: { refreshToken: 'not-a-valid-token' },
});
expect(res.statusCode).toBe(401);
expect(res.body.error.message).toBe('Invalid refresh token');
});
test('Success returns new jwt and rotated refreshToken in JSON by default', async () => {
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
// Login to get a refresh token (default httpOnly=false)
const loginRes = await rqAuth({
method: 'POST',
url: '/local',
body: { identifier: internals.user.email, password: internals.user.password },
});
expect(loginRes.statusCode).toBe(200);
const initialRefresh = loginRes.body.refreshToken;
expect(initialRefresh).toEqual(expect.any(String));
const refreshRes = await rqAuth({
method: 'POST',
url: '/refresh',
body: { refreshToken: initialRefresh },
});
expect(refreshRes.statusCode).toBe(200);
expect(refreshRes.body.jwt).toEqual(expect.any(String));
expect(refreshRes.body.refreshToken).toEqual(expect.any(String));
expect(refreshRes.body.refreshToken).not.toBe(initialRefresh);
});
test('With httpOnly header, sets cookie and omits refreshToken in body', async () => {
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
// Login to get a refresh token first
const loginRes = await rqAuth({
method: 'POST',
url: '/local',
body: { identifier: internals.user.email, password: internals.user.password },
});
const initialRefresh = loginRes.body.refreshToken;
const res = await rqAuth({
method: 'POST',
url: '/refresh',
headers: { 'x-strapi-refresh-cookie': 'httpOnly' },
body: { refreshToken: initialRefresh },
});
expect(res.statusCode).toBe(200);
expect(res.body.jwt).toEqual(expect.any(String));
expect(res.body.refreshToken).toBeUndefined();
const setCookie = res.headers['set-cookie'];
const cookies = Array.isArray(setCookie) ? setCookie.join('\n') : String(setCookie || '');
expect(cookies).toMatch(/strapi_up_refresh=/);
expect(cookies.toLowerCase()).toMatch(/httponly/);
});
test('Even with cookie set, missing body yields 400', async () => {
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
const loginRes = await rqAuth({
method: 'POST',
url: '/local',
headers: { 'x-strapi-refresh-cookie': 'httpOnly' },
body: { identifier: internals.user.email, password: 'Test12345!' },
});
const setCookie = loginRes.headers['set-cookie'];
const cookieHeader = Array.isArray(setCookie) ? setCookie : [setCookie];
const res = await rqAuth({
method: 'POST',
url: '/refresh',
headers: {
Cookie: cookieHeader.filter(Boolean).join('; '),
'x-strapi-refresh-cookie': 'httpOnly',
},
body: {},
});
expect(res.statusCode).toBe(400);
expect(res.body.error.message).toBe('Missing refresh token');
});
});
describe('Logout endpoint', () => {
beforeAll(async () => {
await strapi.destroy();
strapi = await createStrapiInstance({
bypassAuth: false,
async bootstrap({ strapi: s }) {
s.config.set('plugin::users-permissions.jwtManagement', 'refresh');
s.config.set('plugin::users-permissions.sessions.httpOnly', false);
// Disable rate limiting for auth routes in this suite to avoid 429s
s.config.set('plugin::users-permissions.ratelimit', { enabled: false });
// Enable authenticated permission for logout and public for refresh (used later)
const roleService = s.service('plugin::users-permissions.role');
const publicRole = await s.db
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'public' } });
const authenticatedRole = await s.db
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'authenticated' } });
const publicRoleDetails = await roleService.findOne(publicRole.id);
publicRoleDetails.permissions['plugin::users-permissions'] = publicRoleDetails
.permissions['plugin::users-permissions'] || { controllers: {} };
const pubControllers =
publicRoleDetails.permissions['plugin::users-permissions'].controllers || {};
const pubAuthCtrl = pubControllers.auth || {};
pubAuthCtrl.refresh = { enabled: true, policy: '' };
publicRoleDetails.permissions['plugin::users-permissions'].controllers = {
...pubControllers,
auth: pubAuthCtrl,
};
await roleService.updateRole(publicRole.id, {
permissions: publicRoleDetails.permissions,
});
const authRoleDetails = await roleService.findOne(authenticatedRole.id);
authRoleDetails.permissions['plugin::users-permissions'] = authRoleDetails.permissions[
'plugin::users-permissions'
] || { controllers: {} };
const authControllers =
authRoleDetails.permissions['plugin::users-permissions'].controllers || {};
const authAuthCtrl = authControllers.auth || {};
authAuthCtrl.logout = { enabled: true, policy: '' };
authRoleDetails.permissions['plugin::users-permissions'].controllers = {
...authControllers,
auth: authAuthCtrl,
};
await roleService.updateRole(authenticatedRole.id, {
permissions: authRoleDetails.permissions,
});
},
});
await createAuthenticatedUser({ strapi, userInfo: internals.user });
});
test('Requires authentication', async () => {
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
const res = await rqAuth({ method: 'POST', url: '/logout', body: {} });
expect([401, 403]).toContain(res.statusCode);
if (res.statusCode === 401) {
expect(res.body.error.message).toBe('Missing authentication');
}
});
test('Authenticated logout responds ok and clears cookie when requested', async () => {
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
// Login to get access jwt
const loginRes = await rqAuth({
method: 'POST',
url: '/local',
body: { identifier: internals.user.email, password: internals.user.password },
});
expect(loginRes.statusCode).toBe(200);
const jwt = loginRes.body.jwt;
expect(typeof jwt).toBe('string');
const rqAuthed = createRequest({ strapi }).setURLPrefix('/api/auth');
const res = await rqAuthed({
method: 'POST',
url: '/logout',
headers: { 'x-strapi-refresh-cookie': 'httpOnly', Authorization: `Bearer ${jwt}` },
body: {},
});
expect(res.statusCode).toBe(200);
expect(res.body).toMatchObject({ ok: true });
const setCookie = res.headers['set-cookie'];
const cookies = Array.isArray(setCookie) ? setCookie.join('\n') : String(setCookie || '');
expect(cookies).toMatch(/strapi_up_refresh=;/);
});
test('Logout invalidates refresh token', async () => {
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
// Login to get jwt and refresh token
const loginRes = await rqAuth({
method: 'POST',
url: '/local',
body: { identifier: internals.user.email, password: internals.user.password },
});
expect(loginRes.statusCode).toBe(200);
const jwt = loginRes.body.jwt;
const refreshToken = loginRes.body.refreshToken;
expect(typeof jwt).toBe('string');
expect(typeof refreshToken).toBe('string');
// Logout
const rqAuthed = createRequest({ strapi }).setURLPrefix('/api/auth');
const logoutRes = await rqAuthed({
method: 'POST',
url: '/logout',
headers: { Authorization: `Bearer ${jwt}` },
body: {},
});
expect(logoutRes.statusCode).toBe(200);
// Attempt to refresh with the old token should fail
const refreshRes = await rqAuth({
method: 'POST',
url: '/refresh',
body: { refreshToken },
});
expect(refreshRes.statusCode).toBe(401);
expect(refreshRes.body.error.message).toBe('Invalid refresh token');
});
});
});