feat: Support for OpenID Connect (#2431)

* feat: Add OpenID Connect support, speed up docker rebuilds

* fix: Switch Google Cloud API URL to v1

* chore: Remove redux-oidc and use our own components instead
This commit is contained in:
Erik Ziegler authored and GitHub committed 2021-06-23 20:41:27 +02:00
1 parent 5643f8f6d2
commit e7b32cc51e
50 files changed
+2746 -2109

No files matched your search

+4
View File
@@ -112,6 +112,10 @@ module.exports = (env, argv) => {
historyApiFallback: {
disableDotRule: true,
},
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
},
});
+4 -2
View File
@@ -62,17 +62,19 @@
"dicom-parser": "^1.8.3",
"dotenv-webpack": "^1.7.0",
"hammerjs": "^2.0.8",
"history": "5.0.0",
"i18next": "^17.0.3",
"i18next-browser-languagedetector": "^3.0.1",
"lodash.isequal": "4.5.0",
"moment": "^2.24.0",
"oidc-client": "1.11.5",
"prop-types": "^15.7.2",
"query-string": "^6.12.1",
"react-dropzone": "^10.1.7",
"react-i18next": "^10.11.0",
"react-resize-detector": "^4.2.0",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0"
"react-router": "next",
"react-router-dom": "next"
},
"devDependencies": {
"@percy/cypress": "^2.3.0",
+44 -31
View File
@@ -3,7 +3,9 @@ import React from 'react';
import PropTypes from 'prop-types';
import i18n from '@ohif/i18n';
import { I18nextProvider } from 'react-i18next';
import { Router } from 'react-router-dom';
import { BrowserRouter } from 'react-router-dom';
import Compose from './routes/Mode/Compose.js';
import {
DialogProvider,
Modal,
@@ -13,22 +15,18 @@ import {
ViewportDialogProvider,
ViewportGridProvider,
CineProvider,
UserAuthenticationProvider,
} from '@ohif/ui';
// Viewer Project
// TODO: Should this influence study list?
import { AppConfigProvider } from '@state';
import createRoutes from './routes';
import appInit from './appInit.js';
import history from './history'
import OpenIdConnectRoutes from './utils/OpenIdConnectRoutes.jsx';
// TODO: Temporarily for testing
import '@ohif/mode-longitudinal';
/**
* ENV Variable to determine routing behavior
*/
const OHIFRouter = Router
let commandsManager, extensionManager, servicesManager, hotkeysManager;
function App({ config, defaultExtensions }) {
@@ -42,7 +40,8 @@ function App({ config, defaultExtensions }) {
// Set appConfig
const appConfigState = init.appConfig;
const { routerBasename, modes, dataSources } = appConfigState;
const { routerBasename, modes, dataSources, oidc } = appConfigState;
// Use config to create routes
const appRoutes = createRoutes({
modes,
@@ -50,38 +49,52 @@ function App({ config, defaultExtensions }) {
extensionManager,
servicesManager,
hotkeysManager,
routerBasename,
});
const {
UIDialogService,
UIModalService,
UINotificationService,
UIViewportDialogService,
ViewportGridService, // TODO: Should this be a "UI" Service?
CineService
ViewportGridService,
CineService,
UserAuthenticationService,
} = servicesManager.services;
const providers = [
[AppConfigProvider, { value: appConfigState }],
[UserAuthenticationProvider, { service: UserAuthenticationService}],
[I18nextProvider, { i18n }],
[ThemeWrapper],
[ViewportGridProvider, {service: ViewportGridService}],
[ViewportDialogProvider, {service: UIViewportDialogService}],
[CineProvider, {service: CineService}],
[SnackbarProvider, {service: UINotificationService}],
[DialogProvider, {service: UIDialogService}],
[ModalProvider, {service: UIModalService, modal: Modal}],
]
const CombinedProviders = ({ children }) =>
Compose({ components: providers, children });
let authRoutes = null;
if (oidc) {
UserAuthenticationService.set({ enabled: true });
authRoutes = (<OpenIdConnectRoutes
oidc={oidc}
routerBasename={routerBasename}
UserAuthenticationService={UserAuthenticationService}
/>)
}
return (
<AppConfigProvider value={appConfigState}>
<I18nextProvider i18n={i18n}>
<OHIFRouter basename={routerBasename} history={history}>
<ThemeWrapper>
<ViewportGridProvider service={ViewportGridService}>
<ViewportDialogProvider service={UIViewportDialogService}>
<CineProvider service={CineService}>
<SnackbarProvider service={UINotificationService}>
<DialogProvider service={UIDialogService}>
<ModalProvider modal={Modal} service={UIModalService}>
{appRoutes}
</ModalProvider>
</DialogProvider>
</SnackbarProvider>
</CineProvider>
</ViewportDialogProvider>
</ViewportGridProvider>
</ThemeWrapper>
</OHIFRouter>
</I18nextProvider>
</AppConfigProvider>
<CombinedProviders>
<BrowserRouter>
{authRoutes}
{appRoutes}
</BrowserRouter>
</CombinedProviders>
);
}
+6 -3
View File
@@ -13,11 +13,10 @@ import {
ViewportGridService,
HangingProtocolService,
CineService,
UserAuthenticationService,
// utils,
} from '@ohif/core';
// TODO -> this feels bad.
/**
* @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration
* @param {object[]} defaultExtensions - array of extension objects
@@ -60,6 +59,7 @@ function appInit(appConfigOrFunc, defaultExtensions) {
ViewportGridService,
HangingProtocolService,
CineService,
UserAuthenticationService,
]);
/**
@@ -75,13 +75,16 @@ function appInit(appConfigOrFunc, defaultExtensions) {
// TODO: We no longer init webWorkers at app level
// TODO: We no longer init the user Manager
if (!appConfig.modes) {
throw new Error('No modes are defined! Check your app-config.js');
}
// TODO: Remove this
if (!appConfig.modes.length) {
appConfig.modes.push(window.longitudinalMode);
// appConfig.modes.push(window.segmentationMode);
}
return {
appConfig,
commandsManager,
+1
View File
@@ -1,3 +1,4 @@
// TODO: replace with HistoryRouter https://github.com/ReactTraining/react-router/pull/7586
import { createBrowserHistory, createHashHistory } from 'history';
const useHashRouting = JSON.parse(process.env.USE_HASH_ROUTER);
const router = useHashRouting ? createHashHistory() : createBrowserHistory();
@@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
function CallbackPage({ userManager, onRedirectSuccess }) {
const onRedirectError = error => {
throw new Error(error);
};
userManager
.signinRedirectCallback()
.then(user => onRedirectSuccess(user))
.catch(error => onRedirectError(error));
// todo: add i18n (or return null?)
return <div>Redirecting...</div>;
}
CallbackPage.propTypes = {
userManager: PropTypes.object.isRequired,
};
export default CallbackPage;
@@ -3,8 +3,8 @@ import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { MODULE_TYPES } from '@ohif/core';
//
import { useAppConfig } from '@state';
import { extensionManager } from '../App.jsx';
import { useParams, useLocation } from 'react-router';
/**
* Uses route properties to determine the data source that should be passed
@@ -15,8 +15,10 @@ import { extensionManager } from '../App.jsx';
* @param {function} props.children - Layout Template React Component
*/
function DataSourceWrapper(props) {
const [appConfig] = useAppConfig();
const { children: LayoutTemplate, history, ...rest } = props;
const { children: LayoutTemplate, ...rest } = props;
const params = useParams();
const location = useLocation();
// TODO: Fetch by type, name, etc?
const dataSourceModules = extensionManager.modules[MODULE_TYPES.DATA_SOURCE];
// TODO: Good usecase for flatmap?
@@ -30,13 +32,10 @@ function DataSourceWrapper(props) {
return acc.concat(mods);
}, []);
// Grabbing first for now. This isn't hydrated yet, but we should
// hydrate it somewhere based on config...
// ~ default.js
const firstAppConfigDataSource = appConfig.dataSources[0];
const dataSourceConfig = firstAppConfigDataSource.configuration;
const firstWebApiDataSource = webApiDataSources[0];
const dataSource = firstWebApiDataSource.createDataSource(dataSourceConfig);
// Grabbing first for now - should get active?
const name = webApiDataSources[0].name;
// TODO: Why does this return an array?
const dataSource = extensionManager.getDataSources(name)[0]
// Route props --> studies.mapParams
// mapParams --> studies.search
@@ -55,19 +54,18 @@ function DataSourceWrapper(props) {
useEffect(() => {
const queryFilterValues = _getQueryFilterValues(
history.location.search,
location.search,
STUDIES_LIMIT
);
// 204: no content
async function getData() {
setIsLoading(true);
const studies = await dataSource.query.studies.search(queryFilterValues);
setIsLoading(false);
setData({
studies,
studies: studies || [],
total: studies.length,
resultsPerPage: queryFilterValues.resultsPerPage,
pageNumber: queryFilterValues.pageNumber,
@@ -99,14 +97,13 @@ function DataSourceWrapper(props) {
console.warn(ex);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [history.location.search]);
}, [location, params]);
// queryFilterValues
// TODO: Better way to pass DataSource?
return (
<LayoutTemplate
{...rest}
history={history}
data={data.studies}
dataTotal={data.total}
dataSource={dataSource}
+8 -4
View File
@@ -10,14 +10,18 @@ export default function Compose(props) {
const { components = [], children } = props;
return (
<>
{components.reduceRight((acc, Comp) => {
return <Comp>{acc}</Comp>;
<React.Fragment>
{components.reduceRight((acc, curr) => {
const [Comp, props] = Array.isArray(curr)
? [curr[0], curr[1]]
: [curr, {}];
return <Comp {...props}>{acc}</Comp>;
}, children)}
</>
</React.Fragment>
);
}
// https://juliuskoronci.medium.com/avoid-a-long-list-of-react-providers-c45a269d80c1
Compose.propTypes = {
components: PropTypes.array,
children: PropTypes.node.isRequired,
+8
View File
@@ -80,7 +80,9 @@ export default function ModeRoute({
const {
DisplaySetService,
HangingProtocolService,
UserAuthenticationService,
} = servicesManager.services;
const { extensions, sopClassHandlers, hotkeys, hangingProtocols } = mode;
if (dataSourceName === undefined) {
@@ -96,6 +98,12 @@ export default function ModeRoute({
// Only handling one route per mode for now
const route = mode.routes[0];
const layoutTemplateRouteData = route.layoutTemplate({ location });
const layoutTemplateModuleEntry = extensionManager.getModuleEntry(
layoutTemplateRouteData.id
);
const LayoutComponent = layoutTemplateModuleEntry.component;
// For each extension, look up their context modules
// TODO: move to extension manager.
let contextModules = [];
@@ -0,0 +1,15 @@
import React from "react";
import { Route } from "react-router-dom";
import { useUserAuthentication } from "@ohif/ui";
export const PrivateRoute = ({ ...rest }) => {
const [{ user, enabled }, userAuthenticationService] = useUserAuthentication();
if (enabled && !user) {
return userAuthenticationService.handleUnauthenticated();
}
return <Route {...rest}/>;
}
export default PrivateRoute;
@@ -0,0 +1,33 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import PropTypes from 'prop-types';
function SignoutCallbackComponent({ userManager }) {
const navigate = useNavigate();
const onRedirectSuccess = (/* user */) => {
const { pathname, search = '' } = JSON.parse(
sessionStorage.getItem('ohif-redirect-to')
);
navigate(`${pathname}?${search}`);
};
const onRedirectError = error => {
throw new Error(error);
};
userManager
.signoutRedirectCallback()
.then(user => onRedirectSuccess(user))
.catch(error => onRedirectError(error));
// todo: add i18n
return <div>Redirecting...</div>;
}
SignoutCallbackComponent.propTypes = {
userManager: PropTypes.object.isRequired,
};
export default SignoutCallbackComponent;
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import moment from 'moment';
import qs from 'query-string';
import isEqual from 'lodash.isequal';
@@ -38,7 +38,6 @@ const seriesInStudiesMap = new Map();
* - debounce `setFilterValues` (150ms?)
*/
function WorkList({
history,
data: studies,
dataTotal: studiesTotal,
isLoadingData,
@@ -52,6 +51,7 @@ function WorkList({
const [appConfig] = useAppConfig();
// ~ Filters
const query = useQuery();
const navigate = useNavigate();
const STUDIES_LIMIT = 101;
const queryFilterValues = _getQueryFilterValues(query);
const [filterValues, _setFilterValues] = useState({
@@ -175,7 +175,7 @@ function WorkList({
}
});
history.push({
navigate({
pathname: '/',
search: `?${qs.stringify(queryString, {
skipNull: true,
@@ -441,9 +441,6 @@ function WorkList({
}
WorkList.propTypes = {
history: PropTypes.shape({
push: PropTypes.func,
}).isRequired,
data: PropTypes.array.isRequired,
dataSource: PropTypes.shape({
query: PropTypes.object.isRequired,
+20 -21
View File
@@ -26,7 +26,7 @@ export default function buildModeRoutes({
dataSources,
extensionManager,
servicesManager,
hotkeysManager
hotkeysManager,
}) {
const routes = [];
@@ -51,20 +51,19 @@ export default function buildModeRoutes({
// TODO move up.
const component = ({ location }) => (
<ModeRoute
location={location}
mode={mode}
dataSourceName={dataSourceName}
extensionManager={extensionManager}
servicesManager={servicesManager}
hotkeysManager={hotkeysManager}
/>
<ModeRoute
location={location}
mode={mode}
dataSourceName={dataSourceName}
extensionManager={extensionManager}
servicesManager={servicesManager}
hotkeysManager={hotkeysManager}
/>
);
routes.push({
path,
component,
exact: true,
});
});
@@ -74,21 +73,21 @@ export default function buildModeRoutes({
const path = `/${mode.id}`;
// TODO move up.
const component = ({ location }) => (
<ModeRoute
location={location}
mode={mode}
dataSourceName={defaultDataSourceName}
extensionManager={extensionManager}
servicesManager={servicesManager}
hotkeysManager={hotkeysManager}
/>
const children = ({ location }) => (
<ModeRoute
location={location}
mode={mode}
dataSourceName={defaultDataSourceName}
extensionManager={extensionManager}
servicesManager={servicesManager}
hotkeysManager={hotkeysManager}
/>
);
routes.push({
path,
component,
exact: true,
children,
private: true, // todo: all mode routes are private for now
});
});
+38 -23
View File
@@ -1,12 +1,14 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import { Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from '@ohif/ui';
// Route Components
import DataSourceWrapper from './DataSourceWrapper';
import WorkList from './WorkList';
import Local from './Local';
import NotFound from './NotFound';
import buildModeRoutes from './buildModeRoutes';
import { ErrorBoundary } from '@ohif/ui';
import PrivateRoute from './PrivateRoute';
// TODO: Make these configurable
// TODO: Include "routes" debug route if dev build
@@ -14,14 +16,13 @@ const bakedInRoutes = [
// WORK LIST
{
path: '/',
exact: true,
component: DataSourceWrapper,
children: DataSourceWrapper,
private: true,
props: { children: WorkList },
},
{
path: '/local',
exact: true,
component: Local,
children: Local,
},
// NOT FOUND (404)
{ component: NotFound },
@@ -33,6 +34,7 @@ const createRoutes = ({
extensionManager,
servicesManager,
hotkeysManager,
routerBasename,
}) => {
const routes =
buildModeRoutes({
@@ -45,31 +47,44 @@ const createRoutes = ({
const allRoutes = [...routes, ...bakedInRoutes];
function RouteWithErrorBoundary({ route, ...rest }) {
// eslint-disable-next-line react/jsx-props-no-spreading
return (
<ErrorBoundary context={`Route ${route.path}`} fallbackRoute="/">
<route.children
{...rest}
{...route.props}
route={route}
servicesManager={servicesManager}
hotkeysManager={hotkeysManager}
/>
</ErrorBoundary>
);
}
const { UserAuthenticationService } = servicesManager.services;
return (
<Switch>
<Routes basename={routerBasename}>
{allRoutes.map((route, i) => {
return (
return route.private === true ? (
<PrivateRoute
key={i}
path={route.path}
handleUnauthenticated={
UserAuthenticationService.handleUnauthenticated
}
element={<RouteWithErrorBoundary route={route} />}
/>
) : (
<Route
key={i}
path={route.path}
exact={route.exact}
strict={route.strict}
render={props => (
// eslint-disable-next-line react/jsx-props-no-spreading
<ErrorBoundary context={`Route ${route.path}`} fallbackRoute="/">
<route.component
{...props}
{...route.props}
route={route}
servicesManager={servicesManager}
hotkeysManager={hotkeysManager}
/>
</ErrorBoundary>
)}
element={<RouteWithErrorBoundary route={route} />}
/>
);
})}
</Switch>
</Routes>
);
};
@@ -0,0 +1,180 @@
import React from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router';
import CallbackPage from '../routes/CallbackPage';
import SignoutCallbackComponent from '../routes/SignoutCallbackComponent';
import getUserManagerForOpenIdConnectClient from './getUserManagerForOpenIdConnectClient.js';
function _isAbsoluteUrl(url) {
return url.includes('http://') || url.includes('https://');
}
function _makeAbsoluteIfNecessary(url, base_url) {
if (_isAbsoluteUrl(url)) {
return url;
}
/*
* Make sure base_url and url are not duplicating slashes.
*/
if (base_url[base_url.length - 1] === '/') {
base_url = base_url.slice(0, base_url.length - 1);
}
return base_url + url;
}
const initUserManager = (oidc, routerBasename) => {
if (!oidc || !oidc.length) {
return;
}
const firstOpenIdClient = oidc[0];
const { protocol, host } = window.location;
const baseUri = `${protocol}//${host}${routerBasename}`;
const redirect_uri = firstOpenIdClient.redirect_uri || '/callback';
const silent_redirect_uri =
firstOpenIdClient.silent_redirect_uri || '/silent-refresh.html';
const post_logout_redirect_uri =
firstOpenIdClient.post_logout_redirect_uri || '/';
const openIdConnectConfiguration = Object.assign({}, firstOpenIdClient, {
redirect_uri: _makeAbsoluteIfNecessary(redirect_uri, baseUri),
silent_redirect_uri: _makeAbsoluteIfNecessary(
silent_redirect_uri,
baseUri
),
post_logout_redirect_uri: _makeAbsoluteIfNecessary(
post_logout_redirect_uri,
baseUri
),
});
return getUserManagerForOpenIdConnectClient(openIdConnectConfiguration);
}
function LoginComponent(userManager) {
const queryParams = new URLSearchParams(location.search);
const iss = queryParams.get('iss');
const loginHint = queryParams.get('login_hint');
const targetLinkUri = queryParams.get('target_link_uri');
if (iss !== oidcAuthority) {
console.error(
'iss of /login does not match the oidc authority'
);
return null;
}
userManager.removeUser().then(() => {
if (targetLinkUri !== null) {
const ohifRedirectTo = {
pathname: new URL(targetLinkUri).pathname,
};
sessionStorage.setItem(
'ohif-redirect-to',
JSON.stringify(ohifRedirectTo)
);
} else {
const ohifRedirectTo = {
pathname: '/',
};
sessionStorage.setItem(
'ohif-redirect-to',
JSON.stringify(ohifRedirectTo)
);
}
if (loginHint !== null) {
userManager.signinRedirect({ login_hint: loginHint });
} else {
userManager.signinRedirect();
}
});
return null;
}
function OpenIdConnectRoutes({
oidc,
routerBasename,
UserAuthenticationService
}) {
const userManager = initUserManager(oidc, routerBasename);
const getAuthorizationHeader = (user) => {
return {
Authorization: `Bearer ${user.access_token}`
};
}
const handleUnauthenticated = () => {
userManager.signinRedirect()
// return null because this is used in a react component
return null;
};
const navigate = useNavigate();
UserAuthenticationService.setServiceImplementation({
getAuthorizationHeader,
handleUnauthenticated
});
const oidcAuthority = oidc[0].authority;
const location = useLocation();
const { pathname, search } = location;
const redirect_uri = new URL(userManager.settings._redirect_uri).pathname//.replace(routerBasename,'')
const silent_refresh_uri = new URL(userManager.settings._silent_redirect_uri).pathname//.replace(routerBasename,'')
const post_logout_redirect_uri = new URL(userManager.settings._post_logout_redirect_uri).pathname//.replace(routerBasename,'');
// const pathnameRelative = pathname.replace(routerBasename,'');
if (pathname !== redirect_uri) {
sessionStorage.setItem(
'ohif-redirect-to',
JSON.stringify({ pathname, search })
);
}
return (
<Routes basename={routerBasename}>
<Route
path={silent_refresh_uri}
onEnter={window.location.reload}
/>
<Route
path={post_logout_redirect_uri}
element={
<SignoutCallbackComponent
userManager={userManager}
successCallback={() => console.log('Signout successful')}
errorCallback={error => {
console.warn(error);
console.warn('Signout failed');
}}
/>
}
/>
<Route
path={redirect_uri}
element={<CallbackPage userManager={userManager} onRedirectSuccess={(user) => {
const { pathname, search = '' } = JSON.parse(
sessionStorage.getItem('ohif-redirect-to')
);
UserAuthenticationService.setUser(user);
navigate(`${pathname}?${search}`);
}}/>}
/>
<Route
path="/login"
element={<LoginComponent userManager={userManager} oidcAuthority={oidcAuthority}/>}
/>
</Routes>
);
}
export default OpenIdConnectRoutes;
@@ -0,0 +1,31 @@
import { UserManager } from 'oidc-client';
/**
* Creates a userManager from oidcSettings
* LINK: https://github.com/IdentityModel/oidc-client-js/wiki#configuration
*
* @param {Object} oidcSettings
* @param {string} oidcSettings.authServerUrl,
* @param {string} oidcSettings.clientId,
* @param {string} oidcSettings.authRedirectUri,
* @param {string} oidcSettings.postLogoutRedirectUri,
* @param {string} oidcSettings.responseType,
* @param {string} oidcSettings.extraQueryParams,
*/
export default function getUserManagerForOpenIdConnectClient(oidcSettings) {
if (!oidcSettings) {
return;
}
const settings = {
...oidcSettings,
automaticSilentRenew: true,
revokeAccessTokenOnSignout: true,
filterProtocolClaims: true,
loadUserInfo: true,
};
const userManager = new UserManager(settings);
return userManager;
}