From 16f313362a81880c940808c75889344f445423e3 Mon Sep 17 00:00:00 2001 From: Terry Tsai Date: Tue, 3 May 2022 15:01:10 -0700 Subject: [PATCH 1/7] Guard metric api behind auth --- spotlight-api/app.js | 17 ++ spotlight-api/package.json | 2 + spotlight-api/yarn.lock | 244 +++++++++++++++++- spotlight-client/src/DataStore/RootStore.ts | 1 + spotlight-client/src/DataStore/UserStore.ts | 16 +- spotlight-client/src/PageHome/PageHome.tsx | 2 +- .../src/metricsApi/fetchMetrics.ts | 3 + 7 files changed, 275 insertions(+), 10 deletions(-) diff --git a/spotlight-api/app.js b/spotlight-api/app.js index fe4811d5..a1f2bd06 100644 --- a/spotlight-api/app.js +++ b/spotlight-api/app.js @@ -19,6 +19,8 @@ const express = require("express"); const cors = require("cors"); const morgan = require("morgan"); const helmet = require("helmet"); +const jwt = require("express-jwt"); +const jwks = require("jwks-rsa"); const zip = require("express-easy-zip"); const api = require("./routes/api"); @@ -33,6 +35,21 @@ app.use(morgan("dev")); app.use(helmet()); app.use(zip()); +const checkJwt = jwt({ + secret: jwks.expressJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: + "https://recidiviz-spotlight-staging.us.auth0.com/.well-known/jwks.json", + }), + audience: "recidiviz-spotlight-staging", + issuer: "https://spotlight-login-staging.recidiviz.org/", + algorithms: ["RS256"], +}); + +app.use(checkJwt); + app.post("/api/:tenantId/public", express.json(), api.metricsByName); // uptime check endpoint diff --git a/spotlight-api/package.json b/spotlight-api/package.json index 53e23443..a55c2bb6 100644 --- a/spotlight-api/package.json +++ b/spotlight-api/package.json @@ -24,7 +24,9 @@ "dotenv": "^8.2.0", "express": "^4.17.1", "express-easy-zip": "^1.1.5", + "express-jwt": "^6.0.0", "helmet": "^3.23.3", + "jwks-rsa": "^1.4.0", "morgan": "^1.10.0" }, "devDependencies": { diff --git a/spotlight-api/yarn.lock b/spotlight-api/yarn.lock index c8033760..1f8e2b29 100644 --- a/spotlight-api/yarn.lock +++ b/spotlight-api/yarn.lock @@ -664,6 +664,55 @@ dependencies: "@babel/types" "^7.3.0" +"@types/body-parser@*": + version "1.19.2" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" + integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.35" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" + integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ== + dependencies: + "@types/node" "*" + +"@types/express-jwt@0.0.42": + version "0.0.42" + resolved "https://registry.yarnpkg.com/@types/express-jwt/-/express-jwt-0.0.42.tgz#4f04e1fadf9d18725950dc041808a4a4adf7f5ae" + integrity sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag== + dependencies: + "@types/express" "*" + "@types/express-unless" "*" + +"@types/express-serve-static-core@^4.17.18": + version "4.17.28" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz#c47def9f34ec81dc6328d0b1b5303d1ec98d86b8" + integrity sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + +"@types/express-unless@*": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@types/express-unless/-/express-unless-0.5.3.tgz#271f8603617445568ed0d6efe25a7d2f338544c1" + integrity sha512-TyPLQaF6w8UlWdv4gj8i46B+INBVzURBNRahCozCSXfsK2VTlL1wNyTlMKw817VHygBtlcl5jfnPadlydr06Yw== + dependencies: + "@types/express" "*" + +"@types/express@*": + version "4.17.13" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" + integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.18" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/graceful-fs@^4.1.2": version "4.1.5" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" @@ -700,6 +749,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/mime@^1": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" + integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== + "@types/node@*": version "15.6.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-15.6.0.tgz#f0ddca5a61e52627c9dcb771a6039d44694597bc" @@ -715,6 +769,24 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.3.tgz#ef65165aea2924c9359205bf748865b8881753c0" integrity sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA== +"@types/qs@*": + version "6.9.7" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" + integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== + +"@types/range-parser@*": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" + integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== + +"@types/serve-static@*": + version "1.13.10" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9" + integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + "@types/stack-utils@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" @@ -1062,6 +1134,11 @@ async@^3.2.0: resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== +async@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" + integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1087,6 +1164,13 @@ axe-core@^4.0.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.4.tgz#f19cd99a84ee32a318b9c5b5bb8ed373ad94f143" integrity sha512-Pdgfv6iP0gNx9ejRGa3zE7Xgkj/iclXqLfe7BnatdZz0QnLZ3jrRHUVH8wNSdN68w05Sk3ShGTb3ydktMTooig== +axios@^0.21.1: + version "0.21.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" + integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== + dependencies: + follow-redirects "^1.14.0" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" @@ -2469,6 +2553,21 @@ express-easy-zip@^1.1.5: var-clean "^1.0.0" zip-stream "^1.0.0" +express-jwt@^6.0.0: + version "6.1.2" + resolved "https://registry.yarnpkg.com/express-jwt/-/express-jwt-6.1.2.tgz#4a6cc11d1dcff6f23126dd79ec5b2b441333e78b" + integrity sha512-l5dlf5lNM/1EODMsJGfHn1VnrhhsUYEetzrKFStJZLjFQXtR+HGdBiW+jUNZ+ISsFe+h7Wl/hQKjLrY2TX0Qkg== + dependencies: + async "^3.2.2" + express-unless "^1.0.0" + jsonwebtoken "^8.1.0" + lodash "^4.17.21" + +express-unless@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/express-unless/-/express-unless-1.0.0.tgz#ecd1c354c5ccf7709a8a17ece617934e037cccd8" + integrity sha512-zXSSClWBPfcSYjg0hcQNompkFN/MxQQ53eyrzm9BYgik2ut2I7PxAf2foVqBRMYCwWaZx/aWodi+uk76npdSAw== + express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" @@ -2699,6 +2798,11 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== +follow-redirects@^1.14.0: + version "1.14.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7" + integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -3177,7 +3281,7 @@ http-errors@~1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" -http-proxy-agent@^4.0.0: +http-proxy-agent@^4.0.0, http-proxy-agent@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== @@ -4138,6 +4242,22 @@ json5@^2.1.2: dependencies: minimist "^1.2.5" +jsonwebtoken@^8.1.0, jsonwebtoken@^8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -4156,6 +4276,15 @@ jsprim@^1.2.2: array-includes "^3.1.2" object.assign "^4.1.2" +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + jwa@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" @@ -4165,6 +4294,30 @@ jwa@^2.0.0: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" +jwks-rsa@^1.4.0: + version "1.12.3" + resolved "https://registry.yarnpkg.com/jwks-rsa/-/jwks-rsa-1.12.3.tgz#40232f85d16734cb82837f38bb3e350a34435400" + integrity sha512-cFipFDeYYaO9FhhYJcZWX/IyZgc0+g316rcHnDpT2dNRNIE/lMOmWKKqp09TkJoYlNFzrEVODsR4GgXJMgWhnA== + dependencies: + "@types/express-jwt" "0.0.42" + axios "^0.21.1" + debug "^4.1.0" + http-proxy-agent "^4.0.1" + https-proxy-agent "^5.0.0" + jsonwebtoken "^8.5.1" + limiter "^1.1.5" + lru-memoizer "^2.1.2" + ms "^2.1.2" + proxy-from-env "^1.1.0" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + jws@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" @@ -4248,6 +4401,11 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +limiter@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/limiter/-/limiter-1.1.5.tgz#8f92a25b3b16c6131293a0cc834b4a838a2aa7c2" + integrity sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA== + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -4278,7 +4436,47 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" -lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.7.0, lodash@^4.8.0: +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8= + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M= + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w= + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= + +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0, lodash@^4.8.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -4307,6 +4505,22 @@ lru-cache@6.0.0, lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@~4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.2.tgz#1d17679c069cda5d040991a09dbc2c0db377e55e" + integrity sha1-HRdnnAac2l0ECZGgnbwsDbN35V4= + dependencies: + pseudomap "^1.0.1" + yallist "^2.0.0" + +lru-memoizer@^2.1.2: + version "2.1.4" + resolved "https://registry.yarnpkg.com/lru-memoizer/-/lru-memoizer-2.1.4.tgz#b864d92b557f00b1eeb322156a0409cb06dafac6" + integrity sha512-IXAq50s4qwrOBrXJklY+KhgZF+5y98PDaNo0gi/v2KQBFLyWr+JyFvijZXkGKjQj/h9c0OwoE+JZbwUXce76hQ== + dependencies: + lodash.clonedeep "^4.5.0" + lru-cache "~4.0.0" + make-dir@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -4487,6 +4701,11 @@ ms@2.1.2, ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + mute-stream@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" @@ -5054,6 +5273,16 @@ proxy-addr@~2.0.5: forwarded "~0.1.2" ipaddr.js "1.9.1" +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +pseudomap@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= + psl@^1.1.28, psl@^1.1.33: version "1.8.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" @@ -5443,8 +5672,6 @@ rxjs@^6.6.0: version "6.6.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== - dependencies: - tslib "^1.9.0" safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" @@ -5497,7 +5724,7 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.7.1: +"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -6126,7 +6353,7 @@ tsconfig-paths@^3.9.0: minimist "^1.2.0" strip-bom "^3.0.0" -tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -6513,6 +6740,11 @@ y18n@^4.0.0: resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== +yallist@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= + yallist@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" diff --git a/spotlight-client/src/DataStore/RootStore.ts b/spotlight-client/src/DataStore/RootStore.ts index cee902a0..84ce2b8f 100644 --- a/spotlight-client/src/DataStore/RootStore.ts +++ b/spotlight-client/src/DataStore/RootStore.ts @@ -40,6 +40,7 @@ export function getAuthSettings(): Auth0ClientOptions | undefined { domain: "spotlight-login-staging.recidiviz.org", client_id: "ID9plpd8j4vaUin9rPTGxWlJoknSkDX1", redirect_uri: `${window.location.origin}`, + audience: "recidiviz-spotlight-staging", }; } return undefined; diff --git a/spotlight-client/src/DataStore/UserStore.ts b/spotlight-client/src/DataStore/UserStore.ts index 5717c48b..f6709aa7 100644 --- a/spotlight-client/src/DataStore/UserStore.ts +++ b/spotlight-client/src/DataStore/UserStore.ts @@ -15,7 +15,10 @@ // along with this program. If not, see . // ============================================================================= -import createAuth0Client, { Auth0ClientOptions } from "@auth0/auth0-spa-js"; +import createAuth0Client, { + Auth0ClientOptions, + GetTokenSilentlyOptions, +} from "@auth0/auth0-spa-js"; import { intercept, makeAutoObservable, runInAction } from "mobx"; import qs from "qs"; import { AUTH0_APP_METADATA_KEY, ERROR_MESSAGES } from "../constants"; @@ -62,12 +65,16 @@ export default class UserStore { readonly rootStore?: RootStore; + getToken: (options?: GetTokenSilentlyOptions) => Promise; + constructor({ authSettings, isAuthRequired, rootStore }: ConstructorProps) { makeAutoObservable(this, { rootStore: false, authSettings: false }); this.authSettings = authSettings; this.rootStore = rootStore; + this.getToken = () => Promise.resolve("Token not set"); + this.awaitingVerification = false; this.isAuthRequired = isAuthRequired; if (!isAuthRequired) { @@ -96,7 +103,6 @@ export default class UserStore { this.authError = new Error(ERROR_MESSAGES.auth0Configuration); return; } - const auth0 = await createAuth0Client(this.authSettings); const urlQuery = qs.parse(window.location.search, { @@ -123,7 +129,10 @@ export default class UserStore { AUTH0_APP_METADATA_KEY ]?.state_code?.toUpperCase(); runInAction(() => { - this.isLoading = false; + this.getToken = (options?: GetTokenSilentlyOptions) => { + return auth0?.getTokenSilently(options); + }; + if (user.email_verified) { this.isAuthorized = true; this.awaitingVerification = false; @@ -144,6 +153,7 @@ export default class UserStore { } } }); + this.isLoading = false; } else { auth0.loginWithRedirect({ appState: { targetUrl: window.location.href }, diff --git a/spotlight-client/src/PageHome/PageHome.tsx b/spotlight-client/src/PageHome/PageHome.tsx index c0b1b753..37a6a0d4 100644 --- a/spotlight-client/src/PageHome/PageHome.tsx +++ b/spotlight-client/src/PageHome/PageHome.tsx @@ -53,7 +53,7 @@ const PageHome = (): React.ReactElement => { Spotlight {getTenantList().map(({ id, name }) => ( -

+

{ + const token = await DataStore.userStore.getToken(); const response = await fetch( `${process.env.REACT_APP_API_URL}/api/${tenantId}/public`, { @@ -46,6 +48,7 @@ export async function fetchMetrics({ metrics: metricNames, }), headers: { + Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, method: "POST", From 03dcea101efb757bb825943926cb998ef32fa655 Mon Sep 17 00:00:00 2001 From: Terry Tsai Date: Tue, 3 May 2022 16:00:55 -0700 Subject: [PATCH 2/7] verify matching state code when retrieving metrics when auth is enabled, don't verify if auth is disabled --- spotlight-api/.env.example | 2 ++ spotlight-api/README.md | 2 ++ spotlight-api/app.js | 29 +++++++++++---------- spotlight-api/routes/api.js | 16 +++++++++++- spotlight-client/.env.example | 4 ++- spotlight-client/README.md | 5 ++-- spotlight-client/src/DataStore/UserStore.ts | 8 +++--- spotlight-client/src/constants.ts | 2 -- 8 files changed, 45 insertions(+), 23 deletions(-) diff --git a/spotlight-api/.env.example b/spotlight-api/.env.example index 754f6df8..2273141e 100644 --- a/spotlight-api/.env.example +++ b/spotlight-api/.env.example @@ -1,2 +1,4 @@ GOOGLE_APPLICATION_CREDENTIALS={PATH_TO_CREDENTIALS_FILE} METRIC_BUCKET={GCS_BUCKET_NAME} +AUTH_ENABLED={AUTH_ENABLED} +AUTH0_APP_METADATA_KEY={AUTH0_APP_METADATA_KEY} diff --git a/spotlight-api/README.md b/spotlight-api/README.md index 5069e271..cd58d412 100644 --- a/spotlight-api/README.md +++ b/spotlight-api/README.md @@ -44,6 +44,8 @@ Expected backend environment variables include: - `GOOGLE_APPLICATION_CREDENTIALS` - a relative path pointing to the JSON file containing the credentials of the service account used to communicate with Google Cloud Storage, for metric retrieval. - `METRIC_BUCKET` - the name of the Google Cloud Storage bucket where the metrics reside. +- `AUTH_ENABLED` - whether or not we should require authentication to access our endpoints. Currently only used in staging to make the entire site private. No need to enable this locally unless you are developing or testing something auth-related. If set to `true` then `AUTH0_APP_METADATA_KEY` **must** be set to a supported value. +- `AUTH0_APP_METADATA_KEY` - a string that corresponds to the namespace set in the Auth0 custom action to add app_metadata to id tokens. Unless something has changed this should be set to `https://recidiviz.org/app_metadata`. This is only required when auth is enabled. - `IS_DEMO` (OPTIONAL) - whether or not to run the backend in demo mode, which will retrieve static fixture data from the `core/demo_data` directory instead of pulling data from dynamic, live sources. This should only be set when running locally and should be provided through the command line. ### Running the application locally diff --git a/spotlight-api/app.js b/spotlight-api/app.js index a1f2bd06..28991d5f 100644 --- a/spotlight-api/app.js +++ b/spotlight-api/app.js @@ -35,20 +35,21 @@ app.use(morgan("dev")); app.use(helmet()); app.use(zip()); -const checkJwt = jwt({ - secret: jwks.expressJwtSecret({ - cache: true, - rateLimit: true, - jwksRequestsPerMinute: 5, - jwksUri: - "https://recidiviz-spotlight-staging.us.auth0.com/.well-known/jwks.json", - }), - audience: "recidiviz-spotlight-staging", - issuer: "https://spotlight-login-staging.recidiviz.org/", - algorithms: ["RS256"], -}); - -app.use(checkJwt); +if (process.env.AUTH_ENABLED === "true") { + const checkJwt = jwt({ + secret: jwks.expressJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: + "https://recidiviz-spotlight-staging.us.auth0.com/.well-known/jwks.json", + }), + audience: "recidiviz-spotlight-staging", + issuer: "https://spotlight-login-staging.recidiviz.org/", + algorithms: ["RS256"], + }); + app.use(checkJwt); +} app.post("/api/:tenantId/public", express.json(), api.metricsByName); diff --git a/spotlight-api/routes/api.js b/spotlight-api/routes/api.js index 2da34b0c..5f80d0dc 100644 --- a/spotlight-api/routes/api.js +++ b/spotlight-api/routes/api.js @@ -25,6 +25,8 @@ const demoMode = require("../utils/demoMode"); const isDemoMode = demoMode.isDemoMode(); +const { AUTH_ENABLED, AUTH0_APP_METADATA_KEY } = process.env; + /** * A callback which returns either either an error payload or a data payload. */ @@ -39,14 +41,26 @@ function responder(res) { } function metricsByName(req, res) { + const { tenantId } = req.params; const { metrics } = req.body; + const stateCode = + AUTH0_APP_METADATA_KEY && + req.user?.[AUTH0_APP_METADATA_KEY]?.state_code?.toLowerCase(); if (!Array.isArray(metrics)) { res .status(400) .json({ error: "request is missing metrics array parameter" }); + } else if ( + AUTH_ENABLED === "true" && + stateCode !== tenantId.toLowerCase() && + stateCode !== "recidiviz" + ) { + res.status(401).json({ + error: `User is not a member of the requested tenant ${tenantId}`, + }); } else { metricsApi.fetchMetricsByName( - req.params.tenantId, + tenantId, metrics, isDemoMode, responder(res) diff --git a/spotlight-client/.env.example b/spotlight-client/.env.example index 542d4d82..aaafc48c 100644 --- a/spotlight-client/.env.example +++ b/spotlight-client/.env.example @@ -1,3 +1,5 @@ +REACT_APP_API_URL={URL} REACT_APP_AUTH_ENABLED={FLAG} REACT_APP_AUTH_ENV={ENV_NAME} -REACT_APP_API_URL=http://localhost:3001 +REACT_APP_AUTH_METADATA_KEY={KEY} +REACT_APP_ENABLED_TENANTS={US_**} diff --git a/spotlight-client/README.md b/spotlight-client/README.md index 11e229fd..74fba923 100644 --- a/spotlight-client/README.md +++ b/spotlight-client/README.md @@ -62,9 +62,10 @@ The Create React App documentation explains all the possible [env config files]( Expected environment variables include: -- `REACT_APP_AUTH_ENABLED` - set to `true` or `false` to toggle Auth0 protection per environment. Currently only used in staging to make the entire site private. No need to enable this locally unless you are developing or testing something auth-related. If set to `true` then `REACT_APP_AUTH_ENV` **must** be set to a supported value. -- `REACT_APP_AUTH_ENV` - a string indicating the "auth environment" used to point to the correct Auth0 tenant. `development` (which also covers staging) is the only supported value, which **must** be set if `REACT_APP_AUTH_ENABLED` is `true`. - `REACT_APP_API_URL` - the base URL of the backend API server. This should be set to http://localhost:3001 when running the server locally, and to http://localhost:3002 in the test environment (because some tests will make requests to this URL). +- `REACT_APP_AUTH_ENABLED` - set to `true` or `false` to toggle Auth0 protection per environment. Currently only used in staging to make the entire site private. No need to enable this locally unless you are developing or testing something auth-related. If set to `true` then `REACT_APP_AUTH_ENV` and `REACT_APP_AUTH_METADATA_KEY` **must** be set to a supported value. +- `REACT_APP_AUTH_ENV` - a string indicating the "auth environment" used to point to the correct Auth0 tenant. `development` (which also covers staging) is the only supported value, which **must** be set if `REACT_APP_AUTH_ENABLED` is `true`. +- `REACT_APP_AUTH_METADATA_KEY` - a string that corresponds to the namespace set in the Auth0 custom action to add app_metadata to id tokens. Unless something has changed this should be set to `https://recidiviz.org/app_metadata`. This is only required when auth is enabled. - `REACT_APP_ENABLED_TENANTS` - a feature flag for activating individual tenants, in the form of a comma-separated list of tenant IDs (e.g., "US_ND,US_PA") that should be available. Tenants that are configured but not enumerated here will not be accessible to users. (Note that variables must be prefixed with `REACT_APP_` to be available inside the client application.) diff --git a/spotlight-client/src/DataStore/UserStore.ts b/spotlight-client/src/DataStore/UserStore.ts index f6709aa7..30b2fcaa 100644 --- a/spotlight-client/src/DataStore/UserStore.ts +++ b/spotlight-client/src/DataStore/UserStore.ts @@ -21,10 +21,12 @@ import createAuth0Client, { } from "@auth0/auth0-spa-js"; import { intercept, makeAutoObservable, runInAction } from "mobx"; import qs from "qs"; -import { AUTH0_APP_METADATA_KEY, ERROR_MESSAGES } from "../constants"; +import { ERROR_MESSAGES } from "../constants"; import { isTenantId, StateCodes } from "../contentApi/types"; import RootStore from "./RootStore"; +const { REACT_APP_AUTH_METADATA_KEY } = process.env; + type ConstructorProps = { authSettings?: Auth0ClientOptions; isAuthRequired: boolean; @@ -122,11 +124,11 @@ export default class UserStore { if (handleTargetUrl) handleTargetUrl(replacementUrl); } - if (await auth0.isAuthenticated()) { + if (REACT_APP_AUTH_METADATA_KEY && (await auth0.isAuthenticated())) { const user = await auth0.getUser(); const claims = await auth0.getIdTokenClaims(); const stateCode = claims[ - AUTH0_APP_METADATA_KEY + REACT_APP_AUTH_METADATA_KEY ]?.state_code?.toUpperCase(); runInAction(() => { this.getToken = (options?: GetTokenSilentlyOptions) => { diff --git a/spotlight-client/src/constants.ts b/spotlight-client/src/constants.ts index 86bf0b5d..967606d0 100644 --- a/spotlight-client/src/constants.ts +++ b/spotlight-client/src/constants.ts @@ -40,5 +40,3 @@ export const SENTENCE_TYPE_LABELS = { PROBATION: "Probation", DUAL_SENTENCE: "Both", }; - -export const AUTH0_APP_METADATA_KEY = "https://recidiviz.org/app_metadata"; From d7394915bd93c438546e5bb7bb24d7f26aec95cf Mon Sep 17 00:00:00 2001 From: Terry Tsai Date: Tue, 3 May 2022 17:35:53 -0700 Subject: [PATCH 3/7] add tests --- spotlight-api/routes/api.js | 3 +- spotlight-api/routes/api.test.js | 150 +++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 spotlight-api/routes/api.test.js diff --git a/spotlight-api/routes/api.js b/spotlight-api/routes/api.js index 5f80d0dc..e293ae8d 100644 --- a/spotlight-api/routes/api.js +++ b/spotlight-api/routes/api.js @@ -25,8 +25,6 @@ const demoMode = require("../utils/demoMode"); const isDemoMode = demoMode.isDemoMode(); -const { AUTH_ENABLED, AUTH0_APP_METADATA_KEY } = process.env; - /** * A callback which returns either either an error payload or a data payload. */ @@ -41,6 +39,7 @@ function responder(res) { } function metricsByName(req, res) { + const { AUTH_ENABLED, AUTH0_APP_METADATA_KEY } = process.env; const { tenantId } = req.params; const { metrics } = req.body; const stateCode = diff --git a/spotlight-api/routes/api.test.js b/spotlight-api/routes/api.test.js new file mode 100644 index 00000000..2b9cf3de --- /dev/null +++ b/spotlight-api/routes/api.test.js @@ -0,0 +1,150 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2020 Recidiviz, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +const { fetchMetricsByName } = require("../core/metricsApi"); +const { metricsByName } = require("./api"); + +jest.mock("../core/metricsApi"); + +beforeEach(() => { + fetchMetricsByName.mockImplementation( + (tenantId, metrics, isDemoMode, responder) => { + responder(undefined, "passed"); + } + ); +}); + +test("retrieves metrics if auth is disabled", async () => { + process.env.AUTH_ENABLED = "false"; + const mockFn = jest.fn(); + metricsByName( + { + params: { + tenantId: "US_ND", + }, + body: { + metrics: ["test_metric"], + }, + }, + { + send: mockFn, + } + ); + expect(mockFn).toHaveBeenCalledWith("passed"); +}); + +test("returns 401 if there is no metrics array in the request body", async () => { + process.env.AUTH_ENABLED = "false"; + const mockSendFn = jest.fn(); + const mockStatusFn = jest.fn(); + metricsByName( + { + params: { + tenantId: "US_ND", + }, + body: {}, + }, + { + status: () => ({ json: mockStatusFn }), + send: mockSendFn, + } + ); + expect(mockSendFn).not.toHaveBeenCalled(); + expect(mockStatusFn).toHaveBeenCalledWith({ + error: "request is missing metrics array parameter", + }); +}); + +test("retrieves metrics if auth is enabled and user state code is 'recidiviz'", async () => { + process.env.AUTH_ENABLED = "true"; + process.env.AUTH0_APP_METADATA_KEY = "TEST_KEY"; + const mockFn = jest.fn(); + metricsByName( + { + params: { + tenantId: "US_ND", + }, + body: { + metrics: ["test_metric"], + }, + user: { + [process.env.AUTH0_APP_METADATA_KEY]: { + state_code: "recidiviz", + }, + }, + }, + { + send: mockFn, + } + ); + expect(mockFn).toHaveBeenCalledWith("passed"); +}); + +test("retrieves metrics if auth is enabled and user state code matches the request param", async () => { + process.env.AUTH_ENABLED = "true"; + process.env.AUTH0_APP_METADATA_KEY = "TEST_KEY"; + const mockFn = jest.fn(); + metricsByName( + { + params: { + tenantId: "US_ND", + }, + body: { + metrics: ["test_metric"], + }, + user: { + [process.env.AUTH0_APP_METADATA_KEY]: { + state_code: "us_nd", + }, + }, + }, + { + send: mockFn, + } + ); + expect(mockFn).toHaveBeenCalledWith("passed"); +}); + +test("returns 401 if auth is enabled and user state code doesn't match the request param", async () => { + process.env.AUTH_ENABLED = "true"; + process.env.AUTH0_APP_METADATA_KEY = "TEST_KEY"; + const mockStatusFn = jest.fn((number) => (json) => json); + const mockSendFn = jest.fn(); + metricsByName( + { + params: { + tenantId: "US_PA", + }, + body: { + metrics: ["test_metric"], + }, + user: { + [process.env.AUTH0_APP_METADATA_KEY]: { + state_code: "us_nd", + }, + }, + }, + { + status: () => ({ json: mockStatusFn }), + send: mockSendFn, + } + ); + expect(mockSendFn).not.toHaveBeenCalled(); + expect(mockStatusFn).toHaveBeenCalledWith({ + error: "User is not a member of the requested tenant US_PA", + }); +}); From 643f2143c6eb2bcae6b160d9d7d0fcbbf32bde9c Mon Sep 17 00:00:00 2001 From: Terry Tsai Date: Tue, 3 May 2022 17:42:48 -0700 Subject: [PATCH 4/7] fix tests --- spotlight-client/src/App-auth.test.tsx | 16 ++++++++++------ spotlight-client/src/DataStore/UserStore.test.ts | 10 +++++++--- spotlight-client/src/DataStore/UserStore.ts | 4 ++-- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/spotlight-client/src/App-auth.test.tsx b/spotlight-client/src/App-auth.test.tsx index c6a1948b..15de8154 100644 --- a/spotlight-client/src/App-auth.test.tsx +++ b/spotlight-client/src/App-auth.test.tsx @@ -15,8 +15,6 @@ // along with this program. If not, see . // ============================================================================= -import { AUTH0_APP_METADATA_KEY } from "./constants"; - // we have to import everything dynamically to manipulate process.env, // which is weird and Typescript doesn't like it, so silence these warnings // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -95,6 +93,7 @@ test("requires authentication", async () => { // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; + process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is not currently authenticated mockIsAuthenticated.mockResolvedValue(false); @@ -119,6 +118,7 @@ test("requires email verification", async () => { // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; + process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is authenticated but not verified mockIsAuthenticated.mockResolvedValue(true); @@ -141,12 +141,13 @@ test("renders when authenticated and state_code is 'recidiviz'", async () => { // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; + process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is authenticated and verified and assigned a valid state_code mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [AUTH0_APP_METADATA_KEY]: { + [process.env.REACT_APP_AUTH_METADATA_KEY]: { state_code: "recidiviz", }, }); @@ -164,12 +165,13 @@ test("renders when authenticated and state_code is one of our tenants", async () // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; + process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is authenticated and verified and assigned a valid state_code mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [AUTH0_APP_METADATA_KEY]: { + [process.env.REACT_APP_AUTH_METADATA_KEY]: { state_code: "us_nd", }, }); @@ -185,12 +187,13 @@ test("renders when authenticated and state_code is NOT one of our tenants", asyn // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; + process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is authenticated and verified and assigned a valid state_code mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [AUTH0_APP_METADATA_KEY]: { + [process.env.REACT_APP_AUTH_METADATA_KEY]: { state_code: "invalid", }, }); @@ -206,12 +209,13 @@ test("renders when authenticated and state_code is NOT set", async () => { // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; + process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is authenticated and verified and assigned a valid state_code mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [AUTH0_APP_METADATA_KEY]: {}, + [process.env.REACT_APP_AUTH_METADATA_KEY]: {}, }); const App = await getApp(); render(); diff --git a/spotlight-client/src/DataStore/UserStore.test.ts b/spotlight-client/src/DataStore/UserStore.test.ts index ea8b04d8..bba7982f 100644 --- a/spotlight-client/src/DataStore/UserStore.test.ts +++ b/spotlight-client/src/DataStore/UserStore.test.ts @@ -16,7 +16,7 @@ // ============================================================================= import createAuth0Client from "@auth0/auth0-spa-js"; -import { AUTH0_APP_METADATA_KEY, ERROR_MESSAGES } from "../constants"; +import { ERROR_MESSAGES } from "../constants"; import { reactImmediately } from "../testUtils"; import RootStore from "./RootStore"; import UserStore from "./UserStore"; @@ -188,10 +188,12 @@ test("passes target URL to callback", async () => { }); test("retrieves the state code from app_metadata and sets tenantStore's currentTenantId", async () => { + process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; + mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [AUTH0_APP_METADATA_KEY]: { + [process.env.REACT_APP_AUTH_METADATA_KEY]: { state_code: "us_nd", }, }); @@ -214,10 +216,12 @@ test("retrieves the state code from app_metadata and sets tenantStore's currentT }); test("retrieves no state code from app_metadata", async () => { + process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; + mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [AUTH0_APP_METADATA_KEY]: {}, + [process.env.REACT_APP_AUTH_METADATA_KEY]: {}, }); const rootStore = new RootStore(); diff --git a/spotlight-client/src/DataStore/UserStore.ts b/spotlight-client/src/DataStore/UserStore.ts index 30b2fcaa..a18a92bb 100644 --- a/spotlight-client/src/DataStore/UserStore.ts +++ b/spotlight-client/src/DataStore/UserStore.ts @@ -25,8 +25,6 @@ import { ERROR_MESSAGES } from "../constants"; import { isTenantId, StateCodes } from "../contentApi/types"; import RootStore from "./RootStore"; -const { REACT_APP_AUTH_METADATA_KEY } = process.env; - type ConstructorProps = { authSettings?: Auth0ClientOptions; isAuthRequired: boolean; @@ -124,6 +122,8 @@ export default class UserStore { if (handleTargetUrl) handleTargetUrl(replacementUrl); } + const { REACT_APP_AUTH_METADATA_KEY } = process.env; + if (REACT_APP_AUTH_METADATA_KEY && (await auth0.isAuthenticated())) { const user = await auth0.getUser(); const claims = await auth0.getIdTokenClaims(); From f6027dda6b4b09aff4edd36a5c7860fb4f072196 Mon Sep 17 00:00:00 2001 From: Terry Tsai Date: Tue, 3 May 2022 17:47:19 -0700 Subject: [PATCH 5/7] fix lint --- spotlight-api/routes/api.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spotlight-api/routes/api.test.js b/spotlight-api/routes/api.test.js index 2b9cf3de..e6d5308b 100644 --- a/spotlight-api/routes/api.test.js +++ b/spotlight-api/routes/api.test.js @@ -122,7 +122,7 @@ test("retrieves metrics if auth is enabled and user state code matches the reque test("returns 401 if auth is enabled and user state code doesn't match the request param", async () => { process.env.AUTH_ENABLED = "true"; process.env.AUTH0_APP_METADATA_KEY = "TEST_KEY"; - const mockStatusFn = jest.fn((number) => (json) => json); + const mockStatusFn = jest.fn(); const mockSendFn = jest.fn(); metricsByName( { From 823b079a74cdbdd99bc08cd86778898a255e3282 Mon Sep 17 00:00:00 2001 From: Terry Tsai Date: Wed, 4 May 2022 14:11:10 -0700 Subject: [PATCH 6/7] remove unneeded env variable and move to constants --- spotlight-api/.env.example | 1 - spotlight-api/README.md | 3 +-- spotlight-api/routes/api.js | 9 +++++---- spotlight-api/routes/api.test.js | 17 +++++++++++------ spotlight-api/utils/constants.js | 5 +++++ spotlight-client/.env.example | 1 - spotlight-client/README.md | 3 +-- spotlight-client/src/App-auth.test.tsx | 16 ++++++---------- .../src/DataStore/UserStore.test.ts | 10 +++------- spotlight-client/src/DataStore/UserStore.ts | 8 +++----- spotlight-client/src/constants.ts | 2 ++ 11 files changed, 37 insertions(+), 38 deletions(-) create mode 100644 spotlight-api/utils/constants.js diff --git a/spotlight-api/.env.example b/spotlight-api/.env.example index 2273141e..6ad4b119 100644 --- a/spotlight-api/.env.example +++ b/spotlight-api/.env.example @@ -1,4 +1,3 @@ GOOGLE_APPLICATION_CREDENTIALS={PATH_TO_CREDENTIALS_FILE} METRIC_BUCKET={GCS_BUCKET_NAME} AUTH_ENABLED={AUTH_ENABLED} -AUTH0_APP_METADATA_KEY={AUTH0_APP_METADATA_KEY} diff --git a/spotlight-api/README.md b/spotlight-api/README.md index cd58d412..e2d5f4be 100644 --- a/spotlight-api/README.md +++ b/spotlight-api/README.md @@ -44,8 +44,7 @@ Expected backend environment variables include: - `GOOGLE_APPLICATION_CREDENTIALS` - a relative path pointing to the JSON file containing the credentials of the service account used to communicate with Google Cloud Storage, for metric retrieval. - `METRIC_BUCKET` - the name of the Google Cloud Storage bucket where the metrics reside. -- `AUTH_ENABLED` - whether or not we should require authentication to access our endpoints. Currently only used in staging to make the entire site private. No need to enable this locally unless you are developing or testing something auth-related. If set to `true` then `AUTH0_APP_METADATA_KEY` **must** be set to a supported value. -- `AUTH0_APP_METADATA_KEY` - a string that corresponds to the namespace set in the Auth0 custom action to add app_metadata to id tokens. Unless something has changed this should be set to `https://recidiviz.org/app_metadata`. This is only required when auth is enabled. +- `AUTH_ENABLED` - whether or not we should require authentication to access our endpoints. Currently only used in staging to make the entire site private. No need to enable this locally unless you are developing or testing something auth-related. - `IS_DEMO` (OPTIONAL) - whether or not to run the backend in demo mode, which will retrieve static fixture data from the `core/demo_data` directory instead of pulling data from dynamic, live sources. This should only be set when running locally and should be provided through the command line. ### Running the application locally diff --git a/spotlight-api/routes/api.js b/spotlight-api/routes/api.js index e293ae8d..c334644b 100644 --- a/spotlight-api/routes/api.js +++ b/spotlight-api/routes/api.js @@ -21,6 +21,7 @@ */ const metricsApi = require("../core/metricsApi"); +const { AUTH0_APP_METADATA_KEY } = require("../utils/constants"); const demoMode = require("../utils/demoMode"); const isDemoMode = demoMode.isDemoMode(); @@ -39,12 +40,12 @@ function responder(res) { } function metricsByName(req, res) { - const { AUTH_ENABLED, AUTH0_APP_METADATA_KEY } = process.env; + const { AUTH_ENABLED } = process.env; const { tenantId } = req.params; const { metrics } = req.body; - const stateCode = - AUTH0_APP_METADATA_KEY && - req.user?.[AUTH0_APP_METADATA_KEY]?.state_code?.toLowerCase(); + const stateCode = req.user?.[ + AUTH0_APP_METADATA_KEY + ]?.state_code?.toLowerCase(); if (!Array.isArray(metrics)) { res .status(400) diff --git a/spotlight-api/routes/api.test.js b/spotlight-api/routes/api.test.js index e6d5308b..f258d887 100644 --- a/spotlight-api/routes/api.test.js +++ b/spotlight-api/routes/api.test.js @@ -16,10 +16,14 @@ // ============================================================================= const { fetchMetricsByName } = require("../core/metricsApi"); +const { AUTH0_APP_METADATA_KEY } = require("../utils/constants"); const { metricsByName } = require("./api"); jest.mock("../core/metricsApi"); +// mocking the node env is esoteric, see https://stackoverflow.com/a/48042799 +const ORIGINAL_ENV = process.env; + beforeEach(() => { fetchMetricsByName.mockImplementation( (tenantId, metrics, isDemoMode, responder) => { @@ -28,6 +32,10 @@ beforeEach(() => { ); }); +afterEach(() => { + process.env = ORIGINAL_ENV; +}); + test("retrieves metrics if auth is disabled", async () => { process.env.AUTH_ENABLED = "false"; const mockFn = jest.fn(); @@ -71,7 +79,6 @@ test("returns 401 if there is no metrics array in the request body", async () => test("retrieves metrics if auth is enabled and user state code is 'recidiviz'", async () => { process.env.AUTH_ENABLED = "true"; - process.env.AUTH0_APP_METADATA_KEY = "TEST_KEY"; const mockFn = jest.fn(); metricsByName( { @@ -82,7 +89,7 @@ test("retrieves metrics if auth is enabled and user state code is 'recidiviz'", metrics: ["test_metric"], }, user: { - [process.env.AUTH0_APP_METADATA_KEY]: { + [AUTH0_APP_METADATA_KEY]: { state_code: "recidiviz", }, }, @@ -96,7 +103,6 @@ test("retrieves metrics if auth is enabled and user state code is 'recidiviz'", test("retrieves metrics if auth is enabled and user state code matches the request param", async () => { process.env.AUTH_ENABLED = "true"; - process.env.AUTH0_APP_METADATA_KEY = "TEST_KEY"; const mockFn = jest.fn(); metricsByName( { @@ -107,7 +113,7 @@ test("retrieves metrics if auth is enabled and user state code matches the reque metrics: ["test_metric"], }, user: { - [process.env.AUTH0_APP_METADATA_KEY]: { + [AUTH0_APP_METADATA_KEY]: { state_code: "us_nd", }, }, @@ -121,7 +127,6 @@ test("retrieves metrics if auth is enabled and user state code matches the reque test("returns 401 if auth is enabled and user state code doesn't match the request param", async () => { process.env.AUTH_ENABLED = "true"; - process.env.AUTH0_APP_METADATA_KEY = "TEST_KEY"; const mockStatusFn = jest.fn(); const mockSendFn = jest.fn(); metricsByName( @@ -133,7 +138,7 @@ test("returns 401 if auth is enabled and user state code doesn't match the reque metrics: ["test_metric"], }, user: { - [process.env.AUTH0_APP_METADATA_KEY]: { + [AUTH0_APP_METADATA_KEY]: { state_code: "us_nd", }, }, diff --git a/spotlight-api/utils/constants.js b/spotlight-api/utils/constants.js new file mode 100644 index 00000000..a8c30f0b --- /dev/null +++ b/spotlight-api/utils/constants.js @@ -0,0 +1,5 @@ +const AUTH0_APP_METADATA_KEY = "https://recidiviz.org/app_metadata"; + +module.exports = { + AUTH0_APP_METADATA_KEY, +}; diff --git a/spotlight-client/.env.example b/spotlight-client/.env.example index aaafc48c..d5e00d3a 100644 --- a/spotlight-client/.env.example +++ b/spotlight-client/.env.example @@ -1,5 +1,4 @@ REACT_APP_API_URL={URL} REACT_APP_AUTH_ENABLED={FLAG} REACT_APP_AUTH_ENV={ENV_NAME} -REACT_APP_AUTH_METADATA_KEY={KEY} REACT_APP_ENABLED_TENANTS={US_**} diff --git a/spotlight-client/README.md b/spotlight-client/README.md index 74fba923..e61072bf 100644 --- a/spotlight-client/README.md +++ b/spotlight-client/README.md @@ -63,9 +63,8 @@ The Create React App documentation explains all the possible [env config files]( Expected environment variables include: - `REACT_APP_API_URL` - the base URL of the backend API server. This should be set to http://localhost:3001 when running the server locally, and to http://localhost:3002 in the test environment (because some tests will make requests to this URL). -- `REACT_APP_AUTH_ENABLED` - set to `true` or `false` to toggle Auth0 protection per environment. Currently only used in staging to make the entire site private. No need to enable this locally unless you are developing or testing something auth-related. If set to `true` then `REACT_APP_AUTH_ENV` and `REACT_APP_AUTH_METADATA_KEY` **must** be set to a supported value. +- `REACT_APP_AUTH_ENABLED` - set to `true` or `false` to toggle Auth0 protection per environment. Currently only used in staging to make the entire site private. No need to enable this locally unless you are developing or testing something auth-related. If set to `true` then `REACT_APP_AUTH_ENV` **must** be set to a supported value. - `REACT_APP_AUTH_ENV` - a string indicating the "auth environment" used to point to the correct Auth0 tenant. `development` (which also covers staging) is the only supported value, which **must** be set if `REACT_APP_AUTH_ENABLED` is `true`. -- `REACT_APP_AUTH_METADATA_KEY` - a string that corresponds to the namespace set in the Auth0 custom action to add app_metadata to id tokens. Unless something has changed this should be set to `https://recidiviz.org/app_metadata`. This is only required when auth is enabled. - `REACT_APP_ENABLED_TENANTS` - a feature flag for activating individual tenants, in the form of a comma-separated list of tenant IDs (e.g., "US_ND,US_PA") that should be available. Tenants that are configured but not enumerated here will not be accessible to users. (Note that variables must be prefixed with `REACT_APP_` to be available inside the client application.) diff --git a/spotlight-client/src/App-auth.test.tsx b/spotlight-client/src/App-auth.test.tsx index 15de8154..c6a1948b 100644 --- a/spotlight-client/src/App-auth.test.tsx +++ b/spotlight-client/src/App-auth.test.tsx @@ -15,6 +15,8 @@ // along with this program. If not, see . // ============================================================================= +import { AUTH0_APP_METADATA_KEY } from "./constants"; + // we have to import everything dynamically to manipulate process.env, // which is weird and Typescript doesn't like it, so silence these warnings // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -93,7 +95,6 @@ test("requires authentication", async () => { // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; - process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is not currently authenticated mockIsAuthenticated.mockResolvedValue(false); @@ -118,7 +119,6 @@ test("requires email verification", async () => { // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; - process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is authenticated but not verified mockIsAuthenticated.mockResolvedValue(true); @@ -141,13 +141,12 @@ test("renders when authenticated and state_code is 'recidiviz'", async () => { // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; - process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is authenticated and verified and assigned a valid state_code mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [process.env.REACT_APP_AUTH_METADATA_KEY]: { + [AUTH0_APP_METADATA_KEY]: { state_code: "recidiviz", }, }); @@ -165,13 +164,12 @@ test("renders when authenticated and state_code is one of our tenants", async () // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; - process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is authenticated and verified and assigned a valid state_code mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [process.env.REACT_APP_AUTH_METADATA_KEY]: { + [AUTH0_APP_METADATA_KEY]: { state_code: "us_nd", }, }); @@ -187,13 +185,12 @@ test("renders when authenticated and state_code is NOT one of our tenants", asyn // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; - process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is authenticated and verified and assigned a valid state_code mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [process.env.REACT_APP_AUTH_METADATA_KEY]: { + [AUTH0_APP_METADATA_KEY]: { state_code: "invalid", }, }); @@ -209,13 +206,12 @@ test("renders when authenticated and state_code is NOT set", async () => { // configure environment for valid authentication process.env.REACT_APP_AUTH_ENABLED = "true"; process.env.REACT_APP_AUTH_ENV = "development"; - process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; // user is authenticated and verified and assigned a valid state_code mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [process.env.REACT_APP_AUTH_METADATA_KEY]: {}, + [AUTH0_APP_METADATA_KEY]: {}, }); const App = await getApp(); render(); diff --git a/spotlight-client/src/DataStore/UserStore.test.ts b/spotlight-client/src/DataStore/UserStore.test.ts index bba7982f..ea8b04d8 100644 --- a/spotlight-client/src/DataStore/UserStore.test.ts +++ b/spotlight-client/src/DataStore/UserStore.test.ts @@ -16,7 +16,7 @@ // ============================================================================= import createAuth0Client from "@auth0/auth0-spa-js"; -import { ERROR_MESSAGES } from "../constants"; +import { AUTH0_APP_METADATA_KEY, ERROR_MESSAGES } from "../constants"; import { reactImmediately } from "../testUtils"; import RootStore from "./RootStore"; import UserStore from "./UserStore"; @@ -188,12 +188,10 @@ test("passes target URL to callback", async () => { }); test("retrieves the state code from app_metadata and sets tenantStore's currentTenantId", async () => { - process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; - mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [process.env.REACT_APP_AUTH_METADATA_KEY]: { + [AUTH0_APP_METADATA_KEY]: { state_code: "us_nd", }, }); @@ -216,12 +214,10 @@ test("retrieves the state code from app_metadata and sets tenantStore's currentT }); test("retrieves no state code from app_metadata", async () => { - process.env.REACT_APP_AUTH_METADATA_KEY = "TEST_KEY"; - mockIsAuthenticated.mockResolvedValue(true); mockGetUser.mockResolvedValue({ email_verified: true }); mockGetIdTokenClaims.mockResolvedValue({ - [process.env.REACT_APP_AUTH_METADATA_KEY]: {}, + [AUTH0_APP_METADATA_KEY]: {}, }); const rootStore = new RootStore(); diff --git a/spotlight-client/src/DataStore/UserStore.ts b/spotlight-client/src/DataStore/UserStore.ts index a18a92bb..f6709aa7 100644 --- a/spotlight-client/src/DataStore/UserStore.ts +++ b/spotlight-client/src/DataStore/UserStore.ts @@ -21,7 +21,7 @@ import createAuth0Client, { } from "@auth0/auth0-spa-js"; import { intercept, makeAutoObservable, runInAction } from "mobx"; import qs from "qs"; -import { ERROR_MESSAGES } from "../constants"; +import { AUTH0_APP_METADATA_KEY, ERROR_MESSAGES } from "../constants"; import { isTenantId, StateCodes } from "../contentApi/types"; import RootStore from "./RootStore"; @@ -122,13 +122,11 @@ export default class UserStore { if (handleTargetUrl) handleTargetUrl(replacementUrl); } - const { REACT_APP_AUTH_METADATA_KEY } = process.env; - - if (REACT_APP_AUTH_METADATA_KEY && (await auth0.isAuthenticated())) { + if (await auth0.isAuthenticated()) { const user = await auth0.getUser(); const claims = await auth0.getIdTokenClaims(); const stateCode = claims[ - REACT_APP_AUTH_METADATA_KEY + AUTH0_APP_METADATA_KEY ]?.state_code?.toUpperCase(); runInAction(() => { this.getToken = (options?: GetTokenSilentlyOptions) => { diff --git a/spotlight-client/src/constants.ts b/spotlight-client/src/constants.ts index 967606d0..86bf0b5d 100644 --- a/spotlight-client/src/constants.ts +++ b/spotlight-client/src/constants.ts @@ -40,3 +40,5 @@ export const SENTENCE_TYPE_LABELS = { PROBATION: "Probation", DUAL_SENTENCE: "Both", }; + +export const AUTH0_APP_METADATA_KEY = "https://recidiviz.org/app_metadata"; From 552814e51bd3ef07a177175e53cfd016392ac68e Mon Sep 17 00:00:00 2001 From: Terry Tsai Date: Wed, 4 May 2022 17:00:43 -0700 Subject: [PATCH 7/7] Pass in rootStore to fetchMetrics in order to get token in fetch. --- spotlight-client/src/DataStore/TenantStore.ts | 5 +++- spotlight-client/src/contentModels/Metric.ts | 7 ++++++ .../RacialDisparitiesNarrative.ts | 7 ++++++ .../SupervisionSuccessRateMetric.ts | 1 + spotlight-client/src/contentModels/Tenant.ts | 14 ++++++++--- .../src/contentModels/createMetricMapping.ts | 24 +++++++++++++++++++ .../src/metricsApi/fetchMetrics.ts | 11 +++++++-- 7 files changed, 63 insertions(+), 6 deletions(-) diff --git a/spotlight-client/src/DataStore/TenantStore.ts b/spotlight-client/src/DataStore/TenantStore.ts index a8fb9ffa..50792181 100644 --- a/spotlight-client/src/DataStore/TenantStore.ts +++ b/spotlight-client/src/DataStore/TenantStore.ts @@ -87,7 +87,10 @@ export default class TenantStore { try { this.tenants.set( this.currentTenantId, - createTenant({ tenantId: this.currentTenantId }) + createTenant({ + tenantId: this.currentTenantId, + rootStore: this.rootStore, + }) ); } catch (error) { if (!error.message.includes(ERROR_MESSAGES.disabledTenant)) { diff --git a/spotlight-client/src/contentModels/Metric.ts b/spotlight-client/src/contentModels/Metric.ts index e3c65c79..344a81d5 100644 --- a/spotlight-client/src/contentModels/Metric.ts +++ b/spotlight-client/src/contentModels/Metric.ts @@ -30,6 +30,7 @@ import { MetricTypeId, TenantId, } from "../contentApi/types"; +import RootStore from "../DataStore/RootStore"; import { createDemographicCategories, DemographicCategories, @@ -89,6 +90,7 @@ export type BaseMetricConstructorOptions = { localityLabels: RecordFormat extends LocalityFields ? LocalityLabels : undefined; + rootStore?: RootStore; }; /** @@ -125,6 +127,8 @@ export default abstract class Metric error?: Error; + rootStore?: RootStore; + // filter properties private readonly demographicCategories: DemographicCategories; @@ -152,6 +156,7 @@ export default abstract class Metric defaultDemographicView, defaultLocalityId, localityLabels, + rootStore, }: BaseMetricConstructorOptions) { makeObservable, "allRecords">(this, { allRecords: observable.ref, @@ -174,6 +179,7 @@ export default abstract class Metric this.tenantId = tenantId; this.sourceFileName = sourceFileName; this.dataTransformer = dataTransformer; + this.rootStore = rootStore; // initialize filters this.demographicCategories = createDemographicCategories(demographicFilter); @@ -191,6 +197,7 @@ export default abstract class Metric sourceFileName: this.sourceFileName, tenantId: this.tenantId, transformFn: this.dataTransformer, + rootStore: this.rootStore, }); return records; } diff --git a/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts b/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts index 3aebe2e8..ab597d26 100644 --- a/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts +++ b/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts @@ -30,6 +30,7 @@ import { TenantId, DemographicCategoryFilter, } from "../contentApi/types"; +import RootStore from "../DataStore/RootStore"; import { createDemographicCategories, RaceIdentifier, @@ -144,6 +145,7 @@ type ConstructorOpts = { defaultCategory?: RaceIdentifier; content: RacialDisparitiesNarrativeContent; categoryFilter?: DemographicCategoryFilter["raceOrEthnicity"]; + rootStore?: RootStore; }; /** @@ -183,6 +185,8 @@ export default class RacialDisparitiesNarrative implements Hydratable { error?: Error; + rootStore?: RootStore; + // filters readonly allCategories: RaceOrEthnicityCategory[]; @@ -201,6 +205,7 @@ export default class RacialDisparitiesNarrative implements Hydratable { defaultCategory, content, categoryFilter, + rootStore, }: ConstructorOpts) { this.tenantId = tenantId; this.selectedCategory = defaultCategory || "BLACK"; @@ -215,6 +220,7 @@ export default class RacialDisparitiesNarrative implements Hydratable { this.allCategories = createDemographicCategories({ raceOrEthnicity: categoryFilter, }).raceOrEthnicity; + this.rootStore = rootStore; makeAutoObservable(this, { records: observable.ref, @@ -233,6 +239,7 @@ export default class RacialDisparitiesNarrative implements Hydratable { sourceFileName: "racial_disparities", tenantId: this.tenantId, transformFn: createRacialDisparitiesRecords, + rootStore: this.rootStore, }); runInAction(() => { this.records = fetchedData.reduce((mapping, record) => { diff --git a/spotlight-client/src/contentModels/SupervisionSuccessRateMetric.ts b/spotlight-client/src/contentModels/SupervisionSuccessRateMetric.ts index 655f5a68..88c8ebb8 100644 --- a/spotlight-client/src/contentModels/SupervisionSuccessRateMetric.ts +++ b/spotlight-client/src/contentModels/SupervisionSuccessRateMetric.ts @@ -161,6 +161,7 @@ export default class SupervisionSuccessRateMetric extends Metric, - tenantId: TenantId + tenantId: TenantId, + rootStore?: RootStore ) { return createMetricMapping({ localityLabelMapping: allTenantContent.localities, @@ -97,6 +100,7 @@ function getMetricsForTenant( topologyMapping: allTenantContent.topologies, tenantId, demographicFilter: allTenantContent.demographicCategories, + rootStore, }); } @@ -123,10 +127,13 @@ function getSystemNarrativesForTenant({ /** * Factory function for creating an instance of the `Tenant` specified by `tenantId`. */ -export function createTenant({ tenantId }: TenantFactoryOptions): Tenant { +export function createTenant({ + tenantId, + rootStore, +}: TenantFactoryOptions): Tenant { const allTenantContent = retrieveContent({ tenantId }); - const metrics = getMetricsForTenant(allTenantContent, tenantId); + const metrics = getMetricsForTenant(allTenantContent, tenantId, rootStore); const racialDisparitiesNarrative = allTenantContent.racialDisparitiesNarrative && @@ -134,6 +141,7 @@ export function createTenant({ tenantId }: TenantFactoryOptions): Tenant { tenantId, content: allTenantContent.racialDisparitiesNarrative, categoryFilter: allTenantContent.demographicCategories?.raceOrEthnicity, + rootStore, }); return new Tenant({ diff --git a/spotlight-client/src/contentModels/createMetricMapping.ts b/spotlight-client/src/contentModels/createMetricMapping.ts index 7ce2b06e..1121c80c 100644 --- a/spotlight-client/src/contentModels/createMetricMapping.ts +++ b/spotlight-client/src/contentModels/createMetricMapping.ts @@ -54,6 +54,7 @@ import SupervisionSuccessRateMetric from "./SupervisionSuccessRateMetric"; import { ERROR_MESSAGES } from "../constants"; import { NOFILTER_KEY, TOTAL_KEY } from "../demographics"; import { colors } from "../UiLibrary"; +import RootStore from "../DataStore/RootStore"; type MetricMappingFactoryOptions = { localityLabelMapping?: TenantContent["localities"]; @@ -61,6 +62,7 @@ type MetricMappingFactoryOptions = { topologyMapping?: TenantContent["topologies"]; tenantId: TenantId; demographicFilter?: TenantContent["demographicCategories"]; + rootStore?: RootStore; }; /** * Factory function for converting a mapping of content objects by metric ID @@ -74,6 +76,7 @@ export default function createMetricMapping({ topologyMapping, tenantId, demographicFilter, + rootStore, }: MetricMappingFactoryOptions): MetricMapping { const metricMapping: MetricMapping = new Map(); @@ -113,6 +116,7 @@ export default function createMetricMapping({ localityLabels: localityLabelMapping.Sentencing, dataTransformer: sentencePopulationCurrent, sourceFileName: "sentence_type_by_district_by_demographics", + rootStore, }) ); else throw new Error(totalLabelError); @@ -134,6 +138,7 @@ export default function createMetricMapping({ localityLabels: localityLabelMapping.Sentencing, dataTransformer: sentenceTypesCurrent, sourceFileName: "sentence_type_by_district_by_demographics", + rootStore, }) ); break; @@ -155,6 +160,7 @@ export default function createMetricMapping({ dataTransformer: prisonPopulationCurrent, sourceFileName: "incarceration_population_by_facility_by_demographics", + rootStore, }) ); else throw new Error(totalLabelError); @@ -177,6 +183,7 @@ export default function createMetricMapping({ dataTransformer: prisonPopulationCurrent, sourceFileName: "community_corrections_population_by_facility_by_demographics", + rootStore, }) ); else throw new Error(totalLabelError); @@ -198,6 +205,7 @@ export default function createMetricMapping({ dataTransformer: probationPopulationCurrent, sourceFileName: "supervision_population_by_district_by_demographics", + rootStore, }) ); else throw new Error(totalLabelError); @@ -219,6 +227,7 @@ export default function createMetricMapping({ dataTransformer: parolePopulationCurrent, sourceFileName: "supervision_population_by_district_by_demographics", + rootStore, }) ); else throw new Error(totalLabelError); @@ -236,6 +245,7 @@ export default function createMetricMapping({ localityLabels: undefined, dataTransformer: prisonPopulationHistorical, sourceFileName: "incarceration_population_by_month_by_demographics", + rootStore, }) ); break; @@ -252,6 +262,7 @@ export default function createMetricMapping({ localityLabels: undefined, dataTransformer: probationPopulationHistorical, sourceFileName: "supervision_population_by_month_by_demographics", + rootStore, }) ); break; @@ -268,6 +279,7 @@ export default function createMetricMapping({ localityLabels: undefined, dataTransformer: parolePopulationHistorical, sourceFileName: "supervision_population_by_month_by_demographics", + rootStore, }) ); break; @@ -291,6 +303,7 @@ export default function createMetricMapping({ mapData: topologyMapping?.ProgramRegions, dataTransformer: probationProgramParticipationCurrent, sourceFileName: "active_program_participation_by_region", + rootStore, }) ); break; @@ -314,6 +327,7 @@ export default function createMetricMapping({ mapData: topologyMapping?.ProgramRegions, dataTransformer: paroleProgramParticipationCurrent, sourceFileName: "active_program_participation_by_region", + rootStore, }) ); break; @@ -336,6 +350,7 @@ export default function createMetricMapping({ demographicDataTransformer: probationSuccessRateDemographics, demographicSourceFileName: "supervision_success_by_period_by_demographics", + rootStore, }) ); break; @@ -358,6 +373,7 @@ export default function createMetricMapping({ demographicDataTransformer: paroleSuccessRateDemographics, demographicSourceFileName: "supervision_success_by_period_by_demographics", + rootStore, }) ); break; @@ -380,6 +396,7 @@ export default function createMetricMapping({ demographicDataTransformer: paroleTerminationRateDemographics, demographicSourceFileName: "supervision_terminations_by_period_by_demographics", + rootStore, }) ); break; @@ -397,6 +414,7 @@ export default function createMetricMapping({ dataTransformer: probationRevocationReasons, sourceFileName: "supervision_revocations_by_period_by_type_by_demographics", + rootStore, }) ); break; @@ -414,6 +432,7 @@ export default function createMetricMapping({ dataTransformer: paroleRevocationReasons, sourceFileName: "supervision_revocations_by_period_by_type_by_demographics", + rootStore, }) ); break; @@ -437,6 +456,7 @@ export default function createMetricMapping({ return prisonAdmissionReasons(rawRecords, fieldMapping); }, sourceFileName: "incarceration_population_by_admission_reason", + rootStore, }) ); break; @@ -453,6 +473,7 @@ export default function createMetricMapping({ localityLabels: undefined, dataTransformer: prisonReleaseTypes, sourceFileName: "incarceration_releases_by_type_by_period", + rootStore, }) ); break; @@ -469,6 +490,7 @@ export default function createMetricMapping({ localityLabels: undefined, dataTransformer: recidivismRateAllFollowup, sourceFileName: "recidivism_rates_by_cohort_by_year", + rootStore, }) ); break; @@ -486,6 +508,7 @@ export default function createMetricMapping({ localityLabels: undefined, dataTransformer: recidivismRateConventionalFollowup, sourceFileName: "recidivism_rates_by_cohort_by_year", + rootStore, }) ); break; @@ -503,6 +526,7 @@ export default function createMetricMapping({ dataTransformer: prisonStayLengths, sourceFileName: "incarceration_lengths_by_demographics", color: colors.dataVizNamed.teal, + rootStore, }) ); break; diff --git a/spotlight-client/src/metricsApi/fetchMetrics.ts b/spotlight-client/src/metricsApi/fetchMetrics.ts index 2ee1d376..363b3c0b 100644 --- a/spotlight-client/src/metricsApi/fetchMetrics.ts +++ b/spotlight-client/src/metricsApi/fetchMetrics.ts @@ -17,7 +17,7 @@ import { ERROR_MESSAGES } from "../constants"; import { TenantId } from "../contentApi/types"; -import DataStore from "../DataStore"; +import RootStore from "../DataStore/RootStore"; /** * All data comes back from the server as string values; @@ -31,6 +31,7 @@ type ErrorAPIResponse = { error: string }; type FetchMetricOptions = { metricNames: string[]; tenantId: TenantId; + rootStore?: RootStore; }; /** @@ -39,8 +40,11 @@ type FetchMetricOptions = { export async function fetchMetrics({ metricNames, tenantId, + rootStore, }: FetchMetricOptions): Promise { - const token = await DataStore.userStore.getToken(); + // we need some way to get the auth token from the userStore, so we pass a reference to the method in this way. + // ideally fetching metrics should be handled in its own mobx store rather than in the content models. See issue #560 + const token = await rootStore?.userStore.getToken(); const response = await fetch( `${process.env.REACT_APP_API_URL}/api/${tenantId}/public`, { @@ -77,14 +81,17 @@ export async function fetchAndTransformMetric({ sourceFileName, tenantId, transformFn, + rootStore, }: { sourceFileName: string; tenantId: TenantId; transformFn: (d: RawMetricData) => RecordFormat[]; + rootStore?: RootStore; }): Promise { const apiResponse = await fetchMetrics({ metricNames: [sourceFileName], tenantId, + rootStore, }); const rawData = apiResponse[sourceFileName];