Skip to content

Commit

Permalink
feat: admin auth refactor (#532)
Browse files Browse the repository at this point in the history
Co-authored-by: Sukanya Rath <98050194+sukanya-rath@users.noreply.github.com>
  • Loading branch information
banders and sukanya-rath committed Jun 7, 2024
1 parent ef76926 commit 967ce63
Show file tree
Hide file tree
Showing 14 changed files with 592 additions and 540 deletions.
2 changes: 1 addition & 1 deletion backend/src/admin-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ function addLoginPassportUse(
}

//set access and refresh tokens
profile.jwtFrontend = adminAuth.generateUiToken();
profile.jwtFrontend = adminAuth.generateFrontendToken();
profile.jwt = accessToken;
profile._json = parseJwt(accessToken);
profile.refreshToken = refreshToken;
Expand Down
30 changes: 22 additions & 8 deletions backend/src/app.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { app } from './app';
import prisma from './v1/prisma/prisma-client';
import { auth } from './v1/services/auth-service';
import { publicAuth } from './v1/services/public-auth-service';
const request = require('supertest');

// ----------------------------------------------------------------------------
// Setup
// ----------------------------------------------------------------------------

const validFrontendToken = auth.generateUiToken();
const validFrontendToken = publicAuth.generateFrontendToken();
const invalidFrontendToken = 'invalid-token';

jest.mock('./schedulers/run.all', () => ({
Expand Down Expand Up @@ -53,20 +53,34 @@ jest.mock('./v1/prisma/prisma-client', () => {
// as the second part of a two-step authorization phase. To keep the API tests simple we
// omit the session and token checks done by the function. All other functions in this
// module keep the original implementation.
jest.mock('./v1/services/auth-service', () => {
const actualAuth = jest.requireActual('./v1/services/auth-service').auth;
const mockedAuth = (
jest.genMockFromModule('./v1/services/auth-service') as any
).auth;
jest.mock('./v1/services/public-auth-service', () => {
const actualPublicAuth = jest.requireActual(
'./v1/services/public-auth-service',
);
const mockedPublicAuth = jest.genMockFromModule(
'./v1/services/public-auth-service',
) as any;
const mocked = {
...mockedPublicAuth,
...actualPublicAuth,
};
mocked.publicAuth.isValidBackendToken = jest
.fn()
.mockReturnValue(async (req, res, next) => {
next();
});
return mocked;
/*
return {
auth: {
publicAuth: {
...mockedAuth,
...actualAuth,
isValidBackendToken: jest.fn().mockReturnValue(async (req, res, next) => {
next();
}),
},
};
*/
});

// Setup in app.ts requires access to certain config properties. These may be present when
Expand Down
8 changes: 4 additions & 4 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import fileSessionStore from 'session-file-store';
import { config } from './config';
import { logger } from './logger';
import prisma from './v1/prisma/prisma-client';
import authRouter from './v1/routes/auth-routes';
import codeRouter from './v1/routes/code-routes';
import { router as configRouter } from './v1/routes/config-routes';
import { fileUploadRouter } from './v1/routes/file-upload-routes';
import authRouter from './v1/routes/public-auth-routes';
import { reportRouter } from './v1/routes/report-routes';
import userRouter from './v1/routes/user-info-routes';
import { auth } from './v1/services/auth-service';
import { publicAuth } from './v1/services/public-auth-service';
import { utils } from './v1/services/utils-service';

import { run as startJobs } from './schedulers/run.all';
Expand Down Expand Up @@ -159,7 +159,7 @@ function addLoginPassportUse(
}

//set access and refresh tokens
profile.jwtFrontend = auth.generateUiToken();
profile.jwtFrontend = publicAuth.generateFrontendToken();
profile.jwt = accessToken;
profile._json = parseJwt(accessToken);
profile.refreshToken = refreshToken;
Expand Down Expand Up @@ -279,7 +279,7 @@ apiRouter.use('/auth', authRouter);
apiRouter.use(
passport.authenticate('jwt', { session: false }),
(req: Request, res: Response, next: NextFunction) => {
auth.isValidBackendToken()(req, res, next);
publicAuth.isValidBackendToken()(req, res, next);
},
);
apiRouter.use('/user', userRouter);
Expand Down
59 changes: 8 additions & 51 deletions backend/src/v1/routes/admin-auth-routes.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import express, { NextFunction, Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import passport from 'passport';
import { v4 as uuidv4 } from 'uuid';
import { config } from '../../config';
import { logger as log } from '../../logger';
import { LogoutReason, adminAuth } from '../services/admin-auth-service';
import { utils } from '../services/utils-service';

import { body, validationResult } from 'express-validator';
import {
MISSING_TOKENS_ERROR,
OIDC_AZUREIDIR_CALLBACK_NAME,
OIDC_AZUREIDIR_STRATEGY_NAME,
} from '../../constants';
import { logger as log } from '../../logger';
import { LogoutReason, adminAuth } from '../services/admin-auth-service';
import { UnauthorizedRsp } from '../services/auth-utils-service';
import { utils } from '../services/utils-service';

const router = express.Router();

Expand All @@ -23,7 +22,7 @@ router.get(
utils.asyncHandler(
async (req: Request, res: Response, next: NextFunction) => {
log.debug(`Login flow callback idir is called.`);
const logoutReason = await adminAuth.handleCallBackIdir(req);
const logoutReason = await adminAuth.handleCallBackAzureIdir(req);
if (logoutReason == LogoutReason.Login)
return res.redirect(config.get('server:adminFrontend'));
else return logoutHandler(req, res, next, logoutReason);
Expand Down Expand Up @@ -112,11 +111,6 @@ router.get(
),
);

const UnauthorizedRsp = {
error: 'Unauthorized',
error_description: 'Not logged in',
};

//refreshes jwt on refresh if refreshToken is valid
router.post(
'/refresh',
Expand All @@ -137,7 +131,7 @@ router.post(
res.status(401).json(UnauthorizedRsp);
} else if (adminAuth.isTokenExpired(user.jwt)) {
if (user?.refreshToken && adminAuth.isRenewable(user.refreshToken)) {
return generateTokens(req, res);
return adminAuth.renewBackendAndFrontendTokens(req, res);
} else {
res.status(401).json(UnauthorizedRsp);
}
Expand All @@ -155,46 +149,9 @@ router.post(
router.get(
'/token',
utils.asyncHandler(adminAuth.refreshJWT),
(req: Request, res: Response) => {
const user: any = req.user;
const session: any = req.session;
if (user?.jwtFrontend && user?.refreshToken) {
if (session?.passport?.user?._json) {
req.session['correlationID'] = uuidv4();
log.info(
`created correlation id and stored in session for user guid: ${session?.passport?.user?._json?.bceid_user_guid}, user name: ${session?.passport?.user?._json?.bceid_username}, correlation_id: ${session.correlationID}`,
);
}
const responseJson = {
jwtFrontend: user.jwtFrontend,
correlationID: session.correlationID,
};
res.status(200).json(responseJson);
} else {
log.error(JSON.stringify(UnauthorizedRsp));
res.status(401).json(UnauthorizedRsp);
}
},
adminAuth.handleGetToken,
);

async function generateTokens(req: Request, res: Response) {
const user: any = req.user;
const session: any = req.session;
const result = await adminAuth.renew(user.refreshToken);
if (result?.jwt && result?.refreshToken) {
user.jwt = result.jwt;
user.refreshToken = result.refreshToken;
user.jwtFrontend = adminAuth.generateUiToken();
const responseJson = {
jwtFrontend: user.jwtFrontend,
correlationID: session.correlationID,
};
res.status(200).json(responseJson);
} else {
res.status(401).json(UnauthorizedRsp);
}
}

//redirects to the SSO login screen
router.get(
'/login',
Expand Down
2 changes: 1 addition & 1 deletion backend/src/v1/routes/admin-user-info-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import express from 'express';
import { adminAuth } from '../services/admin-auth-service';
import { utils } from '../services/utils-service';
const router = express.Router();
router.get('/', utils.asyncHandler(adminAuth.getUserInfo));
router.get('/', utils.asyncHandler(adminAuth.handleGetUserInfo));
export = router;
Original file line number Diff line number Diff line change
@@ -1,31 +1,48 @@
import express, { Application } from 'express';
import request from 'supertest';
import { MISSING_COMPANY_DETAILS_ERROR } from '../../constants';
import { LogoutReason } from '../services/auth-service';
import router from './auth-routes';
import { LogoutReason, publicAuth } from '../services/public-auth-service';
import router from './public-auth-routes';
let app: Application;

const mockHandleCallbackBusinessBceid = jest.fn();
const mockHandleCallbackBusinessBceid = jest.fn().mockResolvedValue(undefined);
const mockIsTokenExpired = jest.fn();
const mockIsRenewable = jest.fn();
const mockRenew = jest.fn();
const mockGenerateUiToken = jest.fn();
jest.mock('../services/auth-service', () => {
const actualLogoutReason = jest.requireActual(
'../services/auth-service',
).LogoutReason;
return {
auth: {
handleCallBackBusinessBceid: (...args) =>
mockHandleCallbackBusinessBceid(...args),
refreshJWT: jest.fn((req, res, next) => mockRefreshJWT(req, res, next)),
isTokenExpired: () => mockIsTokenExpired(),
isRenewable: () => mockIsRenewable(),
renew: () => mockRenew(),
generateUiToken: () => mockGenerateUiToken(),
},
LogoutReason: actualLogoutReason,
const mockGenerateFrontendToken = jest.fn();
const mockRenewBackendAndFrontendTokens = jest.fn((req, res) => {
res.status(200).json({});
});

jest.mock('../services/public-auth-service', () => {
const actualPublicAuth = jest.requireActual(
'../services/public-auth-service',
);
const mockedPublicAuth = jest.genMockFromModule(
'../services/public-auth-service',
) as any;

const mocked = {
...mockedPublicAuth,
publicAuth: { ...actualPublicAuth.publicAuth },
};
mocked.publicAuth.handleCallBackBusinessBceid = (...args) => {
mockHandleCallbackBusinessBceid(...args);
};
mocked.publicAuth.refreshJWT = jest.fn((req, res, next) =>
mockRefreshJWT(req, res, next),
);
mocked.publicAuth.isTokenExpired = () => mockIsTokenExpired();
mocked.publicAuth.isRenewable = () => mockIsRenewable();
mocked.publicAuth.renew = function () {
mockRenew();
};
mocked.publicAuth.generateFrontendToken = () => mockGenerateFrontendToken();
mocked.publicAuth.renewBackendAndFrontendTokens = (req, res) => {
mockRenewBackendAndFrontendTokens(req, res);
};

return mocked;
});

const mockGetOidcDiscovery = jest.fn();
Expand Down Expand Up @@ -65,7 +82,7 @@ const mockRequest = {
},
};

describe('auth-routes', () => {
describe('public-auth-routes', () => {
beforeEach(() => {
jest.clearAllMocks();
app = express();
Expand All @@ -77,10 +94,9 @@ describe('auth-routes', () => {
mockAuthenticate.mockImplementation((_, __, next) => {
next();
});

mockHandleCallbackBusinessBceid.mockImplementation((req) => {
return LogoutReason.Login;
});
jest
.spyOn(publicAuth, 'handleCallBackBusinessBceid')
.mockResolvedValueOnce(LogoutReason.Login);

return request(app).get('/callback_business_bceid').expect(302);
});
Expand All @@ -99,10 +115,9 @@ describe('auth-routes', () => {
mockAuthenticate.mockImplementation((_, __, next) => {
next();
});

mockHandleCallbackBusinessBceid.mockImplementation((req) => {
return LogoutReason.ContactError;
});
jest
.spyOn(publicAuth, 'handleCallBackBusinessBceid')
.mockResolvedValueOnce(LogoutReason.ContactError);

return request(app).get('/auth/callback_business_bceid').expect(302);
});
Expand Down Expand Up @@ -313,57 +328,68 @@ describe('auth-routes', () => {
});
});
});
it('should renew the token if renewable', () => {
it('should renew the token if renewable', async () => {
const mockCorrelationId = 12;
const mockFrontendToken = 'jwt_value';
mockExists.mockImplementation((req, res, next) => next());
mockValidationResult.mockImplementation(() => {
return {
isEmpty: () => true,
};
});
mockIsTokenExpired.mockReturnValue(true);
mockGenerateUiToken.mockReturnValue('jwt_value');
mockGenerateFrontendToken.mockReturnValue(mockFrontendToken);
mockIsRenewable.mockReturnValue(true);
mockRenew.mockResolvedValue({ jwt: 1, refreshToken: 1 });
mockRenewBackendAndFrontendTokens.mockImplementation((req, res) =>
res.status(200).json({
jwtFrontend: mockFrontendToken,
correlationID: mockCorrelationId,
}),
);

app.use((req: any, res, next) => {
req.session = {
...mockRequest.session,
companyDetails: { id: 1 },
correlationID: 12,
correlationID: mockCorrelationId,
};
req.user = { ...mockRequest.user, jwt: 'jwt', refreshToken: 'jwt' };
req.logout = mockRequest.logout;
next();
});
app.use('/auth', router);

return request(app)
await request(app)
.post('/auth/refresh')
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
jwtFrontend: 'jwt_value',
correlationID: 12,
jwtFrontend: mockFrontendToken,
correlationID: mockCorrelationId,
});
});
});
it('should return unauthorized when it fails to renew tokens', () => {
const mockCorrelationId = 12;
mockExists.mockImplementation((req, res, next) => next());
mockValidationResult.mockImplementation(() => {
return {
isEmpty: () => true,
};
});
mockIsTokenExpired.mockReturnValue(true);
mockGenerateUiToken.mockReturnValue('jwt_value');
mockGenerateFrontendToken.mockReturnValue('jwt_value');
mockIsRenewable.mockReturnValue(true);
mockRenew.mockResolvedValue({});
mockRenewBackendAndFrontendTokens.mockImplementation((req, res) =>
res.status(401).json({}),
);

app.use((req: any, res, next) => {
req.session = {
...mockRequest.session,
companyDetails: { id: 1 },
correlationID: 12,
correlationID: mockCorrelationId,
};
req.user = { ...mockRequest.user, jwt: 'jwt', refreshToken: 'jwt' };
req.logout = mockRequest.logout;
Expand Down
Loading

0 comments on commit 967ce63

Please sign in to comment.