diff --git a/src/app/controllers/spcp.server.controller.js b/src/app/controllers/spcp.server.controller.js index c82c35b89a..34b973da50 100644 --- a/src/app/controllers/spcp.server.controller.js +++ b/src/app/controllers/spcp.server.controller.js @@ -1,212 +1,13 @@ 'use strict' -const config = require('../../config/config') const formsgSdk = require('../../config/formsg-sdk') const { isEmpty } = require('lodash') -const mongoose = require('mongoose') -const crypto = require('crypto') const { StatusCodes } = require('http-status-codes') const { createReqMeta } = require('../utils/request') const logger = require('../../config/logger').createLoggerWithLabel(module) const { mapDataToKey } = require('../../shared/util/verified-content') -const getFormModel = require('../models/form.server.model').default -const getLoginModel = require('../models/login.server.model').default -const Form = getFormModel(mongoose) -const Login = getLoginModel(mongoose) - -const jwtNames = { - SP: 'jwtSp', - CP: 'jwtCp', -} -const destinationRegex = /^\/([\w]+)\/?/ - -const getForm = function (destination, cb) { - let formId = destinationRegex.exec(destination)[1] - Form.findById({ _id: formId }) - .populate({ - path: 'admin', - populate: { - path: 'agency', - model: 'Agency', - }, - }) - .exec(cb) -} - -const destinationIsValid = function (destination) { - // Parse formId from the string - // e.g. "/5b9b5fcfdb4da66ef52b73b3/preview" -> "5b9b5fcfdb4da66ef52b73b3" - // with or without "/preview" - if (!destination || !destinationRegex.test(destination)) { - return false - } - return true -} - -const isArtifactValid = function (idpPartnerEntityIds, samlArt, authType) { - // Artifact should be 44 bytes long, of type 0x0004 and - // source id should be SHA-1 hash of the issuer's entityID - let hexEncodedArtifact = Buffer.from(samlArt, 'base64').toString('hex') - let artifactHexLength = hexEncodedArtifact.length - let typeCode = parseInt(hexEncodedArtifact.substr(0, 4)) - let sourceId = hexEncodedArtifact.substr(8, 40) - let hashedEntityId = crypto - .createHash('sha1') - .update(idpPartnerEntityIds[authType], 'utf8') - .digest('hex') - - return ( - artifactHexLength === 88 && typeCode === 4 && sourceId === hashedEntityId - ) -} - -const isValidAuthenticationQuery = ( - destination, - idpPartnerEntityIds, - samlArt, - authType, -) => { - return ( - destination && - isArtifactValid(idpPartnerEntityIds, samlArt, authType) && - destinationRegex.test(destination) - ) -} - -const handleOOBAuthenticationWith = (ndiConfig, authType, extractUser) => { - const { - authClients, - cpCookieMaxAge, - spCookieMaxAgePreserved, - spCookieMaxAge, - spcpCookieDomain, - idpPartnerEntityIds, - } = ndiConfig - return function (req, res) { - let authClient = authClients[authType] ? authClients[authType] : undefined - let jwtName = jwtNames[authType] - let { SAMLart: samlArt, RelayState: relayState } = req.query - const payloads = String(relayState).split(',') - - if (payloads.length !== 2) { - return res.sendStatus(StatusCodes.BAD_REQUEST) - } - - const destination = payloads[0] - const rememberMe = payloads[1] === 'true' - - if ( - !isValidAuthenticationQuery( - destination, - idpPartnerEntityIds, - samlArt, - authType, - ) - ) { - res.sendStatus(StatusCodes.UNAUTHORIZED) - return - } - - // Resolve known express req.query issue where pluses become spaces - samlArt = String(samlArt).replace(/ /g, '+') - - if (!destinationIsValid(destination)) - return res.sendStatus(StatusCodes.BAD_REQUEST) - - getForm(destination, (err, form) => { - if (err || !form || form.authType !== authType) { - res.sendStatus(StatusCodes.NOT_FOUND) - return - } - authClient.getAttributes(samlArt, destination, (err, data) => { - if (err) { - logger.error({ - message: 'Error retrieving attributes from auth client', - meta: { - action: 'handleOOBAuthenticationWith', - ...createReqMeta(req), - }, - error: err, - }) - } - const { attributes } = data - const { userName, userInfo } = extractUser(attributes) - if (userName && destination) { - // Create JWT - let payload = { - userName, - userInfo, - rememberMe, - } - - let cookieDuration - if (authType === 'CP') { - cookieDuration = cpCookieMaxAge - } else { - cookieDuration = rememberMe - ? spCookieMaxAgePreserved - : spCookieMaxAge - } - - let jwt = authClient.createJWT( - payload, - cookieDuration / 1000, - // NOTE: cookieDuration is interpreted as a seconds count if numeric. - ) - // Add login to DB - Login.addLoginFromForm(form).then(() => { - const spcpSettings = spcpCookieDomain - ? { domain: spcpCookieDomain, path: '/' } - : {} - // Redirect to form - res.cookie(jwtName, jwt, { - maxAge: cookieDuration, - httpOnly: false, // the JWT needs to be read by client-side JS - sameSite: 'lax', // Setting to 'strict' prevents Singpass login on Safari, Firefox - secure: !config.isDev, - ...spcpSettings, - }) - res.redirect(destination) - }) - } else if (destination) { - res.cookie('isLoginError', true) - res.redirect(destination) - } else { - res.redirect('/') - } - }) - }) - } -} - -/** - * Assertion Consumer Endpoint - Authenticates form-filler with SingPass and creates session - * @param {Object} req - Express request object - * @param {Object} res - Express response object - */ -exports.singPassLogin = (ndiConfig) => { - return handleOOBAuthenticationWith(ndiConfig, 'SP', (attributes) => { - let userName = attributes && attributes.UserName - return userName ? { userName } : {} - }) -} - -/** - * Assertion Consumer Endpoint - Authenticates form-filler with CorpPass and creates session - * @param {Object} req - Express request object - * @param {Object} res - Express response object - */ -exports.corpPassLogin = (ndiConfig) => { - return handleOOBAuthenticationWith(ndiConfig, 'CP', (attributes) => { - let userName = - attributes && attributes.UserInfo && attributes.UserInfo.CPEntID - let userInfo = - attributes && attributes.UserInfo && attributes.UserInfo.CPUID - return userName && userInfo ? { userName, userInfo } : {} - }) -} /** * Encrypt and sign verified fields if exist diff --git a/src/app/factories/spcp.factory.js b/src/app/factories/spcp.factory.js index 93a07b2d14..e31116030e 100644 --- a/src/app/factories/spcp.factory.js +++ b/src/app/factories/spcp.factory.js @@ -1,81 +1,14 @@ const spcp = require('../controllers/spcp.server.controller') -const { StatusCodes } = require('http-status-codes') const featureManager = require('../../config/feature-manager').default -const fs = require('fs') -const SPCPAuthClient = require('@opengovsg/spcp-auth-client') -const logger = require('../../config/logger').createLoggerWithLabel(module) const spcpFactory = ({ isEnabled, props }) => { if (isEnabled && props) { - logger.info({ - message: 'Configuring SingPass client...', - meta: { - action: 'spcpFactory', - }, - }) - let singPassAuthClient = new SPCPAuthClient({ - partnerEntityId: props.spPartnerEntityId, - idpLoginURL: props.spIdpLoginUrl, - idpEndpoint: props.spIdpEndpoint, - esrvcID: props.spEsrvcId, - appKey: fs.readFileSync(props.spFormSgKeyPath), - appCert: fs.readFileSync(props.spFormSgCertPath), - spcpCert: fs.readFileSync(props.spIdpCertPath), - extract: SPCPAuthClient.extract.SINGPASS, - }) - logger.info({ - message: 'Configuring CorpPass client...', - meta: { - action: 'spcpFactory', - }, - }) - let corpPassAuthClient = new SPCPAuthClient({ - partnerEntityId: props.cpPartnerEntityId, - idpLoginURL: props.cpIdpLoginUrl, - idpEndpoint: props.cpIdpEndpoint, - esrvcID: props.cpEsrvcId, - appKey: fs.readFileSync(props.cpFormSgKeyPath), - appCert: fs.readFileSync(props.cpFormSgCertPath), - spcpCert: fs.readFileSync(props.cpIdpCertPath), - extract: SPCPAuthClient.extract.CORPPASS, - }) - logger.info({ - message: 'Configuring MyInfo client...', - meta: { - action: 'spcpFactory', - }, - }) - - const authClients = { - SP: singPassAuthClient, - CP: corpPassAuthClient, - } - - const ndiConfig = { - authClients, - cpCookieMaxAge: props.cpCookieMaxAge, - spCookieMaxAgePreserved: props.spCookieMaxAgePreserved, - spCookieMaxAge: props.spCookieMaxAge, - spcpCookieDomain: props.spcpCookieDomain, - idpPartnerEntityIds: { - SP: props.spIdpId, - CP: props.cpIdpId, - }, - } - return { appendVerifiedSPCPResponses: spcp.appendVerifiedSPCPResponses, - singPassLogin: spcp.singPassLogin(ndiConfig), - corpPassLogin: spcp.corpPassLogin(ndiConfig), } } else { - const errMsg = 'SPCP/MyInfo feature is not enabled' return { appendVerifiedSPCPResponses: (req, res, next) => next(), - singPassLogin: (req, res) => - res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: errMsg }), - corpPassLogin: (req, res) => - res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: errMsg }), } } } diff --git a/src/app/modules/spcp/__tests__/spcp.controller.spec.ts b/src/app/modules/spcp/__tests__/spcp.controller.spec.ts index d6b489a1b2..4a37a744d1 100644 --- a/src/app/modules/spcp/__tests__/spcp.controller.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.controller.spec.ts @@ -28,7 +28,7 @@ const MOCK_RESPONSE = expressHandler.mockResponse() const MOCK_REDIRECT_REQ = expressHandler.mockRequest({ query: { target: MOCK_TARGET, - authType: AuthType.SP, + authType: AuthType.SP as const, esrvcId: MOCK_ESRVCID, }, }) diff --git a/src/app/modules/spcp/__tests__/spcp.service.spec.ts b/src/app/modules/spcp/__tests__/spcp.service.spec.ts index 881a00a958..f3c4555807 100644 --- a/src/app/modules/spcp/__tests__/spcp.service.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.service.spec.ts @@ -27,6 +27,7 @@ jest.mock('@opengovsg/spcp-auth-client') const MockAuthClient = mocked(SPCPAuthClient, true) jest.mock('fs', () => ({ readFileSync: jest.fn().mockImplementation((v) => v), + accessSync: jest.requireActual('fs').accessSync, })) jest.mock('axios') const MockAxios = mocked(axios, true) diff --git a/src/app/modules/spcp/spcp.controller.ts b/src/app/modules/spcp/spcp.controller.ts index eab59fc5fa..77e3e820c9 100644 --- a/src/app/modules/spcp/spcp.controller.ts +++ b/src/app/modules/spcp/spcp.controller.ts @@ -2,12 +2,14 @@ import { RequestHandler } from 'express' import { ParamsDictionary } from 'express-serve-static-core' import { StatusCodes } from 'http-status-codes' +import config from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import { AuthType, IPopulatedForm } from '../../../types' import { createReqMeta } from '../../utils/request' +import * as FormService from '../form/form.service' import { SpcpFactory } from './spcp.factory' -import { LoginPageValidationResult } from './spcp.types' +import { JwtName, LoginPageValidationResult } from './spcp.types' import { extractJwt, mapRouteError } from './spcp.util' const logger = createLoggerWithLabel(module) @@ -26,7 +28,7 @@ export const handleRedirect: RequestHandler< ParamsDictionary, { redirectURL: string } | { message: string }, unknown, - { authType: AuthType; target: string; esrvcId: string } + { authType: AuthType.SP | AuthType.CP; target: string; esrvcId: string } > = (req, res) => { const { target, authType, esrvcId } = req.query return SpcpFactory.createRedirectUrl(authType, target, esrvcId) @@ -59,7 +61,7 @@ export const handleValidate: RequestHandler< ParamsDictionary, LoginPageValidationResult | { message: string }, unknown, - { authType: AuthType; target: string; esrvcId: string } + { authType: AuthType.SP | AuthType.CP; target: string; esrvcId: string } > = (req, res) => { const { target, authType, esrvcId } = req.query return SpcpFactory.createRedirectUrl(authType, target, esrvcId) @@ -95,7 +97,7 @@ export const addSpcpSessionInfo: RequestHandler = async ( next, ) => { const { authType } = (req as WithForm).form - if (!authType) return next() + if (authType !== AuthType.SP && authType !== AuthType.CP) return next() const jwt = extractJwt(req.cookies, authType) if (!jwt) return next() @@ -130,7 +132,7 @@ export const isSpcpAuthenticated: RequestHandler = ( next, ) => { const { authType } = (req as WithForm).form - if (!authType) return next() + if (authType !== AuthType.SP && authType !== AuthType.CP) return next() const jwt = extractJwt(req.cookies, authType) if (!jwt) return next() @@ -158,3 +160,88 @@ export const isSpcpAuthenticated: RequestHandler = ( }) }) } + +export const handleLogin: ( + authType: AuthType.SP | AuthType.CP, +) => RequestHandler< + ParamsDictionary, + unknown, + unknown, + { SAMLart: string; RelayState: string } +> = (authType) => async (req, res) => { + const { SAMLart: rawSamlArt, RelayState: rawRelayState } = req.query + const logMeta = { + action: 'handleLogin', + samlArt: rawSamlArt, + relayState: rawRelayState, + } + const parseResult = SpcpFactory.parseOOBParams( + rawSamlArt, + rawRelayState, + authType, + ) + if (parseResult.isErr()) { + logger.error({ + message: 'Invalid SPCP login parameters', + meta: logMeta, + error: parseResult.error, + }) + return res.sendStatus(StatusCodes.BAD_REQUEST) + } + const { + formId, + destination, + rememberMe, + cookieDuration, + samlArt, + } = parseResult.value + const formResult = await FormService.retrieveFullFormById(formId) + if (formResult.isErr()) { + logger.error({ + message: 'Form not found', + meta: logMeta, + error: formResult.error, + }) + return res.sendStatus(StatusCodes.NOT_FOUND) + } + const jwtResult = await SpcpFactory.getSpcpAttributes( + samlArt, + destination, + authType, + ) + .andThen((attributes) => + SpcpFactory.createJWTPayload(attributes, rememberMe, authType), + ) + .andThen((jwtPayload) => + SpcpFactory.createJWT(jwtPayload, cookieDuration, authType), + ) + if (jwtResult.isErr()) { + logger.error({ + message: 'Error creating JWT', + meta: logMeta, + error: jwtResult.error, + }) + res.cookie('isLoginError', true) + return res.redirect(destination) + } + return SpcpFactory.addLogin(formResult.value, authType) + .map(() => { + res.cookie(JwtName[authType], jwtResult.value, { + maxAge: cookieDuration, + httpOnly: false, // the JWT needs to be read by client-side JS + sameSite: 'lax', // Setting to 'strict' prevents Singpass login on Safari, Firefox + secure: !config.isDev, + ...SpcpFactory.getCookieSettings(), + }) + return res.redirect(destination) + }) + .mapErr((error) => { + logger.error({ + message: 'Error while adding login to database', + meta: logMeta, + error, + }) + res.cookie('isLoginError', true) + return res.redirect(destination) + }) +} diff --git a/src/app/modules/spcp/spcp.errors.ts b/src/app/modules/spcp/spcp.errors.ts index 2c3a8cc68a..dd17ba128d 100644 --- a/src/app/modules/spcp/spcp.errors.ts +++ b/src/app/modules/spcp/spcp.errors.ts @@ -1,3 +1,4 @@ +import { AuthType } from '../../../types' import { ApplicationError } from '../../modules/core/core.errors' /** * Error while creating redirect URL @@ -8,15 +9,6 @@ export class CreateRedirectUrlError extends ApplicationError { } } -/** - * Invalid authType given. - */ -export class InvalidAuthTypeError extends ApplicationError { - constructor(authType: unknown) { - super(`Invalid authType: ${authType}`) - } -} - /** * Error while fetching SP/CP login page. */ @@ -43,3 +35,43 @@ export class VerifyJwtError extends ApplicationError { super(message) } } + +/* + * Invalid OOB params passed to login endpoint. + */ +export class InvalidOOBParamsError extends ApplicationError { + constructor(message = 'Invalid OOB params passed to login endpoint') { + super(message) + } +} + +/** + * Error while attempting to retrieve SPCP attributes from SPCP server + */ +export class RetrieveAttributesError extends ApplicationError { + constructor(message = 'Failed to retrieve attributes from SPCP') { + super(message) + } +} + +/** + * Form auth type did not match attempted auth method. + */ +export class AuthTypeMismatchError extends ApplicationError { + constructor(attemptedAuthType: AuthType, formAuthType?: AuthType) { + super( + `Attempted authentication type ${attemptedAuthType} did not match form auth type ${formAuthType}`, + ) + } +} + +/** + * Attributes given by SP/CP did not contain NRIC or entity ID/UID. + */ +export class MissingAttributesError extends ApplicationError { + constructor( + message = 'Attributes given by SP/CP did not contain NRIC or entity ID/UID.', + ) { + super(message) + } +} diff --git a/src/app/modules/spcp/spcp.factory.ts b/src/app/modules/spcp/spcp.factory.ts index 5036201fcc..352b84f815 100644 --- a/src/app/modules/spcp/spcp.factory.ts +++ b/src/app/modules/spcp/spcp.factory.ts @@ -1,44 +1,24 @@ -import { err, errAsync, Result, ResultAsync } from 'neverthrow' +import { err, errAsync } from 'neverthrow' import FeatureManager, { FeatureNames, RegisteredFeature, } from '../../../config/feature-manager' -import { AuthType } from '../../../types' import { MissingFeatureError } from '../core/core.errors' -import { - CreateRedirectUrlError, - FetchLoginPageError, - InvalidAuthTypeError, - LoginPageValidationError, - VerifyJwtError, -} from './spcp.errors' import { SpcpService } from './spcp.service' -import { JwtPayload, LoginPageValidationResult } from './spcp.types' interface ISpcpFactory { - createRedirectUrl( - authType: AuthType, - target: string, - eSrvcId: string, - ): Result< - string, - CreateRedirectUrlError | InvalidAuthTypeError | MissingFeatureError - > - fetchLoginPage( - redirectUrl: string, - ): ResultAsync - validateLoginPage( - loginHtml: string, - ): Result< - LoginPageValidationResult, - LoginPageValidationError | MissingFeatureError - > - extractJwtPayload( - jwt: string, - authType: AuthType, - ): ResultAsync + createRedirectUrl: SpcpService['createRedirectUrl'] + fetchLoginPage: SpcpService['fetchLoginPage'] + validateLoginPage: SpcpService['validateLoginPage'] + extractJwtPayload: SpcpService['extractJwtPayload'] + parseOOBParams: SpcpService['parseOOBParams'] + getSpcpAttributes: SpcpService['getSpcpAttributes'] + createJWT: SpcpService['createJWT'] + addLogin: SpcpService['addLogin'] + createJWTPayload: SpcpService['createJWTPayload'] + getCookieSettings: SpcpService['getCookieSettings'] } export const createSpcpFactory = ({ @@ -52,6 +32,12 @@ export const createSpcpFactory = ({ fetchLoginPage: () => errAsync(error), validateLoginPage: () => err(error), extractJwtPayload: () => errAsync(error), + parseOOBParams: () => err(error), + getSpcpAttributes: () => errAsync(error), + createJWT: () => err(error), + addLogin: () => errAsync(error), + createJWTPayload: () => err(error), + getCookieSettings: () => ({}), } } return new SpcpService(props) diff --git a/src/app/modules/spcp/spcp.middlewares.ts b/src/app/modules/spcp/spcp.middlewares.ts index 09fc8c3da1..6ed2488e20 100644 --- a/src/app/modules/spcp/spcp.middlewares.ts +++ b/src/app/modules/spcp/spcp.middlewares.ts @@ -9,3 +9,10 @@ export const redirectParamsMiddleware = celebrate({ esrvcId: Joi.string().required(), }), }) + +export const loginParamsMiddleware = celebrate({ + [Segments.QUERY]: Joi.object({ + SAMLart: Joi.string().required(), + RelayState: Joi.string().required(), + }), +}) diff --git a/src/app/modules/spcp/spcp.routes.ts b/src/app/modules/spcp/spcp.routes.ts index eb1c60c54e..bcefca23f5 100644 --- a/src/app/modules/spcp/spcp.routes.ts +++ b/src/app/modules/spcp/spcp.routes.ts @@ -1,7 +1,12 @@ import { Router } from 'express' +import { AuthType } from '../../../types' + import * as SpcpController from './spcp.controller' -import { redirectParamsMiddleware } from './spcp.middlewares' +import { + loginParamsMiddleware, + redirectParamsMiddleware, +} from './spcp.middlewares' // Shared routes for Singpass and Corppass export const SpcpRouter = Router() @@ -44,3 +49,21 @@ SpcpRouter.get( redirectParamsMiddleware, SpcpController.handleValidate, ) + +// Handles SingPass login requests +export const SingpassLoginRouter = Router() + +SingpassLoginRouter.get( + '/', + loginParamsMiddleware, + SpcpController.handleLogin(AuthType.SP), +) + +// Handles CorpPass login requests +export const CorppassLoginRouter = Router() + +CorppassLoginRouter.get( + '/', + loginParamsMiddleware, + SpcpController.handleLogin(AuthType.CP), +) diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts index e30831a154..99d1db59aa 100644 --- a/src/app/modules/spcp/spcp.service.ts +++ b/src/app/modules/spcp/spcp.service.ts @@ -2,31 +2,54 @@ import SPCPAuthClient from '@opengovsg/spcp-auth-client' import axios from 'axios' import fs from 'fs' import { StatusCodes } from 'http-status-codes' +import mongoose from 'mongoose' import { err, errAsync, ok, Result, ResultAsync } from 'neverthrow' import { ISpcpMyInfo } from '../../../config/feature-manager' import { createLoggerWithLabel } from '../../../config/logger' -import { AuthType } from '../../../types' +import { AuthType, ILoginSchema, IPopulatedForm } from '../../../types' +import getLoginModel from '../../models/login.server.model' +import { ApplicationError, DatabaseError } from '../core/core.errors' import { + AuthTypeMismatchError, CreateRedirectUrlError, FetchLoginPageError, - InvalidAuthTypeError, + InvalidOOBParamsError, LoginPageValidationError, + MissingAttributesError, + RetrieveAttributesError, VerifyJwtError, } from './spcp.errors' -import { JwtPayload, LoginPageValidationResult } from './spcp.types' -import { getSubstringBetween, verifyJwtPromise } from './spcp.util' +import { + CorppassAttributes, + JwtPayload, + LoginPageValidationResult, + ParsedSpcpParams, + SingpassAttributes, + SpcpDomainSettings, +} from './spcp.types' +import { + extractFormId, + getAttributesPromise, + getSubstringBetween, + isValidAuthenticationQuery, + verifyJwtPromise, +} from './spcp.util' const logger = createLoggerWithLabel(module) +const LoginModel = getLoginModel(mongoose) + const LOGIN_PAGE_HEADERS = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3' const LOGIN_PAGE_TIMEOUT = 10000 // 10 seconds export class SpcpService { #singpassAuthClient: SPCPAuthClient #corppassAuthClient: SPCPAuthClient + #spcpProps: ISpcpMyInfo constructor(props: ISpcpMyInfo) { + this.#spcpProps = props this.#singpassAuthClient = new SPCPAuthClient({ partnerEntityId: props.spPartnerEntityId, idpLoginURL: props.spIdpLoginUrl, @@ -49,6 +72,18 @@ export class SpcpService { }) } + /** + * Retrieve the correct auth client. + * @param authType 'SP' or 'CP' + */ + getAuthClient(authType: AuthType.SP | AuthType.CP): SPCPAuthClient { + if (authType === AuthType.SP) { + return this.#singpassAuthClient + } else { + return this.#corppassAuthClient + } + } + /** * Create the URL to which the client should be redirected for Singpass/ * Corppass login. @@ -60,38 +95,21 @@ export class SpcpService { authType: AuthType.SP | AuthType.CP, target: string, esrvcId: string, - ): Result { - let result: string | Error - switch (authType) { - case AuthType.SP: - result = this.#singpassAuthClient.createRedirectURL(target, esrvcId) - break - case AuthType.CP: - result = this.#corppassAuthClient.createRedirectURL(target, esrvcId) - break - default: - logger.error({ - message: 'Invalid authType', - meta: { - action: 'createRedirectUrl', - authType, - target, - esrvcId, - }, - }) - return err(new InvalidAuthTypeError(authType)) + ): Result { + const logMeta = { + action: 'createRedirectUrl', + authType, + target, + esrvcId, } + const authClient = this.getAuthClient(authType) + const result = authClient.createRedirectURL(target, esrvcId) if (typeof result === 'string') { return ok(result) } else { logger.error({ message: 'Error while creating redirect URL', - meta: { - action: 'createRedirectUrl', - authType, - target, - esrvcId, - }, + meta: logMeta, error: result, }) return err(new CreateRedirectUrlError()) @@ -177,18 +195,8 @@ export class SpcpService { extractJwtPayload( jwt: string, authType: AuthType.SP | AuthType.CP, - ): ResultAsync { - let authClient: SPCPAuthClient - switch (authType) { - case AuthType.SP: - authClient = this.#singpassAuthClient - break - case AuthType.CP: - authClient = this.#corppassAuthClient - break - default: - return errAsync(new InvalidAuthTypeError(authType)) - } + ): ResultAsync { + const authClient = this.getAuthClient(authType) return ResultAsync.fromPromise( verifyJwtPromise(authClient, jwt), (error) => { @@ -204,4 +212,153 @@ export class SpcpService { }, ) } + + parseOOBParams( + samlArt: string, + relayState: string, + authType: AuthType.SP | AuthType.CP, + ): Result { + const logMeta = { + action: 'validateOOBParams', + relayState, + samlArt, + authType, + } + const payloads = relayState.split(',') + const formId = extractFormId(payloads[0]) + if (payloads.length !== 2 || !formId) { + logger.error({ + message: 'RelayState incorrectly formatted', + meta: logMeta, + }) + return err(new InvalidOOBParamsError()) + } + const destination = payloads[0] + const rememberMe = payloads[1] === 'true' + const idpId = + authType === AuthType.SP + ? this.#spcpProps.spIdpId + : this.#spcpProps.cpIdpId + let cookieDuration: number + if (authType === AuthType.CP) { + cookieDuration = this.#spcpProps.cpCookieMaxAge + } else { + cookieDuration = rememberMe + ? this.#spcpProps.spCookieMaxAgePreserved + : this.#spcpProps.spCookieMaxAge + } + if (isValidAuthenticationQuery(samlArt, destination, idpId)) { + return ok({ + formId, + destination, + rememberMe, + cookieDuration, + // Resolve known express req.query issue where pluses become spaces + samlArt: String(samlArt).replace(/ /g, '+'), + }) + } else { + logger.error({ + message: 'Invalid authentication query', + meta: logMeta, + }) + return err(new InvalidOOBParamsError()) + } + } + + getSpcpAttributes( + samlArt: string, + destination: string, + authType: AuthType.SP | AuthType.CP, + ): ResultAsync, RetrieveAttributesError> { + const logMeta = { + action: 'getSpcpAttributes', + authType, + destination, + samlArt, + } + const authClient = this.getAuthClient(authType) + return ResultAsync.fromPromise( + getAttributesPromise(authClient, samlArt, destination), + (error) => { + logger.error({ + message: 'Failed to retrieve attributes from SP/CP', + meta: logMeta, + error, + }) + return new RetrieveAttributesError() + }, + ) + } + + createJWT( + payload: JwtPayload, + cookieDuration: number, + authType: AuthType.SP | AuthType.CP, + ): Result { + const authClient = this.getAuthClient(authType) + return ok( + authClient.createJWT( + payload, + cookieDuration / 1000, + // NOTE: cookieDuration is interpreted as a seconds count if numeric. + ), + ) + } + + addLogin( + form: IPopulatedForm, + authType: AuthType.SP | AuthType.CP, + ): ResultAsync { + const logMeta = { + action: 'addLogin', + formId: form._id, + } + if (form.authType !== authType) { + logger.error({ + message: 'Form auth type did not match attempted auth type', + meta: { + ...logMeta, + attemptedAuthType: authType, + formAuthType: form.authType, + }, + }) + return errAsync(new AuthTypeMismatchError(authType, form.authType)) + } + return ResultAsync.fromPromise( + LoginModel.addLoginFromForm(form), + (error) => { + logger.error({ + message: 'Error adding login to database', + meta: logMeta, + error, + }) + // TODO (#614): Use utility to return better error message + return new DatabaseError() + }, + ) + } + + createJWTPayload( + attributes: Record, + rememberMe: boolean, + authType: AuthType.SP | AuthType.CP, + ): Result { + if (authType === AuthType.SP) { + const userName = (attributes as SingpassAttributes).UserName + return userName && typeof userName === 'string' + ? ok({ userName, rememberMe }) + : err(new MissingAttributesError()) + } + // CorpPass + const userName = (attributes as CorppassAttributes)?.UserInfo?.CPEntID + const userInfo = (attributes as CorppassAttributes)?.UserInfo?.CPUID + return userName && userInfo + ? ok({ userName, userInfo, rememberMe }) + : err(new MissingAttributesError()) + } + + getCookieSettings(): SpcpDomainSettings { + const spcpCookieDomain = this.#spcpProps.spcpCookieDomain + return spcpCookieDomain ? { domain: spcpCookieDomain, path: '/' } : {} + } } diff --git a/src/app/modules/spcp/spcp.types.ts b/src/app/modules/spcp/spcp.types.ts index 66243dee76..b2e1725ea6 100644 --- a/src/app/modules/spcp/spcp.types.ts +++ b/src/app/modules/spcp/spcp.types.ts @@ -14,3 +14,26 @@ export type JwtPayload = { userInfo?: string rememberMe: boolean } + +export interface SingpassAttributes { + UserName?: string +} + +export interface CorppassAttributes { + UserInfo?: { + CPEntID?: string + CPUID?: string + } +} + +export type SpcpDomainSettings = + | { domain: string; path: string } + | { [k: string]: never } + +export interface ParsedSpcpParams { + formId: string + destination: string + rememberMe: boolean + cookieDuration: number + samlArt: string +} diff --git a/src/app/modules/spcp/spcp.util.ts b/src/app/modules/spcp/spcp.util.ts index bba93aeff2..c893612a83 100644 --- a/src/app/modules/spcp/spcp.util.ts +++ b/src/app/modules/spcp/spcp.util.ts @@ -1,4 +1,5 @@ import SPCPAuthClient from '@opengovsg/spcp-auth-client' +import crypto from 'crypto' import { StatusCodes } from 'http-status-codes' import { createLoggerWithLabel } from '../../../config/logger' @@ -8,13 +9,68 @@ import { MissingFeatureError } from '../core/core.errors' import { CreateRedirectUrlError, FetchLoginPageError, - InvalidAuthTypeError, LoginPageValidationError, VerifyJwtError, } from './spcp.errors' import { JwtName, JwtPayload, SpcpCookies } from './spcp.types' const logger = createLoggerWithLabel(module) +const DESTINATION_REGEX = /^\/([\w]+)\/?/ + +const isArtifactValid = function ( + idpPartnerEntityId: string, + samlArt: string, +): boolean { + // Artifact should be 44 bytes long, of type 0x0004 and + // source id should be SHA-1 hash of the issuer's entityID + const hexEncodedArtifact = Buffer.from(samlArt, 'base64').toString('hex') + const artifactHexLength = hexEncodedArtifact.length + const typeCode = parseInt(hexEncodedArtifact.substr(0, 4)) + const sourceId = hexEncodedArtifact.substr(8, 40) + const hashedEntityId = crypto + .createHash('sha1') + .update(idpPartnerEntityId, 'utf8') + .digest('hex') + + return ( + artifactHexLength === 88 && typeCode === 4 && sourceId === hashedEntityId + ) +} + +export const isValidAuthenticationQuery = ( + samlArt: string, + destination: string, + idpPartnerEntityId: string, +): boolean => { + return ( + !!destination && + isArtifactValid(idpPartnerEntityId, samlArt) && + DESTINATION_REGEX.test(destination) + ) +} + +export const extractFormId = (destination: string): string | null => { + const regexSplit = DESTINATION_REGEX.exec(destination) + if (!regexSplit || regexSplit.length < 2) { + return null + } + return regexSplit[1] +} + +export const getAttributesPromise = ( + authClient: SPCPAuthClient, + samlArt: string, + destination: string, +): Promise> => { + return new Promise((resolve, reject) => { + authClient.getAttributes(samlArt, destination, (err, data) => { + if (err || !data || !data.attributes) { + return reject('Auth client could not retrieve attributes') + } + return resolve(data.attributes) + }) + }) +} export const getSubstringBetween = ( text: string, @@ -62,7 +118,6 @@ export const mapRouteError: MapRouteError = (error) => { switch (error.constructor) { case MissingFeatureError: case CreateRedirectUrlError: - case InvalidAuthTypeError: return { statusCode: StatusCodes.INTERNAL_SERVER_ERROR, errorMessage: 'Sorry, something went wrong. Please try again.', diff --git a/src/app/routes/index.js b/src/app/routes/index.js index 6fc7e9c7ef..80d560b9f2 100644 --- a/src/app/routes/index.js +++ b/src/app/routes/index.js @@ -3,5 +3,4 @@ module.exports = [ require('./core.server.routes.js'), require('./frontend.server.routes.js'), require('./public-forms.server.routes.js'), - require('./spcp.server.routes.js'), ] diff --git a/src/app/routes/spcp.server.routes.js b/src/app/routes/spcp.server.routes.js deleted file mode 100644 index 4fe04383ae..0000000000 --- a/src/app/routes/spcp.server.routes.js +++ /dev/null @@ -1,37 +0,0 @@ -'use strict' - -const spcpFactory = require('../factories/spcp.factory') - -/** - * Module dependencies. - */ - -module.exports = function (app) { - /** - * Receive a SAML artifact and target destination from CorpPass, and - * issue a 302 redirect on successful artifact verification - * @route GET /corppass/login - * @group SPCP - SingPass/CorpPass logins for form-fillers - * @param {string} SAMLart.query.required - the SAML artifact - * @param {string} RelayState.query.required - the relative destination URL after login, - * @returns {string} 302 - redirects the user to the specified relay state - * @returns {string} 400 - received on a bad SAML artifact, or bad relay state - * @headers {string} 302.jwtCp - contains the session cookie upon login - * @headers {string} 302.isLoginError - true if we fail to obtain the user's identity - */ - app.route('/corppass/login').get(spcpFactory.corpPassLogin) - - /** - * Receive a SAML artifact and target destination from SingPass, and - * issue a 302 redirect on successful artifact verification - * @route GET /singpass/login - * @group SPCP - SingPass/CorpPass logins for form-fillers - * @param {string} SAMLart.query.required - the SAML artifact - * @param {string} RelayState.query.required - the relative destination URL after login, - * @returns {string} 302 - redirects the user to the specified relay state - * @returns {string} 400 - received on a bad SAML artifact, or bad relay state - * @headers {string} 302.jwtSp - contains the session cookie upon login - * @headers {string} 302.isLoginError - true if we fail to obtain the user's identity - */ - app.route('/singpass/login').get(spcpFactory.singPassLogin) -} diff --git a/src/loaders/express/index.ts b/src/loaders/express/index.ts index ece396fc1d..391957e1dc 100644 --- a/src/loaders/express/index.ts +++ b/src/loaders/express/index.ts @@ -13,7 +13,11 @@ import { AuthRouter } from '../../app/modules/auth/auth.routes' import { BillingRouter } from '../../app/modules/billing/billing.routes' import { BounceRouter } from '../../app/modules/bounce/bounce.routes' import { ExamplesRouter } from '../../app/modules/examples/examples.routes' -import { SpcpRouter } from '../../app/modules/spcp/spcp.routes' +import { + CorppassLoginRouter, + SingpassLoginRouter, + SpcpRouter, +} from '../../app/modules/spcp/spcp.routes' import UserRouter from '../../app/modules/user/user.routes' import { VfnRouter } from '../../app/modules/verification/verification.routes' import apiRoutes from '../../app/routes' @@ -141,7 +145,11 @@ const loadExpressApp = async (connection: Connection) => { app.use('/billing', BillingRouter) app.use('/analytics', AnalyticsRouter) app.use('/examples', ExamplesRouter) + // Internal routes for Singpass/Corppass app.use('/spcp', SpcpRouter) + // Registered routes with the Singpass/Corppass servers + app.use('/singpass/login', SingpassLoginRouter) + app.use('/corppass/login', CorppassLoginRouter) app.use(sentryMiddlewares()) diff --git a/tests/unit/backend/controllers/spcp.server.controller.spec.js b/tests/unit/backend/controllers/spcp.server.controller.spec.js deleted file mode 100644 index 78c78bd283..0000000000 --- a/tests/unit/backend/controllers/spcp.server.controller.spec.js +++ /dev/null @@ -1,443 +0,0 @@ -const crypto = require('crypto') -const { StatusCodes } = require('http-status-codes') -const mongoose = require('mongoose') - -const dbHandler = require('../helpers/db-handler') - -const Form = dbHandler.makeModel('form.server.model', 'Form') -const Login = dbHandler.makeModel('login.server.model', 'Login') - -const Controller = spec('dist/backend/app/controllers/spcp.server.controller', { - mongoose: Object.assign(mongoose, { '@noCallThru': true }), -}) - -describe('SPCP Controller', () => { - // Declare global variables - let req - let res - - let testForm - let testFormSP - let testFormCP - let testAgency - let testUser - - const cpCookieMaxAge = 100 * 1000 - const spCookieMaxAge = 200 * 1000 - const spCookieMaxAgePreserved = 300 * 1000 - const idpPartnerEntityIds = { SP: 'saml.singpass', CP: 'saml.corppass' } - - const singPassAuthClient = jasmine.createSpyObj('singPassAuthClient', [ - 'createRedirectURL', - 'getAttributes', - 'verifyJWT', - 'createJWT', - ]) - const corpPassAuthClient = jasmine.createSpyObj('corpPassAuthClient', [ - 'createRedirectURL', - 'getAttributes', - 'verifyJWT', - 'createJWT', - ]) - - const authClients = { - SP: singPassAuthClient, - CP: corpPassAuthClient, - } - - const ndiConfig = { - authClients, - cpCookieMaxAge, - spCookieMaxAgePreserved, - spCookieMaxAge, - idpPartnerEntityIds, - } - - beforeAll(async () => { - await dbHandler.connect() - }) - afterEach(async () => await dbHandler.clearDatabase()) - afterAll(async () => await dbHandler.closeDatabase()) - - beforeEach(async (done) => { - res = jasmine.createSpyObj('res', [ - 'status', - 'send', - 'json', - 'redirect', - 'cookie', - 'locals', - 'sendStatus', - ]) - res.status.and.returnValue(res) - res.send.and.returnValue(res) - res.json.and.returnValue(res) - res.cookie.and.returnValue(res) - res.redirect.and.returnValue(res) - - // Insert test form before each test - const collection = await dbHandler.preloadCollections({ saveForm: false }) - testAgency = collection.agency - testUser = collection.user - - req = { - query: {}, - params: {}, - body: {}, - session: { - user: { - _id: testUser._id, - email: testUser.email, - }, - }, - headers: {}, - ip: '127.0.0.1', - } - - testForm = new Form({ - title: 'Test Form', - emails: req.session.user.email, - admin: req.session.user._id, - authType: 'NIL', - }) - testFormSP = new Form({ - title: 'Test Form SP', - emails: req.session.user.email, - admin: req.session.user._id, - esrvcId: 'FORMSG-SINGPASS', - }) - testFormCP = new Form({ - title: 'Test Form CP', - emails: req.session.user.email, - admin: req.session.user._id, - esrvcId: 'FORMSG-CORPPASS', - }) - - await Promise.all([testForm.save(), testFormSP.save(), testFormCP.save()]) - .then(() => { - // Need to have access to admin and agency to update authType - let updateAuthType = (form, authType) => { - return Form.findByIdAndUpdate(form._id, { authType }).populate({ - path: 'admin', - populate: { - path: 'agency', - model: 'Agency', - }, - }) - } - return Promise.all([ - updateAuthType(testFormSP, 'SP'), - updateAuthType(testFormCP, 'CP'), - ]) - }) - .then(() => { - done() - }) - }) - - let generateArtifact = (entityId) => { - let hashedEntityId = crypto - .createHash('sha1') - .update(entityId, 'utf8') - .digest('hex') - let hexArtifact = - '00040000' + hashedEntityId + 'e436913660e3e917549a59709fd8c91f2120222f' - let b64Artifact = Buffer.from(hexArtifact, 'hex').toString('base64') - return b64Artifact - } - - const expectRedirectOn = (login, url, cb) => { - res.redirect.and.callFake((input) => { - expect(input).toEqual(url) - if (cb) cb() - return res - }) - login(req, res) - } - - const expectResponse = (code, content) => { - if (content) { - expect(res.status).toHaveBeenCalledWith(code) - expect(res.json).toHaveBeenCalledWith(content) - } else { - expect(res.sendStatus).toHaveBeenCalledWith(code) - } - } - - describe('singPassLogin/corpPassLogin - validation', () => { - let spB64Artifact - let expectedRelayState - - const expectBadRequestOnLogin = (statusCode, done) => { - res.sendStatus.and.callFake((status) => { - expect(status).toEqual(statusCode) - done() - return res - }) - Controller.singPassLogin(ndiConfig)(req, res) - } - - const replyWith = (error, data) => { - singPassAuthClient.getAttributes.and.callFake( - (samlArt, relayState, cb) => { - expect(samlArt).toEqual(spB64Artifact) - expect(relayState).toEqual(expectedRelayState) - cb(error, data) - }, - ) - } - - beforeEach(() => { - spB64Artifact = generateArtifact('saml.singpass') - expectedRelayState = '/' + testFormSP._id - req.query = { - SAMLart: spB64Artifact, - RelayState: `/${testFormSP._id},true`, - } - }) - - it('should return 401 if artifact not provided', () => { - req.query.SAMLart = '' - Controller.singPassLogin(ndiConfig)(req, res) - expectResponse(StatusCodes.UNAUTHORIZED) - }) - - it('should return 400 if relayState not provided', () => { - req.query.RelayState = '' - Controller.singPassLogin(ndiConfig)(req, res) - expectResponse(StatusCodes.BAD_REQUEST) - }) - - it('should return 401 if artifact is invalid', () => { - req.query.SAMLart = '123456789' - Controller.singPassLogin(ndiConfig)(req, res) - expectResponse(StatusCodes.UNAUTHORIZED) - }) - - it('should return 400 if relayState is invalid format', () => { - req.query.RelayState = '/invalidRelayState' - Controller.singPassLogin(ndiConfig)(req, res) - expectResponse(StatusCodes.BAD_REQUEST) - }) - - it('should return 404 if relayState has valid format but invalid formId', (done) => { - req.query.RelayState = '/123,false' - expectBadRequestOnLogin(StatusCodes.NOT_FOUND, done) - }) - - it('should return 401 if relayState has valid format but belongs to non SP form', (done) => { - req.query.RelayState = `$/{testForm._id},false` - expectBadRequestOnLogin(StatusCodes.UNAUTHORIZED, done) - }) - - it('should redirect to relayState with login error if getAttributes returns relayState only', (done) => { - replyWith(null, { relayState: expectedRelayState }) - expectRedirectOn( - Controller.singPassLogin(ndiConfig), - expectedRelayState, - () => { - expect(res.cookie).toHaveBeenCalledWith('isLoginError', true) - done() - }, - ) - }) - }) - - describe('Integration with SPCPAuthClient', () => { - const jwt = 'someUniqueJwt' - - const checkArtifactOnGetAttributes = (artifact) => ( - authClient, - error, - data, - ) => { - authClient.getAttributes.and.callFake((samlArt, relayState, cb) => { - expect(samlArt).toEqual(artifact) - expect(relayState).toEqual(data.relayState) - cb(error, data) - }) - } - - const expectJWTCreation = (authClient, data, cookieDuration) => { - expect(authClient.createJWT).toHaveBeenCalledWith( - jasmine.objectContaining( - data.UserInfo - ? { userName: data.UserName, userInfo: data.UserInfo } - : { userName: data.UserName }, - ), - cookieDuration / 1000, - ) - } - - const expectSetCookie = (name, value, options) => - expect(res.cookie).toHaveBeenCalledWith(name, value, options) - - const expectLoginLogged = (searchCriteria, authType, done) => { - Login.findOne(searchCriteria, (err, login) => { - expect(err).not.toBeTruthy() - expect(login.authType).toEqual(authType) - expect(login.agency).toEqual(testAgency._id) - expect(login.created).toBeCloseTo(Date.now(), -3) - done() - }) - } - - describe('singPassLogin', () => { - const spB64Artifact = generateArtifact('saml.singpass') - let expectedRelayState - - const getAttributesWith = checkArtifactOnGetAttributes(spB64Artifact) - - beforeEach(() => { - expectedRelayState = '/' + testFormSP._id - req.query = { - SAMLart: spB64Artifact, - RelayState: `/${testFormSP._id},false`, - } - }) - - it('should redirect to relayState with cookie and add to DB if getAttributes returns relayState (non preview) and userName', (done) => { - const expected = { - relayState: expectedRelayState, - attributes: { UserName: '123' }, - } - getAttributesWith(singPassAuthClient, null, expected) - singPassAuthClient.createJWT.and.returnValue(jwt) - expectRedirectOn( - Controller.singPassLogin(ndiConfig), - expected.relayState, - () => { - expectJWTCreation( - singPassAuthClient, - expected.attributes, - spCookieMaxAge, - ) - expectSetCookie('jwtSp', jwt, { - maxAge: spCookieMaxAge, - httpOnly: false, - sameSite: 'lax', - secure: false, - }) - let searchCriteria = { - form: testFormSP._id, - admin: req.session.user._id, - } - expectLoginLogged(searchCriteria, 'SP', done) - }, - ) - }) - - it('should redirect to relayState with cookie and add to DB if getAttributes returns relayState (preview) and userName', (done) => { - expectedRelayState += '/preview' - req.query.RelayState = `/${testFormSP._id}/preview,false` - const expected = { - relayState: expectedRelayState, - attributes: { UserName: '123' }, - } - getAttributesWith(singPassAuthClient, null, expected) - singPassAuthClient.createJWT.and.returnValue(jwt) - expectRedirectOn( - Controller.singPassLogin(ndiConfig), - expected.relayState, - () => { - expectJWTCreation( - singPassAuthClient, - expected.attributes, - spCookieMaxAge, - ) - expectSetCookie('jwtSp', jwt, { - maxAge: spCookieMaxAge, - httpOnly: false, - sameSite: 'lax', - secure: false, - }) - let searchCriteria = { - form: testFormSP._id, - admin: req.session.user._id, - } - expectLoginLogged(searchCriteria, 'SP', done) - }, - ) - }) - }) - - describe('corpPassLogin', () => { - const cpB64Artifact = generateArtifact('saml.corppass') - let expectedRelayState - - const getAttributesWith = checkArtifactOnGetAttributes(cpB64Artifact) - - beforeEach(() => { - expectedRelayState = '/' + testFormCP._id - req.query = { - SAMLart: cpB64Artifact, - RelayState: `/${testFormCP._id},false`, - } - }) - - it('should redirect to relayState with cookie and add to DB if getAttributes returns relayState (non preview) and userName', (done) => { - const expected = { - relayState: expectedRelayState, - attributes: { UserInfo: { CPEntID: '123', CPUID: 'abc' } }, - } - getAttributesWith(corpPassAuthClient, null, expected) - corpPassAuthClient.createJWT.and.returnValue(jwt) - expectRedirectOn( - Controller.corpPassLogin(ndiConfig), - expected.relayState, - () => { - expectJWTCreation( - corpPassAuthClient, - { UserName: '123', UserInfo: 'abc' }, - cpCookieMaxAge, - ) - expectSetCookie('jwtCp', jwt, { - maxAge: cpCookieMaxAge, - httpOnly: false, - sameSite: 'lax', - secure: false, - }) - let searchCriteria = { - form: testFormCP._id, - admin: req.session.user._id, - } - expectLoginLogged(searchCriteria, 'CP', done) - }, - ) - }) - - it('should redirect to relayState with cookie and add to DB if getAttributes returns relayState (preview) and userName', (done) => { - req.query.RelayState = '/' + testFormCP._id + '/preview,false' - expectedRelayState += '/preview' - const expected = { - relayState: expectedRelayState, - attributes: { UserInfo: { CPEntID: '123', CPUID: 'abc' } }, - } - getAttributesWith(corpPassAuthClient, null, expected) - corpPassAuthClient.createJWT.and.returnValue(jwt) - expectRedirectOn( - Controller.corpPassLogin(ndiConfig), - expected.relayState, - () => { - expectJWTCreation( - corpPassAuthClient, - { UserName: '123', UserInfo: 'abc' }, - cpCookieMaxAge, - ) - expectSetCookie('jwtCp', jwt, { - maxAge: cpCookieMaxAge, - httpOnly: false, - sameSite: 'lax', - secure: false, - }) - let searchCriteria = { - form: testFormCP._id, - admin: req.session.user._id, - } - expectLoginLogged(searchCriteria, 'CP', done) - }, - ) - }) - }) - }) -})