Skip to content

Commit

Permalink
Merge f56ef81 into baee38c
Browse files Browse the repository at this point in the history
  • Loading branch information
terryttsai committed Apr 28, 2022
2 parents baee38c + f56ef81 commit c4b0529
Show file tree
Hide file tree
Showing 9 changed files with 246 additions and 11 deletions.
4 changes: 4 additions & 0 deletions spotlight-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ The site consumes views of aggregate population data produced by the Recidiviz d

While Spotlight is developed and deployed as a single multi-tenant website, it is primarily consumed as separate single-tenant experiences, under `.gov` subdomains owned by our state partners (e.g. [dashboard.docr.nd.gov](https://dashboard.docr.nd.gov)). To keep our infrastructure simple, this "tenant lock" is implemented in application logic within the data models, based on the URL hostname at runtime. This is why the multi-tenant "homepage" is so plain; it is really only used internally, for convenience, in development and staging environments.

We also lock the staging environment to a single tenant depending on how the logged-in user is configured. In the staging environment, when a user logs in to view the site,we set their `state_code` based on their domain in their `app_metadata` if it's not already set. If their state_code is not `recidiviz`, then they will be locked-in to the tenant that correspons with that `state_code`. This is so we can share a fully-functional but private version of the app with contacts of that associated agency, without exposing data to state actors that do not have permission to view other states’ data.

If their `state_code` is not one of our supported Spotlight tenants, they will only see the "Page Not Found" page. The `state_code` can be configured by going to the `recidiviz-spotlight-staging` tenant in Auth0, looking up the user and modifying the `state_code` in their `app_metadata`.

### Configuration and content

At its core this application is driven by a set of configuration objects, which are JavaScript objects that determine which states (or "Tenants") are displayed; which Narratives and Metrics will appear for each Tenant and what copy will appear on each of those pages (all of which is collectively referred to here as "Content"); and various other settings that can be changed per Tenant.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Handler that will be called during the execution of a PostLogin flow.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onExecutePostLogin = async (event, api) => {
const namespace = "https://recidiviz.org"; // namespace has to be an HTTP URL
// https://auth0.com/docs/actions/triggers/post-login
// https://auth0.com/docs/get-started/apis/scopes/sample-use-cases-scopes-and-claims
// https://auth0.com/docs/secure/tokens/json-web-tokens/create-namespaced-custom-claims
const acceptedStateCodes = ["nd", "pa"];
const emailSplit = event.user.email && event.user.email.split("@");
const userDomain = emailSplit?.[emailSplit.length - 1].toLowerCase();
if (
userDomain &&
!event.user.app_metadata.state_code &&
!event.user?.app_metadata.recidiviz_tester
) {
if (userDomain === "recidiviz.org") {
api.user.setAppMetadata("state_code", "recidiviz");
api.idToken.setCustomClaim(`${namespace}/app_metadata`, {
...event.user.app_metadata,
state_code: "recidiviz",
});
return;
}

/** 2. Add user's state_code to the app_metadata */
const domainSplit = userDomain.split(".");

const tld = domainSplit[domainSplit.length - 1].toLowerCase();

// assumes the state is always the second to last component of the domain
// e.g. @doc.mo.gov or @nd.gov, but not @nd.docr.gov
const state = domainSplit[domainSplit.length - 2].toLowerCase();

if (tld === "gov" && acceptedStateCodes.includes(state)) {
const stateCode = `us_${state}`;
api.user.setAppMetadata("state_code", stateCode);
api.idToken.setCustomClaim(`${namespace}/app_metadata`, {
...event.user.app_metadata,
state_code: stateCode,
});
return;
}
}
api.idToken.setCustomClaim(
`${namespace}/app_metadata`,
event.user.app_metadata
);
};

/**
* Handler that will be invoked when this action is resuming after an external redirect. If your
* onExecutePostLogin function does not perform a redirect, this function can be safely ignored.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
// exports.onContinuePostLogin = async (event, api) => {
// };
77 changes: 75 additions & 2 deletions spotlight-client/src/App-auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// =============================================================================

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
Expand All @@ -36,11 +38,13 @@ export {};
const mockGetUser = jest.fn();
const mockIsAuthenticated = jest.fn();
const mockLoginWithRedirect = jest.fn();
const mockGetIdTokenClaims = jest.fn();
jest.mock("@auth0/auth0-spa-js", () =>
jest.fn().mockResolvedValue({
getUser: mockGetUser,
isAuthenticated: mockIsAuthenticated,
loginWithRedirect: mockLoginWithRedirect,
getIdTokenClaims: mockGetIdTokenClaims,
})
);

Expand Down Expand Up @@ -77,6 +81,8 @@ afterEach(() => {
});

test("no auth required", async () => {
process.env.REACT_APP_AUTH_ENABLED = "false";

const App = await getApp();
render(<App />);
// site home redirects to the ND home
Expand Down Expand Up @@ -117,6 +123,7 @@ test("requires email verification", async () => {
// user is authenticated but not verified
mockIsAuthenticated.mockResolvedValue(true);
mockGetUser.mockResolvedValue({ email_verified: false });
mockGetIdTokenClaims.mockResolvedValue({});

const App = await getApp();
render(<App />);
Expand All @@ -130,14 +137,19 @@ test("requires email verification", async () => {
});
});

test("renders when authenticated", async () => {
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";

// user is authenticated and verified
// 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]: {
state_code: "recidiviz",
},
});
const App = await getApp();
render(<App />);
await waitFor(() => {
Expand All @@ -148,6 +160,67 @@ test("renders when authenticated", async () => {
});
});

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";

// 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]: {
state_code: "us_nd",
},
});
const App = await getApp();
render(<App />);
await waitFor(() => {
const websiteName = screen.getByRole("heading", /North Dakota/i);
expect(websiteName).toBeInTheDocument();
});
});

test("renders when authenticated and state_code is NOT one of our tenants", async () => {
// configure environment for valid authentication
process.env.REACT_APP_AUTH_ENABLED = "true";
process.env.REACT_APP_AUTH_ENV = "development";

// 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]: {
state_code: "invalid",
},
});
const App = await getApp();
render(<App />);
await waitFor(() => {
const websiteName = screen.getByRole("heading", /Page Not Found/i);
expect(websiteName).toBeInTheDocument();
});
});

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";

// 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]: {},
});
const App = await getApp();
render(<App />);
await waitFor(() => {
const websiteName = screen.getByRole("heading", /Page Not Found/i);
expect(websiteName).toBeInTheDocument();
});
});

test("handles an Auth0 configuration error", async () => {
// configure environment for valid authentication
process.env.REACT_APP_AUTH_ENABLED = "true";
Expand Down
21 changes: 18 additions & 3 deletions spotlight-client/src/DataStore/TenantStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ export default class TenantStore {

private validatedSectionNumber?: number;

readonly locked: boolean = false;

rootStore: RootStore;

tenants: Map<TenantId, Tenant>;
Expand All @@ -52,13 +50,30 @@ export default class TenantStore {
// tenant mapped from domain should be locked
const tenantFromDomain = getTenantFromDomain();
if (tenantFromDomain) {
this.locked = true;
this.currentTenantId = tenantFromDomain;
// returning null renders an observable property immutable
intercept(this, "currentTenantId", () => null);
}
}

/**
* Whether or not the app is locked to a single state depends on the following factors:
* - If the app is deployed to production:
* - Authentication is turned off. The app should be locked to the domain of the state that it's deployed on.
* - If the app is deployed on staging:
* - Authentication is turned on. When a user logs in, we check for `state_code` in the account's `app_metadata`.
* - If the state_code matches one of our tenantIds, we lock the app to that state_code.
* - If there is no state_code, or it doesn't match any of our tenantIds, the user sees a "Page Not Found" error.
* - If the state_code is `recidiviz`, the app is unlocked.
* -
*/
get locked(): boolean {
if (!this.rootStore.userStore.isAuthRequired) {
return !!getTenantFromDomain();
}
return this.rootStore.userStore.stateCode !== "RECIDIVIZ";
}

/**
* Retrieves the current tenant from the mapping of available tenants,
* as indicated by this.currentTenantId.
Expand Down
57 changes: 56 additions & 1 deletion spotlight-client/src/DataStore/UserStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
// =============================================================================

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";

jest.mock("@auth0/auth0-spa-js");
Expand All @@ -27,6 +28,7 @@ const mockGetUser = jest.fn();
const mockHandleRedirectCallback = jest.fn();
const mockIsAuthenticated = jest.fn();
const mockLoginWithRedirect = jest.fn();
const mockGetIdTokenClaims = jest.fn();

const testAuthSettings = {
domain: "example.com",
Expand All @@ -40,6 +42,7 @@ beforeEach(() => {
handleRedirectCallback: mockHandleRedirectCallback,
isAuthenticated: mockIsAuthenticated,
loginWithRedirect: mockLoginWithRedirect,
getIdTokenClaims: mockGetIdTokenClaims,
});
});

Expand Down Expand Up @@ -78,6 +81,7 @@ test("authorize requires Auth0 client settings", async () => {
test("authorized when authenticated", async () => {
mockIsAuthenticated.mockResolvedValue(true);
mockGetUser.mockResolvedValue({ email_verified: true });
mockGetIdTokenClaims.mockResolvedValue({});

const store = new UserStore({
authSettings: testAuthSettings,
Expand Down Expand Up @@ -111,6 +115,7 @@ test("requires email verification", async () => {

mockGetUser.mockResolvedValue({ email_verified: false });
mockIsAuthenticated.mockResolvedValue(true);
mockGetIdTokenClaims.mockResolvedValue({});

const store = new UserStore({
authSettings: testAuthSettings,
Expand Down Expand Up @@ -181,3 +186,53 @@ test("passes target URL to callback", async () => {
await store.authorize({ handleTargetUrl: callback });
expect(callback.mock.calls[0][0]).toBe(targetUrl);
});

test("retrieves the state code from app_metadata and sets tenantStore's currentTenantId", async () => {
mockIsAuthenticated.mockResolvedValue(true);
mockGetUser.mockResolvedValue({ email_verified: true });
mockGetIdTokenClaims.mockResolvedValue({
[AUTH0_APP_METADATA_KEY]: {
state_code: "us_nd",
},
});

const rootStore = new RootStore();

const userStore = new UserStore({
authSettings: testAuthSettings,
isAuthRequired: true,
rootStore,
});
await userStore.authorize();
reactImmediately(() => {
expect(userStore.isAuthorized).toBe(true);
expect(userStore.isLoading).toBe(false);
expect(userStore.stateCode).toBe("US_ND");
expect(rootStore.tenantStore.currentTenantId).toBe("US_ND");
});
expect.hasAssertions();
});

test("retrieves no state code from app_metadata", async () => {
mockIsAuthenticated.mockResolvedValue(true);
mockGetUser.mockResolvedValue({ email_verified: true });
mockGetIdTokenClaims.mockResolvedValue({
[AUTH0_APP_METADATA_KEY]: {},
});

const rootStore = new RootStore();

const userStore = new UserStore({
authSettings: testAuthSettings,
isAuthRequired: true,
rootStore,
});
await userStore.authorize();
reactImmediately(() => {
expect(userStore.isAuthorized).toBe(true);
expect(userStore.isLoading).toBe(false);
expect(userStore.stateCode).toBe(undefined);
expect(rootStore.tenantStore.currentTenantId).toBe(undefined);
});
expect.hasAssertions();
});
Loading

0 comments on commit c4b0529

Please sign in to comment.