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

Centralize WTS refresh token initialization #1095

Merged
merged 12 commits into from
Dec 9, 2022
1 change: 1 addition & 0 deletions docs/portal_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ Below is an example, with inline comments describing what each JSON block config
"explorerFilterValuesToHide": ["array of strings"], // optional, Values set in array will be hidden in guppy filters. Intended use is to hide missing data category from filters, for this it should be set to the same as `missing_data_alias` in Guppy server config
"studyRegistration": true, // optional, whether to enable the study registration feature
"workspaceRegistration": true, // optional, whether to enable the workspace registration feature
"workspaceTokenServiceRefreshTokenAtLogin": true, // optional, whether to refresh the WTS token directly at portal login (recommended mode). If not set, this refresh happens only when the user enters the workspace section of the portal (default/old/previous mode).
},
"dataExplorerConfig": { // required only if featureFlags.explorer is true; configuration for the Data Explorer (/explorer); can be replaced by explorerConfig, see Multi Tab Explorer doc
"charts": { // optional; indicates which charts to display in the Data Explorer
Expand Down
25 changes: 6 additions & 19 deletions src/Analysis/GWASWizard/wizardEndpoints/cohortMiddlewareApi.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* eslint-disable camelcase */
import { useState, useEffect } from 'react';
import { cohortMiddlewarePath, wtsPath } from '../../../localconf';
import { fetchWithCreds } from '../../../actions';
import { cohortMiddlewarePath } from '../../../localconf';
import { headers } from '../../../configs';
import { hareConceptId } from '../shared/constants';

Expand Down Expand Up @@ -188,23 +187,11 @@ export const fetchSources = async () => {
export const useSourceFetch = () => {
const [loading, setLoading] = useState(true);
const [sourceId, setSourceId] = useState(undefined);
const getSources = () => { // do wts login and fetch sources on initialization
fetchWithCreds({
path: `${wtsPath}connected`,
method: 'GET',
})
.then(
(res) => {
if (res.status !== 200) {
window.location.href = `${wtsPath}authorization_url?redirect=${window.location.pathname}`;
} else {
fetchSources().then((data) => {
setSourceId(data.sources[0].source_id);
setLoading(false);
});
}
},
);
const getSources = () => { // fetch sources on initialization
fetchSources().then((data) => {
setSourceId(data.sources[0].source_id);
setLoading(false);
});
};
useEffect(() => {
getSources();
Expand Down
37 changes: 24 additions & 13 deletions src/Login/Login.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@ import './Login.less';

const getInitialState = (height) => ({ height });

// Get a url for a given "location" (location object should have at least the .from attribute)
export const getUrlForRedirectLocation = (location) => {
// compose next according to location.from
let next = (location.from) ? `${basename}${location.from}` : basename;
if (location.state && location.state.from) {
next = `${basename}${location.state.from}`;
}
// clean up url: no double slashes
next = next.replace(/\/+/g, '/');
const queryParams = querystring.parse(location.search ? location.search.replace(/^\?+/, '') : '');
if (queryParams.next) {
next = basename === '/' ? queryParams.next : basename + queryParams.next;
}
const regexp = /^\/.*/gi;
const isValidRedirect = new RegExp(regexp).test(next);
if (!isValidRedirect) {
console.log(`Found illegal "next" parameter value ${next}`);
return basename;
}
next = next.replace('?request_access', '?request_access_logged_in');
return `${next}`;
};

const getLoginUrl = (providerLoginUrl, next) => {
const queryChar = providerLoginUrl.includes('?') ? '&' : '?';
return `${providerLoginUrl}${queryChar}redirect=${window.location.origin}${next}`;
Expand Down Expand Up @@ -50,18 +73,7 @@ class Login extends React.Component {

render() {
const { location } = this.props; // this is the react-router "location"
// compose next according to location.from
let next = (location.from) ? `${basename}${location.from}` : basename;
if (location.state && location.state.from) {
next = `${basename}${location.state.from}`;
}
// clean up url: no double slashes
next = next.replace(/\/+/g, '/');
const queryParams = querystring.parse(location.search ? location.search.replace(/^\?+/, '') : '');
if (queryParams.next) {
next = basename === '/' ? queryParams.next : basename + queryParams.next;
}

const next = getUrlForRedirectLocation(location);
let customImage = 'gene';
let displaySideBoxImages = true;
if (components.login && components.login.image !== undefined) {
Expand All @@ -72,7 +84,6 @@ class Login extends React.Component {
}
}
const customImageStyle = { backgroundImage: `url(/src/img/icons/${customImage}.svg)` };
next = next.replace('?request_access', '?request_access_logged_in');

let loginComponent = (
<React.Fragment key='login-component'>
Expand Down
15 changes: 11 additions & 4 deletions src/Login/ProtectedContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import ReduxAuthTimeoutPopup from '../Popup/ReduxAuthTimeoutPopup';
import ReduxSystemUseWarningPopup from '../Popup/SystemUseWarningPopup';
import { intersection, isPageFullScreen } from '../utils';
import './ProtectedContent.css';
import isEnabled from '../helpers/featureFlags';
import { initWorkspaceRefreshToken } from '../Workspace/WorkspaceRefreshToken';

let lastAuthMs = 0;

Expand All @@ -27,8 +29,8 @@ let lastAuthMs = 0;
* @param filter {() => Promise} optional filter to apply before rendering the child component
*/
class ProtectedContent extends React.Component {
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this.state = {
authenticated: false,
dataLoaded: false,
Expand Down Expand Up @@ -56,8 +58,8 @@ class ProtectedContent extends React.Component {
)
.then(
() => this.checkLoginStatus(store, this.state)
.then((newState) => this.props.public || this.checkQuizStatus(newState))
.then((newState) => this.props.public || this.checkApiToken(store, newState)),
.then((newState) => ((this.props.public) ? { ...newState, redirectTo: null } : this.checkQuizStatus(newState))) // don't redirect for public pages
.then((newState) => ((this.props.public) ? { ...newState, redirectTo: null } : this.checkApiToken(store, newState))),
)
.then(
(newState) => {
Expand All @@ -70,6 +72,11 @@ class ProtectedContent extends React.Component {
const latestState = { ...newState };
latestState.dataLoaded = true;
this.setState(latestState);
if (newState.authenticated && isEnabled('workspaceTokenServiceRefreshTokenAtLogin')) {
// initialize WTS:
const { location } = this.props; // this is the react-router "location"
initWorkspaceRefreshToken(location);
}
};
return filterPromise.then(
finish, finish,
Expand Down
30 changes: 30 additions & 0 deletions src/Workspace/WorkspaceRefreshToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { fetchWithCreds } from '../actions';
import { wtsPath } from '../localconf';
import { getUrlForRedirectLocation } from '../Login/Login';

let lastRefreshMs = 0;
const debounceMs = 60000;

// start workspace session for WTS, call optional connectedCallBack if initialized/connected
/* eslint-disable import/prefer-default-export */
export const initWorkspaceRefreshToken = (redirectLocation, connectedCallBack) => {
const redirectUrl = getUrlForRedirectLocation(redirectLocation);
const nowMs = Date.now();
if (nowMs - lastRefreshMs > debounceMs) {
console.log('init/renew WTS refresh token...');
fetchWithCreds({
path: `${wtsPath}connected`,
method: 'GET',
})
.then(
({ status }) => {
if (status !== 200) {
window.location.href = `${wtsPath}/authorization_url?redirect=${redirectUrl}`;
} else if (connectedCallBack) {
connectedCallBack();
}
},
);
lastRefreshMs = Date.now();
}
};
24 changes: 10 additions & 14 deletions src/Workspace/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import isEnabled from '../helpers/featureFlags';
import {
workspaceUrl,
wtsPath,
externalLoginOptionsUrl,
workspaceOptionsUrl,
workspaceLaunchUrl,
Expand All @@ -42,6 +41,7 @@ import WorkspaceOption from './WorkspaceOption';
import WorkspaceLogin from './WorkspaceLogin';
import sessionMonitor from '../SessionMonitor';
import workspaceSessionMonitor from './WorkspaceSessionMonitor';
import { initWorkspaceRefreshToken } from './WorkspaceRefreshToken';

const { Step } = Steps;
const { Panel } = Collapse;
Expand Down Expand Up @@ -73,19 +73,15 @@ class Workspace extends React.Component {
}

componentDidMount() {
fetchWithCreds({
path: `${wtsPath}connected`,
method: 'GET',
})
.then(
({ status }) => {
if (status !== 200) {
window.location.href = `${wtsPath}/authorization_url?redirect=${window.location.pathname}`;
} else {
this.connected();
}
},
);
// Check if workspaceTokenServiceRefreshTokenAtLogin is NOT set.
// Because if is already enabled, then an extra refresh is not
// really needed, since it has already happened at login, so just call the callback:
if (!isEnabled('workspaceTokenServiceRefreshTokenAtLogin')) {
const redirectLocation = { from: `${window.location.pathname}` };
initWorkspaceRefreshToken(redirectLocation, this.connected);
} else {
this.connected();
}
}

componentWillUnmount() {
Expand Down