From 729d78746ab952a2ca7455910e34d76a0a662f05 Mon Sep 17 00:00:00 2001 From: Diego Date: Wed, 4 Dec 2024 12:12:06 -0800 Subject: [PATCH] Enabled cookie responses. --- README.md | 9 ++++- package.json | 2 +- server/src/config/index.ts | 1 + server/src/middlewares/auth.test.ts | 49 +++++++++++++++++++++++- server/src/middlewares/auth.ts | 59 +++++++++++++++++++++-------- server/src/services/service.ts | 2 +- 6 files changed, 102 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index c4a4778..11f4f40 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ yarn add @redon2inc/strapi-plugin-refresh-token ## Config +You will need to set the following environment variables: +``` + PRODUCTION_URL=value # used for cookie security if enabled + REFRESH_JWT_SECRET=string + ``` + This component relies on extending the `user-permissions` types. Extend it by adding the following to `./src/extensions/user-permissions/content-types/user/schema.json` ```javascript @@ -62,6 +68,7 @@ Modify your plugins file `config/plugin.ts` to have the following: refreshTokenExpiresIn: '30d', // this value should be higher than the jwt.expiresIn requestRefreshOnAll: false, // automatically send a refresh token in all login requests. refreshTokenSecret: env('REFRESH_JWT_SECRET') || 'SomethingSecret', + cookieResponse: false // if set to true, the refresh token will be sent in a cookie }, } ``` @@ -101,5 +108,5 @@ if the Refresh token is valid, the API will return ``` ## TODO: -- Currently the tokens do not get removed from the DB on usage. Only if they are expired. +- Currently the tokens do not get removed from the DB on usage. They are cleaned when a new token is requested and the old ones have expired. - Expose API so user can clear all sessions on their own. \ No newline at end of file diff --git a/package.json b/package.json index 809312e..6f25a96 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "type": "git", "directory": "." }, - "version": "0.1.0", + "version": "0.1.1", "keywords": [], "type": "commonjs", "exports": { diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 12d0ff8..73d69b9 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -4,6 +4,7 @@ export default { refreshTokenSecret : 'SuperSecretKey', requestRefreshOnAll : false, refreshTokenExpiresIn : '1d', + cookieResponse: false, }, validator() {}, }; diff --git a/server/src/middlewares/auth.test.ts b/server/src/middlewares/auth.test.ts index 189c238..6b9e115 100644 --- a/server/src/middlewares/auth.test.ts +++ b/server/src/middlewares/auth.test.ts @@ -19,6 +19,7 @@ describe('Auth Middleware', () => { beforeEach(() => { // Mocking Strapi dependencies + process.env.PRODUCTION_URL = 'https://redon2.ca/'; //awesome ppl strapiMock = { config: { get: jest.fn().mockReturnValue({ @@ -72,10 +73,13 @@ describe('Auth Middleware', () => { }, send: jest.fn(), status: 200, + cookies: { + set: jest.fn(), + } }; }); - it('should add refresh token to response body on successful /api/auth/local', async () => { + it('Generate refreshToken in body /api/auth/local', async () => { const middleware = auth({ strapi: strapiMock }); await middleware(ctxMock, () => Promise.resolve()); @@ -89,6 +93,49 @@ describe('Auth Middleware', () => { { expiresIn: '30d' } ); }); + it.each([ + { refreshTokenExpiresIn: '1h' }, + { refreshTokenExpiresIn: '15m' }, + { refreshTokenExpiresIn: '7d' }, + ])('Generate refreshToken in cookie with refreshTokenExpiresIn: %o /api/auth/local', async ({ refreshTokenExpiresIn }) => { + strapiMock.config.get.mockReturnValueOnce({ + ...strapiMock.config.get(), + cookieResponse: true, + refreshTokenExpiresIn + }); + + const middleware = auth({ strapi: strapiMock }); + + await middleware(ctxMock, () => Promise.resolve()); + + // Assert that a refresh token is added to response body + expect(ctxMock.cookies.set).toHaveBeenCalledWith( + 'refreshToken', + expect.any(String), + expect.objectContaining({ + httpOnly: true, + secure: expect.any(Boolean), + maxAge: expect.any(Number), + domain: expect.any(String), + }) + ); + expect(strapiMock.plugin).toHaveBeenCalledWith(expect.stringContaining(PLUGIN_ID)); + expect(jwt.sign).toHaveBeenCalledWith( + expect.objectContaining({ userId: 1, secret: 'testDocumentId' }), + 'testSecretKey', + { expiresIn: refreshTokenExpiresIn } + ); + }); + it('Fail refresh to bad config', async () => { + strapiMock.config.get.mockReturnValueOnce({ + ...strapiMock.config.get(), + cookieResponse: true, + refreshTokenExpiresIn: '1t', //bad param + }); + const middleware = auth({ strapi: strapiMock }); + + await expect(middleware(ctxMock, () => Promise.resolve())).rejects.toThrow('Invalid tokenExpires format. Use formats like "30d", "1h", "15m".'); + }); it('should send a new JWT on valid /api/auth/local/refresh', async () => { ctxMock.request = { diff --git a/server/src/middlewares/auth.ts b/server/src/middlewares/auth.ts index f84a42e..2f95b23 100644 --- a/server/src/middlewares/auth.ts +++ b/server/src/middlewares/auth.ts @@ -2,10 +2,31 @@ import jwt from 'jsonwebtoken'; import { PLUGIN_ID } from '../pluginId'; interface JwtPayload { - userId : number, - secret: string, + userId: number; + secret: string; } +function calculateMaxAge(param){ + const unit = param.slice(-1); // Get the unit (d, h, m) + const value = parseInt(param.slice(0, -1)); // Get the numerical value + let maxAge; + + switch (unit) { + case 'd': + maxAge = 1000 * 60 * 60 * 24 * value; + break; + case 'h': + maxAge = 1000 * 60 * 60 * value; + break; + case 'm': + maxAge = 1000 * 60 * value; + break; + default: + throw new Error('Invalid tokenExpires format. Use formats like "30d", "1h", "15m".'); + } + + return maxAge; +} function auth({ strapi }) { const config = strapi.config.get(`plugin::${PLUGIN_ID}`); @@ -13,8 +34,7 @@ function auth({ strapi }) { await next(); if (ctx.request.method === 'POST' && ctx.request.path === '/api/auth/local') { const requestRefresh = ctx.request.body?.requestRefresh || config.requestRefreshOnAll; - if (ctx.response.body && ctx.response.message==='OK' && requestRefresh) { - + if (ctx.response.body && ctx.response.message === 'OK' && requestRefresh) { const refreshEntry = await strapi .plugin(PLUGIN_ID) .service('service') @@ -26,23 +46,30 @@ function auth({ strapi }) { expiresIn: config.refreshTokenExpiresIn, } ); - ctx.response.body = { - ...ctx.response.body, - refreshToken: refreshToken, - }; + if (config.cookieResponse) { + ctx.cookies.set('refreshToken', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production' ? true : false, + maxAge: calculateMaxAge(config.refreshTokenExpiresIn), + domain: + process.env.NODE_ENV === 'development' ? 'localhost' : process.env.PRODUCTION_URL, + }); + } else { + ctx.response.body = { + ...ctx.response.body, + refreshToken: refreshToken, + }; + } } } else if (ctx.request.method === 'POST' && ctx.request.path === '/api/auth/local/refresh') { const refreshToken = ctx.request.body?.refreshToken; if (refreshToken) { try { - const decoded = await jwt.verify(refreshToken, config.refreshTokenSecret) as JwtPayload; - console.log('Token successfully verified:', decoded); + const decoded = (await jwt.verify(refreshToken, config.refreshTokenSecret)) as JwtPayload; if (decoded) { - const data = await strapi - .query('plugin::refresh-token.token') - .findOne({ - where: { documentId: decoded.secret }, - }); + const data = await strapi.query('plugin::refresh-token.token').findOne({ + where: { documentId: decoded.secret }, + }); if (data) { ctx.send({ @@ -64,4 +91,4 @@ function auth({ strapi }) { } }; } -export default auth; \ No newline at end of file +export default auth; diff --git a/server/src/services/service.ts b/server/src/services/service.ts index 75d628b..372c8c0 100644 --- a/server/src/services/service.ts +++ b/server/src/services/service.ts @@ -41,7 +41,7 @@ const service = ({ strapi }: { strapi: Core.Strapi }) => ({ await strapi.query('plugin::refresh-token.token').delete({ where: { id: token.id }, }); - console.log(`Deleted token with id: ${token.id}`); + // console.log(`Deleted token with id: ${token.id}`); } }, async create(user, request) {