From 8aedff25ff052fc5ac1c12fb22a6b52fbefd238b Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Tue, 17 Oct 2023 13:54:41 +0800 Subject: [PATCH] feat(sgid-login): general availability and multi-employment (#6763) * feat: use pocdex sgid employment scope * feat: fe sgid page, components * fix: req.session typing * feat: add be profiles handling, refactor callback to profile-select * feat: add be routes * fix: add cleaning of response from sgid * feat: handle multi-employment profiles * feat: add error modals * chore: ui design changes on login page * feat: handle empty workprofiles * refactor: convert to async func * refactor: add utils to resolve redirection to local fe url * chore: remove test code * feat: add expiry to prevent login jacking * fix: add error toasts * chore: remove unused type * refactor: rename logincallbackpage to selectprofilepage * chore: add jsdocs comments * chore: remove test code * feat: add cta support url * refactor: move constant external urls to shared constants * chore: promote -sgid config to use env site name * fix: fix playwright finding two button elements * chore: update scope from public_officer_employments to public_officer_details * fix: update to match typing returned from sgid * chore: use agencies instead of organisations in copy * chore: use statuscode constants * refactor: extract apikey as const --- __tests__/e2e/fixtures/auth.ts | 5 +- __tests__/e2e/login.spec.ts | 15 +- frontend/src/app/AppRouter.tsx | 8 +- .../svgrs/singpass/SingpassFullLogoSvgr.tsx | 27 +++ frontend/src/constants/routes.ts | 2 +- frontend/src/features/login/LoginPage.tsx | 35 ++- .../src/features/login/SelectProfilePage.tsx | 225 ++++++++++++++++++ frontend/src/features/login/SgidLoginPage.tsx | 63 ----- .../features/login/components/LoginForm.tsx | 15 +- .../features/login/components/OrDivider.tsx | 11 + .../login/components/SgidLoginButton.tsx | 43 ++++ frontend/src/features/login/index.ts | 2 +- frontend/src/features/login/queries.ts | 23 ++ shared/constants/links.ts | 3 + shared/types/auth.ts | 12 + src/app/modules/auth/auth.types.ts | 7 + .../modules/auth/sgid/auth-sgid.controller.ts | 223 +++++++++++++---- .../modules/auth/sgid/auth-sgid.service.ts | 30 ++- .../routes/api/v3/auth/auth-sgid.routes.ts | 36 ++- src/app/utils/urls.ts | 11 + src/types/vendor/express.d.ts | 2 + 21 files changed, 653 insertions(+), 145 deletions(-) create mode 100644 frontend/src/assets/svgrs/singpass/SingpassFullLogoSvgr.tsx create mode 100644 frontend/src/features/login/SelectProfilePage.tsx delete mode 100644 frontend/src/features/login/SgidLoginPage.tsx create mode 100644 frontend/src/features/login/components/OrDivider.tsx create mode 100644 frontend/src/features/login/components/SgidLoginButton.tsx create mode 100644 frontend/src/features/login/queries.ts create mode 100644 shared/types/auth.ts create mode 100644 src/app/utils/urls.ts diff --git a/__tests__/e2e/fixtures/auth.ts b/__tests__/e2e/fixtures/auth.ts index 6d9f51921b..61f5c4b6f5 100644 --- a/__tests__/e2e/fixtures/auth.ts +++ b/__tests__/e2e/fixtures/auth.ts @@ -25,7 +25,10 @@ export const test = baseTest.extend({ await page.goto(LOGIN_PAGE) await page.getByRole('textbox', { name: /log in/i }).fill(ADMIN_EMAIL) - await page.getByRole('button', { name: /log in/i }).click() + await page + .getByRole('button', { name: /log in/i }) + .first() + .click() // Ensure OTP success message is seen await expect( diff --git a/__tests__/e2e/login.spec.ts b/__tests__/e2e/login.spec.ts index cd2192320b..a70715cd4a 100644 --- a/__tests__/e2e/login.spec.ts +++ b/__tests__/e2e/login.spec.ts @@ -20,7 +20,10 @@ test.describe('login', () => { .getByRole('textbox', { name: /log in/i }) .fill('user@non-white-listed-agency.com') - await page.getByRole('button', { name: /log in/i }).click() + await page + .getByRole('button', { name: /log in/i }) + .first() + .click() // Ensure error message is seen await expect( @@ -36,7 +39,10 @@ test.describe('login', () => { await expect(page).toHaveURL(LOGIN_PAGE) await page.getByRole('textbox', { name: /log in/i }).fill(legitUserEmail) - await page.getByRole('button', { name: /log in/i }).click() + await page + .getByRole('button', { name: /log in/i }) + .first() + .click() // Ensure OTP success message is seen await expect( @@ -61,7 +67,10 @@ test.describe('login', () => { await expect(page).toHaveURL(LOGIN_PAGE) await page.getByRole('textbox', { name: /log in/i }).fill(legitUserEmail) - await page.getByRole('button', { name: /log in/i }).click() + await page + .getByRole('button', { name: /log in/i }) + .first() + .click() // Ensure OTP success message is seen await expect( diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 577636c813..256f665304 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -13,8 +13,8 @@ import { DASHBOARD_ROUTE, LANDING_PAYMENTS_ROUTE, LANDING_ROUTE, + LOGIN_CALLBACK_ROUTE, LOGIN_ROUTE, - OGP_LOGIN_ROUTE, PAYMENT_PAGE_SUBROUTE, PRIVACY_POLICY_ROUTE, PUBLICFORM_ROUTE, @@ -36,7 +36,7 @@ import { ResponsesPage, } from '~features/admin-form/responses' import { SettingsPage } from '~features/admin-form/settings/SettingsPage' -import { SgidLoginPage } from '~features/login' +import { SelectProfilePage } from '~features/login' import { FormPaymentPage } from '~features/public-form/components/FormPaymentPage/FormPaymentPage' import { BillingPage } from '~features/user/billing' @@ -101,8 +101,8 @@ export const AppRouter = (): JSX.Element => { element={} />} /> } />} + path={LOGIN_CALLBACK_ROUTE} + element={} />} /> >((props, ref) => ( + + + + + + + )), +) + +export const SingpassFullLogoSvgr = chakra(MemoSingpassFullLogoSvgr) diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 01eee2bf69..cb1a9a0e9f 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -2,7 +2,7 @@ export const LANDING_ROUTE = '/' export const LANDING_PAYMENTS_ROUTE = '/payments' export const DASHBOARD_ROUTE = '/dashboard' export const LOGIN_ROUTE = '/login' -export const OGP_LOGIN_ROUTE = '/ogp-login' +export const LOGIN_CALLBACK_ROUTE = '/login/select-profile' export const TOU_ROUTE = '/terms' export const PRIVACY_POLICY_ROUTE = '/privacy' diff --git a/frontend/src/features/login/LoginPage.tsx b/frontend/src/features/login/LoginPage.tsx index 30f7a1c0e8..82e2c5e131 100644 --- a/frontend/src/features/login/LoginPage.tsx +++ b/frontend/src/features/login/LoginPage.tsx @@ -1,7 +1,11 @@ -import { useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Stack } from '@chakra-ui/react' +import { StatusCodes } from 'http-status-codes' import { LOGGED_IN_KEY } from '~constants/localStorage' import { useLocalStorage } from '~hooks/useLocalStorage' +import { useToast } from '~hooks/useToast' import { sendLoginOtp, verifyLoginOtp } from '~services/AuthService' import { @@ -10,7 +14,9 @@ import { } from '~features/analytics/AnalyticsService' import { LoginForm, LoginFormInputs } from './components/LoginForm' +import { OrDivider } from './components/OrDivider' import { OtpForm, OtpFormInputs } from './components/OtpForm' +import { SgidLoginButton } from './components/SgidLoginButton' import { LoginPageTemplate } from './LoginPageTemplate' export type LoginOtpData = { @@ -22,6 +28,27 @@ export const LoginPage = (): JSX.Element => { const [email, setEmail] = useState() const [otpPrefix, setOtpPrefix] = useState('') + const [params] = useSearchParams() + const toast = useToast({ isClosable: true, status: 'danger' }) + + const statusCode = params.get('status') + const toastMessage = useMemo(() => { + switch (statusCode) { + case null: + case StatusCodes.OK.toString(): + return + case StatusCodes.UNAUTHORIZED.toString(): + return 'Your sgID login session has expired. Please login again.' + default: + return 'Something went wrong. Please try again later.' + } + }, [statusCode]) + + useEffect(() => { + if (!toastMessage) return + toast({ description: toastMessage }) + }, [toast, toastMessage]) + const handleSendOtp = async ({ email }: LoginFormInputs) => { const trimmedEmail = email.trim() await sendLoginOtp(trimmedEmail).then(({ otpPrefix }) => { @@ -60,7 +87,11 @@ export const LoginPage = (): JSX.Element => { return ( {!email ? ( - + + + + + ) : ( +type ModalErrorMessages = { + hideCloseButton?: boolean + header: string + body: string | (() => React.ReactElement) + cta: string + onCtaClick: (disclosureProps: ErrorDisclosureProps) => void +} + +const MODAL_ERRORS: Record = { + NO_WORKEMAIL: { + hideCloseButton: true, + header: "Singpass login isn't available to you yet", + body: 'It is progressively being made available to agencies. In the meantime, please log in using your email address.', + cta: 'Back to login', + onCtaClick: () => window.location.assign(LOGIN_ROUTE), + }, + INVALID_WORKEMAIL: { + header: "You don't have access to this service", + body: () => ( + + It may be available only to select agencies or authorised individuals. + If you believe you should have access to this service, please{' '} + + contact us + + . + + ), + cta: 'Choose another account', + onCtaClick: (disclosureProps) => disclosureProps.onClose(), + }, +} + +const ErrorDisclosure = ( + props: { + errorMessages: ModalErrorMessages | undefined + } & ErrorDisclosureProps, +) => { + const isMobile = useIsMobile() + if (!props.errorMessages) { + return null + } + const { errorMessages, ...disclosureProps } = props + const { onCtaClick, body, cta, hideCloseButton, header } = errorMessages + return ( + props.onClose()}> + + + {!hideCloseButton ? : null} + {header} + + + {typeof body === 'function' ? body() : {body}} + + + + + + + + ) +} +export const SelectProfilePage = (): JSX.Element => { + const profilesResponse = useSgidProfiles() + const [, setIsAuthenticated] = useLocalStorage(LOGGED_IN_KEY) + const { user } = useUser() + const [errorContext, setErrorContext] = useState< + ModalErrorMessages | undefined + >() + + const errorDisclosure = useDisclosure() + const toast = useToast({ isClosable: true, status: 'danger' }) + + // If redirected back here but already authed, redirect to dashboard. + if (user) window.location.replace(DASHBOARD_ROUTE) + // User doesn't have any profiles, should reattempt to login + if (profilesResponse.error) window.location.replace(LOGIN_ROUTE) + + useEffect(() => { + if (profilesResponse.data?.profiles.length === 0) { + errorDisclosure.onOpen() + setErrorContext(MODAL_ERRORS.NO_WORKEMAIL) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [profilesResponse.data?.profiles.length]) + + const handleSetProfile = async (profile: SgidPublicOfficerEmployment) => { + ApiService.post(SGID_PROFILES_ENDPOINT, { + workEmail: profile.work_email, + }) + .then(() => { + window.location.assign(DASHBOARD_ROUTE) + setIsAuthenticated(true) + }) + .catch((err) => { + console.log({ err }) + if (err.code === StatusCodes.UNAUTHORIZED) { + errorDisclosure.onOpen() + setErrorContext(MODAL_ERRORS.INVALID_WORKEMAIL) + return + } + toast({ description: 'Something went wrong. Please try again later.' }) + }) + } + + return ( + + } + > + + Choose an account to continue to FormSG + + + {!profilesResponse.data ? ( + + ) : ( + profilesResponse.data?.profiles.map((profile) => ( + handleSetProfile(profile)} + /> + )) + )} + + + Or, login manually using email and OTP + + + + + ) +} + +const ProfileItem = ({ + profile, + onClick, +}: { + profile: SgidPublicOfficerEmployment + onClick: () => void +}) => { + return ( + + + + {profile.work_email} + + + {[profile.agency_name, profile.department_name].join(', ')} + + + {profile.employment_title} + + + + + + + ) +} diff --git a/frontend/src/features/login/SgidLoginPage.tsx b/frontend/src/features/login/SgidLoginPage.tsx deleted file mode 100644 index d1b4a97f37..0000000000 --- a/frontend/src/features/login/SgidLoginPage.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useEffect, useMemo } from 'react' -import { BiLogInCircle } from 'react-icons/bi' -import { useMutation } from 'react-query' -import { useSearchParams } from 'react-router-dom' - -import { DASHBOARD_ROUTE } from '~constants/routes' -import { useToast } from '~hooks/useToast' -import { getSgidAuthUrl } from '~services/AuthService' -import Button from '~components/Button' -import { InlineMessage } from '~components/InlineMessage/InlineMessage' - -import { useUser } from '~features/user/queries' - -import { LoginPageTemplate } from './LoginPageTemplate' - -export const SgidLoginPage = (): JSX.Element => { - const [params] = useSearchParams() - const toast = useToast({ isClosable: true, status: 'danger' }) - - const statusCode = params.get('status') - const toastMessage = useMemo(() => { - switch (statusCode) { - case null: - case '200': - return - case '401': - return 'Your SGID-linked work email does not belong to a whitelisted public service email domain. Please use OTP login instead.' - default: - return 'Something went wrong. Please try again later.' - } - }, [statusCode]) - - useEffect(() => { - if (!toastMessage) return - toast({ description: toastMessage }) - }, [toast, toastMessage]) - - const { user } = useUser() - - // If redirected back here but already authed, redirect to dashboard. - if (user) window.location.replace(DASHBOARD_ROUTE) - - const handleLoginMutation = useMutation(getSgidAuthUrl, { - onSuccess: (data) => { - window.location.assign(data.redirectUrl) - }, - }) - - return ( - - - This is an experimental service currently offered to OGP officers only. - - - - ) -} diff --git a/frontend/src/features/login/components/LoginForm.tsx b/frontend/src/features/login/components/LoginForm.tsx index 3acfb93d50..8ff4d87d02 100644 --- a/frontend/src/features/login/components/LoginForm.tsx +++ b/frontend/src/features/login/components/LoginForm.tsx @@ -1,16 +1,14 @@ import { useCallback } from 'react' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { FormControl, Stack, useBreakpointValue } from '@chakra-ui/react' +import { FormControl, Stack } from '@chakra-ui/react' import isEmail from 'validator/lib/isEmail' -import { FORM_GUIDE } from '~constants/links' import { INVALID_EMAIL_ERROR } from '~constants/validation' import Button from '~components/Button' import FormErrorMessage from '~components/FormControl/FormErrorMessage' import FormLabel from '~components/FormControl/FormLabel' import Input from '~components/Input' -import Link from '~components/Link' export type LoginFormInputs = { email: string @@ -36,8 +34,6 @@ export const LoginForm = ({ onSubmit }: LoginFormProps): JSX.Element => { }) } - const isMobile = useBreakpointValue({ base: true, xs: true, lg: false }) - return (
{ spacing="1.5rem" align="center" > - - - {t('features.login.components.LoginForm.haveAQuestion')} - ) diff --git a/frontend/src/features/login/components/OrDivider.tsx b/frontend/src/features/login/components/OrDivider.tsx new file mode 100644 index 0000000000..c9c34be1a4 --- /dev/null +++ b/frontend/src/features/login/components/OrDivider.tsx @@ -0,0 +1,11 @@ +import { Divider, HStack, Text } from '@chakra-ui/react' + +export const OrDivider = (): JSX.Element => { + return ( + + + or + + + ) +} diff --git a/frontend/src/features/login/components/SgidLoginButton.tsx b/frontend/src/features/login/components/SgidLoginButton.tsx new file mode 100644 index 0000000000..f19ebe0641 --- /dev/null +++ b/frontend/src/features/login/components/SgidLoginButton.tsx @@ -0,0 +1,43 @@ +import { useForm } from 'react-hook-form' +import { useMutation } from 'react-query' +import { Flex, Link, Text, VStack } from '@chakra-ui/react' + +import { SGID_VALID_ORG_PAGE } from '~shared/constants' + +import { SingpassFullLogoSvgr } from '~assets/svgrs/singpass/SingpassFullLogoSvgr' +import { getSgidAuthUrl } from '~services/AuthService' +import Button from '~components/Button' + +export const SgidLoginButton = (): JSX.Element => { + const { formState } = useForm() + + const handleLoginMutation = useMutation(getSgidAuthUrl, { + onSuccess: (data) => { + window.location.assign(data.redirectUrl) + }, + }) + return ( + + + + For{' '} + + select agencies + + + + ) +} diff --git a/frontend/src/features/login/index.ts b/frontend/src/features/login/index.ts index f22bc829d7..0b15ab6929 100644 --- a/frontend/src/features/login/index.ts +++ b/frontend/src/features/login/index.ts @@ -1,2 +1,2 @@ export { LoginPage as default } from './LoginPage' -export { SgidLoginPage } from './SgidLoginPage' +export { SelectProfilePage } from './SelectProfilePage' diff --git a/frontend/src/features/login/queries.ts b/frontend/src/features/login/queries.ts new file mode 100644 index 0000000000..9dde41e788 --- /dev/null +++ b/frontend/src/features/login/queries.ts @@ -0,0 +1,23 @@ +import { useQuery, UseQueryResult } from 'react-query' + +import { SgidProfilesDto } from '~shared/types/auth' + +import { ApiError } from '~typings/core' + +import { ApiService } from '~services/ApiService' + +export const SGID_PROFILES_ENDPOINT = '/auth/sgid/profiles' + +const sgidProfileKeys = { + base: ['sgidProfiles'] as const, +} + +export const useSgidProfiles = (): UseQueryResult< + SgidProfilesDto, + ApiError +> => { + return useQuery(sgidProfileKeys.base, async () => { + const { data } = await ApiService.get(SGID_PROFILES_ENDPOINT) + return data + }) +} diff --git a/shared/constants/links.ts b/shared/constants/links.ts index 3e08bb7ff5..2bf18b30f8 100644 --- a/shared/constants/links.ts +++ b/shared/constants/links.ts @@ -1,3 +1,6 @@ export const SUPPORT_FORM_LINK = 'https://go.gov.sg/formsg-support' export const PUBLIC_PAYMENTS_GUIDE_LINK = 'https://go.gov.sg/formsg-guide-payments' + +export const SGID_VALID_ORG_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' diff --git a/shared/types/auth.ts b/shared/types/auth.ts new file mode 100644 index 0000000000..484e8efe61 --- /dev/null +++ b/shared/types/auth.ts @@ -0,0 +1,12 @@ +export type SgidProfilesDto = { + profiles: SgidPublicOfficerEmploymentList +} + +export type SgidPublicOfficerEmployment = { + agency_name: string + department_name: string + employment_title: string + employment_type: string + work_email: string +} +export type SgidPublicOfficerEmploymentList = Array diff --git a/src/app/modules/auth/auth.types.ts b/src/app/modules/auth/auth.types.ts index ba7e1a50ee..1b41ccff46 100644 --- a/src/app/modules/auth/auth.types.ts +++ b/src/app/modules/auth/auth.types.ts @@ -1,3 +1,10 @@ +import { SgidPublicOfficerEmploymentList } from 'shared/types/auth' + import { IPopulatedUser } from 'src/types' export type SessionUser = IPopulatedUser + +export type SgidUser = { + profiles: SgidPublicOfficerEmploymentList + expiry: number +} diff --git a/src/app/modules/auth/sgid/auth-sgid.controller.ts b/src/app/modules/auth/sgid/auth-sgid.controller.ts index 99033b4703..807986a0ed 100644 --- a/src/app/modules/auth/sgid/auth-sgid.controller.ts +++ b/src/app/modules/auth/sgid/auth-sgid.controller.ts @@ -1,6 +1,8 @@ import { StatusCodes } from 'http-status-codes' import { ErrorDto, GetSgidAuthUrlResponseDto } from 'shared/types' +import { SgidProfilesDto } from 'shared/types/auth' +import { resolveRedirectionUrl } from '../../../../app/utils/urls' import { createLoggerWithLabel } from '../../../config/logger' import { createReqMeta } from '../../../utils/request' import { ControllerHandler } from '../../core/core.types' @@ -46,7 +48,14 @@ export const generateAuthUrl: ControllerHandler< }) } -export const handleLogin: ControllerHandler< +/** + * Handler for GET /api/v3/auth/sgid/login/callback endpoint. + * + * @return 200 with redirect to frontend /login/callback if there are no errors + * @return 400 when code or state is not provided, or state is incorrect + * @return 500 when processing the code verifier cookie fails, or when an unknown error occurs + */ +export const handleLoginCallback: ControllerHandler< unknown, ErrorDto | undefined, unknown, @@ -57,7 +66,7 @@ export const handleLogin: ControllerHandler< res.clearCookie(SGID_CODE_VERIFIER_COOKIE_NAME) const logMeta = { - action: 'handleLogin', + action: 'handleLoginCallback', code, state, ...createReqMeta(req), @@ -65,8 +74,6 @@ export const handleLogin: ControllerHandler< const coreErrorMessage = 'Failed to log in via SGID. Please try again later.' - let status - if (!code || state !== SGID_LOGIN_OAUTH_STATE) { logger.error({ message: @@ -74,59 +81,179 @@ export const handleLogin: ControllerHandler< meta: logMeta, }) - status = StatusCodes.BAD_REQUEST - } else if (!codeVerifier) { + const status = StatusCodes.BAD_REQUEST + res.redirect(resolveRedirectionUrl(`/login?status=${status}`)) + return + } + if (!codeVerifier) { logger.error({ message: 'Error logging in via sgID: code verifier cookie is empty', meta: logMeta, }) - status = StatusCodes.BAD_REQUEST - } else { - await AuthSgidService.retrieveAccessToken(code, codeVerifier) - .andThen(({ accessToken, sub }) => - AuthSgidService.retrieveUserInfo(accessToken, sub), - ) - .andThen((email) => - AuthService.validateEmailDomain(email).andThen((agency) => - UserService.retrieveUser(email, agency._id), - ), - ) - .map((user) => { - if (!req.session) { - logger.error({ - message: 'Error logging in user; req.session is undefined', - meta: logMeta, - }) - - status = StatusCodes.INTERNAL_SERVER_ERROR - return - } - - // Add user info to session. - const { _id } = user.toObject() as SessionUser - req.session.user = { _id } - logger.info({ - message: `Successfully logged in user ${user._id}`, - meta: logMeta, - }) + const status = StatusCodes.BAD_REQUEST + res.redirect(resolveRedirectionUrl(`/login?status=${status}`)) + return + } + if (!req.session) { + logger.error({ + message: 'Error logging in user; req.session is undefined', + meta: logMeta, + }) - // Redirect user to the SGID login page - status = StatusCodes.OK - }) - // Step 3b: Error occured in one of the steps. - .mapErr((error) => { - logger.warn({ - message: 'Error occurred when trying to log in via SGID', - meta: logMeta, - error, - }) + const status = StatusCodes.INTERNAL_SERVER_ERROR + res.redirect(resolveRedirectionUrl(`/login?status=${status}`)) + return + } - const { statusCode } = mapRouteError(error, coreErrorMessage) + await AuthSgidService.retrieveAccessToken(code, codeVerifier) + .andThen(({ accessToken, sub }) => + AuthSgidService.retrieveUserInfo(accessToken, sub), + ) + .map((profiles) => { + // expire profiles after 5 minutes to avoid situations where login-jacking when + // the previous user navigated away without selecting a profile + req.session.sgid = { profiles, expiry: Date.now() + 1000 * 60 * 5 } - status = statusCode + // User needs to select profile that will be used for the login + res.redirect(resolveRedirectionUrl(`/login/select-profile`)) + return + }) + .mapErr((error) => { + logger.warn({ + message: 'Error occurred when trying to log in via SGID', + meta: logMeta, + error, }) + + const { statusCode } = mapRouteError(error, coreErrorMessage) + + res.redirect(resolveRedirectionUrl(`/login?status=${statusCode}`)) + return + }) +} + +/** + * Handler for GET /api/v3/auth/sgid/profiles endpoint. + * + * @return 200 with list of profiles + * @return 400 when session or profile is invalid + * @return 401 when session has expired + */ +export const getProfiles: ControllerHandler< + unknown, + SgidProfilesDto, + unknown +> = async (req, res) => { + const logMeta = { + action: 'getProfiles', + ...createReqMeta(req), + } + if (!req.session) { + logger.error({ + message: 'Error logging in via sgID: session is invalid', + meta: logMeta, + }) + return res.status(StatusCodes.BAD_REQUEST).send() + } + + if (!req.session.sgid?.profiles) { + logger.error({ + message: 'Error logging in via sgID: profile is invalid', + meta: logMeta, + }) + return res.status(StatusCodes.BAD_REQUEST).send() + } + + if (req.session.sgid.expiry < Date.now()) { + logger.info({ + message: 'Error logging in via sgID: session has expired', + meta: logMeta, + }) + res.redirect(StatusCodes.UNAUTHORIZED, resolveRedirectionUrl(`/login`)) + return } - return res.redirect(`/ogp-login?status=${status}`) + return res + .status(StatusCodes.OK) + .json({ profiles: req.session.sgid.profiles }) +} + +/** + * Handler for POST /api/v3/auth/sgid/profiles endpoint. + * + * @return 200 when OTP has been been successfully sent + * @return 400 when session, profile, or workEmail is invalid + * @return 401 when email domain is not whitelisted + * @return 500 when unknown errors occurs during email validation, or creating the new account + */ +export const setProfile: ControllerHandler< + unknown, + { message: string } | ErrorDto, + { workEmail: string } +> = async (req, res) => { + const logMeta = { + action: 'setProfile', + ...createReqMeta(req), + } + + const coreErrorMessage = 'Failed to log in via SGID. Please try again later.' + + if (!req.session) { + const message = 'Error logging in via sgID: session is invalid' + logger.error({ + message, + meta: logMeta, + }) + return res.status(StatusCodes.BAD_REQUEST).json({ message }) + } + + if (!req.session.sgid?.profiles) { + const message = 'Error logging in via sgID: profile is invalid' + logger.error({ + message, + meta: logMeta, + }) + return res.status(StatusCodes.BAD_REQUEST).json({ message }) + } + + const selectedProfile = req.session.sgid.profiles.find( + (profile) => profile.work_email === req.body.workEmail, + ) + if (!selectedProfile) { + const message = 'Error logging in via sgID: selected profile is invalid' + logger.error({ + message, + meta: logMeta, + }) + return res.status(StatusCodes.BAD_REQUEST).json({ message }) + } + + await AuthService.validateEmailDomain(selectedProfile.work_email) + .andThen((agency) => + UserService.retrieveUser(selectedProfile.work_email, agency._id), + ) + .map((user) => { + // Add user info to session. + const { _id } = user.toObject() as SessionUser + req.session.user = { _id } + logger.info({ + message: `Successfully logged in user ${user._id}`, + meta: logMeta, + }) + }) + .mapErr((error) => { + const message = 'Error occurred when trying to log in via SGID' + logger.warn({ + message, + meta: logMeta, + error, + }) + + const { statusCode } = mapRouteError(error, coreErrorMessage) + + return res.status(statusCode).json({ message }) + }) + + return res.status(StatusCodes.OK).json({ message: 'Ok' }) } diff --git a/src/app/modules/auth/sgid/auth-sgid.service.ts b/src/app/modules/auth/sgid/auth-sgid.service.ts index 3322b95e75..1426c1a010 100644 --- a/src/app/modules/auth/sgid/auth-sgid.service.ts +++ b/src/app/modules/auth/sgid/auth-sgid.service.ts @@ -1,6 +1,7 @@ import { generatePkcePair, SgidClient } from '@opengovsg/sgid-client' import fs from 'fs' import { err, ok, Result, ResultAsync } from 'neverthrow' +import { SgidPublicOfficerEmploymentList } from 'shared/types/auth' import { ISgidVarsSchema } from 'src/types' @@ -13,9 +14,9 @@ import { } from '../../sgid/sgid.errors' const logger = createLoggerWithLabel(module) - export const SGID_LOGIN_OAUTH_STATE = 'login' -const SGID_OGP_WORK_EMAIL_SCOPE = 'ogpofficerinfo.work_email' +const SGID_POCDEX_PUBLIC_OFFICER_EMPLOYMENTS_SCOPE = + 'pocdex.public_officer_details' export class AuthSgidServiceClass { private client: SgidClient @@ -56,7 +57,9 @@ export class AuthSgidServiceClass { try { const result = this.client.authorizationUrl({ state: SGID_LOGIN_OAUTH_STATE, - scope: ['openid', SGID_OGP_WORK_EMAIL_SCOPE].join(' '), + scope: ['openid', SGID_POCDEX_PUBLIC_OFFICER_EMPLOYMENTS_SCOPE].join( + ' ', + ), nonce: null, codeChallenge, }) @@ -108,11 +111,14 @@ export class AuthSgidServiceClass { retrieveUserInfo( accessToken: string, sub: string, - ): ResultAsync { + ): ResultAsync { return ResultAsync.fromPromise( - this.client - .userinfo({ accessToken, sub }) - .then(({ data }) => data[SGID_OGP_WORK_EMAIL_SCOPE]), + this.client.userinfo({ accessToken, sub }).then(({ data }) => { + const employments: SgidPublicOfficerEmploymentList = JSON.parse( + data[SGID_POCDEX_PUBLIC_OFFICER_EMPLOYMENTS_SCOPE], + ) + return employments + }), (error) => { logger.error({ message: 'Failed to retrieve user info from sgID', @@ -124,7 +130,15 @@ export class AuthSgidServiceClass { }) return new SgidFetchUserInfoError() }, - ) + ).andThen((employments) => { + // Ensure that all emails are in lowercase + const cleanedProfile = employments.map((employment) => ({ + ...employment, + work_email: employment.work_email.toLowerCase(), + })) + + return ok(cleanedProfile) + }) } } diff --git a/src/app/routes/api/v3/auth/auth-sgid.routes.ts b/src/app/routes/api/v3/auth/auth-sgid.routes.ts index bbc1cf0a06..87490cf54a 100644 --- a/src/app/routes/api/v3/auth/auth-sgid.routes.ts +++ b/src/app/routes/api/v3/auth/auth-sgid.routes.ts @@ -6,4 +6,38 @@ export const AuthSGIDRouter = Router() AuthSGIDRouter.get('/authurl', AuthSgidController.generateAuthUrl) -AuthSGIDRouter.get('/login', AuthSgidController.handleLogin) +/** + * Receives the selected login details from Sgid + * Sets the returned profiles in req.session.sgid + * @route POST /api/v3/auth/sgid/login/callback + * + * The frontend should query the available profiles through GET /api/v3/auth/sgid/profiles + * + * @return 200 with redirect to frontend /login/callback if there are no errors + * @return 400 when code or state is not provided, or state is incorrect + * @return 500 when processing the code verifier cookie fails, or when an unknown error occurs + */ +AuthSGIDRouter.get('/login/callback', AuthSgidController.handleLoginCallback) + +/** + * Sets the selected user profile + * Uses get request to retrieve available profiles + * @route GET /api/v3/auth/sgid/profiles + * + * @return 200 with list of profiles + * @return 400 when session or profile is invalid + * @return 401 when session has expired + */ +AuthSGIDRouter.get('/profiles', AuthSgidController.getProfiles) + +/** + * Sets the selected user profile + * Uses post request to select the workemail from the request body + * @route POST /api/v3/auth/sgid/profiles + * + * @return 200 when OTP has been been successfully sent + * @return 400 when session, profile, or workEmail is invalid + * @return 401 when email domain is invalid + * @return 500 when unknown errors occurs during email validation, or creating the new account + */ +AuthSGIDRouter.post('/profiles', AuthSgidController.setProfile) diff --git a/src/app/utils/urls.ts b/src/app/utils/urls.ts new file mode 100644 index 0000000000..99a807f414 --- /dev/null +++ b/src/app/utils/urls.ts @@ -0,0 +1,11 @@ +import { Environment } from '../../types' +import config from '../config/config' + +export const resolveRedirectionUrl = (rootUrl: string) => { + // For local dev, we need to specify the frontend app URL as this is different from the backend's app URL + const hostname = + process.env.NODE_ENV === Environment.Dev ? `${config.app.feAppUrl}` : `` + + const resolvedUrl = `${hostname}${rootUrl}` + return resolvedUrl +} diff --git a/src/types/vendor/express.d.ts b/src/types/vendor/express.d.ts index 9b00e06e8e..779ced97d9 100644 --- a/src/types/vendor/express.d.ts +++ b/src/types/vendor/express.d.ts @@ -1,5 +1,6 @@ import { RateLimitInfo } from 'express-rate-limit' +import { SgidUser } from '../../app/modules/auth/auth.types' import { EncryptSubmissionDto } from '../api' import { IPopulatedEncryptedForm, IPopulatedForm, IUserSchema } from '../types' @@ -37,6 +38,7 @@ declare module 'express-session' { user?: { _id: IUserSchema['_id'] } + sgid?: SgidUser } export interface AuthedSessionData extends SessionData {