diff --git a/.github/workflows/non-serverless-deploy.yml b/.github/workflows/non-serverless-deploy.yml index ba6523ee8..f13be71cd 100644 --- a/.github/workflows/non-serverless-deploy.yml +++ b/.github/workflows/non-serverless-deploy.yml @@ -102,29 +102,29 @@ jobs: uses: ./.github/workflows/e2e.yml secrets: inherit - revert-on-e2e-failure: - runs-on: ubuntu-latest - needs: - - deploy-backend - - deploy-frontend - - deploy-worker - - e2e-test - if: always() - steps: - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v2 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ap-southeast-1 - - run: | - if [ "${{ needs.e2e-test.outputs.e2e_result }}" = "failure" ] && [ "${{ github.ref }}" = "refs/heads/master" ]; then - ${{ needs.deploy-worker.outputs.sending_revert_command }} - ${{ needs.deploy-worker.outputs.logging_revert_command }} - ${{ needs.deploy-frontend.outputs.revert_command }} - ${{ needs.deploy-backend.outputs.revert_command_backend }} - ${{ needs.deploy-backend.outputs.revert_command_callback }} - fi + # revert-on-e2e-failure: + # runs-on: ubuntu-latest + # needs: + # - deploy-backend + # - deploy-frontend + # - deploy-worker + # - e2e-test + # if: always() + # steps: + # - name: Configure AWS credentials + # uses: aws-actions/configure-aws-credentials@v2 + # with: + # aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + # aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + # aws-region: ap-southeast-1 + # - run: | + # if [ "${{ needs.e2e-test.outputs.e2e_result }}" = "failure" ] && [ "${{ github.ref }}" = "refs/heads/master" ]; then + # ${{ needs.deploy-worker.outputs.sending_revert_command }} + # ${{ needs.deploy-worker.outputs.logging_revert_command }} + # ${{ needs.deploy-frontend.outputs.revert_command }} + # ${{ needs.deploy-backend.outputs.revert_command_backend }} + # ${{ needs.deploy-backend.outputs.revert_command_callback }} + # fi slack-success: needs: diff --git a/.secrets.baseline b/.secrets.baseline index 80b284740..b97986102 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -184,14 +184,14 @@ "filename": "backend/.env-example", "hashed_secret": "6748ca60457562b72fd832fddb31552789ce6370", "is_verified": false, - "line_number": 64 + "line_number": 59 }, { "type": "Secret Keyword", "filename": "backend/.env-example", "hashed_secret": "a2eebfeb7f52d750b419d88a37acc905afe38591", "is_verified": false, - "line_number": 87 + "line_number": 82 } ], "backend/src/core/middlewares/auth.middleware.ts": [ @@ -200,7 +200,7 @@ "filename": "backend/src/core/middlewares/auth.middleware.ts", "hashed_secret": "159500287c06851df741128ec4b073ea394414b6", "is_verified": false, - "line_number": 23 + "line_number": 19 } ], "backend/src/core/services/auth.service.ts": [ @@ -209,7 +209,7 @@ "filename": "backend/src/core/services/auth.service.ts", "hashed_secret": "f114703480996b273d7e57cbd195b4ab1e70a21b", "is_verified": false, - "line_number": 65 + "line_number": 31 } ], "backend/src/email/services/tests/email-template.service.test.ts": [ @@ -365,5 +365,5 @@ } ] }, - "generated_at": "2023-11-16T03:18:03Z" + "generated_at": "2024-05-02T12:39:06Z" } diff --git a/backend/.env-example b/backend/.env-example index 5cb351584..7e4a1cd57 100644 --- a/backend/.env-example +++ b/backend/.env-example @@ -50,11 +50,6 @@ EMAIL_CALLBACK_HASH_SECRET="abcde" WORKER_SES_HOST="" TELEGRAM_BOT_CONTACT_US_URL="https://go.gov.sg/postman-contact-us-recipient" TELEGRAM_BOT_GUIDE_URL="https://go.gov.sg/postman-recipient-guide" -SGID_CLIENT_ID="" -SGID_CLIENT_SECRET="" -SGID_PRIVATE_KEY="" -SGID_REDIRECT_URI="" -SGID_VALID_DOMAINS="@open.gov.sg" ##### Email callback ##### # Generate a fake public key using diff --git a/backend/package-lock.json b/backend/package-lock.json index 651cbbe12..374201589 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,7 +13,7 @@ "@aws-sdk/client-sns": "3.370.0", "@aws-sdk/rds-signer": "3.370.0", "@aws-sdk/s3-request-presigner": "3.370.0", - "@opengovsg/sgid-client": "^2.1.0", + "@datadog/browser-rum": "^4.43.0", "@sentry/node": "5.30.0", "async-retry": "1.3.3", "axios": "0.21.4", @@ -2056,6 +2056,36 @@ "kuler": "^2.0.0" } }, + "node_modules/@datadog/browser-core": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@datadog/browser-core/-/browser-core-4.43.0.tgz", + "integrity": "sha512-sign974DvQxSE2BnCtPpHiHI9+MgXq35Gb/I5YcdpdWh5HczgMNIzNWWXTxrkrfkft6+z1M/6AM62yqofeYqog==" + }, + "node_modules/@datadog/browser-rum": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@datadog/browser-rum/-/browser-rum-4.43.0.tgz", + "integrity": "sha512-Cc4e3kViyarfZVwZx4vWHj2/wwyhCF2/KDeWCq+Aa31om5FwP+lzhVfFnxq0711SkhZ8XXa2JjjQb5x4g3B1bw==", + "dependencies": { + "@datadog/browser-core": "4.43.0", + "@datadog/browser-rum-core": "4.43.0" + }, + "peerDependencies": { + "@datadog/browser-logs": "4.43.0" + }, + "peerDependenciesMeta": { + "@datadog/browser-logs": { + "optional": true + } + } + }, + "node_modules/@datadog/browser-rum-core": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@datadog/browser-rum-core/-/browser-rum-core-4.43.0.tgz", + "integrity": "sha512-x9QYLbOnoDId9oVGOGSxS1lZoQ8I6ZujmEt8OWvJ+jykdaKdqzyh3cv0euBzfWj+fCtZP8yrSXUZ+0zpSY4f1A==", + "dependencies": { + "@datadog/browser-core": "4.43.0" + } + }, "node_modules/@datadog/native-appsec": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@datadog/native-appsec/-/native-appsec-3.2.0.tgz", @@ -2682,16 +2712,6 @@ "node": ">= 8" } }, - "node_modules/@opengovsg/sgid-client": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@opengovsg/sgid-client/-/sgid-client-2.1.0.tgz", - "integrity": "sha512-xPd0O6v4OrPODNfdOZaP843pGiFwdxvyPJAX4LbV81i02+nmtjR+zZU7ZlKTc+Xuk0K/OE6FaS+sxF+gI2JmMA==", - "dependencies": { - "jose": "4.9.2", - "node-rsa": "1.1.1", - "openid-client": "5.4.0" - } - }, "node_modules/@opentelemetry/api": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.1.0.tgz", @@ -4470,14 +4490,6 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -8854,14 +8866,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jose": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.2.tgz", - "integrity": "sha512-EqKvu2PqJCD3Jrg3PvcYZVS7D21qMVLSYMDAFcOdGUEOpJSLNtJO7NjLANvu3SYHVl6pdP2ff7ve6EZW2nX7Nw==", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-beautify": { "version": "1.14.7", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.7.tgz", @@ -9906,14 +9910,6 @@ "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==", "dev": true }, - "node_modules/node-rsa": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", - "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", - "dependencies": { - "asn1": "^0.2.4" - } - }, "node_modules/nodemailer": { "version": "6.7.5", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.5.tgz", @@ -9992,14 +9988,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", @@ -10053,14 +10041,6 @@ "resolved": "https://registry.npmjs.org/observable-fns/-/observable-fns-0.6.1.tgz", "integrity": "sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==" }, - "node_modules/oidc-token-hash": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", - "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", - "engines": { - "node": "^10.13.0 || >=12.0.0" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -10111,44 +10091,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openid-client": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.0.tgz", - "integrity": "sha512-hgJa2aQKcM2hn3eyVtN12tEA45ECjTJPXCgUh5YzTzy9qwapCvmDTVPWOcWVL0d34zeQoQ/hbG9lJhl3AYxJlQ==", - "dependencies": { - "jose": "^4.10.0", - "lru-cache": "^6.0.0", - "object-hash": "^2.0.1", - "oidc-token-hash": "^5.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/openid-client/node_modules/jose": { - "version": "4.14.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", - "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/openid-client/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/openid-client/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/opentracing": { "version": "0.14.7", "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", diff --git a/backend/package.json b/backend/package.json index 42d3c2e4d..937561647 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,7 +33,7 @@ "@aws-sdk/client-sns": "3.370.0", "@aws-sdk/rds-signer": "3.370.0", "@aws-sdk/s3-request-presigner": "3.370.0", - "@opengovsg/sgid-client": "^2.1.0", + "@datadog/browser-rum": "^4.43.0", "@sentry/node": "5.30.0", "async-retry": "1.3.3", "axios": "0.21.4", diff --git a/backend/src/core/config.ts b/backend/src/core/config.ts index 8343529bb..ae8e40c62 100644 --- a/backend/src/core/config.ts +++ b/backend/src/core/config.ts @@ -175,12 +175,6 @@ interface ConfigSchema { flamingo: { dbUri: string } - sgid: { - clientId: string - clientSecret: string - privateKey: string - redirectUri: string - } } convict.addFormats({ @@ -813,36 +807,6 @@ const config: Config = convict({ env: 'FLAMINGO_DB_URI', }, }, - sgid: { - clientId: { - doc: 'Client ID of application registered with sgID', - default: '', - env: 'SGID_CLIENT_ID', - format: 'required-string', - sensitive: true, - }, - clientSecret: { - doc: 'Client secret of application registered with sgID', - default: '', - env: 'SGID_CLIENT_SECRET', - format: 'required-string', - sensitive: true, - }, - privateKey: { - doc: 'Private key of application registered with sgID', - default: '', - env: 'SGID_PRIVATE_KEY', - format: 'required-string', - sensitive: true, - }, - redirectUri: { - doc: 'Redirect URI of application registered with sgID', - default: '', - env: 'SGID_REDIRECT_URI', - format: 'required-string', - sensitive: true, - }, - }, }) // If mailFrom was not set in an env var, set it using the app_name diff --git a/backend/src/core/middlewares/auth.middleware.ts b/backend/src/core/middlewares/auth.middleware.ts index d01ad3692..e62f0c7b2 100644 --- a/backend/src/core/middlewares/auth.middleware.ts +++ b/backend/src/core/middlewares/auth.middleware.ts @@ -5,7 +5,6 @@ import { AuthService, experimentService } from '@core/services' import { getRequestIp } from '@core/utils/request' import { DEFAULT_TX_EMAIL_RATE_LIMIT } from '@core/models' import { ApiAuthenticationError } from '@core/errors/rest-api.errors' -import { SgidPublicOfficerEmployment } from '@core/types' export interface AuthMiddleware { getOtp: Handler @@ -13,9 +12,6 @@ export interface AuthMiddleware { getUser: Handler getAuthMiddleware: (authTypes: AuthType[]) => Handler logout: Handler - getSgidUrl: Handler - verifySgidResponse: Handler - selectSgidProfile: Handler } export enum AuthType { @@ -223,110 +219,11 @@ export const InitAuthMiddleware = (authService: AuthService) => { }).catch((err) => next(err)) } - /** - * Fetches sgID authorisation URL - * @param req - * @param res - */ - const getSgidUrl = async (req: Request, res: Response): Promise => { - try { - const url = authService.getSgidUrl(req) - return res.status(200).send(url) - } catch (e) { - logger.error({ - message: 'Error fetching sgID auth url', - error: e, - }) - return res.sendStatus(500) - } - } - - /** - * Verifies that the sgID response is valid and returns the user profiles to choose from - * @param req - * @param res - */ - const verifySgidResponse = async ( - req: Request, - res: Response - ): Promise => { - const { code } = req.body - const logMeta = { code, action: 'verifySgidCode' } - try { - /* - Since Feb 8 2024, this endpoint is called twice when users attempt to log in via SGID on GSIB machines. - This is most likely due to *.postman.gov.sg being whitelisted on SGProxy but the SGID url is not. - The additional API call is made without req.session.sgid set and thus we add a check here and return a HTTP 400 - if this is the case. - */ - if (!req.session || !req.session.sgid) { - logger.error({ message: 'Session object not found!', ...logMeta }) - return res.sendStatus(400) - } - const sgidUserInfo = await authService.verifySgidCode(req, code) - if (!sgidUserInfo.authenticated) { - logger.error({ message: sgidUserInfo.reason, ...logMeta }) - return res.status(401).json({ message: sgidUserInfo.reason }) - } - const userProfiles = await authService.getSgidUserProfiles( - sgidUserInfo.data - ) - // Set user profiles in the session object so we can verify the profile selected by the user - req.session.sgid = { - ...req.session.sgid, - profiles: [...userProfiles], - } - return res.status(200).json({ userProfiles }) - } catch (e) { - const message = (e as Error).message - logger.error({ message, ...logMeta }) - return res.status(500).json({ message }) - } - } - - const selectSgidProfile = async ( - req: Request, - res: Response - ): Promise => { - const { workEmail } = req.body - const logMeta = { action: 'selectSgidProfile' } - try { - if (!req.session) { - logger.error({ message: 'Session object not found!', ...logMeta }) - return res.sendStatus(401) - } - if ( - !req.session.sgid?.profiles || - !req.session.sgid.profiles.some( - (p: SgidPublicOfficerEmployment) => p.workEmail === workEmail - ) - ) { - logger.error({ message: 'Selected profile is not valid', ...logMeta }) - return res.sendStatus(401) - } - const user = await authService.findOrCreateUser(workEmail) - req.session.user = { - id: user.id, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - email: user.email, - } - return res.sendStatus(200) - } catch (e) { - const message = (e as Error).message - logger.error({ message, ...logMeta }) - return res.status(500).json({ message }) - } - } - return { getOtp, verifyOtp, getUser, getAuthMiddleware, logout, - getSgidUrl, - verifySgidResponse, - selectSgidProfile, } } diff --git a/backend/src/core/routes/auth.routes.ts b/backend/src/core/routes/auth.routes.ts index 2e4e563df..6da979fe2 100644 --- a/backend/src/core/routes/auth.routes.ts +++ b/backend/src/core/routes/auth.routes.ts @@ -30,18 +30,6 @@ export const InitAuthRoutes = (authMiddleware: AuthMiddleware): Router => { }), } - const verifySgidCodeValidator = { - [Segments.BODY]: Joi.object({ - code: Joi.string().required(), - }), - } - - const selectSgidProfileValidator = { - [Segments.BODY]: Joi.object({ - workEmail: Joi.string().required(), - }), - } - // actual routes here /** @@ -112,103 +100,6 @@ export const InitAuthRoutes = (authMiddleware: AuthMiddleware): Router => { */ router.post('/login', celebrate(verifyOtpValidator), authMiddleware.verifyOtp) - /** - * paths: - * /auth/login/sgid: - * get: - * summary: Get the authorisation url for sgID login - * tags: - * - Authentication - * - * responses: - * "200": - * content: - * application/json: - * schema: - * type: object - * "500": - * description: Internal Server Error - */ - router.get('/login/sgid', authMiddleware.getSgidUrl) - - /** - * paths: - * /auth/login/sgid: - * post: - * summary: Verify sgid authorisation code - * tags: - * - Authentication - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * code: - * type: string - * required: - * - code - * - * responses: - * "200": - * content: - * application/json: - * schema: - * type: object - * "401": - * content: - * application/json: - * schema: - * type: object - * "500": - * description: Internal Server Error - */ - router.post( - '/login/sgid', - celebrate(verifySgidCodeValidator), - authMiddleware.verifySgidResponse - ) - - /** - * paths: - * /auth/login/sgid/profile: - * post: - * summary: Select sgid profile to login with - * tags: - * - Authentication - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * workEmail: - * type: string - * required: - * - workEmail - * - * responses: - * "200": - * content: - * application/json: - * schema: - * type: object - * "401": - * content: - * application/json: - * schema: - * type: object - * "500": - * description: Internal Server Error - */ - router.post( - '/login/sgid/profile', - celebrate(selectSgidProfileValidator), - authMiddleware.selectSgidProfile - ) - /** * paths: * /auth/userinfo: diff --git a/backend/src/core/services/auth.service.ts b/backend/src/core/services/auth.service.ts index 9376fa7f4..537c4a6fc 100644 --- a/backend/src/core/services/auth.service.ts +++ b/backend/src/core/services/auth.service.ts @@ -8,12 +8,6 @@ import { validateDomain } from '@core/utils/validate-domain' import { ApiKeyService, MailService, RedisService } from '@core/services' import { HashedOtp, VerifyOtpInput } from '@core/interfaces' import { Transaction } from 'sequelize/types' -import { - SgidClient, - UserInfoReturn, - generatePkcePair, -} from '@opengovsg/sgid-client' -import { SgidPublicOfficerEmployment } from '@core/types' export interface AuthService { canSendOtp(email: string): Promise @@ -23,17 +17,6 @@ export interface AuthService { findUser(id: number): Promise checkCookie(req: Request): boolean getUserForApiKey(req: Request): Promise - getSgidUrl(req: Request): string - verifySgidCode( - req: Request, - code: string - ): Promise< - | { authenticated: true; data: UserInfoReturn } - | { authenticated: false; reason: string } - > - getSgidUserProfiles( - userInfo: UserInfoReturn - ): Promise } export const InitAuthService = (redisService: RedisService): AuthService => { @@ -45,23 +28,6 @@ export const InitAuthService = (redisService: RedisService): AuthService => { resendTimeout: OTP_RESEND_TIMEOUT, } = config.get('otp') - const { - clientId: SGID_CLIENT_ID, - clientSecret: SGID_CLIENT_SECRET, - privateKey: SGID_PRIVATE_KEY, - redirectUri: SGID_REDIRECT_URI, - } = config.get('sgid') - - const sgidClient = new SgidClient({ - clientId: SGID_CLIENT_ID, - clientSecret: SGID_CLIENT_SECRET, - privateKey: SGID_PRIVATE_KEY, - redirectUri: SGID_REDIRECT_URI, - }) - - const SGID_PUBLIC_OFFICER_EMPLOYMENT_SCOPE = - 'pocdex.public_officer_employments' - const SGID_FIELD_EMPTY = 'NA' const otpCharset = '234567ABCDEFGHIJKLMNOPQRSTUVWXYZ' /** * Generate a six digit otp @@ -337,187 +303,6 @@ export const InitAuthService = (redisService: RedisService): AuthService => { }) as Promise } - /** - * Get the sgID authorization url to redirect the user to - * @param req - */ - const getSgidUrl = (req: Request): string => { - const { codeChallenge, codeVerifier } = generatePkcePair() - - const { url, nonce } = sgidClient.authorizationUrl({ - scope: ['openid', SGID_PUBLIC_OFFICER_EMPLOYMENT_SCOPE].join(' '), - codeChallenge, - }) - - if (!req.session) { - throw new Error('Unable to find user session') - } - - req.session.sgid = { - codeVerifier, - nonce, - } - - return url - } - - /** - * Checks the user's sgID code and returns their singpass info if valid. - * This function assumes that req.session.sgid has already been validated by the calling function. - * @param req - * @param code - */ - const verifySgidCode = async ( - req: Request, - code: string - ): Promise< - | { authenticated: true; data: UserInfoReturn } - | { authenticated: false; reason: string } - > => { - const { codeVerifier, nonce } = req.session!.sgid - - if (typeof codeVerifier !== 'string' || typeof nonce !== 'string') { - throw new Error('Invalid parameter types') - } - - try { - const { accessToken, sub } = await sgidClient.callback({ - code, - codeVerifier, - nonce, - }) - - const userinfo = await sgidClient.userinfo({ - accessToken, - sub, - }) - - return { - authenticated: true, - data: userinfo, - } - } catch (e) { - return { - authenticated: false, - reason: (e as Error).message, - } - } - } - - /** - * Helper method to retrieve the user's valid profiles from their singpass info. - * @param userInfo - */ - const getSgidUserProfiles = async ( - userInfo: UserInfoReturn - ): Promise => { - const logMeta = { action: 'getSgidUserProfiles' } - const profiles = JSON.parse( - userInfo.data[SGID_PUBLIC_OFFICER_EMPLOYMENT_SCOPE] - ) as SgidPublicOfficerEmployment[] - logger.info({ - message: 'User attempting to log in with the following profiles', - ...logMeta, - profiles, - }) - const cleanedProfiles = cleanSgidUserProfiles(profiles) - const validProfiles = await validateSgidUserProfiles(cleanedProfiles) - - return validProfiles - } - - /** - * Helper method to validate the user's profiles returned by SGID. - * A profile is valid only if the user's work email exists and is whitelisted by Postman - * @param userProfiles - */ - const validateSgidUserProfiles = async ( - userProfiles: SgidPublicOfficerEmployment[] - ): Promise => { - const logMeta = { action: 'validateSgidUserProfiles' } - // Only the value of workEmail is important for access to Postman. - const validProfiles = [] - for (const profile of userProfiles) { - // We want to log the absence of workEmail to measure the data completeness from SGID. - if (profile.workEmail === SGID_FIELD_EMPTY) { - logger.warn({ - message: 'Work email is missing from SGID data', - ...logMeta, - profile, - }) - continue - } - try { - const isWhitelisted = await isWhitelistedEmail(profile.workEmail) - if (isWhitelisted) { - validProfiles.push(profile) - } else { - logger.warn({ - message: 'Work email is not a whitelisted email', - ...logMeta, - profile, - }) - } - } catch (err) { - logger.error({ - message: 'Error occured while whitelisting email', - ...logMeta, - profile, - }) - } - } - return validProfiles - } - - /** - * Helper method to clean the user's profiles returned by SGID - * @param userProfiles - */ - const cleanSgidUserProfiles = ( - userProfiles: SgidPublicOfficerEmployment[] - ): SgidPublicOfficerEmployment[] => { - const logMeta = { action: 'cleanSgidUserProfiles' } - const cleanedProfiles = userProfiles.map((profile) => { - // DB only accepts lowercase emails - profile.workEmail = profile.workEmail.toLowerCase().trim() - // If SGID does not have the field, we want to log the missing value and return an empty string - if (profile.agencyName === SGID_FIELD_EMPTY) { - profile.agencyName = '' - logger.warn({ - message: 'Agency name is missing from SGID data', - ...logMeta, - profile, - }) - } - if (profile.departmentName === SGID_FIELD_EMPTY) { - profile.departmentName = '' - logger.warn({ - message: 'Department name is missing from SGID data', - ...logMeta, - profile, - }) - } - if (profile.employmentTitle === SGID_FIELD_EMPTY) { - profile.employmentTitle = '' - logger.warn({ - message: 'Employment title is missing from SGID data', - ...logMeta, - profile, - }) - } - if (profile.employmentType === SGID_FIELD_EMPTY) { - profile.employmentType = '' - logger.warn({ - message: 'Employment type is missing from SGID data', - ...logMeta, - profile, - }) - } - return profile - }) - return cleanedProfiles - } - return { canSendOtp, sendOtp, @@ -526,8 +311,5 @@ export const InitAuthService = (redisService: RedisService): AuthService => { findUser, checkCookie, getUserForApiKey, - getSgidUrl, - verifySgidCode, - getSgidUserProfiles, } } diff --git a/backend/src/core/types/auth.types.ts b/backend/src/core/types/auth.types.ts deleted file mode 100644 index 377bd876f..000000000 --- a/backend/src/core/types/auth.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type SgidPublicOfficerEmployment = { - agencyName: string - departmentName: string - employmentTitle: string - employmentType: string - workEmail: string -} diff --git a/backend/src/core/types/index.ts b/backend/src/core/types/index.ts index e56d838c8..fdbd2437a 100644 --- a/backend/src/core/types/index.ts +++ b/backend/src/core/types/index.ts @@ -1,2 +1 @@ export * from './parse-csv.types' -export * from './auth.types' diff --git a/backend/src/test-utils/setup.ts b/backend/src/test-utils/setup.ts index a064ea96f..490eae361 100644 --- a/backend/src/test-utils/setup.ts +++ b/backend/src/test-utils/setup.ts @@ -10,11 +10,3 @@ global.console = { // Mock services jest.mock('@shared/clients/mail-client.class') - -jest.mock('@opengovsg/sgid-client', () => { - return { - SgidClient: function () { - return {} - }, - } -}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6bc35e44..baa87f11d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,9 @@ // Components import { Suspense, lazy } from 'react' - import { Route, Routes } from 'react-router-dom' import Landing from 'components/landing' -import Login, { Callback } from 'components/login' +import Login from 'components/login' import ProtectedPage from 'components/protected' import TestUtils from 'components/test-utils' import Unsubscribe from 'components/unsubscribe' @@ -21,7 +20,6 @@ const App = () => { return ( }> - }> }> }> }> diff --git a/frontend/src/components/login/Callback.tsx b/frontend/src/components/login/Callback.tsx deleted file mode 100644 index 965f72771..000000000 --- a/frontend/src/components/login/Callback.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import LoginTemplate from './LoginTemplate' - -import LoginCallback from './login-callback' - -const Callback = () => { - return ( - - - - ) -} - -export { Callback } diff --git a/frontend/src/components/login/index.ts b/frontend/src/components/login/index.ts index 8db12d540..626bc075a 100644 --- a/frontend/src/components/login/index.ts +++ b/frontend/src/components/login/index.ts @@ -1,2 +1 @@ export { default } from './Login' -export { Callback } from './Callback' diff --git a/frontend/src/components/login/login-callback/LoginCallback.module.scss b/frontend/src/components/login/login-callback/LoginCallback.module.scss deleted file mode 100644 index 3e147320b..000000000 --- a/frontend/src/components/login/login-callback/LoginCallback.module.scss +++ /dev/null @@ -1,61 +0,0 @@ -@import 'styles/_variables'; -@import 'styles/_mixins'; - -.mainBlock { - width: 640px; - height: 120px; - padding: 28 24 28 24; - - h3 { - text-align: center; - font-size: 24px; - } - - @include mobile() { - width: 327px; - justify-content: space-between; - } -} - -.profileBlock { - width: 640px; - height: 120px; - padding: 28px 24px 28px 24px; - display: flex; - justify-content: space-between; - align-items: center; - border-top: 1px solid $primary-light; - border-bottom: 1px solid $primary-light; - cursor: pointer; - - @include mobile() { - width: 327px; - justify-content: space-between; - } -} - -// Every subsequent block only has border-bottom -.profileBlock ~ .profileBlock { - border-top: 0px; - border-bottom: 1px solid $primary-light; -} - -.profileMainText { - font-size: 14.4px; - - color: #040651; -} - -.profileSubText { - font-size: 12px; - color: #64707d; -} - -.bottomBlock { - width: 640px; - padding-top: 32px; - font-size: 14px; - display: flex; - justify-content: center; - color: #2c2cdc; -} diff --git a/frontend/src/components/login/login-callback/LoginCallback.tsx b/frontend/src/components/login/login-callback/LoginCallback.tsx deleted file mode 100644 index 6b4470c9c..000000000 --- a/frontend/src/components/login/login-callback/LoginCallback.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react' - -import styles from './LoginCallback.module.scss' - -import RightChevron from 'assets/img/chevron-right.svg' -import { AuthContext } from 'contexts/auth.context' - -import { - SgidUserProfile, - getUser, - loginWithSgid, - selectSgidProfile, - setUserAnalytics, -} from 'services/auth.service' - -import { sendException } from 'services/ga.service' - -const DASHBOARD_PAGE = '/' -const LOGIN_PAGE = '/login' - -const LoginCallback = () => { - const { - setAuthenticated, - setEmail: setAuthContextEmail, - setExperimentalData, - } = useContext(AuthContext) - - const [profiles, setProfiles] = useState([]) - const params = new URL(window.location.href).searchParams - const code = params.get('code') - - const confirmSgidProfile = async (selectedProfile: string) => { - try { - await selectSgidProfile(selectedProfile) - setAuthenticated(true) - const user = await getUser() - if (!user) { - throw new Error('Unable to fetch user data!') - } - setAuthContextEmail(user.email) - setExperimentalData( - user?.experimental_data as { - [feature: string]: Record - } - ) - setUserAnalytics(user) - window.location.replace(DASHBOARD_PAGE) - } catch (e) { - console.error(e) - sendException((e as Error).message) - window.location.replace(LOGIN_PAGE + `?errorCode=SingpassError`) - } - } - - const profileSelection = ( - <> -
-

Choose an account to continue to Postman

-
- {profiles.map((profile) => ( -
confirmSgidProfile(profile.workEmail)} - > -
-
{profile.workEmail}
- {!!profile.agencyName && ( -
- {profile.agencyName} - {!!profile.departmentName && `, ${profile.departmentName}`} -
- )} -
- {!!profile.employmentTitle && profile.employmentTitle} -
-
- Select profile -
- ))} - - Or, log in manually using email and OTP - - - ) - - useEffect(() => { - async function login(code: string) { - try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const { userProfiles } = await loginWithSgid(code) - if (!userProfiles || userProfiles.length == 0) { - setProfiles([]) - window.location.replace(LOGIN_PAGE + `?errorCode=NoSingpassProfile`) - } else { - setProfiles(userProfiles) - } - } catch (e) { - console.error(e) - sendException((e as Error).message) - window.location.replace(LOGIN_PAGE + `?errorCode=SingpassError`) - } - } - - void login(code ?? '') - }, [code, setAuthContextEmail, setAuthenticated, setExperimentalData]) - - return ( - - {profiles.length === 0 && ( - - )} - {profiles.length > 0 && profileSelection} - - ) -} - -export default LoginCallback diff --git a/frontend/src/components/login/login-callback/index.ts b/frontend/src/components/login/login-callback/index.ts deleted file mode 100644 index ac6590d0f..000000000 --- a/frontend/src/components/login/login-callback/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './LoginCallback' diff --git a/frontend/src/components/login/login-input/LoginInput.tsx b/frontend/src/components/login/login-input/LoginInput.tsx index e0202ad45..a8fff0bcf 100644 --- a/frontend/src/components/login/login-input/LoginInput.tsx +++ b/frontend/src/components/login/login-input/LoginInput.tsx @@ -22,7 +22,6 @@ import { loginWithOtp, getUser, setUserAnalytics, - getSgidUrl, } from 'services/auth.service' import { @@ -32,8 +31,6 @@ import { } from 'services/ga.service' const RESEND_WAIT_TIME = 30000 -const SGID_VALID_ORGANISATIONS_PAGE = - 'https://docs.id.gov.sg/faq-users#as-a-government-officer-why-am-i-not-able-to-login-to-my-work-tool-using-sgid' const Login = () => { const { @@ -54,15 +51,12 @@ const Login = () => { return () => timeoutId && clearTimeout(timeoutId) }) - const SINGPASS_ERROR_CODE = 'SingpassError' - const NO_EMPLOYEE_PROFILE_ERROR_CODE = 'NoSingpassProfile' - - const openErrorModal = (errorMessage: string) => + const openErrorModal = (errorString: string) => modalContext.setModalContent( {errorMessage} +

{errorString}

} buttonText="Okay" alternateImage={ErrorImage} @@ -71,56 +65,31 @@ const Login = () => { /> ) - useEffect(() => { - const params = new URL(window.location.href).searchParams - const errorCode = params.get('errorCode') - const openNoEmployeeProfileModal = () => - modalContext.setModalContent( - - Please check{' '} - - here - {' '} - if your government agency is supported. Meanwhile, login via your - email instead. - - } - buttonText="Use Email Login" - alternateImage={ErrorImage} - primary={true} - onConfirm={() => modalContext.close()} - /> - ) - - const openSingpassErrorModal = () => - modalContext.setModalContent( - modalContext.close()} - /> - ) - switch (errorCode) { - case SINGPASS_ERROR_CODE: - void openSingpassErrorModal() - break - case NO_EMPLOYEE_PROFILE_ERROR_CODE: - void openNoEmployeeProfileModal() - break - default: - break - } - }, []) + const openSgidUnavailableModal = () => + modalContext.setModalContent( + + From 5pm, 3 May onwards, Singpass login will no longer be available.{' '} + Please log in using email OTP instead. If you’re unable to access{' '} + your email, refer to this{' '} + + guide + {' '} + to learn how you can forward the email OTP to your phone number + + } + buttonText="Okay" + alternateImage={ErrorImage} + primary={true} + onConfirm={() => modalContext.close()} + /> + ) async function sendOtp() { resetButton() @@ -154,18 +123,6 @@ const Login = () => { } } - async function sgidLogin() { - try { - const authUrl = await getSgidUrl() - if (authUrl) { - window.location.assign(authUrl) - } - } catch (err) { - openErrorModal((err as Error).message) - sendException((err as Error).message) - } - } - function resetButton() { setCanResend(false) setOtp('') @@ -224,20 +181,19 @@ const Login = () => { loadingButtonLabel={Verifying OTP...} /> )} - {/* This feature is experimental and should only be rendered on the demo URL (/sgid-login) */} {!otpSent && (

or

- + openSgidUnavailableModal()}> Log in with Singpass

Can my agency use this? Check{' '} diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index af95c6fb5..8aad7fb88 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -3,14 +3,6 @@ import axios from 'axios' import { setGAUserId } from './ga.service' -export type SgidUserProfile = { - workEmail: string - agencyName: string - departmentName: string - employmentType: string - employmentTitle: string -} - async function getOtpWithEmail(email: string): Promise { try { await axios.post('/auth/otp', { @@ -35,38 +27,6 @@ async function loginWithOtp(email: string, otp: string): Promise { } } -async function getSgidUrl(): Promise { - try { - const response = await axios.get('/auth/login/sgid') - return response.data - } catch (e) { - errorHandler(e) - } -} - -async function loginWithSgid( - code: string -): Promise { - try { - const response = await axios.post(`/auth/login/sgid`, { - code, - }) - return response.data - } catch (e) { - errorHandler(e) - } -} - -async function selectSgidProfile(workEmail: string): Promise { - try { - await axios.post(`/auth/login/sgid/profile`, { - workEmail, - }) - } catch (e) { - errorHandler(e) - } -} - async function getUser(): Promise< | { email: string @@ -115,13 +75,4 @@ function errorHandler(e: unknown, customHandlers: any = {}) { throw new Error(`${e}`) } -export { - getOtpWithEmail, - loginWithOtp, - getUser, - logout, - setUserAnalytics, - getSgidUrl, - loginWithSgid, - selectSgidProfile, -} +export { getOtpWithEmail, loginWithOtp, getUser, logout, setUserAnalytics } diff --git a/package-lock.json b/package-lock.json index 8dc29b24d..6ec2421ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,48 @@ "name": "postmangovsg", "hasInstallScript": true, "license": "MIT", + "dependencies": { + "@datadog/browser-rum": "^5.9.0" + }, "devDependencies": { "concurrently": "^7.2.1", "husky": "^8.0.1", "lint-staged": "^13.0.0", "prettier": "^2.6.2", + "typescript": "^5.3.3", "wait-on": "^6.0.1" } }, + "node_modules/@datadog/browser-core": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@datadog/browser-core/-/browser-core-5.9.0.tgz", + "integrity": "sha512-8YSk/5Qi6XhyBEL9C/uReoYu/9Dpyh9iplCK1efmKUehWDBKOV353TbMYDDJlcY8Ha7iwA5ZoEEUKcWszRlQkA==" + }, + "node_modules/@datadog/browser-rum": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@datadog/browser-rum/-/browser-rum-5.9.0.tgz", + "integrity": "sha512-2Izc3vLS7xBMETr5q82ahKiZK+DIsX80PnRUajiwbOTVpWsQjyvPB3uVshaYdwAXPc654AfRlFoALVDiUs4QEg==", + "dependencies": { + "@datadog/browser-core": "5.9.0", + "@datadog/browser-rum-core": "5.9.0" + }, + "peerDependencies": { + "@datadog/browser-logs": "5.9.0" + }, + "peerDependenciesMeta": { + "@datadog/browser-logs": { + "optional": true + } + } + }, + "node_modules/@datadog/browser-rum-core": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@datadog/browser-rum-core/-/browser-rum-core-5.9.0.tgz", + "integrity": "sha512-jPXNqHBdU6UfgLKvXE9Qq5XBtPGAI/qhYrWWAFbYTABL7AipF8xP7N1QlkbuIhjiln5xC3IaPiDgdMCVQlmB6w==", + "dependencies": { + "@datadog/browser-core": "5.9.0" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -1159,6 +1193,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/wait-on": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-6.0.1.tgz", diff --git a/package.json b/package.json index 475ae2475..10747f339 100644 --- a/package.json +++ b/package.json @@ -63,9 +63,13 @@ "husky": "^8.0.1", "lint-staged": "^13.0.0", "prettier": "^2.6.2", + "typescript": "^5.3.3", "wait-on": "^6.0.1" }, "lint-staged": { "*.{yml,yaml,md}": "prettier --write" + }, + "dependencies": { + "@datadog/browser-rum": "^5.9.0" } }