diff --git a/.ebextensions/25-load-sgid-env.config b/.ebextensions/25-load-sgid-env.config new file mode 100644 index 000000000..97aab3d42 --- /dev/null +++ b/.ebextensions/25-load-sgid-env.config @@ -0,0 +1,41 @@ +# loads SGID environment variables to a .env file from SSM + +commands: + 01-create-env: + command: "/tmp/create-env.sh" + +files: + "/tmp/create-env.sh": + mode: "000755" + content : | + #!/bin/bash + ENV_NAME=$(/opt/elasticbeanstalk/bin/get-config environment -k SSM_PREFIX) + + ENV_VARS=("SGID_CLIENT_ID" "SGID_CLIENT_SECRET" "SGID_PRIVATE_KEY") + + echo "Set AWS region" + aws configure set default.region ap-southeast-1 + + TARGET_DIR=/etc/gogovsg + + echo "Checking if ${TARGET_DIR} exists..." + if [ ! -d ${TARGET_DIR} ]; then + echo "Creating directory ${TARGET_DIR} ..." + mkdir -p ${TARGET_DIR} + if [ $? -ne 0 ]; then + echo 'ERROR: Directory creation failed!' + exit 1 + fi + else + echo "Directory ${TARGET_DIR} already exists!" + fi + + echo "Creating config for ${ENV_NAME} in ${AWS_REGION}" + + for ENV_VAR in "${ENV_VARS[@]}"; do + echo "Running for this ${ENV_NAME}" + echo "Fetching ${ENV_VAR} from SSM" + VALUE=$(aws ssm get-parameter --name "${ENV_NAME}_${ENV_VAR}" --with-decryption --query "Parameter.Value" --output text) + echo "${ENV_VAR}=${VALUE}" >> $TARGET_DIR/.env + echo "Saved ${ENV_VAR}" + done diff --git a/CHANGELOG.md b/CHANGELOG.md index b7aa72ada..73bbacab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,15 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v1.77.0](https://github.com/opengovsg/GoGovSG/compare/v1.76.1...v1.77.0) + +- Experiment go <> sgid integration [`#2247`](https://github.com/opengovsg/GoGovSG/pull/2247) +- [develop] 1.76.1 [`#2241`](https://github.com/opengovsg/GoGovSG/pull/2241) + #### [v1.76.1](https://github.com/opengovsg/GoGovSG/compare/v1.76.0...v1.76.1) +> 13 July 2023 + - build(deps): bump semver from 7.3.8 to 7.5.4 [`#2239`](https://github.com/opengovsg/GoGovSG/pull/2239) - build(deps): bump react-vis from 1.11.7 to 1.11.8 [`#2238`](https://github.com/opengovsg/GoGovSG/pull/2238) - build(deps): bump @sentry/cli from 1.67.2 to 1.75.2 [`#2231`](https://github.com/opengovsg/GoGovSG/pull/2231) diff --git a/Dockerrun.aws.json b/Dockerrun.aws.json index 894ebd200..8cd0436ac 100644 --- a/Dockerrun.aws.json +++ b/Dockerrun.aws.json @@ -8,5 +8,11 @@ { "ContainerPort": "8080" } + ], + "Volumes": [ + { + "HostDirectory": "/etc/gogovsg/.env", + "ContainerDirectory": "/usr/src/gogovsg/.env" + } ] } diff --git a/docker-compose.yml b/docker-compose.yml index f528987a0..a43fc7d3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,8 @@ services: - REDIS_SAFE_BROWSING_URI=redis://redis:6379/4 - SESSION_SECRET=thiscouldbeanything - GA_TRACKING_ID=UA-139330318-1 + - SGID_API_HOSTNAME=https://api.id.gov.sg + - OG_URL=https://go.gov.sg - VALID_EMAIL_GLOB_EXPRESSION=*.gov.sg - COOKIE_MAX_AGE=86400000 diff --git a/package-lock.json b/package-lock.json index 6aaa8e4b6..3fad5af14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "GoGovSG", - "version": "1.76.1", + "version": "1.77.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "GoGovSG", - "version": "1.76.1", + "version": "1.77.0", "license": "MIT", "dependencies": { "@datadog/browser-rum": "^4.15.0", "@hapi/joi": "^17.1.1", "@material-ui/core": "^4.11.4", "@material-ui/lab": "^4.0.0-alpha.61", + "@opengovsg/sgid-client": "^2.0.0", "@sentry/react": "^6.11.0", "@sentry/tracing": "^6.8.0", "@sentry/webpack-plugin": "^1.15.1", @@ -29,6 +29,7 @@ "clean-webpack-plugin": "^3.0.0", "cloudmersive-virus-api-client": "^1.2.2", "connect-redis": "^6.0.0", + "cookie-parser": "^1.4.6", "cookie-session": "^1.4.0", "copy-to-clipboard": "^3.3.1", "core-js": "^3.16.3", @@ -36,6 +37,7 @@ "datadog-winston": "^1.5.1", "date-fns-tz": "^1.3.4", "dd-trace": "^2.30.1", + "dotenv": "^16.3.1", "ejs": "^3.1.7", "express": "^4.17.3", "express-fileupload": "^1.4.0", @@ -103,6 +105,7 @@ "@types/classnames": "^2.3.1", "@types/cloudmersive-virus-api-client": "^1.1.1", "@types/connect-redis": "0.0.17", + "@types/cookie-parser": "^1.4.3", "@types/cookie-session": "^2.0.39", "@types/d3": "^6.7.3", "@types/datadog-winston": "^1.0.5", @@ -4788,6 +4791,16 @@ "node": ">= 8" } }, + "node_modules/@opengovsg/sgid-client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@opengovsg/sgid-client/-/sgid-client-2.0.0.tgz", + "integrity": "sha512-zqcVQz03zB7dAwWh2MJVRAmHYjK1EryqOPnbBgrkr8Jx8BjtcjFa4cCrHstwWP1kVkGomhi0C7e3TRvf1qYSFQ==", + "dependencies": { + "jose": "4.9.2", + "node-rsa": "1.1.1", + "openid-client": "5.4.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", @@ -5304,6 +5317,15 @@ "@types/redis": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz", + "integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookie-session": { "version": "2.0.43", "resolved": "https://registry.npmjs.org/@types/cookie-session/-/cookie-session-2.0.43.tgz", @@ -6574,7 +6596,6 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -7018,7 +7039,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dev": true, "dependencies": { "safer-buffer": "~2.1.0" } @@ -9323,6 +9343,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-session": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-1.4.0.tgz", @@ -10093,7 +10133,6 @@ "version": "2.23.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.23.0.tgz", "integrity": "sha512-5ycpauovVyAk0kXNZz6ZoB9AYMZB4DObse7P3BPWmyEjXNORTI8EJ6X0uaSAq4sCHzM1uajzrkr6HnsLQpxGXA==", - "dev": true, "engines": { "node": ">=0.11" }, @@ -10994,6 +11033,17 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, "node_modules/dottie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.4.tgz", @@ -17192,6 +17242,14 @@ "node": ">= 0.6.0" } }, + "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-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -19218,6 +19276,14 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.73.tgz", "integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==" }, + "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/node-source-walk": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/node-source-walk/-/node-source-walk-4.2.0.tgz", @@ -19402,6 +19468,14 @@ "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.11.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", @@ -19531,6 +19605,14 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, + "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.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -19581,6 +19663,28 @@ "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/opentracing": { "version": "0.14.7", "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", @@ -30709,7 +30813,8 @@ "@material-ui/types": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", - "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==" + "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", + "requires": {} }, "@material-ui/utils": { "version": "4.11.2", @@ -30747,6 +30852,16 @@ "fastq": "^1.6.0" } }, + "@opengovsg/sgid-client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@opengovsg/sgid-client/-/sgid-client-2.0.0.tgz", + "integrity": "sha512-zqcVQz03zB7dAwWh2MJVRAmHYjK1EryqOPnbBgrkr8Jx8BjtcjFa4cCrHstwWP1kVkGomhi0C7e3TRvf1qYSFQ==", + "requires": { + "jose": "4.9.2", + "node-rsa": "1.1.1", + "openid-client": "5.4.0" + } + }, "@opentelemetry/api": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", @@ -31017,7 +31132,7 @@ "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-1.16.0.tgz", "integrity": "sha512-Ax0QZ3a+LFYU876Si2HElPYSj+mX3vinvzH+o9F1g/5T2Z3HqITnX6gg+zVfLFsE819PN9KeLpmoHtO352dlmQ==", "requires": { - "@sentry/cli": "^1.75.2" + "@sentry/cli": "^1.67.1" } }, "@sinonjs/commons": { @@ -31185,6 +31300,15 @@ "@types/redis": "*" } }, + "@types/cookie-parser": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz", + "integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/cookie-session": { "version": "2.0.43", "resolved": "https://registry.npmjs.org/@types/cookie-session/-/cookie-session-2.0.43.tgz", @@ -32309,7 +32433,8 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.0.4.tgz", "integrity": "sha512-cs3XLy+UcxiP6bj0A6u7MLLuwdXJ1c3Dtc0RkKg+wiI1g/Ti1om8+/2hc2A2B60NbBNAbMgyBMHvyymWm/j4wQ==", - "dev": true + "dev": true, + "requires": {} }, "@webpack-cli/info": { "version": "1.3.0", @@ -32324,7 +32449,8 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.5.1.tgz", "integrity": "sha512-4vSVUiOPJLmr45S8rMGy7WDvpWxfFxfP/Qx/cxZFCfvoypTYpPPL1X8VIZMe0WTA+Jr7blUxwUSEZNkjoMTgSw==", - "dev": true + "dev": true, + "requires": {} }, "@xtuc/ieee754": { "version": "1.2.0", @@ -32360,8 +32486,7 @@ "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" }, "acorn-globals": { "version": "6.0.0", @@ -32385,13 +32510,15 @@ "acorn-import-assertions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==" + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "requires": {} }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true + "dev": true, + "requires": {} }, "acorn-walk": { "version": "7.2.0", @@ -32432,12 +32559,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", - "dev": true + "dev": true, + "requires": {} }, "ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==" + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "requires": {} }, "amdefine": { "version": "1.0.1", @@ -32691,7 +32820,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dev": true, "requires": { "safer-buffer": "~2.1.0" } @@ -34516,6 +34644,22 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-session": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-1.4.0.tgz", @@ -34764,7 +34908,8 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.3.0.tgz", "integrity": "sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==", - "dev": true + "dev": true, + "requires": {} }, "coveralls": { "version": "3.1.1", @@ -35130,13 +35275,13 @@ "date-fns": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.23.0.tgz", - "integrity": "sha512-5ycpauovVyAk0kXNZz6ZoB9AYMZB4DObse7P3BPWmyEjXNORTI8EJ6X0uaSAq4sCHzM1uajzrkr6HnsLQpxGXA==", - "dev": true + "integrity": "sha512-5ycpauovVyAk0kXNZz6ZoB9AYMZB4DObse7P3BPWmyEjXNORTI8EJ6X0uaSAq4sCHzM1uajzrkr6HnsLQpxGXA==" }, "date-fns-tz": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.3.4.tgz", - "integrity": "sha512-O47vEyz85F2ax/ZdhMBJo187RivZGjH6V0cPjPzpm/yi6YffJg4upD/8ibezO11ezZwP3QYlBHh/t4JhRNx0Ow==" + "integrity": "sha512-O47vEyz85F2ax/ZdhMBJo187RivZGjH6V0cPjPzpm/yi6YffJg4upD/8ibezO11ezZwP3QYlBHh/t4JhRNx0Ow==", + "requires": {} }, "dd-trace": { "version": "2.40.0", @@ -35825,6 +35970,11 @@ "is-obj": "^2.0.0" } }, + "dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==" + }, "dottie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.4.tgz", @@ -36321,13 +36471,15 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz", "integrity": "sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew==", - "dev": true + "dev": true, + "requires": {} }, "eslint-import-resolver-alias": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/eslint-import-resolver-alias/-/eslint-import-resolver-alias-1.1.2.tgz", "integrity": "sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==", - "dev": true + "dev": true, + "requires": {} }, "eslint-import-resolver-node": { "version": "0.3.4", @@ -36688,7 +36840,8 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true + "dev": true, + "requires": {} }, "eslint-scope": { "version": "5.1.1", @@ -37043,7 +37196,8 @@ "express-joi-validation": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/express-joi-validation/-/express-joi-validation-4.0.3.tgz", - "integrity": "sha512-XnEyhlllurczZDx1vLPWnaohTAQzxlvaP7ifEbvRf2zvYC5C5ZZrgFH75g0/XcL7OuaZ0XlVtB0J0E/R0O1L4A==" + "integrity": "sha512-XnEyhlllurczZDx1vLPWnaohTAQzxlvaP7ifEbvRf2zvYC5C5ZZrgFH75g0/XcL7OuaZ0XlVtB0J0E/R0O1L4A==", + "requires": {} }, "express-rate-limit": { "version": "5.3.0", @@ -39840,7 +39994,8 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true + "dev": true, + "requires": {} }, "jest-regex-util": { "version": "26.0.0", @@ -40507,6 +40662,11 @@ "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==" }, + "jose": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.2.tgz", + "integrity": "sha512-EqKvu2PqJCD3Jrg3PvcYZVS7D21qMVLSYMDAFcOdGUEOpJSLNtJO7NjLANvu3SYHVl6pdP2ff7ve6EZW2nX7Nw==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -42109,6 +42269,14 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.73.tgz", "integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==" }, + "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==", + "requires": { + "asn1": "^0.2.4" + } + }, "node-source-walk": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/node-source-walk/-/node-source-walk-4.2.0.tgz", @@ -42255,6 +42423,11 @@ } } }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" + }, "object-inspect": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", @@ -42345,6 +42518,11 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, + "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==" + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -42383,6 +42561,24 @@ "mimic-fn": "^2.1.0" } }, + "openid-client": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.0.tgz", + "integrity": "sha512-hgJa2aQKcM2hn3eyVtN12tEA45ECjTJPXCgUh5YzTzy9qwapCvmDTVPWOcWVL0d34zeQoQ/hbG9lJhl3AYxJlQ==", + "requires": { + "jose": "^4.10.0", + "lru-cache": "^6.0.0", + "object-hash": "^2.0.1", + "oidc-token-hash": "^5.0.1" + }, + "dependencies": { + "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==" + } + } + }, "opentracing": { "version": "0.14.7", "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", @@ -42700,7 +42896,8 @@ "pg-pool": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.4.1.tgz", - "integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ==" + "integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ==", + "requires": {} }, "pg-protocol": { "version": "1.5.0", @@ -43499,7 +43696,8 @@ "react-ga": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.0.tgz", - "integrity": "sha512-o8RScHj6Lb8cwy3GMrVH6NJvL+y0zpJvKtc0+wmH7Bt23rszJmnqEQxRbyrqUzk9DTJIHoP42bfO5rswC9SWBQ==" + "integrity": "sha512-o8RScHj6Lb8cwy3GMrVH6NJvL+y0zpJvKtc0+wmH7Bt23rszJmnqEQxRbyrqUzk9DTJIHoP42bfO5rswC9SWBQ==", + "requires": {} }, "react-i18next": { "version": "11.11.4", @@ -43880,12 +44078,14 @@ "redux-devtools-extension": { "version": "2.13.9", "resolved": "https://registry.npmjs.org/redux-devtools-extension/-/redux-devtools-extension-2.13.9.tgz", - "integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A==" + "integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A==", + "requires": {} }, "redux-thunk": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", - "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==" + "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==", + "requires": {} }, "reflect-metadata": { "version": "0.1.13", @@ -48340,7 +48540,8 @@ "version": "7.5.3", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz", "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==", - "dev": true + "dev": true, + "requires": {} }, "xml-name-validator": { "version": "3.0.0", diff --git a/package.json b/package.json index 6c6fa924c..cc9258b35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "GoGovSG", - "version": "1.76.1", + "version": "1.77.0", "description": "Link shortener for Singapore government.", "main": "src/server/index.js", "scripts": { @@ -13,9 +13,9 @@ "build": "tsc && webpack --mode production", "postbuild": "copyfiles -u 1 src/server/views/**/* build && copyfiles -u 4 src/server/modules/qr/assets/**/* build/server/modules/qr", "version": "auto-changelog -p && git add CHANGELOG.md", - "start": "node build/server/index.js", + "start": "node -r dotenv/config build/server/index.js", "client-dev": "webpack serve --mode development --host 0.0.0.0 --devtool inline-source-map --hot", - "server-dev": "ts-node-dev --poll --respawn --transpile-only --inspect=0.0.0.0 -- src/server/index.ts", + "server-dev": "ts-node-dev --poll --respawn --transpile-only --inspect=0.0.0.0 -r dotenv/config -- src/server/index.ts", "docker-dev": "concurrently \"npm run server-dev\" \"npm run client-dev\"", "dev": "docker-compose -f docker-compose.yml up --build", "test": "jest --collectCoverage", @@ -40,6 +40,7 @@ "@hapi/joi": "^17.1.1", "@material-ui/core": "^4.11.4", "@material-ui/lab": "^4.0.0-alpha.61", + "@opengovsg/sgid-client": "^2.0.0", "@sentry/react": "^6.11.0", "@sentry/tracing": "^6.8.0", "@sentry/webpack-plugin": "^1.15.1", @@ -56,6 +57,7 @@ "clean-webpack-plugin": "^3.0.0", "cloudmersive-virus-api-client": "^1.2.2", "connect-redis": "^6.0.0", + "cookie-parser": "^1.4.6", "cookie-session": "^1.4.0", "copy-to-clipboard": "^3.3.1", "core-js": "^3.16.3", @@ -63,6 +65,7 @@ "datadog-winston": "^1.5.1", "date-fns-tz": "^1.3.4", "dd-trace": "^2.30.1", + "dotenv": "^16.3.1", "ejs": "^3.1.7", "express": "^4.17.3", "express-fileupload": "^1.4.0", @@ -130,6 +133,7 @@ "@types/classnames": "^2.3.1", "@types/cloudmersive-virus-api-client": "^1.1.1", "@types/connect-redis": "0.0.17", + "@types/cookie-parser": "^1.4.3", "@types/cookie-session": "^2.0.39", "@types/d3": "^6.7.3", "@types/datadog-winston": "^1.0.5", diff --git a/src/client/app/components/pages/RootPage/index.tsx b/src/client/app/components/pages/RootPage/index.tsx index 5e8b51dfa..09476088c 100644 --- a/src/client/app/components/pages/RootPage/index.tsx +++ b/src/client/app/components/pages/RootPage/index.tsx @@ -10,6 +10,7 @@ import { History } from 'history' import PrivateRoute from '../../PrivateRoute' import HomePage from '../../../../home' import LoginPage from '../../../../login' +import SgidLoginPage from '../../../../sgidLogin' import UserPage from '../../../../user' import NotFoundPage from '../NotFoundPage' import DirectoryPage from '../../../../directory' @@ -30,6 +31,7 @@ import { HOME_PAGE, LOGIN_PAGE, NOT_FOUND_PAGE, + SGID_LOGIN_PAGE, USER_PAGE, } from '../../../util/types' import theme from '../../../theme' @@ -47,6 +49,7 @@ const Root: FunctionComponent = ({ store, history }: RootProps) => ( + diff --git a/src/client/app/util/types.ts b/src/client/app/util/types.ts index 131ab6647..7ca360554 100644 --- a/src/client/app/util/types.ts +++ b/src/client/app/util/types.ts @@ -1,5 +1,6 @@ export const HOME_PAGE = '/' export const LOGIN_PAGE = '/login' +export const SGID_LOGIN_PAGE = '/ogp-login' export const USER_PAGE = '/user' export const SEARCH_PAGE = '/search' export const NOT_FOUND_PAGE = '/404/:shortUrl' diff --git a/src/client/sgidLogin/index.tsx b/src/client/sgidLogin/index.tsx new file mode 100644 index 000000000..e361c1cec --- /dev/null +++ b/src/client/sgidLogin/index.tsx @@ -0,0 +1,208 @@ +import React, { useEffect } from 'react' +import i18next from 'i18next' +import { + Button, + Hidden, + Link, + Typography, + createStyles, + makeStyles, +} from '@material-ui/core' +import GoLogo from '@assets/go-logo-graphics/go-main-logo.svg' +import LoginGraphics from '@assets/login-page-graphics/login-page-graphics.svg' +import { useDispatch } from 'react-redux' +import rootActions from '../app/components/pages/RootPage/actions' + +import assetVariant from '../../shared/util/asset-variant' + +import { htmlSanitizer } from '../app/util/format' +import Section from '../app/components/Section' +import BaseLayout from '../app/components/BaseLayout' +import { get } from '../app/util/requests' + +const URL_PREFIX_LENGTH = '#/ogp-login'.length + +const useStyles = makeStyles((theme) => + createStyles({ + container: { + display: 'flex', + flexGrow: 1, + '-ms-flex': '1 1 auto', + }, + loginContainer: { + display: 'flex', + width: '100%', + [theme.breakpoints.up('lg')]: { + width: '50%', + }, + }, + verticalAlign: { + display: 'flex', + width: '100%', + [theme.breakpoints.up('lg')]: { + alignItems: 'center', + }, + }, + signInButton: { + width: '250px', + minWidth: '120px', + marginRight: theme.spacing(2), + }, + loginWrapper: { + display: 'block', + [theme.breakpoints.up('lg')]: { + // Gives the contents slightly more than enough height, + // so that validation messages do not shift the centering. + height: '400px', + maxHeight: '80vh', + }, + }, + headerGroup: { + marginBottom: theme.spacing(4), + }, + logo: { + maxWidth: '130px', + width: '40%', + }, + loginHeader: { + marginTop: theme.spacing(1), + }, + loginReferral: { + fontSize: '0.85rem', + color: '#767676', + marginBottom: theme.spacing(4), + }, + graphicColorFill: { + backgroundColor: theme.palette.primary.dark, + width: '50vw', + height: '100%', + // allocates space for the government masthead when on gov variant + maxHeight: assetVariant === 'gov' ? 'calc(100vh - 28px)' : '100vh', + textAlign: 'center', + overflow: 'hidden', + }, + loginGraphic: { + userDrag: 'none', + height: '100%', + }, + '@media screen\\0': { + // Styles for Internet Explorer compatibility + logoLink: { + marginBottom: '0', + }, + }, + }), +) + +const SgidLoginPage = (): JSX.Element => { + const dispatch = useDispatch() + const classes = useStyles() + const queryParams = new URLSearchParams( + new URL(window.location.href).hash.substring(URL_PREFIX_LENGTH), + ) + + const setLoginStatusMessage = (message: string) => + dispatch(rootActions.setInfoMessage(message)) + + useEffect(() => { + const officerEmail = queryParams.get('officerEmail') + const statusCode = queryParams.get('statusCode') + if (officerEmail) { + setLoginStatusMessage( + `${officerEmail} doesn't look like a valid ${i18next.t( + 'general.emailDomain', + )} email.`, + ) + } else if (statusCode === '403') { + setLoginStatusMessage( + `Unable to fetch a valid work email for authentication.`, + ) + } else if (statusCode === '400') { + setLoginStatusMessage(`Authentication failed. Please try again.`) + } + }, []) + + return ( + +
+
+ +
+ Login page graphic +
+
+
+
+
+
+
+ + + GoGovSG logo + + + {/* NOTE: dangerouslySetInnerHTML is used as copy includes tag */} + + + {i18next.t('login.referrals.1.officerPhrase')} can use their{' '} + {i18next.t('login.referrals.1.emailDomain')} emails at{' '} + + {i18next.t('login.referrals.1.link')} + + , and {i18next.t('login.referrals.2.officerPhrase')} can use + their {i18next.t('login.referrals.2.emailDomain')} emails at{' '} + + {i18next.t('login.referrals.2.link')} + {' '} + to shorten links. + + + +
+
+
+
+
+
+ ) +} + +export default SgidLoginPage diff --git a/src/server/api/index.ts b/src/server/api/index.ts index 30da753ce..261da7355 100644 --- a/src/server/api/index.ts +++ b/src/server/api/index.ts @@ -16,6 +16,7 @@ const router = Express.Router() /* Public routes that do not need to be protected */ router.use('/logout', require('./logout')) router.use('/login', require('./login')) +router.use('/sgidLogin', require('./sgidLogin')) router.use('/stats', require('./statistics')) router.use('/sentry', require('./sentry')) router.use('/links', require('./links')) diff --git a/src/server/api/sgidLogin/index.ts b/src/server/api/sgidLogin/index.ts new file mode 100644 index 000000000..3a3ad9ef5 --- /dev/null +++ b/src/server/api/sgidLogin/index.ts @@ -0,0 +1,16 @@ +import Express from 'express' +import { SgidLoginController } from '../../modules/auth' +import { container } from '../../util/inversify' +import { DependencyIds } from '../../constants' + +const router: Express.Router = Express.Router() + +const sgidLoginController = container.get( + DependencyIds.sgidLoginController, +) + +router.get('/authurl', sgidLoginController.generateAuthUrl) + +router.get('/authenticate', sgidLoginController.handleLogin) + +module.exports = router diff --git a/src/server/config.ts b/src/server/config.ts index 6d0cae35c..604eb0752 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -272,3 +272,7 @@ export const ffExternalApi: boolean = process.env.FF_EXTERNAL_API === 'true' export const apiAdmins: string[] = process.env.ADMIN_API_EMAILS ? process.env.ADMIN_API_EMAILS.split(',') : [] +export const sgidClientId = process.env.SGID_CLIENT_ID || '' +export const sgidPrivateKey = process.env.SGID_PRIVATE_KEY || '' +export const sgidClientSecret = process.env.SGID_CLIENT_SECRET || '' +export const sgidApiHostname = process.env.SGID_API_HOSTNAME || '' diff --git a/src/server/constants.ts b/src/server/constants.ts index 27386c928..3525c6b18 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -35,6 +35,7 @@ export const DependencyIds = { linksController: Symbol.for('linksController'), authService: Symbol.for('authService'), loginController: Symbol.for('loginController'), + sgidLoginController: Symbol.for('sgidLoginController'), logoutController: Symbol.for('logoutController'), urlManagementService: Symbol.for('urlManagementService'), userController: Symbol.for('userController'), diff --git a/src/server/index.ts b/src/server/index.ts index 0ee01b901..b4c068bdf 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -7,6 +7,7 @@ import helmet from 'helmet' import morgan from 'morgan' import session from 'express-session' import cookieSession from 'cookie-session' +import cookieParser from 'cookie-parser' import connectRedis from 'connect-redis' import jsonMessage from './util/json' import bindInversifyDependencies from './inversify.config' @@ -94,6 +95,7 @@ if (sentryDns) { } const app = express() +app.use(cookieParser()) app.use( helmet({ contentSecurityPolicy: { diff --git a/src/server/inversify.config.ts b/src/server/inversify.config.ts index 2e6308152..08ab8e7fa 100644 --- a/src/server/inversify.config.ts +++ b/src/server/inversify.config.ts @@ -49,7 +49,11 @@ import { RotatingLinksController } from './modules/display/RotatingLinksControll import { SentryController } from './modules/sentry/SentryController' import { AuthService, CryptographyBcrypt } from './modules/auth/services' -import { LoginController, LogoutController } from './modules/auth' +import { + LoginController, + LogoutController, + SgidLoginController, +} from './modules/auth' import { UrlManagementService } from './modules/user/services' import { UserController } from './modules/user' import { DirectoryController } from './modules/directory' @@ -130,6 +134,7 @@ export default () => { bindIfUnbound(DependencyIds.linksController, RotatingLinksController) bindIfUnbound(DependencyIds.sentryController, SentryController) bindIfUnbound(DependencyIds.loginController, LoginController) + bindIfUnbound(DependencyIds.sgidLoginController, SgidLoginController) bindIfUnbound(DependencyIds.authService, AuthService) bindIfUnbound(DependencyIds.logoutController, LogoutController) bindIfUnbound(DependencyIds.urlManagementService, UrlManagementService) diff --git a/src/server/modules/auth/SgidLoginController.ts b/src/server/modules/auth/SgidLoginController.ts new file mode 100644 index 000000000..d16011636 --- /dev/null +++ b/src/server/modules/auth/SgidLoginController.ts @@ -0,0 +1,95 @@ +import Express, { CookieOptions } from 'express' +import { inject, injectable } from 'inversify' +import { SGID_LOGIN_OAUTH_STATE, SgidAuthService } from '../../services/sgid' +import { DependencyIds } from '../../constants' +import { logger } from '../../config' +import { isValidGovEmail } from '../../util/email' +import { AuthService } from './interfaces' +import jsonMessage from '../../util/json' + +export const OFFICER_EMAIL_SCOPE = 'ogpofficerinfo.work_email' +const SGID_STATE_COOKIE_NAME = 'gogovsg_sgid_state' +const SgidStateCookieConfig: CookieOptions = { + httpOnly: true, +} + +@injectable() +export class SgidLoginController { + private sgidService + + private authService: AuthService + + constructor(@inject(DependencyIds.authService) authService: AuthService) { + this.sgidService = SgidAuthService + this.authService = authService + } + + public generateAuthUrl: ( + req: Express.Request, + res: Express.Response, + ) => void = (_req, res) => { + try { + const { url, codeVerifier, nonce } = this.sgidService.authorizationUrl() + + res.cookie( + SGID_STATE_COOKIE_NAME, + { codeVerifier, nonce }, + SgidStateCookieConfig, + ) + res.send(url) + return + } catch (err) { + logger.error(err) + res.status(400) + res.badRequest( + jsonMessage('SGID login not supported by edu and health domains.'), + ) + return + } + } + + public handleLogin = async (req: Express.Request, res: Express.Response) => { + try { + const { code, state } = req.query + const sessionData = req.cookies[SGID_STATE_COOKIE_NAME] + + if (state !== SGID_LOGIN_OAUTH_STATE) { + res.redirect(`/#/ogp-login?statusCode=400`) + return + } + + const { sub, accessToken } = await this.sgidService.callback( + String(code), + String(sessionData.nonce), + String(sessionData.codeVerifier), + ) + + const { data } = await this.sgidService.userinfo(accessToken, sub) + const officerEmail = data[OFFICER_EMAIL_SCOPE] + + if ( + !officerEmail || + !officerEmail.length || + !isValidGovEmail(officerEmail) + ) { + // redirect back to sgid login page, if authentication fails, + // or officer email is not valid + res.redirect(`/#/ogp-login?statusCode=403&officerEmail=${officerEmail}`) + return + } + const dbUser = await this.authService.genDBUserWithOfficerEmail( + data[OFFICER_EMAIL_SCOPE], + ) + + req.session!.user = dbUser + + res.redirect(`/#/user`) + return + } catch (error) { + logger.error(error) + res.status(500).render('error', { error }) + } + } +} + +export default SgidLoginController diff --git a/src/server/modules/auth/__tests__/LoginController.test.ts b/src/server/modules/auth/__tests__/LoginController.test.ts index e324e2d8f..70433dede 100644 --- a/src/server/modules/auth/__tests__/LoginController.test.ts +++ b/src/server/modules/auth/__tests__/LoginController.test.ts @@ -39,7 +39,11 @@ describe('LoginController', () => { loggerErrorSpy.mockClear() }) describe('getIsLoggedIn', () => { - const authService = { generateOtp: jest.fn(), verifyOtp: jest.fn() } + const authService = { + generateOtp: jest.fn(), + verifyOtp: jest.fn(), + genDBUserWithOfficerEmail: jest.fn(), + } const controller = new LoginController(authService) test('session contains user', () => { @@ -64,7 +68,11 @@ describe('LoginController', () => { }) describe('getLoginMessage', () => { - const authService = { generateOtp: jest.fn(), verifyOtp: jest.fn() } + const authService = { + generateOtp: jest.fn(), + verifyOtp: jest.fn(), + genDBUserWithOfficerEmail: jest.fn(), + } const controller = new LoginController(authService) test('returns login message', () => { @@ -78,7 +86,11 @@ describe('LoginController', () => { }) describe('getEmailDomains', () => { - const authService = { generateOtp: jest.fn(), verifyOtp: jest.fn() } + const authService = { + generateOtp: jest.fn(), + verifyOtp: jest.fn(), + genDBUserWithOfficerEmail: jest.fn(), + } const controller = new LoginController(authService) test('returns domains', () => { diff --git a/src/server/modules/auth/index.ts b/src/server/modules/auth/index.ts index b3025d475..eaaff0719 100644 --- a/src/server/modules/auth/index.ts +++ b/src/server/modules/auth/index.ts @@ -1,5 +1,6 @@ export { LoginController } from './LoginController' export { LogoutController } from './LogoutController' +export { SgidLoginController } from './SgidLoginController' export type EmailProperty = { email: string diff --git a/src/server/modules/auth/interfaces/AuthService.ts b/src/server/modules/auth/interfaces/AuthService.ts index d6e232b96..9218ae7ea 100644 --- a/src/server/modules/auth/interfaces/AuthService.ts +++ b/src/server/modules/auth/interfaces/AuthService.ts @@ -17,4 +17,13 @@ export interface AuthService { * @returns Promise that resolves to the user if the otp is valid. */ verifyOtp(email: string, otp: string): Promise + + /** + * Generates the user with the officer email provided, if the + * user with the specified officer email does not exist then we + * create a new user for the input email. + * @param {string} email Email of the user. + * @returns Promise that creates or find the existing user. + */ + genDBUserWithOfficerEmail(email: string): Promise } diff --git a/src/server/modules/auth/services/AuthService.ts b/src/server/modules/auth/services/AuthService.ts index 6e5178e0c..8331da907 100644 --- a/src/server/modules/auth/services/AuthService.ts +++ b/src/server/modules/auth/services/AuthService.ts @@ -125,6 +125,12 @@ export class AuthService implements interfaces.AuthService { throw new Error('Error creating user.') } } + + public genDBUserWithOfficerEmail: ( + officerEmail: string, + ) => Promise = async (officerEmail) => { + return this.userRepository.findOrCreateWithEmail(officerEmail) + } } export default AuthService diff --git a/src/server/services/sgid.ts b/src/server/services/sgid.ts new file mode 100644 index 000000000..1a74cb296 --- /dev/null +++ b/src/server/services/sgid.ts @@ -0,0 +1,108 @@ +import SgidClient, { generatePkcePair } from '@opengovsg/sgid-client' +import { + logger, + ogUrl, + sgidApiHostname, + sgidClientId, + sgidClientSecret, + sgidPrivateKey, +} from '../config' + +interface SgidServiceOption { + clientId: string + clientSecret: string + privateKey: string + redirectUri: string + hostname: string +} + +export const SGID_LOGIN_OAUTH_STATE = 'login' + +class SgidService { + // SGID client will be null for other domains like edu, health + // so we will need to handle cases for those as well, so that + // they continue to work in prod without sgid client being initialised + private sgidClient: SgidClient | null + + constructor({ + clientId, + clientSecret, + privateKey, + redirectUri, + hostname, + }: SgidServiceOption) { + try { + this.sgidClient = new SgidClient({ + clientId, + clientSecret, + privateKey, + redirectUri, + hostname, + }) + logger.info('SGID client initialised successfully') + } catch (e) { + logger.error('SGID client initialisation failed', e) + this.sgidClient = null + } + } + + /** + * Fetches the url via sgid SDK. + */ + authorizationUrl(): { url: string; codeVerifier: string; nonce?: string } { + if (!this.sgidClient) { + throw new Error('SGID client not initialised') + } + try { + const { codeChallenge, codeVerifier } = generatePkcePair() + const { url, nonce } = this.sgidClient.authorizationUrl({ + state: SGID_LOGIN_OAUTH_STATE, + scope: ['openid', 'ogpofficerinfo.work_email'].join(' '), + codeChallenge, + }) + return { url, codeVerifier, nonce } + } catch (e) { + throw new Error(`Error retrieving url via sgid-client ${e.message}`) + } + } + + /** + * Fetches the token via sgid SDK. + */ + async callback(code: string, nonce: string, codeVerifier: string) { + if (!code || !this.sgidClient) + throw new Error(`code cannot be empty or sgid client not initialised`) + try { + const { sub, accessToken } = await this.sgidClient.callback({ + code, + nonce, + codeVerifier, + }) + return { sub, accessToken } + } catch (e) { + throw new Error('Error retrieving access token via sgid-client') + } + } + + /** + * Fetches the user info via sgid SDK. + */ + async userinfo(accessToken: string, sub: string) { + if (!accessToken || !this.sgidClient) + throw new Error(`accessToken cannot be empty`) + try { + return await this.sgidClient.userinfo({ accessToken, sub }) + } catch (e) { + throw new Error('Error retrieving user info via sgid-client') + } + } +} + +// Initialised the sgidService object with the different environments +export const SgidAuthService = new SgidService({ + clientId: sgidClientId, + clientSecret: sgidClientSecret, + privateKey: sgidPrivateKey, + redirectUri: `${ogUrl}/api/sgidLogin/authenticate`, + hostname: sgidApiHostname, +})