Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add optional Auth0 integration #229

Merged
merged 12 commits into from
Oct 19, 2020
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@
}
},
"lint-staged": {
"*.{ts,tsx}": "bash -c tsc",
"*.{js,jsx,ts,tsx,css,scss}": "eslint --fix",
"*.json": "prettier --write",
"**/*.{ts,tsx}": "bash -c tsc",
"**/*.{js,jsx,ts,tsx,css,scss}": "eslint --fix",
"**/*.json": "prettier --write"
"**.{ts,tsx}": [
"bash -c tsc",
"eslint --fix"
],
"**.{js,jsx}": "eslint --fix",
"**.json": "prettier --write"
}
}
2 changes: 2 additions & 0 deletions spotlight-client/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
REACT_APP_AUTH_ENABLED={FLAG}
REACT_APP_AUTH_ENV={ENV_NAME}
19 changes: 19 additions & 0 deletions spotlight-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ If you have followed the [setup instructions](../README.md#getting-set-up) in th

We suggest installing a linting package for your preferred code editor that hooks into [eslint](#yarn-lint). We recommend [linter-eslint](https://atom.io/packages/linter-eslint) if you're using Atom.

#### Environment variables

Second and last, set up your environment variables. Copy the `.env.example` file and set variables accordingly per environment. The app can be deployed to both staging and production environments. Staging relies on environment variables stored in `.env.development` and production relies on variables in `.env.production`. Local relies on `.env.development.local`.

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`.

(Note that variables must be prefixed with `REACT_APP_` to be available inside the client application.)

The build process, as described below, ensures that the proper values are compiled and included in the static bundle at build time, for the right environment.

### Running the application locally

`yarn dev` will start a Webpack development server on port `3000` and open the homepage in your browser.
Expand All @@ -21,6 +34,12 @@ The development servers will remain active until you either close your terminal

**Note:** The frontend server does not need to be restarted when frontend source code is modified. The assets will automatically be recompiled and the browser will be refreshed.

### Authentication

This app may optionally be authenticated via [Auth0](https://auth0.com/). Auth0 settings can be inspected in the `AuthProvider` component, which wraps the entire application in a global React context using the [`@auth0/auth0-react`](https://www.npmjs.com/package/@auth0/auth0-react) library.

There is no per-view authentication; enabling auth (via environment variable, as described above) protects the entire application. We currently only enable this on our staging environment. If you are setting this app up completely fresh, you will need to create your own Auth0 account on the staging site in order to access it.

## Deploys

Not yet implemented! Instructions will be found here once a process is in place.
Expand Down
49 changes: 49 additions & 0 deletions spotlight-client/auth0/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Auth0 Integration

This application uses Auth0 as its authentication and authorization service. Auth0 provides a number
of configuration options and can integrate with apps in a variety of ways.

If you are so inclined to set up an Auth0 tenant, either in the event of disaster recovery or to
support a new environment, these instructions should help.

## Initial Setup

Follow the initial setup instructions provided by Auth0 for creating a new account, or creating a
new tenant within an existing account. Specifically, follow the quickstart for React apps
[here](https://auth0.com/docs/quickstart/spa/react). Whether you are doing this only to test the
app in full auth mode or for production purposes, you should at least start by configuring all of
the various urls in that quickstart with the set of localhost urls, e.g. `http://localhost:3000` for
the callback urls and logout urls.

When going through the quickstart, you do not need to perform any of the coding-related steps.
However, it would still be wise to read these steps to understand how the system works as a whole.

### Connections

At present, the app uses only the _Database_ connection, which provides basic username-password
credential authentication. Other connections may be added in the future.

## Rules and Hooks

Auth0 has a system of [rules](https://auth0.com/docs/rules) and [hooks](https://auth0.com/docs/hooks)
for expanding functionality. The names, order, and actual code for rules and hooks are configured in
the Auth0 dashboard itself. However, for the sake of tracking updates, we commit those same rules
and hooks in `auth0/`, even though the files therein _are not_ used by the app in any way.

### Usage

For each file in `auth0/rules/`, create a new rule. The name does not matter beyond reminding
you what is in each rule at a glance. Copy and paste the full content of the file into the rule's
code and save it. Order the rules in the same order they are in within the `/rules/` folder.

Do the same thing for `auth0/hooks`, but creating a new hook for each file instead of a new
rule. There are different kinds of hooks that execute at different points in the authentication
workflow. Each hook file should have a suffix indicating which type of hook it should be created as.

## Logging

Auth0 maintains good logging for all interactions with Auth0 APIs. For compliance reasons,
specifically the need to store authentication logs for longer retention periods, we copy Auth0 logs
to segment. You can set this up by creating an Auth0 [extension](https://auth0.com/docs/extensions).
If you are in a situation where you need to do this for Recidiviz, speak to someone internally about
how to configure this.
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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 <https://www.gnu.org/licenses/>.
// =============================================================================

/**
@param {object} user - The user being created
@param {string} user.tenant - Auth0 tenant name
@param {string} user.username - user name
@param {string} user.password - user's password
@param {string} user.email - email
@param {boolean} user.emailVerified - is e-mail verified?
@param {string} user.phoneNumber - phone number
@param {boolean} user.phoneNumberVerified - is phone number verified?
@param {object} context - Auth0 connection and other context info
@param {string} context.requestLanguage - language of the client agent
@param {object} context.connection - information about the Auth0 connection
@param {object} context.connection.id - connection id
@param {object} context.connection.name - connection name
@param {object} context.connection.tenant - connection tenant
@param {object} context.webtask - webtask context
@param {function} cb - function (error, response)
*/
module.exports = function (user, context, cb) {
const response = {};

const whitelist = []; // add authorized domains here
const userHasAccess = whitelist.some(function (domain) {
const emailSplit = user.email.split("@");
return emailSplit[emailSplit.length - 1].toLowerCase() === domain;
});

if (userHasAccess) {
response.user = user;
cb(null, response);
} else {
cb("Access denied.", null);
}
};
1 change: 1 addition & 0 deletions spotlight-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test": "react-scripts test"
},
"dependencies": {
"@auth0/auth0-react": "^1.1.0",
"@types/react": "^16.9.0",
"@types/react-dom": "^16.9.0",
"react": "^16.13.1",
Expand Down
111 changes: 108 additions & 3 deletions spotlight-client/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,118 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// =============================================================================

import React from "react";
import { Auth0Provider, useAuth0 } from "@auth0/auth0-react";
import { render } from "@testing-library/react";
import React from "react";
import App from "./App";
import { getAuthSettings, isAuthEnabled } from "./AuthWall/utils";

test("does not explode", () => {
const { getByText } = render(<App />);
const { getByRole } = render(<App />);
// seems like a pretty safe bet this word will always be there somewhere!
const websiteName = getByText(/spotlight/i);
const websiteName = getByRole("heading", /spotlight/i);
expect(websiteName).toBeInTheDocument();
});

jest.mock("./AuthWall/utils", () => ({
getAuthSettings: jest.fn(),
isAuthEnabled: jest.fn(),
}));
const getAuthSettingsMock = getAuthSettings as jest.MockedFunction<
typeof getAuthSettings
>;

const isAuthEnabledMock = isAuthEnabled as jest.MockedFunction<
typeof isAuthEnabled
>;

// Although mocking the Auth0 library is not necessarily a great practice,
// it has a number of side effects (e.g. issuing XHRs, navigating to new URLs)
// that are challenging to handle or even simulate in this test environment,
// so this seemed like the better solution here
jest.mock("@auth0/auth0-react", () => {
return {
Auth0Provider: jest.fn(),
useAuth0: jest.fn(),
};
});

describe("with auth required", () => {
const MOCK_DOMAIN = "test.local";
const MOCK_CLIENT_ID = "abcdef";

beforeEach(() => {
// mock the environment configuration to enable auth
getAuthSettingsMock.mockReturnValue({
domain: MOCK_DOMAIN,
clientId: MOCK_CLIENT_ID,
});
isAuthEnabledMock.mockReturnValue(true);
});

afterEach(() => {
jest.restoreAllMocks();
});

test("require auth for the entire site", async () => {
// mock the auth0 provider
const PROVIDER_TEST_ID = "Auth0Provider";
(Auth0Provider as jest.Mock).mockImplementation(({ children }) => {
// here we mock just enough to verify that the context provider is being included;
// we can use this as a proxy for the relationship between provider and hook.
return <div data-testid={PROVIDER_TEST_ID}>{children}</div>;
});

// mock the auth0 hook
const mockLoginWithRedirect = jest.fn();
(useAuth0 as jest.Mock).mockReturnValue({
isAuthenticated: false,
loginWithRedirect: mockLoginWithRedirect,
});

const { queryByRole, getByRole, getByTestId } = render(<App />);

// verify that we have included an Auth0Provider
expect(getByTestId(PROVIDER_TEST_ID)).toBeInTheDocument();

// verify that we supplied that provider with
// the settings designated by our mock environment
expect((Auth0Provider as jest.Mock).mock.calls[0][0]).toEqual(
expect.objectContaining({ domain: MOCK_DOMAIN, clientId: MOCK_CLIENT_ID })
);

// verify that we have initiated an Auth0 login
expect(mockLoginWithRedirect.mock.calls.length).toBe(1);

// application contents should not have been rendered unauthed
expect(queryByRole("heading", { name: /spotlight/i })).toBeNull();
expect(getByRole("status", { name: /loading/i })).toBeInTheDocument();
});

test("require email verification for authed users", () => {
(useAuth0 as jest.Mock).mockReturnValue({
isAuthenticated: true,
user: {
email_verified: false,
},
});

const { getByRole, queryByRole } = render(<App />);
// application contents should not have been rendered without verification
expect(queryByRole("heading", { name: /spotlight/i })).toBeNull();
// there should be a message about the verification requirement
expect(getByRole("heading", { name: /verification/i })).toBeInTheDocument();
});

test("loading state", () => {
(useAuth0 as jest.Mock).mockReturnValue({
isLoading: true,
});

const { queryByRole, getByRole } = render(<App />);

// application contents should not have been rendered while auth is pending
expect(queryByRole("heading", { name: /spotlight/i })).toBeNull();
expect(getByRole("status", { name: /loading/i })).toBeInTheDocument();
});
});
17 changes: 10 additions & 7 deletions spotlight-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@
// =============================================================================

import React from "react";
import AuthWall from "./AuthWall";

function App(): JSX.Element {
const App: React.FC = () => {
return (
<div>
<header>
<h1>Spotlight</h1>
</header>
</div>
<AuthWall>
<div>
<header>
<h1>Spotlight</h1>
</header>
</div>
</AuthWall>
);
}
};

export default App;
Loading