Skip to content

Commit

Permalink
feat: store prefill in frontend for spcp/myinfo forms, enable prefill…
Browse files Browse the repository at this point in the history
… for SGID forms (#3920)

* feat: store prefill params in frontend instead of relaying through backend and auth server

* feat: enable prefill for sgid forms

* chore: use redirect target const, update tests

* chore: use sessionStorage instead of localStorage

* chore: beef up docs

* chore: use cuid instead of bson for lightweight

* chore: use constants
  • Loading branch information
tshuli authored Jul 1, 2022
1 parent cbc3350 commit e3b2ec2
Show file tree
Hide file tree
Showing 9 changed files with 138 additions and 23 deletions.
7 changes: 6 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"cookie-parser": "~1.4.6",
"css-toggle-switch": "^4.1.0",
"csv-string": "^4.1.0",
"cuid": "^2.1.8",
"date-fns": "^2.28.0",
"dd-trace": "^2.10.0",
"dedent-js": "~1.0.1",
Expand Down
3 changes: 2 additions & 1 deletion src/app/modules/form/public-form/public-form.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ export const handleGetPublicForm: ControllerHandler<
* NOTE: This is exported only for testing
* Generates redirect URL to Official SingPass/CorpPass log in page
* @param isPersistentLogin whether the client wants to have their login information stored
* @param encodedQuery base64 encoded querystring (usually contains prefilled form information)
* @param encodedQuery base64 encoded queryId for frontend to retrieve stored query params (usually contains prefilled form information)
* @returns 200 with the redirect url when the user authenticates successfully
* @returns 400 when there is an error on the authType of the form
* @returns 400 when the eServiceId of the form does not exist
Expand Down Expand Up @@ -453,6 +453,7 @@ export const _handleFormAuthRedirect: ControllerHandler<
return SgidService.createRedirectUrl(
formId,
Boolean(isPersistentLogin),
encodedQuery,
)
})
default:
Expand Down
1 change: 1 addition & 0 deletions src/app/modules/sgid/__tests__/sgid.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe('sgid.service', () => {
it('should parse state', () => {
const state = SgidService.parseState(MOCK_STATE)
expect(state._unsafeUnwrap()).toStrictEqual({
decodedQuery: '',
formId: MOCK_DESTINATION,
rememberMe: MOCK_REMEMBER_ME,
})
Expand Down
10 changes: 6 additions & 4 deletions src/app/modules/sgid/sgid.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export const handleLogin: ControllerHandler<
return res.sendStatus(StatusCodes.BAD_REQUEST)
}

const { formId, rememberMe } = parsedState.value
const { formId, rememberMe, decodedQuery } = parsedState.value
const target = decodedQuery ? `/${formId}${decodedQuery}` : `/${formId}`

const formResult = await FormService.retrieveFullFormById(formId)
if (formResult.isErr()) {
logger.error({
Expand All @@ -54,7 +56,7 @@ export const handleLogin: ControllerHandler<
},
})
res.cookie('isLoginError', true)
return res.redirect(`/${formId}`)
return res.redirect(target)
}

const jwtResult = await SgidService.retrieveAccessToken(code)
Expand All @@ -68,7 +70,7 @@ export const handleLogin: ControllerHandler<
error: jwtResult.error,
})
res.cookie('isLoginError', true)
return res.redirect(`/${formId}`)
return res.redirect(target)
}

const { maxAge, jwt } = jwtResult.value
Expand All @@ -79,5 +81,5 @@ export const handleLogin: ControllerHandler<
secure: !config.isDev,
...SgidService.getCookieSettings(),
})
return res.redirect(`/${formId}`)
return res.redirect(target)
}
45 changes: 36 additions & 9 deletions src/app/modules/sgid/sgid.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,17 @@ export class SgidServiceClass {
* Create a URL to sgID which is used to redirect the user for authentication
* @param formId - the form id to redirect to after authentication
* @param rememberMe - whether we create a JWT that remembers the user
* @param encodedQuery base64 encoded queryId for frontend to retrieve stored query params (usually contains prefilled form information)
* for an extended period of time
*/
createRedirectUrl(
formId: string,
rememberMe: boolean,
encodedQuery?: string,
): Result<string, SgidCreateRedirectUrlError> {
const state = `${formId},${rememberMe}`
const state = encodedQuery
? `${formId},${rememberMe},${encodedQuery}`
: `${formId},${rememberMe}`
const logMeta = {
action: 'createRedirectUrl',
state,
Expand All @@ -84,18 +88,41 @@ export class SgidServiceClass {
* Parses the string serialization containing the form id and if the
* user should be remembered, both needed when redirecting the user back to
* the form post-authentication
* @param state - a comma-separated string of the form id and a boolean flag
* indicating if the user should be remembered
* @returns {Result<{ formId: string; rememberMe: boolean }, SgidInvalidStateError>}
* the form id and whether the user should be remembered
* @param state - a comma-separated string of the form id, a boolean flag
* indicating if the user should be remembered, and an optional encodedQuery
* @returns {Result<{ formId: string; rememberMe: boolean; decodedQuery?: string }, SgidInvalidStateError>}
*/
parseState(
state: string,
): Result<{ formId: string; rememberMe: boolean }, SgidInvalidStateError> {
const [formId, rememberMeStr] = state.split(',')
const rememberMe = rememberMeStr === 'true'
): Result<
{ formId: string; rememberMe: boolean; decodedQuery: string },
SgidInvalidStateError
> {
const payloads = state.split(',')
const formId = payloads[0]
const rememberMe = payloads[1] === 'true'

const encodedQuery = payloads.length === 3 ? payloads[2] : ''
let decodedQuery = ''

try {
decodedQuery = encodedQuery
? `?${Buffer.from(encodedQuery, 'base64').toString('utf8')}`
: ''
} catch (e) {
logger.error({
message: 'Unable to decode encodedQuery',
meta: {
action: 'parseOOBParams',
encodedQuery,
},
error: e,
})
return err(new SgidInvalidStateError())
}

return formId
? ok({ formId, rememberMe })
? ok({ formId, rememberMe, decodedQuery })
: err(new SgidInvalidStateError())
}

Expand Down
4 changes: 4 additions & 0 deletions src/public/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -727,3 +727,7 @@ angular
},
})
.constant('responseModeEnum', { ENCRYPT: 'encrypt', EMAIL: 'email' })
.constant('prefill', {
QUERY_ID: 'queryId',
STORED_QUERY: 'storedQuery',
})
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ angular
'$document',
'GTag',
'Toastr',
'prefill',
SubmitFormController,
])

Expand All @@ -21,26 +22,68 @@ function SubmitFormController(
$document,
GTag,
Toastr,
prefill,
) {
const vm = this

// The form attribute of the FormData object contains the form fields, logic etc
vm.myform = FormData.form

const isSpcpSgidForm = !!['SP', 'CP', 'SGID'].includes(vm.myform.authType)
const isMyInfoForm = !!(
!FormData.isTemplate && vm.myform.authType === 'MyInfo'
)
const isUserLoggedIn = !!(
FormData.spcpSession && FormData.spcpSession.userName
)

// Handle prefills
// If it is an authenticated form, read the storedQuery from local storage and append to query params
// As a design decision, regardless of whether user is logged in, we should replace the queryId with the
// stored query params
// queryId could exist even though user is not logged in if user initiates the login flow
// but does not complete it and returns to the public form view within the same session

if (isSpcpSgidForm || isMyInfoForm) {
const location = $window.location.toString().split('?')
if (location.length > 1) {
const queryParams = new URLSearchParams(location[1])
const queryId = queryParams.get(prefill.QUERY_ID)

let storedQuery

try {
// If storedQuery is not valid JSON, JSON.parse throws a SyntaxError
// In try-catch block as this should not prevent rest of form from being loaded
storedQuery = JSON.parse(
$window.sessionStorage.getItem(prefill.STORED_QUERY),
)
} catch (e) {
console.error('Unable to parse storedQuery, not valid JSON string')
}

if (
queryId &&
storedQuery &&
storedQuery._id === queryId &&
storedQuery.queryString
) {
$window.location.href = `${location[0]}?${storedQuery.queryString}` // Replace the queryId with stored queryString
$window.sessionStorage.removeItem(prefill.STORED_QUERY) // Delete after reading the stored queryString, as only needed once
}
}
}

// For SP / CP forms, also include the spcpSession details
// This allows the log out button to be correctly populated with the UID
// Also provides time to cookie expiry so that client can refresh page
if (
['SP', 'CP', 'SGID'].includes(vm.myform.authType) &&
FormData.spcpSession &&
FormData.spcpSession.userName
) {
if (isSpcpSgidForm && isUserLoggedIn) {
SpcpSession.setUser(FormData.spcpSession)
}

// Set MyInfo login status
if (!FormData.isTemplate && vm.myform.authType === 'MyInfo') {
if (FormData.spcpSession && FormData.spcpSession.userName) {
if (isMyInfoForm) {
if (isUserLoggedIn) {
SpcpSession.setUserName(FormData.spcpSession.userName)
} else {
SpcpSession.clearUserName()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict'
const cloneDeep = require('lodash/cloneDeep')
const get = require('lodash/get')
const cuid = require('cuid')

const FieldVerificationService = require('../../../../services/FieldVerificationService')
const PublicFormAuthService = require('../../../../services/PublicFormAuthService')
Expand Down Expand Up @@ -40,6 +41,7 @@ angular
'$uibModal',
'$timeout',
'$location',
'prefill',
submitFormDirective,
])

Expand All @@ -56,6 +58,7 @@ function submitFormDirective(
$uibModal,
$timeout,
$location,
prefill,
) {
return {
restrict: 'E',
Expand Down Expand Up @@ -101,7 +104,35 @@ function submitFormDirective(
if (isPersistentLogin) GTag.persistentLoginUse(scope.form)

const query = $location.url().split('?')
const encodedQuery = query.length > 1 ? btoa(query[1]) : undefined
const queryString = query.length > 1 ? query[1] : undefined
const queryId = queryString ? cuid() : undefined
const encodedQuery = queryId
? btoa(`${prefill.QUERY_ID}=${queryId}`)
: undefined
const queryObject = {
_id: queryId,
queryString,
}

if (queryString) {
// Defensive - try catch block in case the storage is full
// See https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem
try {
// We use storedQuery as the sessionStorage key rather than just using the queryId
// because only one person can be logging in at a time in a given session, so
// we should only store one set of prefill params. Meanwhile, we use queryId and pass it to the
// backend instead of simply storing the query params directly in sessionStorage, so as to
// ensure that the stored query is loaded only for the session where it was generated
sessionStorage.setItem(
prefill.STORED_QUERY,
JSON.stringify(queryObject),
)
} catch (e) {
console.error('Failed to store query string')
// Login can proceed, since after login, user can still prefill form by accessing
// url with query params
}
}

return $q
.when(
Expand Down

0 comments on commit e3b2ec2

Please sign in to comment.