Skip to content

Commit

Permalink
enable option to require authentication for abs-h (microsoft#817)
Browse files Browse the repository at this point in the history
* add auth utils with method to get user token

* move loading project into main router

* update launch config for server

* enable absh as auth provider on server

* use COMPOSER_* for client env vars

* update client-side auth

* move action types to actions dir

start to make use of discriminated union types to strongly type the store dispatch

* move project templates into store

allows us to handle error conditions better

* handle session expired

* move token management to store

allows us to use store mechanics to handle when the user's session state changes

* use store to login user in component

* handle blocked popups

use a redirect strategy if a browser blocks popups

* only cancel api requests after getting 401

* show message and prompt user to login when session expires

* use different env vars to construct login url

* add authentication doc

* decode user token and put results into state

* refresh user token 5 minutes before it expires

* add test for jwt expiration

* update session expired message

* get auth resource from env var

* fix bad merge

* correct usage of fetchTemplates

* fix nav bar spec

when landing on home page, the bot won't automatically load

* use lodash.once

* only set up axios when auth is required
  • Loading branch information
a-b-r-o-w-n authored and cwhitten committed Sep 9, 2019
1 parent 6e6fab3 commit e0ec01f
Show file tree
Hide file tree
Showing 34 changed files with 1,015 additions and 100 deletions.
31 changes: 11 additions & 20 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@
"type": "node",
"request": "launch",
"name": "Server: Launch",
"runtimeArgs": [
"-r",
"ts-node/register"
],
"args": [
"${workspaceFolder}/Composers/packages/server/src/server.ts"
],
"restart": true
"args": ["./build/server.js"],
"preLaunchTask": "server: build",
"restart": true,
"outFiles": ["./build/*"],
"envFile": "${workspaceFolder}/Composer/packages/server/.env",
"outputCapture": "std",
"cwd": "${workspaceFolder}/Composer/packages/server"
},
{
"type": "node",
Expand All @@ -21,14 +20,8 @@
"name": "Jest Debug",
"program": "${workspaceRoot}/Composer/node_modules/jest/bin/jest",
"stopOnEntry": false,
"args": [
"--runInBand",
"--env=jsdom",
"--config=jest.config.js"
],
"runtimeArgs": [
"--inspect-brk"
],
"args": ["--runInBand", "--env=jsdom", "--config=jest.config.js"],
"runtimeArgs": ["--inspect-brk"],
"cwd": "${workspaceRoot}/Composer/packages/server",
"sourceMaps": true,
"console": "integratedTerminal"
Expand All @@ -40,9 +33,7 @@
"port": 9229,
"sourceMaps": true,
"restart": true,
"outFiles": [
"${workspaceFolder}/Composer/pacakges/server/build/**/*.js"
]
"outFiles": ["${workspaceFolder}/Composer/pacakges/server/build/**/*.js"]
}
]
}
}
14 changes: 14 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "server: build",
"type": "npm",
"script": "build",
"path": "Composer/packages/server/",
"problemMatcher": []
}
]
}
2 changes: 2 additions & 0 deletions Composer/cypress/integration/LeftNavBar.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
context('check Nav Expandion ', () => {
beforeEach(() => {
cy.visit(Cypress.env('COMPOSER_URL'));
cy.openBot('ToDoBot');
});

it('can expand left Nav Bar', () => {
cy.get('[data-testid="LeftNavButton"]').click();
cy.get('[data-testid="LeftNav-CommandBarButtonDesign Flow"]').should('exist');
Expand Down
27 changes: 27 additions & 0 deletions Composer/packages/client/__tests__/utils/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { isTokenExpired } from '../../src/utils/auth';

// token that expires on Sep 5, 2019 @ 14:00 PDT
const jwtToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1Njc3MTcyMDB9.YZbb01qF36O-GbKNOqxsuZe1fOg3kUtcimRUGHp42VI';

describe('isTokenExpired', () => {
it('is false when token is valid', () => {
// @ts-ignore
Date.now = jest.spyOn(Date, 'now').mockImplementation(() => 1567630800000); // 2019-09-04 14:00 PDT
expect(isTokenExpired(jwtToken)).toBe(false);
});

it('is true when token cannot be decoded', () => {
expect(isTokenExpired('invalid token')).toBe(true);
});

it('is true when token is expired', () => {
// @ts-ignore
Date.now = jest.spyOn(Date, 'now').mockImplementation(() => 1567717200000); // 2019-09-05 14:00 PDT
expect(isTokenExpired(jwtToken)).toBe(true);

// @ts-ignore
Date.now = jest.spyOn(Date, 'now').mockImplementation(() => 1567803600000); // 2019-09-06 14:00 PDT
expect(isTokenExpired(jwtToken)).toBe(true);
});
});
6 changes: 3 additions & 3 deletions Composer/packages/client/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,13 @@ process.env.NODE_PATH = (process.env.NODE_PATH || '')
.map(folder => path.resolve(appDirectory, folder))
.join(path.delimiter);

// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
// Grab NODE_ENV and COMPOSER_* environment variables and prepare them to be
// injected into the application via DefinePlugin in Webpack configuration.
const REACT_APP = /^REACT_APP_/i;
const COMPOSER = /^COMPOSER_/i;

function getClientEnvironment(publicUrl) {
const raw = Object.keys(process.env)
.filter(key => REACT_APP.test(key))
.filter(key => COMPOSER.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
Expand Down
5 changes: 5 additions & 0 deletions Composer/packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@
"identity-obj-proxy": "3.0.0",
"immer": "^2.1.4",
"jsonlint-webpack": "^1.1.0",
"jwt-decode": "^2.2.0",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
"lodash.find": "^4.6.0",
"lodash.findindex": "^4.6.0",
"lodash.findlastindex": "^4.6.0",
"lodash.get": "^4.4.2",
"lodash.once": "^4.1.1",
"lodash.set": "^4.3.2",
"lodash.startcase": "^4.4.0",
"mini-css-extract-plugin": "0.5.0",
Expand All @@ -55,6 +57,7 @@
"postcss-preset-env": "6.5.0",
"postcss-safe-parser": "4.0.1",
"prop-types": "^15.7.2",
"query-string": "^6.8.2",
"react": "^16.8.4",
"react-app-polyfill": "^0.2.1",
"react-codemirror2": "^5.1.0",
Expand Down Expand Up @@ -95,6 +98,8 @@
"@babel/runtime": "7.3.4",
"@emotion/babel-preset-css-prop": "^10.0.14",
"@types/jest": "^24.0.16",
"@types/jwt-decode": "^2.2.1",
"@types/lodash.once": "^4.1.6",
"@types/reach__router": "^1.2.4",
"@types/react": "^16.8.23",
"@types/react-dom": "^16.8.5",
Expand Down
35 changes: 18 additions & 17 deletions Composer/packages/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, { forwardRef } from 'react';
import { Fragment, useContext, useEffect, useState } from 'react';
import React, { forwardRef, useContext, useState } from 'react';
import { initializeIcons } from 'office-ui-fabric-react/lib/Icons';
import { IconButton } from 'office-ui-fabric-react/lib/Button';
import formatMessage from 'format-message';
Expand All @@ -12,13 +11,15 @@ import { StoreContext } from './store';
import { main, sideBar, content, divider, globalNav, leftNavBottom, rightPanel, dividerTop } from './styles';
import { resolveToBasePath } from './utils/fileUtil';
import { CreationFlow } from './CreationFlow';
import { ErrorBoundary } from './components/ErrorBoundary';
import { RequireAuth } from './components/RequireAuth';

initializeIcons(undefined, { disableWarnings: true });

// eslint-disable-next-line react/display-name
const Content = forwardRef<HTMLDivElement>((props, ref) => <div css={content} {...props} ref={ref} />);

const topLinks = [
const topLinks = (botLoaded: boolean) => [
{
to: '/home',
iconName: 'Home',
Expand All @@ -32,6 +33,7 @@ const topLinks = [
labelName: 'Design Flow',
activeIfUrlContains: 'dialogs',
exact: false,
underTest: !botLoaded,
},
{
to: '/test-conversation',
Expand All @@ -47,13 +49,15 @@ const topLinks = [
labelName: 'Bot Says',
activeIfUrlContains: 'language-generation',
exact: false,
underTest: !botLoaded,
},
{
to: 'language-understanding/',
iconName: 'People',
labelName: 'User Says',
activeIfUrlContains: 'language-understanding',
exact: false,
underTest: !botLoaded,
},
{
to: '/evaluate-performance',
Expand All @@ -69,6 +73,7 @@ const topLinks = [
labelName: 'Settings',
activeIfUrlContains: 'setting',
exact: false,
underTest: !botLoaded,
},
];

Expand All @@ -94,19 +99,11 @@ export const App: React.FC = () => {
const { state, actions } = useContext(StoreContext);
const [sideBarExpand, setSideBarExpand] = useState(false);
const { botName, creationFlowStatus } = state;
const { fetchProject, setCreationFlowStatus } = actions;
const { setCreationFlowStatus } = actions;
const mapNavItemTo = x => resolveToBasePath(BASEPATH, x);

useEffect(() => {
init();
}, []);

async function init() {
await fetchProject();
}

return (
<Fragment>
<>
<Header botName={botName} />
<div css={main}>
<nav css={sideBar(sideBarExpand)}>
Expand All @@ -123,7 +120,7 @@ export const App: React.FC = () => {
ariaLabel={sideBarExpand ? formatMessage('Collapse Nav') : formatMessage('Expand Nav')}
/>
<div css={dividerTop} />{' '}
{topLinks.map((link, index) => {
{topLinks(!!botName).map((link, index) => {
return (
<NavItem
key={'NavLeftBar' + index}
Expand Down Expand Up @@ -159,10 +156,14 @@ export const App: React.FC = () => {
</div>
</nav>
<div css={rightPanel}>
<CreationFlow creationFlowStatus={creationFlowStatus} setCreationFlowStatus={setCreationFlowStatus} />
<Routes component={Content} />
<ErrorBoundary>
<RequireAuth>
<CreationFlow creationFlowStatus={creationFlowStatus} setCreationFlowStatus={setCreationFlowStatus} />
<Routes component={Content} />
</RequireAuth>
</ErrorBoundary>
</div>
</div>
</Fragment>
</>
);
};
12 changes: 3 additions & 9 deletions Composer/packages/client/src/CreationFlow/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,25 @@ import { navigateTo } from './../utils/navigation';

export function CreationFlow(props) {
const { state, actions } = useContext(StoreContext);
const [templates, setTemplates] = useState([]);
const [bots, setBots] = useState([]);
const [step, setStep] = useState();
// eslint-disable-next-line react/prop-types
const { creationFlowStatus, setCreationFlowStatus } = props;
const { fetchTemplates, getAllProjects, openBotProject, createProject, saveProjectAs, saveTemplateId } = actions;
const { botName, templateId } = state;
const { botName, templateId, templateProjects } = state;

useEffect(() => {
init();
}, [creationFlowStatus]);

const getTemplates = async () => {
const data = await fetchTemplates();
setTemplates(data);
};

const getAllBots = async () => {
const data = await getAllProjects();
setBots(data);
};

const init = async () => {
if (creationFlowStatus !== CreationFlowStatus.CLOSE) {
getTemplates();
fetchTemplates();
await getAllBots();
}

Expand Down Expand Up @@ -115,7 +109,7 @@ export function CreationFlow(props) {
const steps = {
[Steps.CREATE]: {
...DialogInfo.CREATE_NEW_BOT,
children: <CreateOptions templates={templates} onDismiss={handleDismiss} onNext={handleCreateNext} />,
children: <CreateOptions templates={templateProjects} onDismiss={handleDismiss} onNext={handleCreateNext} />,
},
[Steps.LOCATION]: {
...DialogInfo.SELECT_LOCATION,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const ErrorPopup = props => {
};

ErrorPopup.propTypes = {
error: PropTypes.string,
error: PropTypes.node,
title: PropTypes.string,
onDismiss: PropTypes.func,
};
67 changes: 67 additions & 0 deletions Composer/packages/client/src/components/RequireAuth/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { useEffect, useState, useContext } from 'react';
import { Spinner, SpinnerSize, Dialog, DialogType, DialogFooter, PrimaryButton } from 'office-ui-fabric-react';
import formatMessage from 'format-message';
import once from 'lodash.once';

import { StoreContext } from '../../store';

import { loading, dialog, consoleStyle } from './styles';

// only attempt to login once
const loginOnce = once((login: () => void) => {
if (process.env.COMPOSER_REQUIRE_AUTH) {
login();
}
});

export const RequireAuth: React.FC = props => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const { state, actions } = useContext(StoreContext);
const { currentUser } = state;

useEffect(() => {
loginOnce(actions.loginUser);
}, []);

useEffect(() => {
setIsLoading(!currentUser.token);
}, [currentUser.token]);

const sessionExpiredDialog = currentUser.sessionExpired && (
<Dialog
hidden={false}
onDismiss={() => false}
dialogContentProps={{
type: DialogType.normal,
title: formatMessage('Session expired'),
styles: dialog,
}}
modalProps={{
isBlocking: false,
styles: { main: { maxWidth: 450 } },
}}
>
<div css={consoleStyle}>{formatMessage('Please log in before continuing.')}</div>
<DialogFooter>
<PrimaryButton onClick={() => actions.loginUser()} text={formatMessage('Login')} />
</DialogFooter>
</Dialog>
);

if (process.env.COMPOSER_REQUIRE_AUTH) {
if (!currentUser.sessionExpired && isLoading) {
return (
<div css={loading}>
<Spinner label={formatMessage('Loading...')} size={SpinnerSize.large} />
</div>
);
}
}

return (
<>
{sessionExpiredDialog}
{props.children}
</>
);
};
Loading

0 comments on commit e0ec01f

Please sign in to comment.