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

DT-659: Use ECM instead of Shibboleth for eRA Commons Authentication #2664

Merged
merged 47 commits into from
Mar 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
cb1d752
feat: ECM POC
rushtong Sep 5, 2024
34b622d
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Oct 18, 2024
fd9c2ce
feat: docs and diagram
rushtong Oct 18, 2024
bffcd1d
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Oct 24, 2024
3b67f6c
feat: doc updates
rushtong Oct 24, 2024
a8948f9
feat: new post oauthcode method
rushtong Oct 24, 2024
623c3c1
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Oct 29, 2024
59640c9
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Nov 13, 2024
9a411b1
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Nov 18, 2024
fd74078
feat: use the post api to get nih auth url
rushtong Nov 18, 2024
66737a6
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Dec 4, 2024
d119982
feat: add stub for ecm call
rushtong Dec 4, 2024
f28b5aa
feat: prefer axios over fetch
rushtong Dec 4, 2024
beb9569
feat: use new redirect
rushtong Dec 4, 2024
489cf7a
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Dec 4, 2024
00bc4e5
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Jan 2, 2025
b61ec5a
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Feb 5, 2025
4c5f49c
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Feb 5, 2025
e1d32b1
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Feb 11, 2025
88e5bc9
fix: RAS changes
rushtong Feb 11, 2025
86af3d7
fix: carry through method name refactor
rushtong Feb 11, 2025
ad98af8
doc: minor doc updates
rushtong Feb 12, 2025
cc02601
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Feb 12, 2025
6851c1f
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Feb 13, 2025
ad53e19
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Feb 25, 2025
446bd87
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Mar 6, 2025
e3983bb
feat: clean up; add enabled; merge fixes
rushtong Mar 6, 2025
c714fa9
feat: handle redirect response from ECM
rushtong Mar 6, 2025
5ffd45e
npm lint
rushtong Mar 6, 2025
dacacb3
fix: add stub to render test
rushtong Mar 7, 2025
97b67bf
Merge branch 'refs/heads/develop' into gr-DCJ-659-ecm-for-era-commons
rushtong Mar 7, 2025
a9653ce
feat: complete auth/redirect process
rushtong Mar 7, 2025
027539a
feat: docs, cleanup
rushtong Mar 7, 2025
fd29864
feat: replace eRA Commons with RAS
rushtong Mar 7, 2025
bf92725
lint
rushtong Mar 7, 2025
61289d3
feat: use RAS link
rushtong Mar 7, 2025
6af938f
revert quote reformat
rushtong Mar 7, 2025
8150455
revert quote reformat
rushtong Mar 7, 2025
ae862b1
docs
rushtong Mar 7, 2025
df55cc2
formatting
rushtong Mar 7, 2025
ed135a0
revert quote formatting
rushtong Mar 7, 2025
f8919f8
feat: prevent double run
rushtong Mar 7, 2025
a8c5e33
feat: clean up todo
rushtong Mar 7, 2025
7322061
feat: rm unused
rushtong Mar 7, 2025
5a53665
lint
rushtong Mar 7, 2025
33265e8
feat: use nih logo for RAS
rushtong Mar 7, 2025
5f47b5c
feat: article change
rushtong Mar 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions cypress/component/UserProfile/user_profile.spec.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/* eslint-disable no-undef */

import {mount} from 'cypress/react';
import React from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should have some basic tests for ECM.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏽 Yes, we have a test that works with eRA Commons, but not with ECM.

import {Storage} from '../../../src/libs/storage';
Expand Down
4 changes: 2 additions & 2 deletions cypress/support/commands.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/* eslint-disable no-undef */

// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
Expand Down Expand Up @@ -27,6 +25,7 @@
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

Cypress.Commands.add('auth', async (roleName) => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { auth } = require('google-auth-library');
const keys = Cypress.env(roleName);
const client = auth.fromJSON(keys);
Expand All @@ -50,6 +49,7 @@ Cypress.Commands.add('initApplicationConfig', () => {
'ontologyApiUrl': '',
'terraUrl': '',
'tdrApiUrl': '',
'ecmApiUrl': '',
'errorApiKey': '',
'profileUrl': '',
'nihUrl': '',
Expand Down
35 changes: 35 additions & 0 deletions docs/eRA_Commons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# RAS/eRA Commons Integration

DUOS uses ECM as an intermediary to allow users to authenticate
with NIH. ECM provides a redirect url that we point the user to.
Once authenticated, the user is redirected back to ECM which saves
the authentication information and then redirects the user back to
the originating URL. DUOS, historically, also saved this information
locally in Consent. This allows Data Access Committees the ability to
see if a researcher is an NIH user.

```mermaid
%%{init: { 'theme': 'forest' } }%%
sequenceDiagram
User ->> DUOS: clicks the eRA Commons button
DUOS ->> ECM: Get authorization url
Note over DUOS, ECM: POST /api/oauth/v1/{provider}/authorization-url
Note over DUOS, ECM: include a redirectUri query parameter
Note over DUOS, ECM: include a { "redirectTo": "url" } request body
ECM ->> DUOS: return auth url
DUOS ->> User: send user new url to follow
User ->> NIH: User is forwarded to NIH
NIH ->> NIH: User Auths
NIH ->> DUOS: Return with user state
Note over DUOS, NIH: Gets the oauth code from NIH
DUOS ->> ECM: Post oauthcode to ECM
Note over DUOS, ECM: POST /api/oauth/v1/{provider}/oauthcode
Note over DUOS, ECM: include state, oauthcode
ECM ->> DUOS: return LinkInfo
Note over ECM, DUOS: response includes externalUserId redirectTo
DUOS ->> DUOS: Decode/validate ECM response
DUOS ->> Consent: Save eRA Commons state to Consent for local purposes
DUOS ->> User: Redirect user to original redirectTo
User ->> DUOS: Original page is refreshed
DUOS ->> User: Updates user display
```
1 change: 1 addition & 0 deletions public/config-example.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"ontologyApiUrl": "https://ontologyURL.org/",
"terraUrl": "https://terraURL.org/",
"tdrApiUrl": "https://tdrApiUrl.org/",
"ecmApiUrl": "https://ecmApiUrl.org",
"errorApiKey": "example",
"gaId": "",
"profileUrl": "https://profile-dot-broad-shibboleth-prod.appspot.com/dev",
Expand Down
48 changes: 38 additions & 10 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React, { useEffect, useState } from 'react';
import React, {useEffect, useState} from 'react';
import ReactGA from 'react-ga4';
import Modal from 'react-modal';
import './App.css';
import {AuthenticateNIH} from './libs/ajax/AuthenticateNIH.js';
import {Config} from './libs/config';
import DuosFooter from './components/DuosFooter';
import DuosHeader from './components/DuosHeader';
import {useHistory, useLocation} from 'react-router-dom';
import loadingImage from './images/loading-indicator.svg';

import {SpinnerComponent as Spinner} from './components/SpinnerComponent';
import {StackdriverReporter} from './libs/stackdriverReporter';
import {Storage} from './libs/storage';
Expand All @@ -16,11 +16,11 @@ import Routes from './Routes';
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [env, setEnv] = useState('');
let history = useHistory();
let location = useLocation();
const history = useHistory();
const location = useLocation();

const trackPageView = (location) => {
ReactGA.send({ hitType: 'pageview', page: location.pathname+location.search });
ReactGA.send({hitType: 'pageview', page: location.pathname + location.search});
};

useEffect(() => {
Expand Down Expand Up @@ -58,23 +58,51 @@ function App() {
});

useEffect(() => {
const setUserIsLogged = async () => {
const isLogged = await Storage.userIsLogged();
const setUserIsLogged = () => {
const isLogged = Storage.userIsLogged();
setIsLoggedIn(isLogged);
};
setUserIsLogged();
});

/**
* Check for RAS Authentication URL params. If we have a code and state, we will call ECM APIs to get redirect
* information and user linkage information. With that, we can save the updated NIH username and expiration time,
* and then redirect the user to the original page they authenticated from.
*/
useEffect(() => {
const checkRASAuthentication = async () => {
const queryParams = new URLSearchParams(window.location.search);
const code = queryParams.get('code')
const state = queryParams.get('state')
if (code && state) {
const linkInfo = await AuthenticateNIH.getECMProviderLinkInfo(code, state);
if (linkInfo?.externalUserId && linkInfo?.expirationTimestamp) {
const nihUser = {
linkedNihUsername: linkInfo.externalUserId,
linkExpireTime: `${new Date(linkInfo.expirationTimestamp).getTime()}`,
status: 'true',
}
await AuthenticateNIH.saveNihUsr(nihUser);
}
if (linkInfo?.additionalState?.redirectTo) {
window.location.href = linkInfo.additionalState.redirectTo;
}
}
};
checkRASAuthentication();
}, []);

return (
<div className="body">
<div className="wrap">
<div className="main">
<DuosHeader/>
<Spinner name="mainSpinner" group="duos" loadingImage={loadingImage} />
<Routes isLogged={isLoggedIn} env={env} />
<Spinner name="mainSpinner" group="duos" loadingImage={loadingImage}/>
<Routes isLogged={isLoggedIn} env={env}/>
</div>
</div>
<DuosFooter />
<DuosFooter/>
</div>
);
}
Expand Down
9 changes: 9 additions & 0 deletions src/components/ERACommons.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
display: inline-block;
}

.nih-logo-style {
height: 27px;
width: 38px;
background-repeat: no-repeat;
background-size: contain;
background-image: url(../images/nih-2012-logo.png);
display: inline-block;
}

.era-button-state:hover {
box-shadow: 1px 1px 3px #00609f;
}
Expand Down
85 changes: 58 additions & 27 deletions src/components/ERACommons.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,34 @@ import {get} from 'lodash';
import {isNil} from 'lodash/fp';
import queryString from 'query-string';
import './ERACommons.css';
import { AuthenticateNIH } from '../libs/ajax/AuthenticateNIH';
import { User } from '../libs/ajax/User';
import {AuthenticateNIH} from '../libs/ajax/AuthenticateNIH';
import {User} from '../libs/ajax/User';
import {Config} from '../libs/config';
import './Animations.css';
import {decodeNihToken, extractEraAuthenticationState} from '../../src/utils/ERACommonsUtils';
import {
decodeNihToken,
extractEraAuthenticationState,
rasEnabled,
nihAccountLabel
} from '../../src/utils/ERACommonsUtils';
import ReactTooltip from 'react-tooltip';

export default function ERACommons(props) {

const {onNihStatusUpdate, header = false, required = false, destination = '', researcherProfile = undefined, readOnly = false} = props;
const {
onNihStatusUpdate,
header = false,
required = false,
destination = '',
researcherProfile = undefined,
readOnly = false
} = props;
const [search, setSearch] = useState(props.location?.search || '');
const [isAuthorized, setAuthorized] = useState(false);
const [expirationCount, setExpirationCount] = useState(0);
const [eraCommonsId, setEraCommonsId] = useState('');
const [nihError, setNihError] = useState(false);
const accountLabel = nihAccountLabel();

/**
* This hook is called only when the user is redirected back to the original page after authenticating with NIH.
Expand All @@ -39,12 +52,19 @@ export default function ERACommons(props) {
'status': true
};
const newUserProps = await AuthenticateNIH.saveNihUsr(nihPayload);
const eraAuthState = extractEraAuthenticationState({properties: newUserProps, eraCommonsId: decodedToken.eraCommonsUsername});
const eraAuthState = extractEraAuthenticationState({
properties: newUserProps,
eraCommonsId: decodedToken.eraCommonsUsername
});
setAuthorized(eraAuthState.isAuthorized);
setExpirationCount(eraAuthState.expirationCount);
setEraCommonsId(eraAuthState.eraCommonsId);
onNihStatusUpdate(eraAuthState.nihValid);
document.getElementById('era-commons-id').scrollIntoView({block: 'start', inline: 'nearest', behavior: 'smooth'});
document.getElementById('era-commons-id').scrollIntoView({
block: 'start',
inline: 'nearest',
behavior: 'smooth'
});
}
};
initEraAuthSuccess();
Expand All @@ -68,7 +88,15 @@ export default function ERACommons(props) {

const redirectToNihLogin = async () => {
const returnUrl = window.location.origin + '/' + destination + '?nih-username-token=<token>';
window.location.href = `${ await Config.getNihUrl() }?${queryString.stringify({ 'return-url': returnUrl })}`;
window.location.href = `${await Config.getNihUrl()}?${queryString.stringify({'return-url': returnUrl})}`;
};

const redirectToECMAuthUrl = async () => {
const origin = window.location.origin;
const redirectTo = origin + '/' + destination;
const authUrl = await AuthenticateNIH.getECMProviderAuthUrl(origin, redirectTo);
console.log('authUrl', authUrl);
window.location.href = authUrl;
};

const deleteNihAccount = async () => {
Expand All @@ -78,7 +106,7 @@ export default function ERACommons(props) {
const eraAuthState = extractEraAuthenticationState(response.properties);
setAuthorized(eraAuthState.isAuthorized);
setExpirationCount(eraAuthState.expirationCount);
setEraCommonsId(researcherProfile.eraCommonsId);
setEraCommonsId(undefined);
onNihStatusUpdate(eraAuthState.nihValid);
setSearch('');
} else {
Expand All @@ -91,41 +119,44 @@ export default function ERACommons(props) {
const nihErrorMessage = 'Something went wrong. Please try again.';

return (
<div id={'era-commons-id'} style={{ minHeight: 65 }}>
<div id={'era-commons-id'} style={{minHeight: 65}}>
{header && <label className='era-control-label'>
<span data-cy='era-commons-header'>NIH eRA Commons ID
<span data-cy='era-commons-header'>NIH {accountLabel} ID
{required ? <span data-cy='era-commons-required'>*</span> : ''}
</span>
</label>}
{(!isAuthorized || expirationCount < 0) && (!readOnly &&
<a
data-cy='era-commons-authenticate-link'
className={validationErrorState ? 'era-button-state-error' : 'era-button-state'}
onClick={redirectToNihLogin}
target='_blank'>
<div className={'era-logo-style'}/>
<span style={{verticalAlign: '50%'}}>Authenticate your account</span>
</a>
<a
data-cy='era-commons-authenticate-link'
className={validationErrorState ? 'era-button-state-error' : 'era-button-state'}
onClick={rasEnabled() ? redirectToECMAuthUrl : redirectToNihLogin}
target='_blank'>
<div className={rasEnabled() ? 'nih-logo-style' : 'era-logo-style'}/>
<span style={{verticalAlign: '50%'}}>Authenticate your account</span>
</a>
)}
{nihError && <span className='era-cancel-color era-required-field-error-span'>{nihErrorMessage}</span>}
{isAuthorized && <div>
{expirationCount >= 0 && <div className='era-commons-id-value'>
<span data-cy='era-commons-id-value'>{eraCommonsId}</span>
{!readOnly &&
<button className='era-delete-icon' type='button' onClick={deleteNihAccount}>
<span className='glyphicon glyphicon-remove-circle' data-tip='Clear account' data-for='tip_clear_era_commons_link' />
</button>
<button className='era-delete-icon' type='button' onClick={deleteNihAccount}>
<span className='glyphicon glyphicon-remove-circle' data-tip='Clear account'
data-for='tip_clear_era_commons_link'/>
</button>
}
{!readOnly &&
<ReactTooltip
place={'right'}
effect={'solid'}
id={`tip_clear_era_commons_link`}>Clear eRA Commons Account Link</ReactTooltip>
<ReactTooltip
place={'right'}
effect={'solid'}
id={`tip_clear_era_commons_link`}>Clear {accountLabel} Account Link</ReactTooltip>
}
</div>}
<div className='era-expiration-value'>
{expirationCount >= 0 && <div className='era-fadein'>{`${readOnly ? 'This user\'s' : 'Your'} NIH authentication will expire in ${expirationCount} days`}</div>}
{expirationCount < 0 && <div className='era-fadein'>{`${readOnly ? 'This user\'s' : 'Your'} NIH authentication has expired`}</div>}
{expirationCount >= 0 && <div
className='era-fadein'>{`${readOnly ? 'This user\'s' : 'Your'} NIH authentication will expire in ${expirationCount} days`}</div>}
{expirationCount < 0 &&
<div className='era-fadein'>{`${readOnly ? 'This user\'s' : 'Your'} NIH authentication has expired`}</div>}
</div>
</div>}
</div>
Expand Down
Binary file added src/images/nih-2012-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/libs/ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export const getOntologyUrl = async() => {
return await Config.getOntologyApiUrl();
};

export const getECMUrl = async() => {
return await Config.getECMUrl();
};

export const fetchOk = async (...args) => {
//TODO: Remove spinnerService calls
spinnerService.showAll();
Expand Down
Loading
Loading